feat(files): add default action support

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
pull/37824/head
John Molakvoæ 1 year ago
parent c85c04e4a8
commit bb4d7969b9
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF

@ -33,7 +33,7 @@
<!-- Link to file --> <!-- Link to file -->
<td class="files-list__row-name"> <td class="files-list__row-name">
<a ref="name" v-bind="linkTo"> <a ref="name" v-bind="linkTo" @click="execDefaultAction">
<!-- Icon or preview --> <!-- Icon or preview -->
<span class="files-list__row-icon"> <span class="files-list__row-icon">
<FolderIcon v-if="source.type === 'folder'" /> <FolderIcon v-if="source.type === 'folder'" />
@ -49,6 +49,13 @@
:style="{ backgroundImage: mimeIconUrl }" /> :style="{ backgroundImage: mimeIconUrl }" />
<FileIcon v-else /> <FileIcon v-else />
<!-- Favorite icon -->
<span v-if="isFavorite"
class="files-list__row-icon-favorite"
:aria-label="t('files', 'Favorite')">
<StarIcon aria-hidden="true" :size="20" />
</span>
</span> </span>
<!-- File name --> <!-- File name -->
@ -64,6 +71,8 @@
<!-- Menu actions --> <!-- Menu actions -->
<NcActions v-if="active" <NcActions v-if="active"
ref="actionsMenu" ref="actionsMenu"
:boundaries-element="boundariesElement"
:container="boundariesElement"
:disabled="source._loading" :disabled="source._loading"
:force-title="true" :force-title="true"
:inline="enabledInlineActions.length" :inline="enabledInlineActions.length"
@ -84,7 +93,8 @@
<!-- Size --> <!-- Size -->
<td v-if="isSizeAvailable" <td v-if="isSizeAvailable"
:style="{ opacity: sizeOpacity }" :style="{ opacity: sizeOpacity }"
class="files-list__row-size"> class="files-list__row-size"
@click="execDefaultAction">
<span>{{ size }}</span> <span>{{ size }}</span>
</td> </td>
@ -92,7 +102,8 @@
<td v-for="column in columns" <td v-for="column in columns"
:key="column.id" :key="column.id"
:class="`files-list__row-${currentView?.id}-${column.id}`" :class="`files-list__row-${currentView?.id}-${column.id}`"
class="files-list__row-column-custom"> class="files-list__row-column-custom"
@click="execDefaultAction">
<CustomElementRender v-if="active" <CustomElementRender v-if="active"
:current-view="currentView" :current-view="currentView"
:render="column.render" :render="column.render"
@ -115,9 +126,11 @@ import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import StarIcon from 'vue-material-design-icons/Star.vue'
import Vue from 'vue' import Vue from 'vue'
import { getFileActions } from '../services/FileAction.ts' import { getFileActions } from '../services/FileAction.ts'
import { hashCode } from '../utils/hashUtils.ts'
import { isCachedPreview } from '../services/PreviewService.ts' import { isCachedPreview } from '../services/PreviewService.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts' import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useFilesStore } from '../store/files.ts' import { useFilesStore } from '../store/files.ts'
@ -144,6 +157,7 @@ export default Vue.extend({
NcActions, NcActions,
NcCheckboxRadioSwitch, NcCheckboxRadioSwitch,
NcLoadingIcon, NcLoadingIcon,
StarIcon,
}, },
props: { props: {
@ -192,6 +206,7 @@ export default Vue.extend({
return { return {
backgroundFailed: false, backgroundFailed: false,
backgroundImage: '', backgroundImage: '',
boundariesElement: document.querySelector('.app-content > .files-list'),
loading: '', loading: '',
} }
}, },
@ -204,7 +219,6 @@ export default Vue.extend({
currentView() { currentView() {
return this.$navigation.active return this.$navigation.active
}, },
columns() { columns() {
// Hide columns if the list is too small // Hide columns if the list is too small
if (this.filesListWidth < 512) { if (this.filesListWidth < 512) {
@ -217,7 +231,6 @@ export default Vue.extend({
// Remove any trailing slash but leave root slash // Remove any trailing slash but leave root slash
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
}, },
fileid() { fileid() {
return this.source?.fileid?.toString?.() return this.source?.fileid?.toString?.()
}, },
@ -225,6 +238,7 @@ export default Vue.extend({
return this.source.attributes.displayName return this.source.attributes.displayName
|| this.source.basename || this.source.basename
}, },
size() { size() {
const size = parseInt(this.source.size, 10) || 0 const size = parseInt(this.source.size, 10) || 0
if (typeof size !== 'number' || size < 0) { if (typeof size !== 'number' || size < 0) {
@ -232,7 +246,6 @@ export default Vue.extend({
} }
return formatFileSize(size, true) return formatFileSize(size, true)
}, },
sizeOpacity() { sizeOpacity() {
const size = parseInt(this.source.size, 10) || 0 const size = parseInt(this.source.size, 10) || 0
if (!size || size < 0) { if (!size || size < 0) {
@ -247,6 +260,15 @@ export default Vue.extend({
}, },
linkTo() { linkTo() {
if (this.enabledDefaultActions.length > 0) {
const action = this.enabledDefaultActions[0]
const displayName = action.displayName([this.source], this.currentView)
return {
title: displayName,
role: 'button',
}
}
if (this.source.type === 'folder') { if (this.source.type === 'folder') {
const to = { ...this.$route, query: { dir: join(this.dir, this.source.basename) } } const to = { ...this.$route, query: { dir: join(this.dir, this.source.basename) } }
return { return {
@ -272,7 +294,6 @@ export default Vue.extend({
cropPreviews() { cropPreviews() {
return this.userConfig.crop_image_previews return this.userConfig.crop_image_previews
}, },
previewUrl() { previewUrl() {
try { try {
const url = new URL(window.location.origin + this.source.attributes.previewUrl) const url = new URL(window.location.origin + this.source.attributes.previewUrl)
@ -280,13 +301,12 @@ export default Vue.extend({
url.searchParams.set('x', '32') url.searchParams.set('x', '32')
url.searchParams.set('y', '32') url.searchParams.set('y', '32')
// Handle cropping // Handle cropping
url.searchParams.set('a', this.cropPreviews === true ? '1' : '0') url.searchParams.set('a', this.cropPreviews === true ? '0' : '1')
return url.href return url.href
} catch (e) { } catch (e) {
return null return null
} }
}, },
mimeIconUrl() { mimeIconUrl() {
const mimeType = this.source.mime || 'application/octet-stream' const mimeType = this.source.mime || 'application/octet-stream'
const mimeIconUrl = window.OC?.MimeType?.getIconUrl?.(mimeType) const mimeIconUrl = window.OC?.MimeType?.getIconUrl?.(mimeType)
@ -301,29 +321,38 @@ export default Vue.extend({
.filter(action => !action.enabled || action.enabled([this.source], this.currentView)) .filter(action => !action.enabled || action.enabled([this.source], this.currentView))
.sort((a, b) => (a.order || 0) - (b.order || 0)) .sort((a, b) => (a.order || 0) - (b.order || 0))
}, },
enabledInlineActions() { enabledInlineActions() {
if (this.filesListWidth < 768) { if (this.filesListWidth < 768) {
return [] return []
} }
return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView)) return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
}, },
enabledMenuActions() { enabledMenuActions() {
if (this.filesListWidth < 768) { if (this.filesListWidth < 768) {
// If we have a default action, do not render the first one
if (this.enabledDefaultActions.length > 0) {
return this.enabledActions.slice(1)
}
return this.enabledActions return this.enabledActions
} }
return [ const actions = [
...this.enabledInlineActions, ...this.enabledInlineActions,
...this.enabledActions.filter(action => !action.inline), ...this.enabledActions.filter(action => !action.inline),
] ]
},
uniqueId() { // If we have a default action, do not render the first one
return this.hashCode(this.source.source) if (this.enabledDefaultActions.length > 0) {
}, return actions.slice(1)
}
return actions
},
enabledDefaultActions() {
return [
...this.enabledActions.filter(action => action.default),
]
},
openedMenu: { openedMenu: {
get() { get() {
return this.actionsMenuStore.opened === this.uniqueId return this.actionsMenuStore.opened === this.uniqueId
@ -332,6 +361,14 @@ export default Vue.extend({
this.actionsMenuStore.opened = opened ? this.uniqueId : null this.actionsMenuStore.opened = opened ? this.uniqueId : null
}, },
}, },
uniqueId() {
return hashCode(this.source.source)
},
isFavorite() {
return this.source.attributes.favorite === 1
},
}, },
watch: { watch: {
@ -457,16 +494,6 @@ export default Vue.extend({
} }
}, },
hashCode(str) {
let hash = 0
for (let i = 0, len = str.length; i < len; i++) {
const chr = str.charCodeAt(i)
hash = (hash << 5) - hash + chr
hash |= 0 // Convert to 32bit integer
}
return hash
},
async onActionClick(action) { async onActionClick(action) {
const displayName = action.displayName([this.source], this.currentView) const displayName = action.displayName([this.source], this.currentView)
try { try {
@ -475,6 +502,12 @@ export default Vue.extend({
Vue.set(this.source, '_loading', true) Vue.set(this.source, '_loading', true)
const success = await action.exec(this.source, this.currentView) const success = await action.exec(this.source, this.currentView)
// If the action returns null, we stay silent
if (success === null) {
return
}
if (success) { if (success) {
showSuccess(this.t('files', '"{displayName}" action executed successfully', { displayName })) showSuccess(this.t('files', '"{displayName}" action executed successfully', { displayName }))
return return
@ -489,6 +522,14 @@ export default Vue.extend({
Vue.set(this.source, '_loading', false) Vue.set(this.source, '_loading', false)
} }
}, },
execDefaultAction(event) {
if (this.enabledDefaultActions.length > 0) {
event.preventDefault()
event.stopPropagation()
// Execute the first default action if any
this.enabledDefaultActions[0].exec(this.source, this.currentView)
}
},
onSelectionChange(selection) { onSelectionChange(selection) {
const newSelectedIndex = this.index const newSelectedIndex = this.index

@ -167,11 +167,18 @@ export default Vue.extend({
// Dispatch action execution // Dispatch action execution
const results = await action.execBatch(this.nodes, this.currentView) const results = await action.execBatch(this.nodes, this.currentView)
// Check if all actions returned null
if (results.filter(result => result !== null).length === 0) {
// If the actions returned null, we stay silent
this.selectionStore.reset()
return
}
// Handle potential failures // Handle potential failures
if (results.some(result => result !== true)) { if (results.some(result => result === false)) {
// Remove the failed ids from the selection // Remove the failed ids from the selection
const failedIds = selectionIds const failedIds = selectionIds
.filter((fileid, index) => results[index] !== true) .filter((fileid, index) => results[index] === false)
this.selectionStore.set(failedIds) this.selectionStore.set(failedIds)
showError(this.t('files', '"{displayName}" failed on some elements ', { displayName })) showError(this.t('files', '"{displayName}" failed on some elements ', { displayName }))

@ -22,6 +22,9 @@ import router from './router/router.js'
window.OCA.Files = window.OCA.Files ?? {} window.OCA.Files = window.OCA.Files ?? {}
window.OCP.Files = window.OCP.Files ?? {} window.OCP.Files = window.OCP.Files ?? {}
// Expose router
Object.assign(window.OCP.Files, { Router: router })
// Init Pinia store // Init Pinia store
Vue.use(PiniaVuePlugin) Vue.use(PiniaVuePlugin)
const pinia = createPinia() const pinia = createPinia()
@ -57,7 +60,7 @@ const FilesList = new ListView({
}) })
FilesList.$mount('#app-content-vue') FilesList.$mount('#app-content-vue')
// Init legacy files views // Init legacy and new files views
processLegacyFilesViews() processLegacyFilesViews()
// Register preview service worker // Register preview service worker

@ -48,13 +48,14 @@ interface FileActionData {
* @returns true if the action was executed, false otherwise * @returns true if the action was executed, false otherwise
* @throws Error if the action failed * @throws Error if the action failed
*/ */
exec: (file: Node, view) => Promise<boolean>, exec: (file: Node, view) => Promise<boolean|null>,
/** /**
* Function executed on multiple files action * Function executed on multiple files action
* @returns true if the action was executed, false otherwise * @returns true if the action was executed successfully,
* false otherwise and null if the action is silent/undefined.
* @throws Error if the action failed * @throws Error if the action failed
*/ */
execBatch?: (files: Node[], view) => Promise<boolean[]> execBatch?: (files: Node[], view) => Promise<(boolean|null)[]>
/** This action order in the list */ /** This action order in the list */
order?: number, order?: number,
/** Make this action the default */ /** Make this action the default */

@ -0,0 +1,28 @@
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
export const hashCode = function(str: string): number {
return str.split('').reduce(function(a, b) {
a = ((a << 5) - a) + b.charCodeAt(0)
return a & a
}, 0)
}

@ -166,7 +166,7 @@ export default Vue.extend({
return [] return []
} }
const customColumn = this.currentView.columns const customColumn = (this.currentView?.columns || [])
.find(column => column.id === this.sortingMode) .find(column => column.id === this.sortingMode)
// Custom column must provide their own sorting methods // Custom column must provide their own sorting methods

@ -175,7 +175,6 @@ export default {
this.Navigation.setActive(view) this.Navigation.setActive(view)
logger.debug('Navigation changed', { id: view.id, view }) logger.debug('Navigation changed', { id: view.id, view })
// debugger
this.showView(view, oldView) this.showView(view, oldView)
}, },
}, },

3
custom.d.ts vendored

@ -19,7 +19,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
* *
*/ */
declare module '*.svg' { declare module '*.svg?raw' {
const content: any const content: any
export default content export default content
} }
@ -28,4 +28,3 @@ declare module '*.vue' {
import Vue from 'vue' import Vue from 'vue'
export default Vue export default Vue
} }

Loading…
Cancel
Save