Skip to content

Commit

Permalink
Merge pull request #50364 from nextcloud/fix/files-header-submenu
Browse files Browse the repository at this point in the history
  • Loading branch information
skjnldsv authored Feb 7, 2025
2 parents a7db692 + d8df001 commit f21ffab
Show file tree
Hide file tree
Showing 16 changed files with 453 additions and 79 deletions.
3 changes: 2 additions & 1 deletion apps/files/src/actions/moveOrCopyAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,8 +294,9 @@ async function openFilePickerForAction(
return promise
}

export const ACTION_COPY_MOVE = 'move-copy'
export const action = new FileAction({
id: 'move-copy',
id: ACTION_COPY_MOVE,
displayName(nodes: Node[]) {
switch (getActionForNodes(nodes)) {
case MoveCopyAction.MOVE:
Expand Down
57 changes: 9 additions & 48 deletions apps/files/src/components/FileEntry/FileEntryActions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@
:ref="`action-${action.id}`"
:class="{
[`files-list__row-action-${action.id}`]: true,
[`files-list__row-action--menu`]: isMenu(action.id)
[`files-list__row-action--menu`]: isValidMenu(action)
}"
:close-after-click="!isMenu(action.id)"
:close-after-click="!isValidMenu(action)"
:data-cy-files-list-row-action="action.id"
:is-menu="isMenu(action.id)"
:is-menu="isValidMenu(action)"
:aria-label="action.title?.([source], currentView)"
:title="action.title?.([source], currentView)"
@click="onActionClick(action)">
Expand All @@ -48,7 +48,7 @@
<!-- Submenu actions list-->
<template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]">
<!-- Back to top-level button -->
<NcActionButton class="files-list__row-action-back" @click="onBackToMenuClick(openedSubmenu)">
<NcActionButton class="files-list__row-action-back" data-cy-files-list-row-action="menu-back" @click="onBackToMenuClick(openedSubmenu)">
<template #icon>
<ArrowLeftIcon />
</template>
Expand Down Expand Up @@ -83,8 +83,8 @@ import type { FileAction, Node } from '@nextcloud/files'
import { DefaultType, NodeStatus } from '@nextcloud/files'
import { defineComponent, inject } from 'vue'
import { translate as t } from '@nextcloud/l10n'

import { useHotKey } from '@nextcloud/vue/dist/Composables/useHotKey.js'

import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
import CustomElementRender from '../CustomElementRender.vue'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
Expand All @@ -98,6 +98,7 @@ import { useActiveStore } from '../../store/active.ts'
import { useFileListWidth } from '../../composables/useFileListWidth.ts'
import { useNavigation } from '../../composables/useNavigation'
import { useRouteParameters } from '../../composables/useRouteParameters.ts'
import actionsMixins from '../../mixins/actionsMixin.ts'
import logger from '../../logger.ts'

export default defineComponent({
Expand All @@ -113,6 +114,8 @@ export default defineComponent({
NcLoadingIcon,
},

mixins: [actionsMixins],

props: {
opened: {
type: Boolean,
Expand Down Expand Up @@ -146,12 +149,6 @@ export default defineComponent({
}
},

data() {
return {
openedSubmenu: null as FileAction | null,
}
},

computed: {
isActive() {
return this.activeStore?.activeNode?.source === this.source.source
Expand Down Expand Up @@ -209,18 +206,6 @@ export default defineComponent({
return actions.filter(action => !(action.parent && topActionsIds.includes(action.parent)))
},

enabledSubmenuActions() {
return this.enabledFileActions
.filter(action => action.parent)
.reduce((arr, action) => {
if (!arr[action.parent!]) {
arr[action.parent!] = []
}
arr[action.parent!].push(action)
return arr
}, {} as Record<string, FileAction[]>)
},

openedMenu: {
get() {
return this.opened
Expand Down Expand Up @@ -287,7 +272,7 @@ export default defineComponent({
return this.activeStore?.activeAction?.id === action.id
},

async onActionClick(action, isSubmenu = false) {
async onActionClick(action) {
// If the action is a submenu, we open it
if (this.enabledSubmenuActions[action.id]) {
this.openedSubmenu = action
Expand All @@ -299,30 +284,6 @@ export default defineComponent({

// Execute the action
await executeAction(action)

// If that was a submenu, we just go back after the action
if (isSubmenu) {
this.openedSubmenu = null
}
},

isMenu(id: string) {
return this.enabledSubmenuActions[id]?.length > 0
},

async onBackToMenuClick(action: FileAction) {
this.openedSubmenu = null
// Wait for first render
await this.$nextTick()

// Focus the previous menu action button
this.$nextTick(() => {
// Focus the action button
const menuAction = this.$refs[`action-${action.id}`]?.[0]
if (menuAction) {
menuAction.$el.querySelector('button')?.focus()
}
})
},

onKeyDown(event: KeyboardEvent) {
Expand Down
134 changes: 124 additions & 10 deletions apps/files/src/components/FilesListTableHeaderActions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,74 @@
container="#app-content-vue"
:disabled="!!loading || areSomeNodesLoading"
:force-name="true"
:inline="inlineActions"
:menu-name="inlineActions <= 1 ? t('files', 'Actions') : null"
:open.sync="openedMenu">
<NcActionButton v-for="action in enabledActions"
: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"
:aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */"
:class="'files-list__row-actions-batch-' + 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 { Node, View } from '@nextcloud/files'
import type { FileAction, Node, View } from '@nextcloud/files'
import type { PropType } from 'vue'
import type { FileSource } from '../types'

import { NodeStatus, getFileActions } from '@nextcloud/files'
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/dist/Components/NcActionButton.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
Expand All @@ -47,6 +86,7 @@ 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
Expand All @@ -56,12 +96,15 @@ export default defineComponent({
name: 'FilesListTableHeaderActions',

components: {
ArrowLeftIcon,
NcActions,
NcActionButton,
NcIconSvgWrapper,
NcLoadingIcon,
},

mixins: [actionsMixins],

props: {
currentView: {
type: Object as PropType<View>,
Expand Down Expand Up @@ -97,13 +140,78 @@ export default defineComponent({
},

computed: {
enabledActions() {
enabledFileActions(): FileAction[] {
return actions
.filter(action => action.execBatch)
// 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))
Expand Down Expand Up @@ -148,6 +256,12 @@ export default defineComponent({
},

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)
Expand Down
Loading

0 comments on commit f21ffab

Please sign in to comment.