Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(files): support submenu in batch actions header too #50364

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/files/src/actions/convertUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@
*
* @param view The current view
* @param path The path to the file
* @returns The parent folder
* @return The parent folder

Check warning on line 134 in apps/files/src/actions/convertUtils.ts

View workflow job for this annotation

GitHub Actions / NPM lint

Missing JSDoc @return type
*/
export const getParentFolder = function(view: View, path: string): Folder | null {
const filesStore = useFilesStore(getPinia())
Expand Down
3 changes: 2 additions & 1 deletion apps/files/src/actions/moveOrCopyAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@
* @param action The action to open the file picker for
* @param dir The directory to start the file picker in
* @param nodes The nodes to move/copy
* @return The picked destination or false if cancelled by user

Check warning on line 210 in apps/files/src/actions/moveOrCopyAction.ts

View workflow job for this annotation

GitHub Actions / NPM lint

Missing JSDoc @return type
*/
async function openFilePickerForAction(
action: MoveCopyAction,
Expand Down Expand Up @@ -294,8 +294,9 @@
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
Loading