aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJohn Molakvoæ <skjnldsv@protonmail.com>2023-03-28 13:47:52 +0200
committerJohn Molakvoæ <skjnldsv@protonmail.com>2023-04-06 14:49:32 +0200
commit60b74e3d6d626a7f7599acbeaadb650cbf56cc6c (patch)
treeaa449108c8996f352d5b8a5529a8d208a8ab6934
parent4942747ff85dc8671ff7a6a42cd07a886d361b07 (diff)
downloadnextcloud-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.ts28
-rw-r--r--apps/files/src/components/FileEntry.vue11
-rw-r--r--apps/files/src/components/FilesListActionsHeader.vue168
-rw-r--r--apps/files/src/components/FilesListFooter.vue1
-rw-r--r--apps/files/src/components/FilesListHeader.vue82
-rw-r--r--apps/files/src/components/FilesListHeaderButton.vue2
-rw-r--r--apps/files_trashbin/src/actions/restoreAction.ts42
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,
}))