diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-06-21 15:48:37 +0200 |
---|---|---|
committer | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-06-23 16:53:27 +0200 |
commit | 3ed32ffbb4fda77e7860e8ae6ac7f6a1c4ca902b (patch) | |
tree | 28923dffa09ec40e474c4949e775dee7141c6270 /apps/files/src | |
parent | dea5559d35c1ecf1a26e658223a3bb051b253f57 (diff) | |
download | nextcloud-server-3ed32ffbb4fda77e7860e8ae6ac7f6a1c4ca902b.tar.gz nextcloud-server-3ed32ffbb4fda77e7860e8ae6ac7f6a1c4ca902b.zip |
refactor: Use composable for `currentView` and `views` to make it reactive when shared with other Vue apps
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Diffstat (limited to 'apps/files/src')
-rw-r--r-- | apps/files/src/components/BreadCrumbs.vue | 20 | ||||
-rw-r--r-- | apps/files/src/components/DragAndDropNotice.vue | 22 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry.vue | 16 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry/FileEntryActions.vue | 24 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry/FileEntryName.vue | 17 | ||||
-rw-r--r-- | apps/files/src/components/FileEntryGrid.vue | 5 | ||||
-rw-r--r-- | apps/files/src/components/FileEntryMixin.ts | 4 | ||||
-rw-r--r-- | apps/files/src/components/FilesListTableHeader.vue | 20 | ||||
-rw-r--r-- | apps/files/src/composables/useNavigation.spec.ts | 98 | ||||
-rw-r--r-- | apps/files/src/composables/useNavigation.ts | 46 | ||||
-rw-r--r-- | apps/files/src/views/FilesList.vue | 19 | ||||
-rw-r--r-- | apps/files/src/views/Navigation.cy.ts | 97 | ||||
-rw-r--r-- | apps/files/src/views/Navigation.vue | 65 |
13 files changed, 321 insertions, 132 deletions
diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue index 2c678ba82fa..02ccac8b669 100644 --- a/apps/files/src/components/BreadCrumbs.vue +++ b/apps/files/src/components/BreadCrumbs.vue @@ -35,6 +35,7 @@ <script lang="ts"> import type { Node } from '@nextcloud/files' +import type { FileSource } from '../types.ts' import { basename } from 'path' import { defineComponent } from 'vue' @@ -45,6 +46,7 @@ import NcBreadcrumb from '@nextcloud/vue/dist/Components/NcBreadcrumb.js' import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js' import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import { useNavigation } from '../composables/useNavigation' import { onDropInternalFiles, dataTransferToFileTree, onDropExternalFiles } from '../services/DropService' import { showError } from '@nextcloud/dialogs' import { useDragAndDropStore } from '../store/dragging.ts' @@ -54,7 +56,6 @@ import { useSelectionStore } from '../store/selection.ts' import { useUploaderStore } from '../store/uploader.ts' import filesListWidthMixin from '../mixins/filesListWidth.ts' import logger from '../logger' -import type { FileSource } from '../types.ts' export default defineComponent({ name: 'BreadCrumbs', @@ -82,6 +83,7 @@ export default defineComponent({ const pathsStore = usePathsStore() const selectionStore = useSelectionStore() const uploaderStore = useUploaderStore() + const { currentView } = useNavigation() return { draggingStore, @@ -89,14 +91,12 @@ export default defineComponent({ pathsStore, selectionStore, uploaderStore, + + currentView, } }, computed: { - currentView() { - return this.$navigation.active - }, - dirs(): string[] { const cumulativePath = (acc: string) => (value: string) => (acc += `${value}/`) // Generate a cumulative path for each path segment: ['/', '/foo', '/foo/bar', ...] etc @@ -150,15 +150,15 @@ export default defineComponent({ getNodeFromSource(source: FileSource): Node | undefined { return this.filesStore.getNode(source) }, - getFileSourceFromPath(path: string): FileSource | undefined { - return this.pathsStore.getPath(this.currentView?.id, path) + getFileSourceFromPath(path: string): FileSource | null { + return (this.currentView && this.pathsStore.getPath(this.currentView.id, path)) ?? null }, getDirDisplayName(path: string): string { if (path === '/') { return this.$navigation?.active?.name || t('files', 'Home') } - const source: FileSource | undefined = this.getFileSourceFromPath(path) + const source: FileSource | null = this.getFileSourceFromPath(path) const node: Node | undefined = source ? this.getNodeFromSource(source) : undefined return node?.attributes?.displayname || basename(path) }, @@ -170,6 +170,10 @@ export default defineComponent({ }, onDragOver(event: DragEvent, path: string) { + if (!event.dataTransfer) { + return + } + // Cannot drop on the current directory if (path === this.dirs[this.dirs.length - 1]) { event.dataTransfer.dropEffect = 'none' diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue index 2f013927640..0483e71f43b 100644 --- a/apps/files/src/components/DragAndDropNotice.vue +++ b/apps/files/src/components/DragAndDropNotice.vue @@ -26,16 +26,18 @@ </template> <script lang="ts"> -import { defineComponent } from 'vue' -import { Folder, Permission } from '@nextcloud/files' +import type { Folder } from '@nextcloud/files' +import { Permission } from '@nextcloud/files' import { showError } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' import { UploadStatus } from '@nextcloud/upload' +import { defineComponent, type PropType } from 'vue' import TrayArrowDownIcon from 'vue-material-design-icons/TrayArrowDown.vue' -import logger from '../logger.js' +import { useNavigation } from '../composables/useNavigation' import { dataTransferToFileTree, onDropExternalFiles } from '../services/DropService' +import logger from '../logger.js' export default defineComponent({ name: 'DragAndDropNotice', @@ -46,11 +48,19 @@ export default defineComponent({ props: { currentFolder: { - type: Folder, + type: Object as PropType<Folder>, required: true, }, }, + setup() { + const { currentView } = useNavigation() + + return { + currentView, + } + }, + data() { return { dragover: false, @@ -58,10 +68,6 @@ export default defineComponent({ }, computed: { - currentView() { - return this.$navigation.active - }, - /** * Check if the current folder has create permissions */ diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index 0ee04739282..847decd4378 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -86,9 +86,10 @@ <script lang="ts"> import { defineComponent } from 'vue' -import { Permission, formatFileSize } from '@nextcloud/files' +import { formatFileSize } from '@nextcloud/files' import moment from '@nextcloud/moment' +import { useNavigation } from '../composables/useNavigation' import { useActionsMenuStore } from '../store/actionsmenu.ts' import { useDragAndDropStore } from '../store/dragging.ts' import { useFilesStore } from '../store/files.ts' @@ -140,12 +141,16 @@ export default defineComponent({ const filesStore = useFilesStore() const renamingStore = useRenamingStore() const selectionStore = useSelectionStore() + const { currentView } = useNavigation() + return { actionsMenuStore, draggingStore, filesStore, renamingStore, selectionStore, + + currentView, } }, @@ -179,21 +184,22 @@ export default defineComponent({ }, size() { - const size = parseInt(this.source.size, 10) - if (typeof size !== 'number' || isNaN(size) || size < 0) { + const size = this.source.size + if (!size || size < 0) { return this.t('files', 'Pending') } return formatFileSize(size, true) }, + sizeOpacity() { const maxOpacitySize = 10 * 1024 * 1024 - const size = parseInt(this.source.size, 10) + const size = this.source.size if (!size || isNaN(size) || size < 0) { return {} } - const ratio = Math.round(Math.min(100, 100 * Math.pow((this.source.size / maxOpacitySize), 2))) + const ratio = Math.round(Math.min(100, 100 * Math.pow((size / maxOpacitySize), 2))) return { color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`, } diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue index 21d5cd9e796..24b26bd225e 100644 --- a/apps/files/src/components/FileEntry/FileEntryActions.vue +++ b/apps/files/src/components/FileEntry/FileEntryActions.vue @@ -76,11 +76,13 @@ </template> <script lang="ts"> -import type { PropType } from 'vue' +import type { PropType, ShallowRef } from 'vue' +import type { FileAction, Node, View } from '@nextcloud/files' -import { DefaultType, FileAction, Node, NodeStatus, View, getFileActions } from '@nextcloud/files' +import { DefaultType, NodeStatus, getFileActions } from '@nextcloud/files' import { showError, showSuccess } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' @@ -88,8 +90,8 @@ import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator. import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue' -import Vue, { defineComponent } from 'vue' +import { useNavigation } from '../../composables/useNavigation' import CustomElementRender from '../CustomElementRender.vue' import logger from '../../logger.js' @@ -132,6 +134,15 @@ export default defineComponent({ }, }, + setup() { + const { currentView } = useNavigation() + + return { + // The file list is guaranteed to be only shown with active view + currentView: currentView as ShallowRef<View>, + } + }, + data() { return { openedSubmenu: null as FileAction | null, @@ -143,9 +154,6 @@ export default defineComponent({ // Remove any trailing slash but leave root slash return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1') }, - currentView(): View { - return this.$navigation.active as View - }, isLoading() { return this.source.status === NodeStatus.LOADING }, @@ -269,7 +277,7 @@ export default defineComponent({ try { // Set the loading marker this.$emit('update:loading', action.id) - Vue.set(this.source, 'status', NodeStatus.LOADING) + this.$set(this.source, 'status', NodeStatus.LOADING) const success = await action.exec(this.source, this.currentView, this.currentDir) @@ -289,7 +297,7 @@ export default defineComponent({ } finally { // Reset the loading marker this.$emit('update:loading', '') - Vue.set(this.source, 'status', undefined) + this.$set(this.source, 'status', undefined) // If that was a submenu, we just go back after the action if (isSubmenu) { diff --git a/apps/files/src/components/FileEntry/FileEntryName.vue b/apps/files/src/components/FileEntry/FileEntryName.vue index fdd3c215a98..5e9036ad63d 100644 --- a/apps/files/src/components/FileEntry/FileEntryName.vue +++ b/apps/files/src/components/FileEntry/FileEntryName.vue @@ -37,7 +37,7 @@ </template> <script lang="ts"> -import type { Node, View } from '@nextcloud/files' +import type { Node } from '@nextcloud/files' import type { PropType } from 'vue' import { showError, showSuccess } from '@nextcloud/dialogs' @@ -46,10 +46,11 @@ import { FileType, NodeStatus, Permission } from '@nextcloud/files' import { loadState } from '@nextcloud/initial-state' import { translate as t } from '@nextcloud/l10n' import axios, { isAxiosError } from '@nextcloud/axios' -import Vue, { defineComponent } from 'vue' +import { defineComponent } from 'vue' import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import { useNavigation } from '../../composables/useNavigation' import { useRenamingStore } from '../../store/renaming.ts' import logger from '../../logger.js' @@ -90,17 +91,17 @@ export default defineComponent({ }, setup() { + const { currentView } = useNavigation() const renamingStore = useRenamingStore() + return { + currentView, + renamingStore, } }, computed: { - currentView(): View { - return this.$navigation.active as View - }, - isRenaming() { return this.renamingStore.renamingNode === this.source }, @@ -282,7 +283,7 @@ export default defineComponent({ } // Set loading state - Vue.set(this.source, 'status', NodeStatus.LOADING) + this.$set(this.source, 'status', NodeStatus.LOADING) // Update node this.source.rename(newName) @@ -327,7 +328,7 @@ export default defineComponent({ // Unknown error showError(t('files', 'Could not rename "{oldName}"', { oldName })) } finally { - Vue.set(this.source, 'status', undefined) + this.$set(this.source, 'status', undefined) } }, diff --git a/apps/files/src/components/FileEntryGrid.vue b/apps/files/src/components/FileEntryGrid.vue index 8238c1a53d1..31df827eea2 100644 --- a/apps/files/src/components/FileEntryGrid.vue +++ b/apps/files/src/components/FileEntryGrid.vue @@ -60,6 +60,7 @@ <script lang="ts"> import { defineComponent } from 'vue' +import { useNavigation } from '../composables/useNavigation' import { useActionsMenuStore } from '../store/actionsmenu.ts' import { useDragAndDropStore } from '../store/dragging.ts' import { useFilesStore } from '../store/files.ts' @@ -93,12 +94,16 @@ export default defineComponent({ const filesStore = useFilesStore() const renamingStore = useRenamingStore() const selectionStore = useSelectionStore() + const { currentView } = useNavigation() + return { actionsMenuStore, draggingStore, filesStore, renamingStore, selectionStore, + + currentView, } }, diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts index f0f63f9dd30..b9cc0398ef5 100644 --- a/apps/files/src/components/FileEntryMixin.ts +++ b/apps/files/src/components/FileEntryMixin.ts @@ -48,10 +48,6 @@ export default defineComponent({ }, computed: { - currentView(): View { - return this.$navigation.active as View - }, - currentDir() { // Remove any trailing slash but leave root slash return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1') diff --git a/apps/files/src/components/FilesListTableHeader.vue b/apps/files/src/components/FilesListTableHeader.vue index 7134c957fb8..723dd574da8 100644 --- a/apps/files/src/components/FilesListTableHeader.vue +++ b/apps/files/src/components/FilesListTableHeader.vue @@ -54,17 +54,21 @@ </template> <script lang="ts"> +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 { defineComponent } from 'vue' + import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' -import { defineComponent, type PropType } from 'vue' +import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue' +import { useNavigation } from '../composables/useNavigation' import { useFilesStore } from '../store/files.ts' import { useSelectionStore } from '../store/selection.ts' -import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue' import filesSortingMixin from '../mixins/filesSorting.ts' import logger from '../logger.js' -import type { Node } from '@nextcloud/files' -import type { FileSource } from '../types.ts' export default defineComponent({ name: 'FilesListTableHeader', @@ -100,17 +104,17 @@ export default defineComponent({ setup() { const filesStore = useFilesStore() const selectionStore = useSelectionStore() + const { currentView } = useNavigation() + return { filesStore, selectionStore, + + currentView, } }, computed: { - currentView() { - return this.$navigation.active - }, - columns() { // Hide columns if the list is too small if (this.filesListWidth < 512) { diff --git a/apps/files/src/composables/useNavigation.spec.ts b/apps/files/src/composables/useNavigation.spec.ts new file mode 100644 index 00000000000..360e12660f3 --- /dev/null +++ b/apps/files/src/composables/useNavigation.spec.ts @@ -0,0 +1,98 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { Navigation, View } from '@nextcloud/files' +import { mount } from '@vue/test-utils' +import { defineComponent, nextTick } from 'vue' +import { useNavigation } from './useNavigation' + +import nextcloudFiles from '@nextcloud/files' + +// 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 = jest.spyOn(nextcloudFiles, 'getNavigation') + let navigation: Navigation + + describe('currentView', () => { + beforeEach(() => { + navigation = new 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 View({ getContents: () => Promise.reject(), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) + navigation.register(view) + navigation.setActive(view) + // 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 View({ getContents: () => Promise.reject(), 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) + // 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 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 View({ getContents: () => Promise.reject(), 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 View({ getContents: () => Promise.reject(), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) + const view2 = new View({ getContents: () => Promise.reject(), 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]) + }) + }) +}) diff --git a/apps/files/src/composables/useNavigation.ts b/apps/files/src/composables/useNavigation.ts new file mode 100644 index 00000000000..f410aec895f --- /dev/null +++ b/apps/files/src/composables/useNavigation.ts @@ -0,0 +1,46 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { View } from '@nextcloud/files' + +import { getNavigation } from '@nextcloud/files' +import { onMounted, onUnmounted, shallowRef, type ShallowRef } from 'vue' + +/** + * Composable to get the currently active files view from the files navigation + */ +export function useNavigation() { + const navigation = getNavigation() + const views: ShallowRef<View[]> = shallowRef(navigation.views) + const currentView: ShallowRef<View | null> = shallowRef(navigation.active) + + /** + * Event listener to update the `currentView` + * @param event The update event + */ + function onUpdateActive(event: CustomEvent<View|null>) { + currentView.value = event.detail + } + + /** + * Event listener to update all registered views + */ + function onUpdateViews() { + views.value = navigation.views + } + + onMounted(() => { + navigation.addEventListener('update', onUpdateViews) + navigation.addEventListener('updateActive', onUpdateActive) + }) + onUnmounted(() => { + navigation.removeEventListener('update', onUpdateViews) + navigation.removeEventListener('updateActive', onUpdateActive) + }) + + return { + currentView, + views, + } +} diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index 493437225d6..fabf1cae6b9 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -107,7 +107,7 @@ </template> <script lang="ts"> -import type { View, ContentsWithRoot } from '@nextcloud/files' +import type { ContentsWithRoot } from '@nextcloud/files' import type { Upload } from '@nextcloud/upload' import type { CancelablePromise } from 'cancelable-promise' import type { ComponentPublicInstance } from 'vue' @@ -137,6 +137,7 @@ import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue' import ViewGridIcon from 'vue-material-design-icons/ViewGrid.vue' import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { useNavigation } from '../composables/useNavigation.ts' import { useFilesStore } from '../store/files.ts' import { usePathsStore } from '../store/paths.ts' import { useSelectionStore } from '../store/selection.ts' @@ -186,10 +187,13 @@ export default defineComponent({ const uploaderStore = useUploaderStore() const userConfigStore = useUserConfigStore() const viewConfigStore = useViewConfigStore() + const { currentView } = useNavigation() const enableGridView = (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true) return { + currentView, + filesStore, pathsStore, selectionStore, @@ -228,10 +232,6 @@ export default defineComponent({ return this.userConfigStore.userConfig }, - currentView(): View { - return this.$navigation.active || this.$navigation.views.find((view) => view.id === (this.$route.params?.view ?? 'files'))! - }, - pageHeading(): string { return this.currentView?.name ?? t('files', 'Files') }, @@ -475,7 +475,7 @@ export default defineComponent({ subscribe('files:node:deleted', this.onNodeDeleted) subscribe('files:node:updated', this.onUpdatedNode) subscribe('nextcloud:unified-search.search', this.onSearch) - subscribe('nextcloud:unified-search.reset', this.onSearch) + subscribe('nextcloud:unified-search.reset', this.resetSearch) // reload on settings change this.unsubscribeStoreCallback = this.userConfigStore.$subscribe(() => this.fetchContent(), { deep: true }) @@ -485,7 +485,7 @@ export default defineComponent({ unsubscribe('files:node:deleted', this.onNodeDeleted) unsubscribe('files:node:updated', this.onUpdatedNode) unsubscribe('nextcloud:unified-search.search', this.onSearch) - unsubscribe('nextcloud:unified-search.reset', this.onSearch) + unsubscribe('nextcloud:unified-search.reset', this.resetSearch) this.unsubscribeStoreCallback() }, @@ -656,6 +656,9 @@ export default defineComponent({ * Reset the search query */ resetSearch() { + // Reset debounced calls to not set the query again + this.onSearch.clear() + // Reset filter query this.filterText = '' }, @@ -668,7 +671,7 @@ export default defineComponent({ if (window?.OCA?.Files?.Sidebar?.setActiveTab) { window.OCA.Files.Sidebar.setActiveTab('sharing') } - sidebarAction.exec(this.currentFolder, this.currentView, this.currentFolder.path) + sidebarAction.exec(this.currentFolder, this.currentView!, this.currentFolder.path) }, toggleGridView() { this.userConfigStore.update('grid_view', !this.userConfig.grid_view) diff --git a/apps/files/src/views/Navigation.cy.ts b/apps/files/src/views/Navigation.cy.ts index a555a04a910..46360a2357a 100644 --- a/apps/files/src/views/Navigation.cy.ts +++ b/apps/files/src/views/Navigation.cy.ts @@ -2,22 +2,38 @@ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import FolderSvg from '@mdi/svg/svg/folder.svg' -import ShareSvg from '@mdi/svg/svg/share-variant.svg' +import type { Navigation } from '@nextcloud/files' +import FolderSvg from '@mdi/svg/svg/folder.svg?raw' import { createTestingPinia } from '@pinia/testing' import NavigationView from './Navigation.vue' -import router from '../router/router' import { useViewConfigStore } from '../store/viewConfig' import { Folder, View, getNavigation } from '@nextcloud/files' import Vue from 'vue' +import router from '../router/router' + +const resetNavigation = () => { + const nav = getNavigation() + ;[...nav.views].forEach(({ id }) => nav.remove(id)) + nav.setActive(null) +} + +const createView = (id: string, name: string, parent?: string) => new View({ + id, + name, + getContents: async () => ({ folder: {} as Folder, contents: [] }), + icon: FolderSvg, + order: 1, + parent, +}) describe('Navigation renders', () => { - delete window._nc_navigation - const Navigation = getNavigation() + let Navigation: Navigation before(() => { + delete window._nc_navigation + Navigation = getNavigation() Vue.prototype.$navigation = Navigation cy.mockInitialState('files', 'storageStats', { @@ -44,29 +60,31 @@ describe('Navigation renders', () => { }) describe('Navigation API', () => { - delete window._nc_navigation - const Navigation = getNavigation() + let Navigation: Navigation + + before(async () => { + delete window._nc_navigation + Navigation = getNavigation() - before(() => { Vue.prototype.$navigation = Navigation + await router.replace({ name: 'filelist', params: { view: 'files' } }) }) + beforeEach(() => resetNavigation()) + it('Check API entries rendering', () => { - Navigation.register(new View({ - id: 'files', - name: 'Files', - getContents: async () => ({ folder: {} as Folder, contents: [] }), - icon: FolderSvg, - order: 1, - })) + Navigation.register(createView('files', 'Files')) + console.warn(Navigation.views) cy.mount(NavigationView, { + router, global: { - plugins: [createTestingPinia({ - createSpy: cy.spy, - })], + plugins: [ + createTestingPinia({ + createSpy: cy.spy, + }), + ], }, - router, }) cy.get('[data-cy-files-navigation]').should('be.visible') @@ -76,21 +94,16 @@ describe('Navigation API', () => { }) it('Adds a new entry and render', () => { - Navigation.register(new View({ - id: 'sharing', - name: 'Sharing', - getContents: async () => ({ folder: {} as Folder, contents: [] }), - icon: ShareSvg, - order: 2, - })) + Navigation.register(createView('files', 'Files')) + Navigation.register(createView('sharing', 'Sharing')) cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, })], }, - router, }) cy.get('[data-cy-files-navigation]').should('be.visible') @@ -100,22 +113,17 @@ describe('Navigation API', () => { }) it('Adds a new children, render and open menu', () => { - Navigation.register(new View({ - id: 'sharingin', - name: 'Shared with me', - getContents: async () => ({ folder: {} as Folder, contents: [] }), - parent: 'sharing', - icon: ShareSvg, - order: 1, - })) + Navigation.register(createView('files', 'Files')) + Navigation.register(createView('sharing', 'Sharing')) + Navigation.register(createView('sharingin', 'Shared with me', 'sharing')) cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, })], }, - router, }) cy.wrap(useViewConfigStore()).as('viewConfigStore') @@ -143,23 +151,18 @@ describe('Navigation API', () => { }) it('Throws when adding a duplicate entry', () => { - expect(() => { - Navigation.register(new View({ - id: 'files', - name: 'Files', - getContents: async () => ({ folder: {} as Folder, contents: [] }), - icon: FolderSvg, - order: 1, - })) - }).to.throw('View id files is already registered') + Navigation.register(createView('files', 'Files')) + expect(() => Navigation.register(createView('files', 'Files'))) + .to.throw('View id files is already registered') }) }) describe('Quota rendering', () => { - delete window._nc_navigation - const Navigation = getNavigation() + let Navigation: Navigation before(() => { + delete window._nc_navigation + Navigation = getNavigation() Vue.prototype.$navigation = Navigation }) diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue index 71e9bf38068..b69c6d5f7f2 100644 --- a/apps/files/src/views/Navigation.vue +++ b/apps/files/src/views/Navigation.vue @@ -45,7 +45,7 @@ :name="t('files', 'Files settings')" data-cy-files-navigation-settings-button @click.prevent.stop="openSettings"> - <Cog slot="icon" :size="20" /> + <IconCog slot="icon" :size="20" /> </NcAppNavigationItem> </ul> </template> @@ -61,22 +61,26 @@ import type { View } from '@nextcloud/files' import { emit } from '@nextcloud/event-bus' -import { translate } from '@nextcloud/l10n' -import Cog from 'vue-material-design-icons/Cog.vue' +import { translate as t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import IconCog from 'vue-material-design-icons/Cog.vue' import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js' import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js' import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NavigationQuota from '../components/NavigationQuota.vue' +import SettingsModal from './Settings.vue' +import { useNavigation } from '../composables/useNavigation' import { useViewConfigStore } from '../store/viewConfig.ts' import logger from '../logger.js' -import NavigationQuota from '../components/NavigationQuota.vue' -import SettingsModal from './Settings.vue' -export default { +export default defineComponent({ name: 'Navigation', components: { - Cog, + IconCog, + NavigationQuota, NcAppNavigation, NcAppNavigationItem, @@ -86,7 +90,12 @@ export default { setup() { const viewConfigStore = useViewConfigStore() + const { currentView, views } = useNavigation() + return { + currentView, + views, + viewConfigStore, } }, @@ -98,18 +107,13 @@ export default { }, computed: { + /** + * The current view ID from the route params + */ currentViewId() { return this.$route?.params?.view || 'files' }, - currentView(): View { - return this.views.find(view => view.id === this.currentViewId)! - }, - - views(): View[] { - return this.$navigation.views - }, - parentViews(): View[] { return this.views // filter child views @@ -137,24 +141,27 @@ export default { }, watch: { - currentView(view, oldView) { - if (view.id !== oldView?.id) { - this.$navigation.setActive(view) - logger.debug(`Navigation changed from ${oldView.id} to ${view.id}`, { from: oldView, to: view }) - + currentViewId(newView, oldView) { + if (this.currentViewId !== this.currentView?.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 the new view as active this.showView(view) + logger.debug(`Navigation changed from ${oldView} to ${newView}`, { to: view }) } }, }, beforeMount() { - if (this.currentView) { - logger.debug('Navigation mounted. Showing requested view', { view: this.currentView }) - this.showView(this.currentView) - } + // 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)! + this.showView(view) + logger.debug('Navigation mounted. Showing requested view', { view }) }, methods: { + t, + /** * Only use exact route matching on routes with child views * Because if a view does not have children (like the files view) then multiple routes might be matched for it @@ -165,9 +172,13 @@ export default { return this.childViews[view.id]?.length > 0 }, + /** + * Set the view as active on the navigation and handle internal state + * @param view View to set active + */ showView(view: View) { // Closing any opened sidebar - window?.OCA?.Files?.Sidebar?.close?.() + window.OCA?.Files?.Sidebar?.close?.() this.$navigation.setActive(view) emit('files:navigation:changed', view) }, @@ -221,10 +232,8 @@ export default { onSettingsClose() { this.settingsOpened = false }, - - t: translate, }, -} +}) </script> <style scoped lang="scss"> |