mirror of
https://github.com/nextcloud/server.git
synced 2026-04-28 09:37:29 -04:00
refactor(files): migrate to files registry for reactive file actions
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
parent
643a815557
commit
38644873f2
8 changed files with 197 additions and 30 deletions
|
|
@ -115,6 +115,7 @@ import FileEntryActions from './FileEntry/FileEntryActions.vue'
|
|||
import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
|
||||
import FileEntryName from './FileEntry/FileEntryName.vue'
|
||||
import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
|
||||
import { useFileActions } from '../composables/useFileActions.ts'
|
||||
import { useFileListWidth } from '../composables/useFileListWidth.ts'
|
||||
import { useRouteParameters } from '../composables/useRouteParameters.ts'
|
||||
import { useActionsMenuStore } from '../store/actionsmenu.ts'
|
||||
|
|
@ -170,7 +171,10 @@ export default defineComponent({
|
|||
activeView,
|
||||
} = useActiveStore()
|
||||
|
||||
const actions = useFileActions()
|
||||
|
||||
return {
|
||||
actions,
|
||||
actionsMenuStore,
|
||||
activeFolder,
|
||||
activeNode,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ import FileEntryActions from './FileEntry/FileEntryActions.vue'
|
|||
import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
|
||||
import FileEntryName from './FileEntry/FileEntryName.vue'
|
||||
import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
|
||||
import { useFileActions } from '../composables/useFileActions.ts'
|
||||
import { useFileListWidth } from '../composables/useFileListWidth.ts'
|
||||
import { useRouteParameters } from '../composables/useRouteParameters.ts'
|
||||
import { useActionsMenuStore } from '../store/actionsmenu.ts'
|
||||
|
|
@ -122,7 +123,10 @@ export default defineComponent({
|
|||
activeView,
|
||||
} = useActiveStore()
|
||||
|
||||
const actions = useFileActions()
|
||||
|
||||
return {
|
||||
actions,
|
||||
actionsMenuStore,
|
||||
activeFolder,
|
||||
activeNode,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type { PropType } from 'vue'
|
|||
import type { FileSource } from '../types.ts'
|
||||
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { FileType, Folder, getFileActions, File as NcFile, Node, NodeStatus, Permission } from '@nextcloud/files'
|
||||
import { FileType, Folder, File as NcFile, Node, NodeStatus, Permission } from '@nextcloud/files'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { isPublicShare } from '@nextcloud/sharing/public'
|
||||
|
|
@ -24,8 +24,6 @@ import { isDownloadable } from '../utils/permissions.ts'
|
|||
|
||||
Vue.directive('onClickOutside', vOnClickOutside)
|
||||
|
||||
const actions = getFileActions()
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
source: {
|
||||
|
|
@ -233,7 +231,7 @@ export default defineComponent({
|
|||
return []
|
||||
}
|
||||
|
||||
return actions
|
||||
return this.actions
|
||||
.filter((action: IFileAction) => {
|
||||
if (!action.enabled) {
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -6,20 +6,22 @@
|
|||
<script setup lang="ts">
|
||||
import type { IHotkeyConfig } from '@nextcloud/files'
|
||||
|
||||
import { getFileActions } from '@nextcloud/files'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import NcAppSettingsShortcutsSection from '@nextcloud/vue/components/NcAppSettingsShortcutsSection'
|
||||
import NcHotkey from '@nextcloud/vue/components/NcHotkey'
|
||||
import NcHotkeyList from '@nextcloud/vue/components/NcHotkeyList'
|
||||
import { useFileActions } from '../../composables/useFileActions.ts'
|
||||
|
||||
const actionHotkeys = getFileActions()
|
||||
const actions = useFileActions()
|
||||
const actionHotkeys = computed(() => actions.value
|
||||
.filter((action) => !!action.hotkey)
|
||||
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
||||
.map((action) => ({
|
||||
id: action.id,
|
||||
label: action.hotkey!.description,
|
||||
hotkey: hotkeyToString(action.hotkey!),
|
||||
}))
|
||||
})))
|
||||
|
||||
/**
|
||||
* Convert a hotkey configuration to a hotkey string.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
:disabled="!!loading || areSomeNodesLoading"
|
||||
:force-name="true"
|
||||
:inline="enabledInlineActions.length"
|
||||
:menu-name="enabledInlineActions.length <= 1 ? t('files', 'Actions') : null"
|
||||
:menu-name="enabledInlineActions.length <= 1 ? t('files', 'Actions') : undefined"
|
||||
@close="openedSubmenu = null">
|
||||
<!-- Default actions list-->
|
||||
<NcActionButton
|
||||
|
|
@ -75,7 +75,7 @@ import type { PropType } from 'vue'
|
|||
import type { FileSource } from '../types.ts'
|
||||
|
||||
import { showError, showSuccess } from '@nextcloud/dialogs'
|
||||
import { DefaultType, getFileActions, NodeStatus } from '@nextcloud/files'
|
||||
import { DefaultType, NodeStatus } from '@nextcloud/files'
|
||||
import { translate } from '@nextcloud/l10n'
|
||||
import { computed, defineComponent } from 'vue'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
|
|
@ -84,6 +84,7 @@ import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
|
|||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
|
||||
import { useFileActions } from '../composables/useFileActions.ts'
|
||||
import { useFileListWidth } from '../composables/useFileListWidth.ts'
|
||||
import logger from '../logger.ts'
|
||||
import actionsMixins from '../mixins/actionsMixin.ts'
|
||||
|
|
@ -92,9 +93,6 @@ import { useActiveStore } from '../store/active.ts'
|
|||
import { useFilesStore } from '../store/files.ts'
|
||||
import { useSelectionStore } from '../store/selection.ts'
|
||||
|
||||
// The registered actions list
|
||||
const actions = getFileActions()
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FilesListTableHeaderActions',
|
||||
|
||||
|
|
@ -128,7 +126,7 @@ export default defineComponent({
|
|||
const selectionStore = useSelectionStore()
|
||||
const { isMedium, isNarrow } = useFileListWidth()
|
||||
|
||||
const boundariesElement = document.getElementById('app-content-vue')
|
||||
const boundariesElement = document.getElementById('app-content-vue') as HTMLElement
|
||||
|
||||
const inlineActions = computed(() => {
|
||||
if (isNarrow.value) {
|
||||
|
|
@ -140,7 +138,10 @@ export default defineComponent({
|
|||
return 3
|
||||
})
|
||||
|
||||
const actions = useFileActions()
|
||||
|
||||
return {
|
||||
actions,
|
||||
actionsMenuStore,
|
||||
activeFolder,
|
||||
filesStore,
|
||||
|
|
@ -159,7 +160,7 @@ export default defineComponent({
|
|||
|
||||
computed: {
|
||||
enabledFileActions(): IFileAction[] {
|
||||
return actions
|
||||
return this.actions
|
||||
// We don't handle renderInline actions in this component
|
||||
.filter((action) => !action.renderInline)
|
||||
// We don't handle actions that are not visible
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ import type { ComponentPublicInstance, PropType } from 'vue'
|
|||
import type { UserConfig } from '../types.ts'
|
||||
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { FileType, Folder, getFileActions, getSidebar, Permission, View } from '@nextcloud/files'
|
||||
import { FileType, Folder, getSidebar, Permission, View } from '@nextcloud/files'
|
||||
import { n, t } from '@nextcloud/l10n'
|
||||
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
|
||||
import { computed, defineComponent } from 'vue'
|
||||
|
|
@ -363,21 +363,13 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
if (node.type === FileType.File) {
|
||||
const defaultAction = getFileActions()
|
||||
// Get only default actions (visible and hidden)
|
||||
.filter((action) => !!action?.default)
|
||||
// Find actions that are either always enabled or enabled for the current node
|
||||
.filter((action) => (!action.enabled || action.enabled({
|
||||
nodes: [node],
|
||||
view: this.currentView,
|
||||
folder: this.currentFolder,
|
||||
contents: this.nodes,
|
||||
})))
|
||||
.filter((action) => action.id !== 'download')
|
||||
// Sort enabled default actions by order
|
||||
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
||||
// Get the first one
|
||||
.at(0)
|
||||
const actions = useEnabledFileActions({
|
||||
nodes: [node],
|
||||
view: this.currentView,
|
||||
folder: this.currentFolder,
|
||||
contents: this.nodes,
|
||||
})
|
||||
const defaultAction = actions.value.find((action) => action.id !== 'download' && !!action.default)
|
||||
|
||||
// Some file types do not have a default action (e.g. they can only be downloaded)
|
||||
// So if there is an enabled default action, so execute it
|
||||
|
|
|
|||
124
apps/files/src/composables/useFileActions.spec.ts
Normal file
124
apps/files/src/composables/useFileActions.spec.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IFileAction, INode, registerFileAction } from '@nextcloud/files'
|
||||
import type * as composable from './useFileActions.ts'
|
||||
|
||||
import { Folder, View } from '@nextcloud/files'
|
||||
import { defaultRemoteURL, defaultRootPath } from '@nextcloud/files/dav'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
interface Context {
|
||||
useFileActions: typeof composable.useFileActions
|
||||
useEnabledFileActions: typeof composable.useEnabledFileActions
|
||||
registerFileAction: typeof registerFileAction
|
||||
}
|
||||
|
||||
describe('useFileActions', () => {
|
||||
beforeEach(async (context: Context) => {
|
||||
delete globalThis._nc_files_scope
|
||||
// reset modules to reset internal variables (the headers ref) of the composable and the library (the scoped globals)
|
||||
vi.resetModules()
|
||||
context.useFileActions = (await import('./useFileActions.ts')).useFileActions
|
||||
context.useEnabledFileActions = (await import('./useFileActions.ts')).useEnabledFileActions
|
||||
context.registerFileAction = (await import('@nextcloud/files')).registerFileAction
|
||||
})
|
||||
|
||||
it<Context>('gets the actions', ({ useFileActions, registerFileAction }) => {
|
||||
const action: IFileAction = { id: '1', order: 5, displayName: () => 'Action', iconSvgInline: vi.fn(), exec: vi.fn() }
|
||||
registerFileAction(action)
|
||||
|
||||
const actions = useFileActions()
|
||||
expect(actions.value).toEqual([action])
|
||||
})
|
||||
|
||||
it<Context>('composable is reactive', async ({ useFileActions, registerFileAction }) => {
|
||||
const action: IFileAction = { id: '1', order: 5, displayName: () => 'Action', iconSvgInline: vi.fn(), exec: vi.fn() }
|
||||
registerFileAction(action)
|
||||
await nextTick()
|
||||
|
||||
const actions = useFileActions()
|
||||
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1'])
|
||||
// now add a new action
|
||||
const action2: IFileAction = { id: '2', order: 9, displayName: () => 'Action', iconSvgInline: vi.fn(), exec: vi.fn() }
|
||||
registerFileAction(action2)
|
||||
|
||||
// reactive update, lower order first
|
||||
await nextTick()
|
||||
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1', '2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useEnabledFileActions', () => {
|
||||
beforeEach(async (context: Context) => {
|
||||
delete globalThis._nc_files_scope
|
||||
// reset modules to reset internal variables (the headers ref) of the composable and the library (the scoped globals)
|
||||
vi.resetModules()
|
||||
context.useFileActions = (await import('./useFileActions.ts')).useFileActions
|
||||
context.useEnabledFileActions = (await import('./useFileActions.ts')).useEnabledFileActions
|
||||
context.registerFileAction = (await import('@nextcloud/files')).registerFileAction
|
||||
})
|
||||
|
||||
it<Context>('gets the actions', ({ useEnabledFileActions, registerFileAction }) => {
|
||||
registerFileAction({ id: '1', order: 0, displayName: () => 'Action 1', iconSvgInline: vi.fn(), exec: vi.fn() })
|
||||
registerFileAction({ id: '2', order: 5, displayName: () => 'Action 2', enabled: () => false, iconSvgInline: vi.fn(), exec: vi.fn() })
|
||||
registerFileAction({ id: '3', order: 9, displayName: () => 'Action 3', enabled: () => true, iconSvgInline: vi.fn(), exec: vi.fn() })
|
||||
|
||||
const folder = new Folder({ owner: 'owner', root: defaultRootPath, source: defaultRemoteURL + defaultRootPath })
|
||||
const view = new View({ id: 'view', getContents: vi.fn(), icon: '<svg></svg>', name: 'View' })
|
||||
const contents = []
|
||||
const actions = useEnabledFileActions({ folder, contents, view })
|
||||
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1', '3'])
|
||||
})
|
||||
|
||||
it<Context>('composable is reactive', async ({ useEnabledFileActions, registerFileAction }) => {
|
||||
registerFileAction({ id: '1', order: 0, displayName: () => 'Action 1', iconSvgInline: vi.fn(), exec: vi.fn() })
|
||||
registerFileAction({ id: '2', order: 5, displayName: () => 'Action 2', enabled: () => false, iconSvgInline: vi.fn(), exec: vi.fn() })
|
||||
|
||||
const folder = new Folder({ owner: 'owner', root: defaultRootPath, source: defaultRemoteURL + defaultRootPath })
|
||||
const view = new View({ id: 'view', getContents: vi.fn(), icon: '<svg></svg>', name: 'View' })
|
||||
const contents = []
|
||||
const actions = useEnabledFileActions({ folder, contents, view })
|
||||
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1'])
|
||||
|
||||
registerFileAction({ id: '3', order: 9, displayName: () => 'Action 3', enabled: () => true, iconSvgInline: vi.fn(), exec: vi.fn() })
|
||||
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1', '3'])
|
||||
})
|
||||
|
||||
it<Context>('composable is reactive to context changes', async ({ useEnabledFileActions, registerFileAction }) => {
|
||||
// only enabled if view id === 'enabled-view'
|
||||
registerFileAction({ id: '1', order: 0, displayName: () => 'Action 1', enabled: ({ view }) => view.id === 'enabled-view', iconSvgInline: vi.fn(), exec: vi.fn() })
|
||||
// only enabled if contents has items
|
||||
registerFileAction({ id: '2', order: 5, displayName: () => 'Action 2', enabled: ({ contents }) => contents.length > 0, iconSvgInline: vi.fn(), exec: vi.fn() })
|
||||
// only enabled if folder owner is 'owner2'
|
||||
registerFileAction({ id: '3', order: 9, displayName: () => 'Action 3', enabled: ({ folder }) => folder.owner === 'owner2', iconSvgInline: vi.fn(), exec: vi.fn() })
|
||||
|
||||
const context = ref({
|
||||
folder: new Folder({ owner: 'owner', root: defaultRootPath, source: defaultRemoteURL + defaultRootPath }),
|
||||
view: new View({ id: 'disabled-view', getContents: vi.fn(), icon: '<svg></svg>', name: 'View' }),
|
||||
contents: ref<INode[]>([(new Folder({ owner: 'owner', root: defaultRootPath, source: defaultRemoteURL + defaultRootPath }))]),
|
||||
})
|
||||
const actions = useEnabledFileActions(context)
|
||||
|
||||
// we have contents but wrong folder and view so only 2 is enabled
|
||||
expect(actions.value.map(({ id }) => id)).toStrictEqual(['2'])
|
||||
|
||||
// no contents so nothing is enabled
|
||||
context.value.contents = []
|
||||
await nextTick()
|
||||
expect(actions.value.map(({ id }) => id)).toStrictEqual([])
|
||||
|
||||
// correct owner for action 3
|
||||
context.value.folder = new Folder({ owner: 'owner2', root: defaultRootPath, source: defaultRemoteURL + defaultRootPath })
|
||||
await nextTick()
|
||||
expect(actions.value.map(({ id }) => id)).toStrictEqual(['3'])
|
||||
|
||||
// correct view for action 1
|
||||
context.value.view = new View({ id: 'enabled-view', getContents: vi.fn(), icon: '<svg></svg>', name: 'View' })
|
||||
await nextTick()
|
||||
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1', '3'])
|
||||
})
|
||||
})
|
||||
42
apps/files/src/composables/useFileActions.ts
Normal file
42
apps/files/src/composables/useFileActions.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { ActionContext, IFileAction } from '@nextcloud/files'
|
||||
import type { MaybeRefOrGetter } from '@vueuse/core'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { getFileActions, getFilesRegistry } from '@nextcloud/files'
|
||||
import { toValue } from '@vueuse/core'
|
||||
import { computed, readonly, ref } from 'vue'
|
||||
|
||||
const actions = ref<IFileAction[] | undefined>()
|
||||
|
||||
/**
|
||||
* Get the registered and sorted file actions.
|
||||
*/
|
||||
export function useFileActions() {
|
||||
if (!actions.value) {
|
||||
// if not initialized by other component yet, initialize and subscribe to registry changes
|
||||
actions.value = getFileActions()
|
||||
getFilesRegistry().addEventListener('register:action', () => {
|
||||
actions.value = getFileActions()
|
||||
})
|
||||
}
|
||||
|
||||
return readonly(actions as Ref<IFileAction[]>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the enabled file actions for the given context.
|
||||
*
|
||||
* @param context - The context to check the enabled state of the actions against
|
||||
*/
|
||||
export function useEnabledFileActions(context: MaybeRefOrGetter<ActionContext>) {
|
||||
const actions = useFileActions()
|
||||
return computed(() => actions.value
|
||||
.filter((action) => action.enabled === undefined
|
||||
|| action.enabled(toValue(context)!))
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)))
|
||||
}
|
||||
Loading…
Reference in a new issue