diff options
author | John Molakvoæ <skjnldsv@protonmail.com> | 2023-03-28 13:47:52 +0200 |
---|---|---|
committer | John Molakvoæ <skjnldsv@protonmail.com> | 2023-04-06 14:49:32 +0200 |
commit | 60b74e3d6d626a7f7599acbeaadb650cbf56cc6c (patch) | |
tree | aa449108c8996f352d5b8a5529a8d208a8ab6934 | |
parent | 4942747ff85dc8671ff7a6a42cd07a886d361b07 (diff) | |
download | nextcloud-server-60b74e3d6d626a7f7599acbeaadb650cbf56cc6c.tar.gz nextcloud-server-60b74e3d6d626a7f7599acbeaadb650cbf56cc6c.zip |
feat(files): batch actions
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
-rw-r--r-- | apps/files/src/actions/deleteAction.ts | 28 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry.vue | 11 | ||||
-rw-r--r-- | apps/files/src/components/FilesListActionsHeader.vue | 168 | ||||
-rw-r--r-- | apps/files/src/components/FilesListFooter.vue | 1 | ||||
-rw-r--r-- | apps/files/src/components/FilesListHeader.vue | 82 | ||||
-rw-r--r-- | apps/files/src/components/FilesListHeaderButton.vue | 2 | ||||
-rw-r--r-- | apps/files_trashbin/src/actions/restoreAction.ts | 42 |
7 files changed, 263 insertions, 71 deletions
diff --git a/apps/files/src/actions/deleteAction.ts b/apps/files/src/actions/deleteAction.ts index acf855c8d15..88ec345fcf2 100644 --- a/apps/files/src/actions/deleteAction.ts +++ b/apps/files/src/actions/deleteAction.ts @@ -19,11 +19,13 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ +import { emit } from '@nextcloud/event-bus' import { registerFileAction, Permission, FileAction, Node } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' import axios from '@nextcloud/axios' import TrashCan from '@mdi/svg/svg/trash-can.svg?raw' -import { emit } from '@nextcloud/event-bus' + +import logger from '../logger' registerFileAction(new FileAction({ id: 'delete', @@ -33,20 +35,30 @@ registerFileAction(new FileAction({ : t('files', 'Delete') }, iconSvgInline: () => TrashCan, + enabled(nodes: Node[]) { return nodes.length > 0 && nodes .map(node => node.permissions) .every(permission => (permission & Permission.DELETE) !== 0) }, + async exec(node: Node) { - // No try...catch here, let the files app handle the error - await axios.delete(node.source) + try { + await axios.delete(node.source) - // Let's delete even if it's moved to the trashbin - // since it has been removed from the current view - // and changing the view will trigger a reload anyway. - emit('files:file:deleted', node) - return true + // Let's delete even if it's moved to the trashbin + // since it has been removed from the current view + // and changing the view will trigger a reload anyway. + emit('files:file:deleted', node) + return true + } catch (error) { + logger.error('Error while deleting a file', { error, source: node.source, node }) + return false + } + }, + async execBatch(nodes: Node[], view) { + return Promise.all(nodes.map(node => this.exec(node, view))) }, + order: 100, })) diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index 420af67f9eb..fa4c67cb553 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -138,7 +138,6 @@ export default Vue.extend({ Fragment, NcActionButton, NcActions, - NcButton, NcCheckboxRadioSwitch, NcLoadingIcon, }, @@ -328,16 +327,6 @@ export default Vue.extend({ }, methods: { - /** - * Get a cached note from the store - * - * @param {number} fileId the file id to get - * @return {Folder|File} - */ - getNode(fileId) { - return this.filesStore.getNode(fileId) - }, - async debounceIfNotCached() { if (!this.previewUrl) { return diff --git a/apps/files/src/components/FilesListActionsHeader.vue b/apps/files/src/components/FilesListActionsHeader.vue new file mode 100644 index 00000000000..9abb30c6c2e --- /dev/null +++ b/apps/files/src/components/FilesListActionsHeader.vue @@ -0,0 +1,168 @@ +<!-- + - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> + - + - @author Gary Kim <gary@garykim.dev> + - + - @license GNU AGPL version 3 or any later version + - + - 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/>. + - + --> +<template> + <th class="files-list__column files-list__row-actions-batch" colspan="2"> + <NcActions ref="actionsMenu" + :disabled="!!loading" + :force-title="true" + :inline="3"> + <NcActionButton v-for="action in enabledActions" + :key="action.id" + :class="'files-list__row-actions-batch-' + action.id" + @click="onActionClick(action)"> + <template #icon> + <NcLoadingIcon v-if="loading === action.id" :size="18" /> + <CustomSvgIconRender v-else :svg="action.iconSvgInline(nodes, currentView)" /> + </template> + {{ action.displayName(nodes, currentView) }} + </NcActionButton> + </NcActions> + </th> +</template> + +<script lang="ts"> +import { showError, showSuccess } from '@nextcloud/dialogs' +import { getFileActions } from '@nextcloud/files' +import { translate } from '@nextcloud/l10n' +import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' +import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' +import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' +import Vue from 'vue' + +import { useFilesStore } from '../store/files' +import { useSelectionStore } from '../store/selection' +import CustomSvgIconRender from './CustomSvgIconRender.vue' +import logger from '../logger.js' + +// The registered actions list +const actions = getFileActions() + +export default Vue.extend({ + name: 'FilesListActionsHeader', + + components: { + CustomSvgIconRender, + NcActions, + NcActionButton, + NcLoadingIcon, + }, + + props: { + currentView: { + type: Object, + required: true, + }, + selectedNodes: { + type: Array, + default: () => ([]), + }, + }, + + setup() { + const filesStore = useFilesStore() + const selectionStore = useSelectionStore() + return { + filesStore, + selectionStore, + } + }, + + data() { + return { + loading: null, + } + }, + + computed: { + enabledActions() { + return actions + .filter(action => action.execBatch) + .filter(action => !action.enabled || action.enabled(this.nodes, this.currentView)) + .sort((a, b) => (a.order || 0) - (b.order || 0)) + }, + + nodes() { + return this.selectedNodes + .map(fileid => this.getNode(fileid)) + .filter(node => node) + }, + }, + + methods: { + /** + * Get a cached note from the store + * + * @param {number} fileId the file id to get + * @return {Folder|File} + */ + getNode(fileId) { + return this.filesStore.getNode(fileId) + }, + + async onActionClick(action) { + const displayName = action.displayName(this.nodes, this.currentView) + const selectionIds = this.selectedNodes + try { + this.loading = action.id + const results = await action.execBatch(this.nodes, this.currentView) + + if (results.some(result => result !== true)) { + // Remove the failed ids from the selection + const failedIds = selectionIds + .filter((fileid, index) => results[index] !== true) + this.selectionStore.set(failedIds) + + showError(this.t('files', '"{displayName}" failed on some elements ', { displayName })) + return + } + + // Show success message and clear selection + showSuccess(this.t('files', '"{displayName}" batch action successfully executed', { displayName })) + this.selectionStore.reset() + } catch (e) { + logger.error('Error while executing action', { action, e }) + showError(this.t('files', 'Error while executing action "{displayName}"', { displayName })) + } finally { + this.loading = null + } + }, + + t: translate, + }, +}) +</script> + +<style scoped lang="scss"> +.files-list__row-actions-batch { + flex: 1 1 100% !important; + + // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged + ::v-deep .button-vue__wrapper { + width: 100%; + span.button-vue__text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } +} +</style> diff --git a/apps/files/src/components/FilesListFooter.vue b/apps/files/src/components/FilesListFooter.vue index 51907e03a9c..6f2cad358b1 100644 --- a/apps/files/src/components/FilesListFooter.vue +++ b/apps/files/src/components/FilesListFooter.vue @@ -149,6 +149,7 @@ tr { border-top: 1px solid var(--color-border); // Prevent hover effect on the whole row background-color: transparent !important; + border-bottom: none !important; } td { diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue index e39d7b4cade..66b2845ea11 100644 --- a/apps/files/src/components/FilesListHeader.vue +++ b/apps/files/src/components/FilesListHeader.vue @@ -25,35 +25,43 @@ <NcCheckboxRadioSwitch v-bind="selectAllBind" @update:checked="onToggleAll" /> </th> - <!-- Link to file --> - <th class="files-list__column files-list__row-name files-list__column--sortable" - @click.stop.prevent="toggleSortBy('basename')"> - <!-- Icon or preview --> - <span class="files-list__row-icon" /> - - <!-- Name --> - <FilesListHeaderButton :name="t('files', 'Name')" mode="basename" /> - </th> - - <!-- Actions --> - <th class="files-list__row-actions" /> - - <!-- Size --> - <th v-if="isSizeAvailable" - :class="{'files-list__column--sortable': isSizeAvailable}" - class="files-list__column files-list__row-size"> - <FilesListHeaderButton :name="t('files', 'Size')" mode="size" /> - </th> - - <!-- Custom views columns --> - <th v-for="column in columns" - :key="column.id" - :class="classForColumn(column)"> - <FilesListHeaderButton v-if="!!column.sort" :name="column.title" :mode="column.id" /> - <span v-else> - {{ column.title }} - </span> - </th> + <!-- Actions multiple if some are selected --> + <FilesListActionsHeader v-if="!isNoneSelected" + :current-view="currentView" + :selected-nodes="selectedNodes" /> + + <!-- Columns display --> + <template v-else> + <!-- Link to file --> + <th class="files-list__column files-list__row-name files-list__column--sortable" + @click.stop.prevent="toggleSortBy('basename')"> + <!-- Icon or preview --> + <span class="files-list__row-icon" /> + + <!-- Name --> + <FilesListHeaderButton :name="t('files', 'Name')" mode="basename" /> + </th> + + <!-- Actions --> + <th class="files-list__row-actions" /> + + <!-- Size --> + <th v-if="isSizeAvailable" + :class="{'files-list__column--sortable': isSizeAvailable}" + class="files-list__column files-list__row-size"> + <FilesListHeaderButton :name="t('files', 'Size')" mode="size" /> + </th> + + <!-- Custom views columns --> + <th v-for="column in columns" + :key="column.id" + :class="classForColumn(column)"> + <FilesListHeaderButton v-if="!!column.sort" :name="column.title" :mode="column.id" /> + <span v-else> + {{ column.title }} + </span> + </th> + </template> </tr> </template> @@ -66,9 +74,10 @@ import Vue from 'vue' import { useFilesStore } from '../store/files' import { useSelectionStore } from '../store/selection' import { useSortingStore } from '../store/sorting' +import FilesListActionsHeader from './FilesListActionsHeader.vue' +import FilesListHeaderButton from './FilesListHeaderButton.vue' import logger from '../logger.js' import Navigation from '../services/Navigation' -import FilesListHeaderButton from './FilesListHeaderButton.vue' export default Vue.extend({ name: 'FilesListHeader', @@ -76,6 +85,7 @@ export default Vue.extend({ components: { FilesListHeaderButton, NcCheckboxRadioSwitch, + FilesListActionsHeader, }, props: { @@ -129,22 +139,22 @@ export default Vue.extend({ } }, + selectedNodes() { + return this.selectionStore.selected + }, + isAllSelected() { - return this.selectedFiles.length === this.nodes.length + return this.selectedNodes.length === this.nodes.length }, isNoneSelected() { - return this.selectedFiles.length === 0 + return this.selectedNodes.length === 0 }, isSomeSelected() { return !this.isAllSelected && !this.isNoneSelected }, - selectedFiles() { - return this.selectionStore.selected - }, - sortingMode() { return this.sortingStore.getSortingMode(this.currentView.id) || this.currentView.defaultSortKey diff --git a/apps/files/src/components/FilesListHeaderButton.vue b/apps/files/src/components/FilesListHeaderButton.vue index 2e0c398ab25..cde77ff21fe 100644 --- a/apps/files/src/components/FilesListHeaderButton.vue +++ b/apps/files/src/components/FilesListHeaderButton.vue @@ -123,6 +123,7 @@ export default Vue.extend({ .button-vue__wrapper { flex-direction: row-reverse; // Take max inner width for text overflow ellipsis + // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged width: 100%; } @@ -133,6 +134,7 @@ export default Vue.extend({ opacity: 0; } + // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged .button-vue__text { overflow: hidden; white-space: nowrap; diff --git a/apps/files_trashbin/src/actions/restoreAction.ts b/apps/files_trashbin/src/actions/restoreAction.ts index 201fffe6a53..fe896f6b618 100644 --- a/apps/files_trashbin/src/actions/restoreAction.ts +++ b/apps/files_trashbin/src/actions/restoreAction.ts @@ -19,13 +19,13 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ +import { emit } from '@nextcloud/event-bus' +import { generateRemoteUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' import { registerFileAction, Permission, FileAction, Node } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' import axios from '@nextcloud/axios' import History from '@mdi/svg/svg/history.svg?raw' -import { generateRemoteUrl } from '@nextcloud/router' -import { getCurrentUser } from '@nextcloud/auth' -import { emit } from '@nextcloud/event-bus' registerFileAction(new FileAction({ id: 'restore', @@ -33,6 +33,7 @@ registerFileAction(new FileAction({ return t('files_trashbin', 'Restore') }, iconSvgInline: () => History, + enabled(nodes: Node[], view) { // Only available in the trashbin view if (view.id !== 'trashbin') { @@ -44,22 +45,31 @@ registerFileAction(new FileAction({ .map(node => node.permissions) .every(permission => (permission & Permission.READ) !== 0) }, + async exec(node: Node) { - // No try...catch here, let the files app handle the error - const destination = generateRemoteUrl(`dav/trashbin/${getCurrentUser()?.uid}/restore/${node.basename}`) - await axios({ - method: 'MOVE', - url: node.source, - headers: { - destination, - }, - }) + try { + const destination = generateRemoteUrl(`dav/trashbin/${getCurrentUser()?.uid}/restore/${node.basename}`) + await axios({ + method: 'MOVE', + url: node.source, + headers: { + destination, + }, + }) - // Let's pretend the file is deleted since - // we don't know the restored location - emit('files:file:deleted', node) - return true + // Let's pretend the file is deleted since + // we don't know the restored location + emit('files:file:deleted', node) + return true + } catch (error) { + console.error(error) + return false + } + }, + async execBatch(nodes: Node[], view) { + return Promise.all(nodes.map(node => this.exec(node, view))) }, + order: 1, inline: () => true, })) |