aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorskjnldsv <skjnldsv@protonmail.com>2025-07-14 11:51:23 +0200
committerskjnldsv <skjnldsv@protonmail.com>2025-07-15 08:22:28 +0200
commitc8dac22eb198df251b3cd9f969fd963699ae1965 (patch)
tree137aa18c23185019ba31d0d14a4e2f3f639dd249
parentc5b3768e21662663442cfe6b31c60b2701dc9de1 (diff)
downloadnextcloud-server-fix/files-actions-subcomponent.tar.gz
nextcloud-server-fix/files-actions-subcomponent.zip
feat(files): show destructive actions as importantfix/files-actions-subcomponent
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
-rw-r--r--apps/files/src/actions/deleteAction.ts1
-rw-r--r--apps/files/src/components/FileEntry/FileEntryAction.vue168
-rw-r--r--apps/files/src/components/FileEntry/FileEntryActions.vue112
-rw-r--r--apps/files/src/components/FileEntryMixin.ts12
4 files changed, 222 insertions, 71 deletions
diff --git a/apps/files/src/actions/deleteAction.ts b/apps/files/src/actions/deleteAction.ts
index 1f0665ced8f..63f7fd442c5 100644
--- a/apps/files/src/actions/deleteAction.ts
+++ b/apps/files/src/actions/deleteAction.ts
@@ -112,5 +112,6 @@ export const action = new FileAction({
return Promise.all(promises)
},
+ destructive: true,
order: 100,
})
diff --git a/apps/files/src/components/FileEntry/FileEntryAction.vue b/apps/files/src/components/FileEntry/FileEntryAction.vue
new file mode 100644
index 00000000000..70ac7a61965
--- /dev/null
+++ b/apps/files/src/components/FileEntry/FileEntryAction.vue
@@ -0,0 +1,168 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcActionButton :ref="buttonRef"
+ class="files-list__row-action"
+ :class="buttonClasses"
+ :close-after-click="variant !== 'menu'"
+ :data-cy-files-list-row-action="action.id"
+ :is-menu="variant === 'menu'"
+ :aria-label="actionTitle"
+ :title="actionTitle"
+ @click="onActionClick">
+ <template #icon>
+ <NcLoadingIcon v-if="isLoading" size="18" />
+ <NcIconSvgWrapper v-else
+ class="files-list__row-action-icon"
+ :svg="action.iconSvgInline([source], currentView)" />
+ </template>
+ {{ displayName }}
+ </NcActionButton>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { FileAction, Node } from '@nextcloud/files'
+
+import { defineComponent } from 'vue'
+
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+
+import { useFileListWidth } from '../../composables/useFileListWidth.ts'
+import { useNavigation } from '../../composables/useNavigation.ts'
+import logger from '../../logger.ts'
+
+export default defineComponent({
+ name: 'FileEntryAction',
+
+ components: {
+ NcActionButton,
+ NcIconSvgWrapper,
+ NcLoadingIcon,
+ },
+
+ props: {
+ action: {
+ type: Object as PropType<FileAction>,
+ required: true,
+ },
+ source: {
+ type: Object as PropType<Node>,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ default: false,
+ },
+ isMenu: {
+ type: Boolean,
+ default: false,
+ },
+ gridMode: {
+ type: Boolean,
+ default: false,
+ },
+ variant: {
+ type: String as PropType<'inline'|'menu'|'submenu'>,
+ default: undefined,
+ },
+ },
+
+ emits: ['click'],
+
+ setup() {
+ // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag
+ const { currentView } = useNavigation(true)
+ const filesListWidth = useFileListWidth()
+
+ return {
+ currentView,
+ filesListWidth,
+ }
+ },
+
+ computed: {
+ buttonRef() {
+ return `action-${this.action.id}`
+ },
+
+ buttonClasses() {
+ const classes = {
+ [`files-list__row-action-${this.action.id}`]: true,
+ }
+
+ switch (this.variant) {
+ case 'inline':
+ classes['files-list__row-action--inline'] = true
+ break
+ case 'submenu':
+ classes['files-list__row-action--submenu'] = true
+ break
+ case 'menu':
+ classes['files-list__row-action--menu'] = true
+ break
+ }
+
+ return classes
+ },
+
+ actionTitle() {
+ try {
+ return this.action.title?.([this.source], this.currentView)
+ } catch (error) {
+ logger.error('Error while getting action title', { action: this.action, error })
+ return this.action.id
+ }
+ },
+
+ displayName() {
+ try {
+ if ((this.gridMode || (this.filesListWidth < 768 && this.action.inline)) && typeof this.action.title === 'function') {
+ // if an inline action is rendered in the menu for
+ // lack of space we use the title first if defined
+ const title = this.action.title([this.source], this.currentView)
+ if (title) return title
+ }
+ return this.action.displayName([this.source], this.currentView)
+ } catch (error) {
+ logger.error('Error while getting action display name', { action: this.action, error })
+ // Not ideal, but better than nothing
+ return this.action.id
+ }
+ },
+ },
+
+ methods: {
+ onActionClick() {
+ this.$emit('click', this.action)
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.files-list__row-action {
+ --max-icon-size: calc(var(--default-clickable-area) - 2 * var(--default-grid-baseline));
+
+ // inline icons can have clickable area size so they still fit into the row
+ &.files-list__row-action--inline {
+ --max-icon-size: var(--default-clickable-area);
+ }
+ // destructive actions should be highlighted in red
+ &.files-list__row-action--destructive {
+ ::deep(button) {
+ color: var(--color-error) !important;
+ }
+ }
+
+ // Some icons exceed the default size so we need to enforce a max width and height
+ .files-list__row-action-icon :deep(svg) {
+ max-height: var(--max-icon-size) !important;
+ max-width: var(--max-icon-size) !important;
+ }
+}
+</style>
diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue
index ec111a1235d..4e9c7dbd8b0 100644
--- a/apps/files/src/components/FileEntry/FileEntryActions.vue
+++ b/apps/files/src/components/FileEntry/FileEntryActions.vue
@@ -26,29 +26,29 @@
@close="onMenuClose"
@closed="onMenuClosed">
<!-- Default actions list-->
- <NcActionButton v-for="action, index in enabledMenuActions"
+ <FileEntryAction v-for="(action, index) in renderedNonDestructiveActions"
:key="action.id"
- :ref="`action-${action.id}`"
- class="files-list__row-action"
- :class="{
- [`files-list__row-action-${action.id}`]: true,
- 'files-list__row-action--inline': index < enabledInlineActions.length,
- 'files-list__row-action--menu': isValidMenu(action)
- }"
- :close-after-click="!isValidMenu(action)"
- :data-cy-files-list-row-action="action.id"
+ :action="action"
+ :grid-mode="gridMode"
+ :is-loading="isLoadingAction(action)"
:is-menu="isValidMenu(action)"
- :aria-label="action.title?.([source], currentView)"
- :title="action.title?.([source], currentView)"
- @click="onActionClick(action)">
- <template #icon>
- <NcLoadingIcon v-if="isLoadingAction(action)" />
- <NcIconSvgWrapper v-else
- class="files-list__row-action-icon"
- :svg="action.iconSvgInline([source], currentView)" />
- </template>
- {{ actionDisplayName(action) }}
- </NcActionButton>
+ :source="source"
+ :variant="(index < enabledInlineActions.length) ? 'inline' : undefined"
+ @click="onActionClick(action)" />
+
+ <template v-if="renderedDestructiveActions.length">
+ <NcActionSeparator />
+
+ <!-- Destructive actions list-->
+ <FileEntryAction v-for="action in renderedDestructiveActions"
+ :key="action.id"
+ :action="action"
+ :grid-mode="gridMode"
+ :is-loading="isLoadingAction(action)"
+ :source="source"
+ :variant="undefined /* inline already rendered above */"
+ @click="onActionClick(action)" />
+ </template>
<!-- Submenu actions list-->
<template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]">
@@ -62,20 +62,14 @@
<NcActionSeparator />
<!-- Submenu actions -->
- <NcActionButton v-for="action in enabledSubmenuActions[openedSubmenu?.id]"
+ <FileEntryAction v-for="action in enabledSubmenuActions[openedSubmenu?.id]"
:key="action.id"
- :class="`files-list__row-action-${action.id}`"
- class="files-list__row-action--submenu"
- close-after-click
- :data-cy-files-list-row-action="action.id"
- :title="action.title?.([source], currentView)"
- @click="onActionClick(action)">
- <template #icon>
- <NcLoadingIcon v-if="isLoadingAction(action)" :size="18" />
- <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
- </template>
- {{ actionDisplayName(action) }}
- </NcActionButton>
+ :action="action"
+ :grid-mode="gridMode"
+ :is-loading="isLoadingAction(action)"
+ :source="source"
+ variant="submenu"
+ @click="onActionClick(action)" />
</template>
</NcActions>
</td>
@@ -85,18 +79,17 @@
import type { PropType } from 'vue'
import type { FileAction, Node } from '@nextcloud/files'
-import { DefaultType, NodeStatus } from '@nextcloud/files'
+import { DefaultType } from '@nextcloud/files'
import { defineComponent, inject } from 'vue'
import { t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
import CustomElementRender from '../CustomElementRender.vue'
+import FileEntryAction from './FileEntryAction.vue'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
-import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
-import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import { executeAction } from '../../utils/actionUtils.ts'
import { useActiveStore } from '../../store/active.ts'
@@ -112,11 +105,10 @@ export default defineComponent({
components: {
ArrowLeftIcon,
CustomElementRender,
+ FileEntryAction,
NcActionButton,
NcActions,
NcActionSeparator,
- NcIconSvgWrapper,
- NcLoadingIcon,
},
mixins: [actionsMixins],
@@ -151,6 +143,9 @@ export default defineComponent({
enabledFileActions,
filesListWidth,
t,
+ // Allow nested components to be detected as valid NcActionButton
+ // https://github.com/nextcloud-libraries/nextcloud-vue/blob/a498a5df4f8abbcf3e9ef1f581f379098b4ede6d/src/components/NcActions/NcActions.vue#L1260-L1279
+ type: { name: NcActionButton },
}
},
@@ -159,10 +154,6 @@ export default defineComponent({
return this.activeStore?.activeNode?.source === this.source.source
},
- isLoading() {
- return this.source.status === NodeStatus.LOADING
- },
-
// Enabled action that are displayed inline
enabledInlineActions() {
if (this.filesListWidth < 768 || this.gridMode) {
@@ -211,6 +202,14 @@ export default defineComponent({
return actions.filter(action => !(action.parent && topActionsIds.includes(action.parent)))
},
+ renderedNonDestructiveActions() {
+ return this.enabledMenuActions.filter(action => !action.destructive)
+ },
+
+ renderedDestructiveActions() {
+ return this.enabledMenuActions.filter(action => action.destructive)
+ },
+
openedMenu: {
get() {
return this.opened
@@ -250,22 +249,6 @@ export default defineComponent({
},
methods: {
- actionDisplayName(action: FileAction) {
- try {
- if ((this.gridMode || (this.filesListWidth < 768 && action.inline)) && typeof action.title === 'function') {
- // if an inline action is rendered in the menu for
- // lack of space we use the title first if defined
- const title = action.title([this.source], this.currentView)
- if (title) return title
- }
- return action.displayName([this.source], this.currentView)
- } catch (error) {
- logger.error('Error while getting action display name', { action, error })
- // Not ideal, but better than nothing
- return action.id
- }
- },
-
isLoadingAction(action: FileAction) {
if (!this.isActive) {
return false
@@ -338,16 +321,5 @@ main.app-content[style*="mouse-pos-x"] .v-popper__popper {
<style scoped lang="scss">
.files-list__row-action {
--max-icon-size: calc(var(--default-clickable-area) - 2 * var(--default-grid-baseline));
-
- // inline icons can have clickable area size so they still fit into the row
- &.files-list__row-action--inline {
- --max-icon-size: var(--default-clickable-area);
- }
-
- // Some icons exceed the default size so we need to enforce a max width and height
- .files-list__row-action-icon :deep(svg) {
- max-height: var(--max-icon-size) !important;
- max-width: var(--max-icon-size) !important;
- }
}
</style>
diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts
index 735490c45b3..e8adefc3890 100644
--- a/apps/files/src/components/FileEntryMixin.ts
+++ b/apps/files/src/components/FileEntryMixin.ts
@@ -250,7 +250,17 @@ export default defineComponent({
return false
}
})
- .sort((a, b) => (a.order || 0) - (b.order || 0))
+ .sort((a, b) => {
+ // Sort destructive actions to the end
+ if (a.destructive && !b.destructive) {
+ return 1
+ } else if (!a.destructive && b.destructive) {
+ return -1
+ }
+
+ // Sort by order, if not defined, use 0
+ return (a.order || 0) - (b.order || 0)
+ })
},
defaultFileAction() {