mirror of
https://github.com/nextcloud/server.git
synced 2026-04-22 14:50:17 -04:00
refactor(files): migrate file list actions to new files registry
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
parent
c73b85aecb
commit
643a815557
5 changed files with 237 additions and 65 deletions
|
|
@ -87,6 +87,7 @@ import FilesListTableFooter from './FilesListTableFooter.vue'
|
|||
import FilesListTableHeader from './FilesListTableHeader.vue'
|
||||
import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue'
|
||||
import VirtualList from './VirtualList.vue'
|
||||
import { useEnabledFileActions } from '../composables/useFileActions.ts'
|
||||
import { useFileListHeaders } from '../composables/useFileListHeaders.ts'
|
||||
import { useFileListWidth } from '../composables/useFileListWidth.ts'
|
||||
import { useRouteParameters } from '../composables/useRouteParameters.ts'
|
||||
|
|
|
|||
133
apps/files/src/composables/useFileListActions.spec.ts
Normal file
133
apps/files/src/composables/useFileListActions.spec.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IFileListAction, INode, registerFileListAction } from '@nextcloud/files'
|
||||
import type * as composable from './useFileListActions.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, shallowRef } from 'vue'
|
||||
|
||||
interface Context {
|
||||
useFileListActions: typeof composable.useFileListActions
|
||||
useEnabledFileListActions: typeof composable.useEnabledFileListActions
|
||||
registerFileListAction: typeof registerFileListAction
|
||||
}
|
||||
|
||||
describe('useFileListActions', () => {
|
||||
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.useFileListActions = (await import('./useFileListActions.ts')).useFileListActions
|
||||
context.useEnabledFileListActions = (await import('./useFileListActions.ts')).useEnabledFileListActions
|
||||
context.registerFileListAction = (await import('@nextcloud/files')).registerFileListAction
|
||||
})
|
||||
|
||||
it<Context>('gets the actions', ({ useFileListActions, registerFileListAction }) => {
|
||||
const action: IFileListAction = { id: '1', order: 5, displayName: () => 'Action', exec: vi.fn() }
|
||||
registerFileListAction(action)
|
||||
|
||||
const actions = useFileListActions()
|
||||
expect(actions.value).toEqual([action])
|
||||
})
|
||||
|
||||
it<Context>('actions are sorted', ({ useFileListActions, registerFileListAction }) => {
|
||||
const action: IFileListAction = { id: '1', order: 5, displayName: () => 'Action 1', exec: vi.fn() }
|
||||
const action2: IFileListAction = { id: '2', order: 0, displayName: () => 'Action 2', exec: vi.fn() }
|
||||
registerFileListAction(action)
|
||||
registerFileListAction(action2)
|
||||
|
||||
const actions = useFileListActions()
|
||||
// lower order first
|
||||
expect(actions.value.map(({ id }) => id)).toStrictEqual(['2', '1'])
|
||||
})
|
||||
|
||||
it<Context>('composable is reactive', async ({ useFileListActions, registerFileListAction }) => {
|
||||
const action: IFileListAction = { id: '1', order: 5, displayName: () => 'Action', exec: vi.fn() }
|
||||
registerFileListAction(action)
|
||||
await nextTick()
|
||||
|
||||
const actions = useFileListActions()
|
||||
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1'])
|
||||
// now add a new action
|
||||
const action2: IFileListAction = { id: '2', order: 0, displayName: () => 'Action', exec: vi.fn() }
|
||||
registerFileListAction(action2)
|
||||
|
||||
// reactive update, lower order first
|
||||
await nextTick()
|
||||
expect(actions.value.map(({ id }) => id)).toStrictEqual(['2', '1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useEnabledFileListActions', () => {
|
||||
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.useFileListActions = (await import('./useFileListActions.ts')).useFileListActions
|
||||
context.useEnabledFileListActions = (await import('./useFileListActions.ts')).useEnabledFileListActions
|
||||
context.registerFileListAction = (await import('@nextcloud/files')).registerFileListAction
|
||||
})
|
||||
|
||||
it<Context>('gets the actions sorted', ({ useEnabledFileListActions, registerFileListAction }) => {
|
||||
registerFileListAction({ id: '1', order: 0, displayName: () => 'Action 1', exec: vi.fn() })
|
||||
registerFileListAction({ id: '2', order: 5, displayName: () => 'Action 2', enabled: () => false, exec: vi.fn() })
|
||||
registerFileListAction({ id: '3', order: 9, displayName: () => 'Action 3', enabled: () => true, 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 = useEnabledFileListActions(folder, contents, view)
|
||||
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1', '3'])
|
||||
})
|
||||
|
||||
it<Context>('composable is reactive', async ({ useEnabledFileListActions, registerFileListAction }) => {
|
||||
registerFileListAction({ id: '1', order: 0, displayName: () => 'Action 1', exec: vi.fn() })
|
||||
registerFileListAction({ id: '2', order: 5, displayName: () => 'Action 2', enabled: () => false, 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 = useEnabledFileListActions(folder, contents, view)
|
||||
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1'])
|
||||
|
||||
registerFileListAction({ id: '3', order: 9, displayName: () => 'Action 3', enabled: () => true, exec: vi.fn() })
|
||||
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1', '3'])
|
||||
})
|
||||
|
||||
it<Context>('composable is reactive to context changes', async ({ useEnabledFileListActions, registerFileListAction }) => {
|
||||
// only enabled if view id === 'enabled-view'
|
||||
registerFileListAction({ id: '1', order: 0, displayName: () => 'Action 1', enabled: ({ view }) => view.id === 'enabled-view', exec: vi.fn() })
|
||||
// only enabled if contents has items
|
||||
registerFileListAction({ id: '2', order: 5, displayName: () => 'Action 2', enabled: ({ contents }) => contents.length > 0, exec: vi.fn() })
|
||||
// only enabled if folder owner is 'owner2'
|
||||
registerFileListAction({ id: '3', order: 9, displayName: () => 'Action 3', enabled: ({ folder }) => folder.owner === 'owner2', exec: vi.fn() })
|
||||
|
||||
const folder = shallowRef(new Folder({ owner: 'owner', root: defaultRootPath, source: defaultRemoteURL + defaultRootPath }))
|
||||
const view = shallowRef(new View({ id: 'disabled-view', getContents: vi.fn(), icon: '<svg></svg>', name: 'View' }))
|
||||
const contents = ref<INode[]>([folder.value])
|
||||
const actions = useEnabledFileListActions(folder, contents, view)
|
||||
|
||||
// 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
|
||||
contents.value = []
|
||||
await nextTick()
|
||||
expect(actions.value.map(({ id }) => id)).toStrictEqual([])
|
||||
|
||||
// correct owner for action 3
|
||||
folder.value = new Folder({ owner: 'owner2', root: defaultRootPath, source: defaultRemoteURL + defaultRootPath })
|
||||
await nextTick()
|
||||
expect(actions.value.map(({ id }) => id)).toStrictEqual(['3'])
|
||||
|
||||
// correct view for action 1
|
||||
view.value = new View({ id: 'enabled-view', getContents: vi.fn(), icon: '<svg></svg>', name: 'View' })
|
||||
await nextTick()
|
||||
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1', '3'])
|
||||
})
|
||||
})
|
||||
53
apps/files/src/composables/useFileListActions.ts
Normal file
53
apps/files/src/composables/useFileListActions.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IFileListAction, IFolder, INode, IView } from '@nextcloud/files'
|
||||
import type { MaybeRefOrGetter } from '@vueuse/core'
|
||||
import type { ComputedRef } from 'vue'
|
||||
|
||||
import { getFileListActions, getFilesRegistry } from '@nextcloud/files'
|
||||
import { toValue } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const actions = ref<IFileListAction[]>()
|
||||
const sorted = computed(() => [...(actions.value ?? [])].sort((a, b) => a.order - b.order))
|
||||
|
||||
/**
|
||||
* Get the registered and sorted file list actions.
|
||||
*/
|
||||
export function useFileListActions(): ComputedRef<IFileListAction[]> {
|
||||
if (!actions.value) {
|
||||
// if not initialized by other component yet, initialize and subscribe to registry changes
|
||||
actions.value = getFileListActions()
|
||||
getFilesRegistry().addEventListener('register:listAction', () => {
|
||||
actions.value = getFileListActions()
|
||||
})
|
||||
}
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the enabled file list actions for the given folder, contents and view.
|
||||
*
|
||||
* @param folder - The current folder
|
||||
* @param contents - The contents of the current folder
|
||||
* @param view - The current view
|
||||
*/
|
||||
export function useEnabledFileListActions(
|
||||
folder: MaybeRefOrGetter<IFolder | undefined>,
|
||||
contents: MaybeRefOrGetter<INode[]>,
|
||||
view: MaybeRefOrGetter<IView | undefined>,
|
||||
) {
|
||||
const actions = useFileListActions()
|
||||
return computed(() => {
|
||||
if (toValue(folder) === undefined || toValue(view) === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
return actions.value.filter((action) => action.enabled === undefined
|
||||
|| action.enabled({ folder: toValue(folder)!, contents: toValue(contents), view: toValue(view)! }))
|
||||
})
|
||||
}
|
||||
|
|
@ -1,15 +1,30 @@
|
|||
/**
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IFileAction, IFolder, INode, IView } from '@nextcloud/files'
|
||||
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import { subscribe } from '@nextcloud/event-bus'
|
||||
import { getNavigation } from '@nextcloud/files'
|
||||
import { Folder, getNavigation, Permission } from '@nextcloud/files'
|
||||
import { getRemoteURL, getRootPath } from '@nextcloud/files/dav'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, shallowRef, watch } from 'vue'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { useRouteParameters } from '../composables/useRouteParameters.ts'
|
||||
import logger from '../logger.ts'
|
||||
import { useFilesStore } from './files.ts'
|
||||
|
||||
// Temporary fake folder to use until we have the first valid folder
|
||||
// fetched and cached. This allow us to mount the FilesListVirtual
|
||||
// at all time and avoid unmount/mount and undesired rendering issues.
|
||||
const dummyFolder = new Folder({
|
||||
id: 0,
|
||||
source: getRemoteURL() + getRootPath(),
|
||||
root: getRootPath(),
|
||||
owner: getCurrentUser()?.uid || null,
|
||||
permissions: Permission.NONE,
|
||||
})
|
||||
|
||||
export const useActiveStore = defineStore('active', () => {
|
||||
/**
|
||||
|
|
@ -17,11 +32,6 @@ export const useActiveStore = defineStore('active', () => {
|
|||
*/
|
||||
const activeAction = shallowRef<IFileAction>()
|
||||
|
||||
/**
|
||||
* The currently active folder
|
||||
*/
|
||||
const activeFolder = ref<IFolder>()
|
||||
|
||||
/**
|
||||
* The current active node within the folder
|
||||
*/
|
||||
|
|
@ -32,6 +42,20 @@ export const useActiveStore = defineStore('active', () => {
|
|||
*/
|
||||
const activeView = shallowRef<IView>()
|
||||
|
||||
const filesStore = useFilesStore()
|
||||
const { directory } = useRouteParameters()
|
||||
/**
|
||||
* The currently active folder
|
||||
*/
|
||||
const activeFolder = computed<IFolder>(() => {
|
||||
if (!activeView.value?.id) {
|
||||
return dummyFolder
|
||||
}
|
||||
|
||||
return filesStore.getDirectoryByPath(activeView.value.id, directory.value)
|
||||
?? dummyFolder
|
||||
})
|
||||
|
||||
// Set the active node on the router params
|
||||
watch(activeNode, () => {
|
||||
if (typeof activeNode.value?.fileid !== 'number' || activeNode.value.fileid === activeFolder.value?.fileid) {
|
||||
|
|
|
|||
|
|
@ -160,11 +160,9 @@ import type { ComponentPublicInstance } from 'vue'
|
|||
import type { Route } from 'vue-router'
|
||||
import type { UserConfig } from '../types.ts'
|
||||
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import { showError, showSuccess, showWarning } from '@nextcloud/dialogs'
|
||||
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
|
||||
import { Folder, getFileListActions, Permission, sortNodes } from '@nextcloud/files'
|
||||
import { getRemoteURL, getRootPath } from '@nextcloud/files/dav'
|
||||
import { Permission, sortNodes } from '@nextcloud/files'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { dirname, join } from '@nextcloud/paths'
|
||||
|
|
@ -189,6 +187,7 @@ import BreadCrumbs from '../components/BreadCrumbs.vue'
|
|||
import DragAndDropNotice from '../components/DragAndDropNotice.vue'
|
||||
import FileListFilters from '../components/FileListFilter/FileListFilters.vue'
|
||||
import FilesListVirtual from '../components/FilesListVirtual.vue'
|
||||
import { useEnabledFileListActions } from '../composables/useFileListActions.ts'
|
||||
import { useFileListWidth } from '../composables/useFileListWidth.ts'
|
||||
import { useRouteParameters } from '../composables/useRouteParameters.ts'
|
||||
import logger from '../logger.ts'
|
||||
|
|
@ -258,13 +257,27 @@ export default defineComponent({
|
|||
const forbiddenCharacters = loadState<string[]>('files', 'forbiddenCharacters', [])
|
||||
|
||||
const currentView = computed(() => activeStore.activeView)
|
||||
const currentFolder = computed(() => activeStore.activeFolder)
|
||||
const dirContents = computed<INode[]>(() => {
|
||||
const sources = (currentFolder.value as { _children?: string[] })?._children ?? []
|
||||
return sources.map(filesStore.getNode)
|
||||
.filter(Boolean) as INode[]
|
||||
})
|
||||
|
||||
const enabledFileListActions = useEnabledFileListActions(
|
||||
currentFolder,
|
||||
dirContents,
|
||||
currentView,
|
||||
)
|
||||
|
||||
return {
|
||||
currentFolder,
|
||||
currentView,
|
||||
dirContents,
|
||||
directory,
|
||||
enabledFileListActions,
|
||||
fileId,
|
||||
isNarrow,
|
||||
t,
|
||||
|
||||
sidebar,
|
||||
activeStore,
|
||||
|
|
@ -280,6 +293,7 @@ export default defineComponent({
|
|||
enableGridView,
|
||||
forbiddenCharacters,
|
||||
ShareType,
|
||||
t,
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -329,34 +343,6 @@ export default defineComponent({
|
|||
return `${this.currentFolder.displayname} - ${title}`
|
||||
},
|
||||
|
||||
/**
|
||||
* The current folder.
|
||||
*/
|
||||
currentFolder(): Folder {
|
||||
// Temporary fake folder to use until we have the first valid folder
|
||||
// fetched and cached. This allow us to mount the FilesListVirtual
|
||||
// at all time and avoid unmount/mount and undesired rendering issues.
|
||||
const dummyFolder = new Folder({
|
||||
id: 0,
|
||||
source: getRemoteURL() + getRootPath(),
|
||||
root: getRootPath(),
|
||||
owner: getCurrentUser()?.uid || null,
|
||||
permissions: Permission.NONE,
|
||||
})
|
||||
|
||||
if (!this.currentView?.id) {
|
||||
return dummyFolder
|
||||
}
|
||||
|
||||
return this.filesStore.getDirectoryByPath(this.currentView.id, this.directory) || dummyFolder
|
||||
},
|
||||
|
||||
dirContents(): Node[] {
|
||||
return (this.currentFolder?._children || [])
|
||||
.map(this.filesStore.getNode)
|
||||
.filter((node: Node) => !!node)
|
||||
},
|
||||
|
||||
/**
|
||||
* The current directory contents.
|
||||
*/
|
||||
|
|
@ -445,27 +431,6 @@ export default defineComponent({
|
|||
return !this.loading && this.isEmptyDir && this.currentView?.emptyView !== undefined
|
||||
},
|
||||
|
||||
enabledFileListActions() {
|
||||
if (!this.currentView || !this.currentFolder) {
|
||||
return []
|
||||
}
|
||||
|
||||
const actions = getFileListActions()
|
||||
const enabledActions = actions
|
||||
.filter((action) => {
|
||||
if (action.enabled === undefined) {
|
||||
return true
|
||||
}
|
||||
return action.enabled({
|
||||
view: this.currentView!,
|
||||
folder: this.currentFolder!,
|
||||
contents: this.dirContents,
|
||||
})
|
||||
})
|
||||
.toSorted((a, b) => a.order - b.order)
|
||||
return enabledActions
|
||||
},
|
||||
|
||||
/**
|
||||
* Using the filtered content if filters are active
|
||||
*/
|
||||
|
|
@ -495,10 +460,6 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
|
||||
currentFolder() {
|
||||
this.activeStore.activeFolder = this.currentFolder
|
||||
},
|
||||
|
||||
currentView(newView, oldView) {
|
||||
if (newView?.id === oldView?.id) {
|
||||
return
|
||||
|
|
|
|||
Loading…
Reference in a new issue