<?php
/**
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.<<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="files-list__column files-list__row-actions-batch" data-cy-files-list-selection-actions>
<NcActions ref="actionsMenu"
container="#app-content-vue"
:boundaries-element="boundariesElement"
:disabled="!!loading || areSomeNodesLoading"
:force-name="true"
:inline="enabledInlineActions.length"
:menu-name="enabledInlineActions.length <= 1 ? t('files', 'Actions') : null"
:open.sync="openedMenu"
@close="openedSubmenu = null">
<!-- Default actions list-->
<NcActionButton v-for="action in enabledMenuActions"
:key="action.id"
:ref="`action-batch-${action.id}`"
:class="{
[`files-list__row-actions-batch-${action.id}`]: true,
[`files-list__row-actions-batch--menu`]: isValidMenu(action)
}"
:close-after-click="!isValidMenu(action)"
:data-cy-files-list-selection-action="action.id"
:is-menu="isValidMenu(action)"
:aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */"
:title="action.title?.(nodes, currentView)"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="loading === action.id" :size="18" />
<NcIconSvgWrapper v-else :svg="action.iconSvgInline(nodes, currentView)" />
</template>
{{ action.displayName(nodes, currentView) }}
</NcActionButton>
<!-- Submenu actions list-->
<template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]">
<!-- Back to top-level button -->
<NcActionButton class="files-list__row-actions-batch-back" data-cy-files-list-selection-action="menu-back" @click="onBackToMenuClick(openedSubmenu)">
<template #icon>
<ArrowLeftIcon />
</template>
{{ t('files', 'Back') }}
</NcActionButton>
<NcActionSeparator />
<!-- Submenu actions -->
<NcActionButton v-for="action in enabledSubmenuActions[openedSubmenu?.id]"
:key="action.id"
:class="`files-list__row-actions-batch-${action.id}`"
class="files-list__row-actions-batch--submenu"
close-after-click
:data-cy-files-list-selection-action="action.id"
:aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */"
:title="action.title?.(nodes, currentView)"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="loading === action.id" :size="18" />
<NcIconSvgWrapper v-else :svg="action.iconSvgInline(nodes, currentView)" />
</template>
{{ action.displayName(nodes, currentView) }}
</NcActionButton>
</template>
</NcActions>
</div>
</template>
<script lang="ts">
import type { FileAction, Node, View } from '@nextcloud/files'
import type { PropType } from 'vue'
import type { FileSource } from '../types'
import { getFileActions, NodeStatus, DefaultType } from '@nextcloud/files'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useFilesStore } from '../store/files.ts'
import { useSelectionStore } from '../store/selection.ts'
import actionsMixins from '../mixins/actionsMixin.ts'
import logger from '../logger.ts'
// The registered actions list
const actions = getFileActions()
export default defineComponent({
name: 'FilesListTableHeaderActions',
components: {
ArrowLeftIcon,
NcActions,
NcActionButton,
NcIconSvgWrapper,
NcLoadingIcon,
},
mixins: [actionsMixins],
props: {
currentView: {
type: Object as PropType<View>,
required: true,
},
selectedNodes: {
type: Array as PropType<FileSource[]>,
default: () => ([]),
},
},
setup() {
const actionsMenuStore = useActionsMenuStore()
const filesStore = useFilesStore()
const selectionStore = useSelectionStore()
const fileListWidth = useFileListWidth()
const { directory } = useRouteParameters()
const boundariesElement = document.getElementById('app-content-vue')
return {
directory,
fileListWidth,
actionsMenuStore,
filesStore,
selectionStore,
boundariesElement,
}
},
data() {
return {
loading: null,
}
},
computed: {
enabledFileActions(): FileAction[] {
return actions
// We don't handle renderInline actions in this component
.filter(action => !action.renderInline)
// We don't handle actions that are not visible
.filter(action => action.default !== DefaultType.HIDDEN)
.filter(action => !action.enabled || action.enabled(this.nodes, this.currentView))
.sort((a, b) => (a.order || 0) - (b.order || 0))
},
/**
* Return the list of enabled actions that are
* allowed to be rendered inlined.
* This means that they are not within a menu, nor
* being the parent of submenu actions.
*/
enabledInlineActions(): FileAction[] {
return this.enabledFileActions
// Remove all actions that are not top-level actions
.filter(action => action.parent === undefined)
// Remove all actions that are not batch actions
.filter(action => action.execBatch !== undefined)
// Remove all top-menu entries
.filter(action => !this.isValidMenu(action))
// Return a maximum actions to fit the screen
.slice(0, this.inlineActions)
},
/**
* Return the rest of enabled actions that are not
* rendered inlined.
*/
enabledMenuActions(): FileAction[] {
// If we're in a submenu, only render the inline
// actions before the filtered submenu
if (this.openedSubmenu) {
return this.enabledInlineActions
}
// We filter duplicates to prevent inline actions to be shown twice
const actions = this.enabledFileActions.filter((value, index, self) => {
return index === self.findIndex(action => action.id === value.id)
})
// Generate list of all top-level actions ids
const childrenActionsIds = actions.filter(action => action.parent).map(action => action.parent) as string[]
const menuActions = actions
.filter(action => {
// If the action is not a batch action, we need
// to make sure it's a top-level parent entry
// and that we have some children actions bound to it
if (!action.execBatch) {
return childrenActionsIds.includes(action.id)
}
// Rendering second-level actions is done in the template
// when openedSubmenu is set.
if (action.parent) {
return false
}
return true
})
.filter(action => !this.enabledInlineActions.includes(action))
// Make sure we render the inline actions first
// and then the rest of the actions.
// We do NOT want nested actions to be rendered inlined
return [...this.enabledInlineActions, ...menuActions]
},
nodes() {
return this.selectedNodes
.map(source => this.getNode(source))
.filter(Boolean) as Node[]
},
areSomeNodesLoading() {
return this.nodes.some(node => node.status === NodeStatus.LOADING)
},
openedMenu: {
get() {
return this.actionsMenuStore.opened === 'global'
},
set(opened) {
this.actionsMenuStore.opened = opened ? 'global' : null
},
},
inlineActions() {
if (this.fileListWidth < 512) {
return 0
}
if (this.fileListWidth < 768) {
return 1
}
if (this.fileListWidth < 1024) {
return 2
}
return 3
},
},
methods: {
/**
* Get a cached note from the store
*
* @param source The source of the node to get
*/
getNode(source: string): Node|undefined {
return this.filesStore.getNode(source)
},
async onActionClick(action) {
// If the action is a submenu, we open it
if (this.enabledSubmenuActions[action.id]) {
this.openedSubmenu = action
return
}
let displayName = action.id
try {
displayName = action.displayName(this.nodes, this.currentView)
} catch (error) {
logger.error('Error while getting action display name', { action, error })
}
const selectionSources = this.selectedNodes
try {
// Set loading markers
this.loading = action.id
this.nodes.forEach(node => {
this.$set(node, 'status', NodeStatus.LOADING)
})
// Dispatch action execution
const results = await action.execBatch(this.nodes, this.currentView, this.directory)
// Check if all actions returned null
if (!results.some(result => result !== null)) {
// If the actions returned null, we stay silent
this.selectionStore.reset()
return
}
// Handle potential failures
if (results.some(result => result === false)) {
// Remove the failed ids from the selection
const failedSources = selectionSources
.filter((source, index) => results[index] === false)
this.selectionStore.set(failedSources)
if (results.some(result => result === null)) {
// If some actions returned null, we assume that the dev
// is handling the error messages and we stay silent
return
}
showError(this.t('files', '"{displayName}" failed on some elements', { displayName }))
return
}
// Show success message and clear selection
showSuccess(this.t('files', '"{displayName}" batch action executed successfully', { displayName }))
this.selectionStore.reset()
} catch (e) {
logger.error('Error while executing action', { action, e })
showError(this.t('files', '"{displayName}" action failed', { displayName }))
} finally {
// Remove loading markers
this.loading = null
this.nodes.forEach(node => {
this.$set(node, 'status', undefined)
})
}
},
t: translate,
},
})
</script>
<style scoped lang="scss">
.files-list__row-actions-batch {
flex: 1 1 100% !important;
max-width: 100%;
}
</style>