feat(files): batch actions

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
pull/36534/head
John Molakvoæ 2023-03-28 13:47:52 +07:00
parent 4942747ff8
commit 60b74e3d6d
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF
7 changed files with 263 additions and 71 deletions

@ -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,
}))

@ -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

@ -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>

@ -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 {

@ -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

@ -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;

@ -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,
}))