mirror of
https://github.com/nextcloud/server.git
synced 2026-05-28 04:32:30 -04:00
refactor(files): migrate from deprecated useNavigation to activeStore
Small preparation for upcoming Vue 3 migration of the files app. Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
parent
f42493bf1e
commit
47acb66b9c
17 changed files with 216 additions and 262 deletions
|
|
@ -1,9 +1,10 @@
|
|||
/**
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IFilePickerButton } from '@nextcloud/dialogs'
|
||||
import type { Folder, Node } from '@nextcloud/files'
|
||||
import type { IFolder, INode } from '@nextcloud/files'
|
||||
import type { FileStat, ResponseDataDetailed, WebDAVClientError } from 'webdav'
|
||||
import type { MoveCopyResult } from './moveOrCopyActionUtils.ts'
|
||||
|
||||
|
|
@ -14,7 +15,7 @@ import { FilePickerClosed, getFilePickerBuilder, showError, showInfo, TOAST_PERM
|
|||
import { emit } from '@nextcloud/event-bus'
|
||||
import { FileAction, FileType, getUniqueName, NodeStatus, Permission } from '@nextcloud/files'
|
||||
import { defaultRootPath, getClient, getDefaultPropfind, resultToNode } from '@nextcloud/files/dav'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { hasConflict, openConflictPicker } from '@nextcloud/upload'
|
||||
import { basename, join } from 'path'
|
||||
import Vue from 'vue'
|
||||
|
|
@ -28,7 +29,7 @@ import { canCopy, canMove, getQueue, MoveCopyAction } from './moveOrCopyActionUt
|
|||
* @param nodes The nodes to check against
|
||||
* @return The action that is possible for the given nodes
|
||||
*/
|
||||
function getActionForNodes(nodes: Node[]): MoveCopyAction {
|
||||
function getActionForNodes(nodes: INode[]): MoveCopyAction {
|
||||
if (canMove(nodes)) {
|
||||
if (canCopy(nodes)) {
|
||||
return MoveCopyAction.MOVE_OR_COPY
|
||||
|
|
@ -76,7 +77,7 @@ function createLoadingNotification(mode: MoveCopyAction, source: string, destina
|
|||
* @param overwrite Whether to overwrite the destination if it exists
|
||||
* @return A promise that resolves when the copy/move is done
|
||||
*/
|
||||
export async function handleCopyMoveNodeTo(node: Node, destination: Folder, method: MoveCopyAction.COPY | MoveCopyAction.MOVE, overwrite = false) {
|
||||
export async function handleCopyMoveNodeTo(node: INode, destination: IFolder, method: MoveCopyAction.COPY | MoveCopyAction.MOVE, overwrite = false) {
|
||||
if (!destination) {
|
||||
return
|
||||
}
|
||||
|
|
@ -217,13 +218,13 @@ export async function handleCopyMoveNodeTo(node: Node, destination: Folder, meth
|
|||
async function openFilePickerForAction(
|
||||
action: MoveCopyAction,
|
||||
dir = '/',
|
||||
nodes: Node[],
|
||||
nodes: INode[],
|
||||
): Promise<MoveCopyResult | false> {
|
||||
const { resolve, reject, promise } = Promise.withResolvers<MoveCopyResult | false>()
|
||||
const fileIDs = nodes.map((node) => node.fileid).filter(Boolean)
|
||||
const filePicker = getFilePickerBuilder(t('files', 'Choose destination'))
|
||||
.allowDirectories(true)
|
||||
.setFilter((n: Node) => {
|
||||
.setFilter((n: INode) => {
|
||||
// We don't want to show the current nodes in the file picker
|
||||
return !fileIDs.includes(n.fileid)
|
||||
})
|
||||
|
|
@ -234,7 +235,7 @@ async function openFilePickerForAction(
|
|||
.setMimeTypeFilter([])
|
||||
.setMultiSelect(false)
|
||||
.startAt(dir)
|
||||
.setButtonFactory((selection: Node[], path: string) => {
|
||||
.setButtonFactory((selection: INode[], path: string) => {
|
||||
const buttons: IFilePickerButton[] = []
|
||||
const target = basename(path)
|
||||
|
||||
|
|
@ -246,9 +247,9 @@ async function openFilePickerForAction(
|
|||
label: target ? t('files', 'Copy to {target}', { target }, { escape: false, sanitize: false }) : t('files', 'Copy'),
|
||||
variant: 'primary',
|
||||
icon: CopyIconSvg,
|
||||
async callback(destination: Node[]) {
|
||||
async callback(destination: INode[]) {
|
||||
resolve({
|
||||
destination: destination[0] as Folder,
|
||||
destination: destination[0] as IFolder,
|
||||
action: MoveCopyAction.COPY,
|
||||
} as MoveCopyResult)
|
||||
},
|
||||
|
|
@ -276,9 +277,9 @@ async function openFilePickerForAction(
|
|||
label: target ? t('files', 'Move to {target}', { target }, undefined, { escape: false, sanitize: false }) : t('files', 'Move'),
|
||||
variant: action === MoveCopyAction.MOVE ? 'primary' : 'secondary',
|
||||
icon: FolderMoveSvg,
|
||||
async callback(destination: Node[]) {
|
||||
async callback(destination: INode[]) {
|
||||
resolve({
|
||||
destination: destination[0] as Folder,
|
||||
destination: destination[0] as IFolder,
|
||||
action: MoveCopyAction.MOVE,
|
||||
} as MoveCopyResult)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
/**
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Folder, Node } from '@nextcloud/files'
|
||||
import type { IFolder, INode } from '@nextcloud/files'
|
||||
import type { ShareAttribute } from '../../../files_sharing/src/sharing.ts'
|
||||
|
||||
import { Permission } from '@nextcloud/files'
|
||||
|
|
@ -36,24 +36,26 @@ export enum MoveCopyAction {
|
|||
}
|
||||
|
||||
export type MoveCopyResult = {
|
||||
destination: Folder
|
||||
destination: IFolder
|
||||
action: MoveCopyAction.COPY | MoveCopyAction.MOVE
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given nodes can be moved
|
||||
*
|
||||
* @param nodes
|
||||
* @param nodes - The nodes to check
|
||||
*/
|
||||
export function canMove(nodes: Node[]) {
|
||||
export function canMove(nodes: INode[]) {
|
||||
const minPermission = nodes.reduce((min, node) => Math.min(min, node.permissions), Permission.ALL)
|
||||
return Boolean(minPermission & Permission.DELETE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given nodes can be downloaded
|
||||
*
|
||||
* @param nodes
|
||||
* @param nodes - The nodes to check
|
||||
*/
|
||||
export function canDownload(nodes: Node[]) {
|
||||
export function canDownload(nodes: INode[]) {
|
||||
return nodes.every((node) => {
|
||||
const shareAttributes = JSON.parse(node.attributes?.['share-attributes'] ?? '[]') as Array<ShareAttribute>
|
||||
return !shareAttributes.some((attribute) => attribute.scope === 'permissions' && attribute.value === false && attribute.key === 'download')
|
||||
|
|
@ -61,10 +63,11 @@ export function canDownload(nodes: Node[]) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Check if the given nodes can be copied
|
||||
*
|
||||
* @param nodes
|
||||
* @param nodes - The nodes to check
|
||||
*/
|
||||
export function canCopy(nodes: Node[]) {
|
||||
export function canCopy(nodes: INode[]) {
|
||||
// a shared file cannot be copied if the download is disabled
|
||||
if (!canDownload(nodes)) {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -50,9 +50,10 @@ import NcBreadcrumb from '@nextcloud/vue/components/NcBreadcrumb'
|
|||
import NcBreadcrumbs from '@nextcloud/vue/components/NcBreadcrumbs'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import { useFileListWidth } from '../composables/useFileListWidth.ts'
|
||||
import { useNavigation } from '../composables/useNavigation.ts'
|
||||
import { useViews } from '../composables/useViews.ts'
|
||||
import logger from '../logger.ts'
|
||||
import { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts'
|
||||
import { useActiveStore } from '../store/active.ts'
|
||||
import { useDragAndDropStore } from '../store/dragging.ts'
|
||||
import { useFilesStore } from '../store/files.ts'
|
||||
import { usePathsStore } from '../store/paths.ts'
|
||||
|
|
@ -76,22 +77,24 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
setup() {
|
||||
const draggingStore = useDragAndDropStore()
|
||||
const activeStore = useActiveStore()
|
||||
const filesStore = useFilesStore()
|
||||
const pathsStore = usePathsStore()
|
||||
const draggingStore = useDragAndDropStore()
|
||||
const selectionStore = useSelectionStore()
|
||||
const uploaderStore = useUploaderStore()
|
||||
|
||||
const fileListWidth = useFileListWidth()
|
||||
const { currentView, views } = useNavigation()
|
||||
const views = useViews()
|
||||
|
||||
return {
|
||||
activeStore,
|
||||
draggingStore,
|
||||
filesStore,
|
||||
pathsStore,
|
||||
selectionStore,
|
||||
uploaderStore,
|
||||
|
||||
currentView,
|
||||
fileListWidth,
|
||||
views,
|
||||
}
|
||||
|
|
@ -134,7 +137,7 @@ export default defineComponent({
|
|||
|
||||
// used to show the views icon for the first breadcrumb
|
||||
viewIcon(): string {
|
||||
return this.currentView?.icon ?? HomeSvg
|
||||
return this.activeStore.activeView?.icon ?? HomeSvg
|
||||
},
|
||||
|
||||
selectedFiles() {
|
||||
|
|
@ -152,12 +155,12 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
getFileSourceFromPath(path: string): FileSource | null {
|
||||
return (this.currentView && this.pathsStore.getPath(this.currentView.id, path)) ?? null
|
||||
return (this.activeStore.activeView && this.pathsStore.getPath(this.activeStore.activeView.id, path)) ?? null
|
||||
},
|
||||
|
||||
getDirDisplayName(path: string): string {
|
||||
if (path === '/') {
|
||||
return this.currentView?.name || t('files', 'Home')
|
||||
return this.activeStore.activeView?.name || t('files', 'Home')
|
||||
}
|
||||
|
||||
const source = this.getFileSourceFromPath(path)
|
||||
|
|
@ -169,7 +172,7 @@ export default defineComponent({
|
|||
if (dir === '/') {
|
||||
return {
|
||||
...this.$route,
|
||||
params: { view: this.currentView?.id },
|
||||
params: { view: this.activeStore.activeView?.id },
|
||||
query: {},
|
||||
}
|
||||
}
|
||||
|
|
@ -233,7 +236,8 @@ export default defineComponent({
|
|||
const fileTree = await dataTransferToFileTree(items)
|
||||
|
||||
// We might not have the target directory fetched yet
|
||||
const contents = await this.currentView?.getContents(path)
|
||||
const controller = new AbortController()
|
||||
const contents = await this.activeStore.activeView?.getContents(path, { signal: controller.signal })
|
||||
const folder = contents?.folder
|
||||
if (!folder) {
|
||||
showError(this.t('files', 'Target folder does not exist any more'))
|
||||
|
|
@ -275,14 +279,12 @@ export default defineComponent({
|
|||
} else if (index === 0) {
|
||||
return t('files', 'Go to the "{dir}" directory', section)
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
ariaForSection(section) {
|
||||
if (section?.to?.query?.dir === this.$route.query.dir) {
|
||||
return t('files', 'Reload current directory')
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
t,
|
||||
|
|
|
|||
|
|
@ -38,9 +38,9 @@ import { UploadStatus } from '@nextcloud/upload'
|
|||
import debounce from 'debounce'
|
||||
import { defineComponent } from 'vue'
|
||||
import TrayArrowDownIcon from 'vue-material-design-icons/TrayArrowDown.vue'
|
||||
import { useNavigation } from '../composables/useNavigation.ts'
|
||||
import logger from '../logger.ts'
|
||||
import { dataTransferToFileTree, onDropExternalFiles } from '../services/DropService.ts'
|
||||
import { useActiveStore } from '../store/active.ts'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'DragAndDropNotice',
|
||||
|
|
@ -57,10 +57,10 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
setup() {
|
||||
const { currentView } = useNavigation()
|
||||
const activeStore = useActiveStore()
|
||||
|
||||
return {
|
||||
currentView,
|
||||
activeStore,
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -177,7 +177,8 @@ export default defineComponent({
|
|||
const fileTree = await dataTransferToFileTree(items)
|
||||
|
||||
// We might not have the target directory fetched yet
|
||||
const contents = await this.currentView?.getContents(this.currentFolder.path)
|
||||
const controller = new AbortController()
|
||||
const contents = await this.activeStore.activeView?.getContents(this.currentFolder.path, { signal: controller.signal })
|
||||
const folder = contents?.folder
|
||||
if (!folder) {
|
||||
showError(this.t('files', 'Target folder does not exist any more'))
|
||||
|
|
@ -211,13 +212,7 @@ export default defineComponent({
|
|||
...this.$route.params,
|
||||
fileid: String(lastUpload.response!.headers['oc-fileid']),
|
||||
},
|
||||
|
||||
query: {
|
||||
...this.$route.query,
|
||||
},
|
||||
}
|
||||
// Remove open file from query
|
||||
delete location.query?.openfile
|
||||
this.$router.push(location)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -75,14 +75,15 @@ import type { Node } from '@nextcloud/files'
|
|||
import type { PropType } from 'vue'
|
||||
import type { FileSource } from '../types.ts'
|
||||
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
|
||||
import { defineComponent } from 'vue'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue'
|
||||
import { useNavigation } from '../composables/useNavigation.ts'
|
||||
import { useRouteParameters } from '../composables/useRouteParameters.ts'
|
||||
import logger from '../logger.ts'
|
||||
import filesSortingMixin from '../mixins/filesSorting.ts'
|
||||
import { useActiveStore } from '../store/active.ts'
|
||||
import { useFilesStore } from '../store/files.ts'
|
||||
import { useSelectionStore } from '../store/selection.ts'
|
||||
|
||||
|
|
@ -126,15 +127,17 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
setup() {
|
||||
const activeStore = useActiveStore()
|
||||
const filesStore = useFilesStore()
|
||||
const selectionStore = useSelectionStore()
|
||||
const { currentView } = useNavigation()
|
||||
const { directory } = useRouteParameters()
|
||||
|
||||
return {
|
||||
activeStore,
|
||||
filesStore,
|
||||
selectionStore,
|
||||
|
||||
currentView,
|
||||
directory,
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -144,12 +147,12 @@ export default defineComponent({
|
|||
if (this.filesListWidth < 512) {
|
||||
return []
|
||||
}
|
||||
return this.currentView?.columns || []
|
||||
return this.activeStore.activeView?.columns || []
|
||||
},
|
||||
|
||||
dir() {
|
||||
// Remove any trailing slash but leave root slash
|
||||
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
|
||||
return this.directory.replace(/^(.+)\/$/, '$1')
|
||||
},
|
||||
|
||||
selectAllBind() {
|
||||
|
|
@ -195,11 +198,10 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
methods: {
|
||||
ariaSortForMode(mode: string): 'ascending' | 'descending' | null {
|
||||
ariaSortForMode(mode: string): 'ascending' | 'descending' | undefined {
|
||||
if (this.sortingMode === mode) {
|
||||
return this.isAscSorting ? 'ascending' : 'descending'
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
classForColumn(column) {
|
||||
|
|
@ -207,7 +209,7 @@ export default defineComponent({
|
|||
'files-list__column': true,
|
||||
'files-list__column--sortable': !!column.sort,
|
||||
'files-list__row-column-custom': true,
|
||||
[`files-list__row-${this.currentView?.id}-${column.id}`]: true,
|
||||
[`files-list__row-${this.activeStore.activeView?.id}-${column.id}`]: true,
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ import { defineComponent } from 'vue'
|
|||
import { Fragment } from 'vue-frag'
|
||||
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import { useNavigation } from '../composables/useNavigation.js'
|
||||
import { useActiveStore } from '../store/active.js'
|
||||
import { useViewConfigStore } from '../store/viewConfig.js'
|
||||
|
||||
const maxLevel = 7 // Limit nesting to not exceed max call stack size
|
||||
|
|
@ -77,10 +77,10 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
setup() {
|
||||
const { currentView } = useNavigation()
|
||||
const activeStore = useActiveStore()
|
||||
const viewConfigStore = useViewConfigStore()
|
||||
return {
|
||||
currentView,
|
||||
activeStore,
|
||||
viewConfigStore,
|
||||
}
|
||||
},
|
||||
|
|
@ -89,7 +89,7 @@ export default defineComponent({
|
|||
currentViews(): View[] {
|
||||
if (this.level >= maxLevel) { // Filter for all remaining decendants beyond the max level
|
||||
return (Object.values(this.views).reduce((acc, views) => [...acc, ...views], []) as View[])
|
||||
.filter((view) => view.params?.dir.startsWith(this.parent.params?.dir))
|
||||
.filter((view) => this.parent.params && view.params?.dir.startsWith(this.parent.params.dir))
|
||||
}
|
||||
return this.filterVisible(this.views[this.parent.id] ?? [])
|
||||
},
|
||||
|
|
@ -106,7 +106,7 @@ export default defineComponent({
|
|||
|
||||
methods: {
|
||||
filterVisible(views: View[]) {
|
||||
return views.filter(({ id, hidden }) => id === this.currentView?.id || hidden !== true)
|
||||
return views.filter(({ id, hidden }) => id === this.activeStore.activeView?.id || hidden !== true)
|
||||
},
|
||||
|
||||
hasChildViews(view: View): boolean {
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ import NcActions from '@nextcloud/vue/components/NcActions'
|
|||
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import { onBeforeNavigation } from '../composables/useBeforeNavigation.ts'
|
||||
import { useNavigation } from '../composables/useNavigation.ts'
|
||||
import { useActiveStore } from '../store/active.ts'
|
||||
import { useSearchStore } from '../store/search.ts'
|
||||
import { VIEW_ID } from '../views/search.ts'
|
||||
|
||||
const { currentView } = useNavigation(true)
|
||||
const activeStore = useActiveStore()
|
||||
const searchStore = useSearchStore()
|
||||
|
||||
/**
|
||||
|
|
@ -49,7 +49,7 @@ onBeforeNavigation((to, from, next) => {
|
|||
* Are we currently on the search view.
|
||||
* Needed to disable the action menu (we cannot change the search mode there)
|
||||
*/
|
||||
const isSearchView = computed(() => currentView.value.id === VIEW_ID)
|
||||
const isSearchView = computed(() => activeStore.activeView?.id === VIEW_ID)
|
||||
|
||||
/**
|
||||
* Different searchbox label depending if filtering or searching
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Navigation, View } from '@nextcloud/files'
|
||||
|
||||
import * as nextcloudFiles from '@nextcloud/files'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { useNavigation } from './useNavigation.ts'
|
||||
|
||||
// Just a wrapper so we can test the composable
|
||||
const TestComponent = defineComponent({
|
||||
template: '<div></div>',
|
||||
setup() {
|
||||
const { currentView, views } = useNavigation()
|
||||
return {
|
||||
currentView,
|
||||
views,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
describe('Composables: useNavigation', () => {
|
||||
const spy = vi.spyOn(nextcloudFiles, 'getNavigation')
|
||||
let navigation: Navigation
|
||||
|
||||
describe('currentView', () => {
|
||||
beforeEach(() => {
|
||||
navigation = new nextcloudFiles.Navigation()
|
||||
spy.mockImplementation(() => navigation)
|
||||
})
|
||||
|
||||
it('should return null without active navigation', () => {
|
||||
const wrapper = mount(TestComponent)
|
||||
expect((wrapper.vm as unknown as { currentView: View | null }).currentView).toBe(null)
|
||||
})
|
||||
|
||||
it('should return already active navigation', async () => {
|
||||
const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 })
|
||||
navigation.register(view)
|
||||
navigation.setActive(view.id)
|
||||
// Now the navigation is already set it should take the active navigation
|
||||
const wrapper = mount(TestComponent)
|
||||
expect((wrapper.vm as unknown as { currentView: View | null }).currentView).toBe(view)
|
||||
})
|
||||
|
||||
it('should be reactive on updating active navigation', async () => {
|
||||
const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 })
|
||||
navigation.register(view)
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
// no active navigation
|
||||
expect((wrapper.vm as unknown as { currentView: View | null }).currentView).toBe(null)
|
||||
|
||||
navigation.setActive(view.id)
|
||||
// Now the navigation is set it should take the active navigation
|
||||
expect((wrapper.vm as unknown as { currentView: View | null }).currentView).toBe(view)
|
||||
})
|
||||
})
|
||||
|
||||
describe('views', () => {
|
||||
beforeEach(() => {
|
||||
navigation = new nextcloudFiles.Navigation()
|
||||
spy.mockImplementation(() => navigation)
|
||||
})
|
||||
|
||||
it('should return empty array without registered views', () => {
|
||||
const wrapper = mount(TestComponent)
|
||||
expect((wrapper.vm as unknown as { views: View[] }).views).toStrictEqual([])
|
||||
})
|
||||
|
||||
it('should return already registered views', () => {
|
||||
const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 })
|
||||
// register before mount
|
||||
navigation.register(view)
|
||||
// now mount and check that the view is listed
|
||||
const wrapper = mount(TestComponent)
|
||||
expect((wrapper.vm as unknown as { views: View[] }).views).toStrictEqual([view])
|
||||
})
|
||||
|
||||
it('should be reactive on registering new views', () => {
|
||||
const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 })
|
||||
const view2 = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-2', name: 'My View 2', order: 1 })
|
||||
|
||||
// register before mount
|
||||
navigation.register(view)
|
||||
// now mount and check that the view is listed
|
||||
const wrapper = mount(TestComponent)
|
||||
expect((wrapper.vm as unknown as { views: View[] }).views).toStrictEqual([view])
|
||||
|
||||
// now register view 2 and check it is reactivly added
|
||||
navigation.register(view2)
|
||||
expect((wrapper.vm as unknown as { views: View[] }).views).toStrictEqual([view, view2])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import type { View } from '@nextcloud/files'
|
||||
import type { ShallowRef } from 'vue'
|
||||
|
||||
import { subscribe } from '@nextcloud/event-bus'
|
||||
import { getNavigation } from '@nextcloud/files'
|
||||
import { onMounted, onUnmounted, shallowRef, triggerRef } from 'vue'
|
||||
|
||||
/**
|
||||
* Composable to get the currently active files view from the files navigation
|
||||
*
|
||||
* @param _loaded If set enforce a current view is loaded
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function useNavigation<T extends boolean>(_loaded?: T) {
|
||||
type MaybeView = T extends true ? View : (View | null)
|
||||
const navigation = getNavigation()
|
||||
const views: ShallowRef<View[]> = shallowRef(navigation.views)
|
||||
|
||||
/** @deprecated use activeStore.activeView instead */
|
||||
const currentView: ShallowRef<MaybeView> = shallowRef(navigation.active as MaybeView)
|
||||
|
||||
/**
|
||||
* Event listener to update the `currentView`
|
||||
*
|
||||
* @param event The update event
|
||||
*/
|
||||
function onUpdateActive(event: CustomEvent<View | null>) {
|
||||
currentView.value = event.detail as MaybeView
|
||||
}
|
||||
|
||||
/**
|
||||
* Event listener to update all registered views
|
||||
*/
|
||||
function onUpdateViews() {
|
||||
views.value = navigation.views
|
||||
triggerRef(views)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
navigation.addEventListener('update', onUpdateViews)
|
||||
navigation.addEventListener('updateActive', onUpdateActive)
|
||||
subscribe('files:navigation:updated', onUpdateViews)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
navigation.removeEventListener('update', onUpdateViews)
|
||||
navigation.removeEventListener('updateActive', onUpdateActive)
|
||||
})
|
||||
|
||||
return {
|
||||
currentView,
|
||||
views,
|
||||
}
|
||||
}
|
||||
63
apps/files/src/composables/useViews.spec.ts
Normal file
63
apps/files/src/composables/useViews.spec.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Navigation, View } from '@nextcloud/files'
|
||||
|
||||
import * as nextcloudFiles from '@nextcloud/files'
|
||||
import { enableAutoDestroy, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { useViews } from './useViews.ts'
|
||||
|
||||
// Just a wrapper so we can test the composable
|
||||
const TestComponent = defineComponent({
|
||||
template: '<div></div>',
|
||||
setup() {
|
||||
return {
|
||||
views: useViews(),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
enableAutoDestroy(afterEach)
|
||||
|
||||
describe('Composables: useViews', () => {
|
||||
const spy = vi.spyOn(nextcloudFiles, 'getNavigation')
|
||||
let navigation: Navigation
|
||||
|
||||
beforeEach(() => {
|
||||
navigation = new nextcloudFiles.Navigation()
|
||||
spy.mockImplementation(() => navigation)
|
||||
})
|
||||
|
||||
it('should return empty array without registered views', () => {
|
||||
const wrapper = mount(TestComponent)
|
||||
expect((wrapper.vm as unknown as { views: View[] }).views).toStrictEqual([])
|
||||
})
|
||||
|
||||
it('should return already registered views', () => {
|
||||
const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 })
|
||||
// register before mount
|
||||
navigation.register(view)
|
||||
// now mount and check that the view is listed
|
||||
const wrapper = mount(TestComponent)
|
||||
expect((wrapper.vm as unknown as { views: View[] }).views).toStrictEqual([view])
|
||||
})
|
||||
|
||||
it('should be reactive on registering new views', () => {
|
||||
const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 })
|
||||
const view2 = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-2', name: 'My View 2', order: 1 })
|
||||
|
||||
// register before mount
|
||||
navigation.register(view)
|
||||
// now mount and check that the view is listed
|
||||
const wrapper = mount(TestComponent)
|
||||
expect((wrapper.vm as unknown as { views: View[] }).views).toStrictEqual([view])
|
||||
|
||||
// now register view 2 and check it is reactively added
|
||||
navigation.register(view2)
|
||||
expect((wrapper.vm as unknown as { views: View[] }).views).toStrictEqual([view, view2])
|
||||
})
|
||||
})
|
||||
36
apps/files/src/composables/useViews.ts
Normal file
36
apps/files/src/composables/useViews.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { getNavigation } from '@nextcloud/files'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { onUnmounted, shallowRef, triggerRef } from 'vue'
|
||||
|
||||
/**
|
||||
* Composable to get the currently available views
|
||||
*/
|
||||
export const useViews = createSharedComposable(useInternalViews)
|
||||
|
||||
/**
|
||||
* Composable to get the currently available views
|
||||
*/
|
||||
export function useInternalViews() {
|
||||
const navigation = getNavigation()
|
||||
const views = shallowRef(navigation.views)
|
||||
|
||||
/**
|
||||
* Event listener to update all registered views
|
||||
*/
|
||||
function onUpdateViews() {
|
||||
views.value = navigation.views
|
||||
triggerRef(views)
|
||||
}
|
||||
|
||||
navigation.addEventListener('update', onUpdateViews)
|
||||
onUnmounted(() => {
|
||||
navigation.removeEventListener('update', onUpdateViews)
|
||||
})
|
||||
|
||||
return views
|
||||
}
|
||||
|
|
@ -1,18 +1,19 @@
|
|||
import { mapState } from 'pinia'
|
||||
/**
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { mapState } from 'pinia'
|
||||
import Vue from 'vue'
|
||||
import { useNavigation } from '../composables/useNavigation.ts'
|
||||
import { useActiveStore } from '../store/active.ts'
|
||||
import { useViewConfigStore } from '../store/viewConfig.ts'
|
||||
|
||||
export default Vue.extend({
|
||||
setup() {
|
||||
const { currentView } = useNavigation()
|
||||
const activeStore = useActiveStore()
|
||||
|
||||
return {
|
||||
currentView,
|
||||
activeStore,
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -23,8 +24,8 @@ export default Vue.extend({
|
|||
* Get the sorting mode for the current view
|
||||
*/
|
||||
sortingMode(): string {
|
||||
return this.getConfig(this.currentView.id)?.sorting_mode as string
|
||||
|| this.currentView?.defaultSortKey
|
||||
return this.getConfig(this.activeStore.activeView?.id)?.sorting_mode as string
|
||||
|| this.activeStore.activeView?.defaultSortKey
|
||||
|| 'basename'
|
||||
},
|
||||
|
||||
|
|
@ -32,7 +33,7 @@ export default Vue.extend({
|
|||
* Get the sorting direction for the current view
|
||||
*/
|
||||
isAscSorting(): boolean {
|
||||
const sortingDirection = this.getConfig(this.currentView.id)?.sorting_direction
|
||||
const sortingDirection = this.getConfig(this.activeStore.activeView?.id)?.sorting_direction
|
||||
return sortingDirection !== 'desc'
|
||||
},
|
||||
},
|
||||
|
|
@ -41,11 +42,11 @@ export default Vue.extend({
|
|||
toggleSortBy(key: string) {
|
||||
// If we're already sorting by this key, flip the direction
|
||||
if (this.sortingMode === key) {
|
||||
this.toggleSortingDirection(this.currentView.id)
|
||||
this.toggleSortingDirection(this.activeStore.activeView?.id)
|
||||
return
|
||||
}
|
||||
// else sort ASC by this new key
|
||||
this.setSortingBy(key, this.currentView.id)
|
||||
this.setSortingBy(key, this.activeStore.activeView?.id)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
/**
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Folder, Node } from '@nextcloud/files'
|
||||
import type { IFolder, INode } from '@nextcloud/files'
|
||||
import type { Upload } from '@nextcloud/upload'
|
||||
import type { RootDirectory } from './DropServiceUtils.ts'
|
||||
|
||||
|
|
@ -96,7 +96,7 @@ export async function dataTransferToFileTree(items: DataTransferItem[]): Promise
|
|||
* @param destination - The destination folder
|
||||
* @param contents - The contents of the destination folder
|
||||
*/
|
||||
export async function onDropExternalFiles(root: RootDirectory, destination: Folder, contents: Node[]): Promise<Upload[]> {
|
||||
export async function onDropExternalFiles(root: RootDirectory, destination: IFolder, contents: INode[]): Promise<Upload[]> {
|
||||
const uploader = getUploader()
|
||||
|
||||
// Check for conflicts on root elements
|
||||
|
|
@ -172,13 +172,14 @@ export async function onDropExternalFiles(root: RootDirectory, destination: Fold
|
|||
}
|
||||
|
||||
/**
|
||||
* Handle dropping internal files
|
||||
*
|
||||
* @param nodes
|
||||
* @param destination
|
||||
* @param contents
|
||||
* @param isCopy
|
||||
* @param nodes - The nodes being dropped
|
||||
* @param destination - The destination folder
|
||||
* @param contents - The contents of the destination folder
|
||||
* @param isCopy - Whether the operation is a copy
|
||||
*/
|
||||
export async function onDropInternalFiles(nodes: Node[], destination: Folder, contents: Node[], isCopy = false) {
|
||||
export async function onDropInternalFiles(nodes: INode[], destination: IFolder, contents: INode[], isCopy = false) {
|
||||
const queue = [] as Promise<void>[]
|
||||
|
||||
// Check for conflicts on root elements
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import type { Folder, Node } from '@nextcloud/files'
|
||||
/**
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IFolder, INode } from '@nextcloud/files'
|
||||
import type { FileStat, ResponseDataDetailed } from 'webdav'
|
||||
|
||||
import { showInfo, showWarning } from '@nextcloud/dialogs'
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import { getClient, getDefaultPropfind, resultToNode } from '@nextcloud/files/dav'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { openConflictPicker } from '@nextcloud/upload'
|
||||
import logger from '../logger.ts'
|
||||
|
||||
|
|
@ -131,8 +132,9 @@ function readDirectory(directory: FileSystemDirectoryEntry): Promise<FileSystemE
|
|||
}
|
||||
|
||||
/**
|
||||
* Create a directory if it does not exist
|
||||
*
|
||||
* @param absolutePath
|
||||
* @param absolutePath - the absolute path of the directory to create
|
||||
*/
|
||||
export async function createDirectoryIfNotExists(absolutePath: string) {
|
||||
const davClient = getClient()
|
||||
|
|
@ -146,20 +148,21 @@ export async function createDirectoryIfNotExists(absolutePath: string) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Resolve conflicts between existing files and incoming files
|
||||
*
|
||||
* @param files
|
||||
* @param destination
|
||||
* @param contents
|
||||
* @param files - incoming files
|
||||
* @param destination - destination folder
|
||||
* @param contents - existing contents of the destination folder
|
||||
*/
|
||||
export async function resolveConflict<T extends ((Directory | File) | Node)>(files: Array<T>, destination: Folder, contents: Node[]): Promise<T[]> {
|
||||
export async function resolveConflict<T extends ((Directory | File) | INode)>(files: Array<T>, destination: IFolder, contents: INode[]): Promise<T[]> {
|
||||
try {
|
||||
// List all conflicting files
|
||||
const conflicts = files.filter((file: File | Node) => {
|
||||
return contents.find((node: Node) => node.basename === (file instanceof File ? file.name : file.basename))
|
||||
}).filter(Boolean) as (File | Node)[]
|
||||
const conflicts = files.filter((file: File | INode) => {
|
||||
return contents.find((node: INode) => node.basename === (file instanceof File ? file.name : file.basename))
|
||||
}).filter(Boolean) as (File | INode)[]
|
||||
|
||||
// List of incoming files that are NOT in conflict
|
||||
const uploads = files.filter((file: File | Node) => {
|
||||
const uploads = files.filter((file: File | INode) => {
|
||||
return !conflicts.includes(file)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ import { getCurrentUser } from '@nextcloud/auth'
|
|||
import { getCapabilities } from '@nextcloud/capabilities'
|
||||
import { showError, showSuccess, showWarning } from '@nextcloud/dialogs'
|
||||
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
|
||||
import { Folder, getFileListActions, getSidebar, Permission, sortNodes } from '@nextcloud/files'
|
||||
import { Folder, getFileListActions, Permission, sortNodes } from '@nextcloud/files'
|
||||
import { getRemoteURL, getRootPath } from '@nextcloud/files/dav'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
|
|
@ -178,7 +178,7 @@ import { ShareType } from '@nextcloud/sharing'
|
|||
import { UploadPicker, UploadStatus } from '@nextcloud/upload'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import { normalize, relative } from 'path'
|
||||
import { defineComponent } from 'vue'
|
||||
import { computed, defineComponent } from 'vue'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import NcActions from '@nextcloud/vue/components/NcActions'
|
||||
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
|
||||
|
|
@ -196,7 +196,6 @@ import BreadCrumbs from '../components/BreadCrumbs.vue'
|
|||
import DragAndDropNotice from '../components/DragAndDropNotice.vue'
|
||||
import FilesListVirtual from '../components/FilesListVirtual.vue'
|
||||
import { useFileListWidth } from '../composables/useFileListWidth.ts'
|
||||
import { useNavigation } from '../composables/useNavigation.ts'
|
||||
import { useRouteParameters } from '../composables/useRouteParameters.ts'
|
||||
import logger from '../logger.ts'
|
||||
import filesSortingMixin from '../mixins/filesSorting.ts'
|
||||
|
|
@ -205,6 +204,7 @@ import { useFilesStore } from '../store/files.ts'
|
|||
import { useFiltersStore } from '../store/filters.ts'
|
||||
import { usePathsStore } from '../store/paths.ts'
|
||||
import { useSelectionStore } from '../store/selection.ts'
|
||||
import { useSidebarStore } from '../store/sidebar.ts'
|
||||
import { useUploaderStore } from '../store/uploader.ts'
|
||||
import { useUserConfigStore } from '../store/userconfig.ts'
|
||||
import { useViewConfigStore } from '../store/viewConfig.ts'
|
||||
|
|
@ -249,12 +249,7 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
setup() {
|
||||
const sidebar = getSidebar()
|
||||
|
||||
const { currentView } = useNavigation()
|
||||
const { directory, fileId } = useRouteParameters()
|
||||
const fileListWidth = useFileListWidth()
|
||||
|
||||
const sidebar = useSidebarStore()
|
||||
const activeStore = useActiveStore()
|
||||
const filesStore = useFilesStore()
|
||||
const filtersStore = useFiltersStore()
|
||||
|
|
@ -264,9 +259,14 @@ export default defineComponent({
|
|||
const userConfigStore = useUserConfigStore()
|
||||
const viewConfigStore = useViewConfigStore()
|
||||
|
||||
const fileListWidth = useFileListWidth()
|
||||
const { directory, fileId } = useRouteParameters()
|
||||
|
||||
const enableGridView = (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true)
|
||||
const forbiddenCharacters = loadState<string[]>('files', 'forbiddenCharacters', [])
|
||||
|
||||
const currentView = computed(() => activeStore.activeView)
|
||||
|
||||
return {
|
||||
currentView,
|
||||
directory,
|
||||
|
|
@ -274,6 +274,7 @@ export default defineComponent({
|
|||
fileListWidth,
|
||||
t,
|
||||
|
||||
sidebar,
|
||||
activeStore,
|
||||
filesStore,
|
||||
filtersStore,
|
||||
|
|
@ -284,7 +285,6 @@ export default defineComponent({
|
|||
viewConfigStore,
|
||||
|
||||
// non reactive data
|
||||
sidebar,
|
||||
enableGridView,
|
||||
forbiddenCharacters,
|
||||
ShareType,
|
||||
|
|
@ -319,7 +319,8 @@ export default defineComponent({
|
|||
}
|
||||
// If not found in the files store (cache)
|
||||
// use the current view to fetch the content for the requested path
|
||||
return (await view.getContents(normalizedPath)).contents
|
||||
const controller = new AbortController()
|
||||
return (await view.getContents(normalizedPath, { signal: controller.signal })).contents
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -58,8 +58,9 @@ import FilesNavigationItem from '../components/FilesNavigationItem.vue'
|
|||
import FilesNavigationSearch from '../components/FilesNavigationSearch.vue'
|
||||
import NavigationQuota from '../components/NavigationQuota.vue'
|
||||
import FilesAppSettings from './FilesAppSettings.vue'
|
||||
import { useNavigation } from '../composables/useNavigation.ts'
|
||||
import { useViews } from '../composables/useViews.ts'
|
||||
import logger from '../logger.ts'
|
||||
import { useActiveStore } from '../store/active.ts'
|
||||
import { useFiltersStore } from '../store/filters.ts'
|
||||
import { useSidebarStore } from '../store/sidebar.ts'
|
||||
import { useViewConfigStore } from '../store/viewConfig.ts'
|
||||
|
|
@ -89,18 +90,19 @@ export default defineComponent({
|
|||
|
||||
setup() {
|
||||
const sidebar = useSidebarStore()
|
||||
const activeStore = useActiveStore()
|
||||
const filtersStore = useFiltersStore()
|
||||
const viewConfigStore = useViewConfigStore()
|
||||
const { currentView, views } = useNavigation()
|
||||
|
||||
return {
|
||||
currentView,
|
||||
t,
|
||||
views,
|
||||
|
||||
sidebar,
|
||||
activeStore,
|
||||
filtersStore,
|
||||
viewConfigStore,
|
||||
|
||||
views: useViews(),
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -138,7 +140,7 @@ export default defineComponent({
|
|||
|
||||
watch: {
|
||||
currentViewId(newView, oldView) {
|
||||
if (this.currentViewId !== this.currentView?.id) {
|
||||
if (this.currentViewId !== this.activeStore.activeView?.id) {
|
||||
// This is guaranteed to be a view because `currentViewId` falls back to the default 'files' view
|
||||
const view = this.views.find(({ id }) => id === this.currentViewId)!
|
||||
// The new view as active
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"include": ["./apps/**/*.ts", "./apps/**/*.vue", "./core/**/*.ts", "./core/**/*.vue", "./*.d.ts"],
|
||||
"exclude": ["./**/*.cy.ts"],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "ESNext"],
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"types": ["node", "vue", "vue-router"],
|
||||
"outDir": "./dist/",
|
||||
"target": "ESNext",
|
||||
|
|
|
|||
Loading…
Reference in a new issue