mirror of
https://github.com/nextcloud/server.git
synced 2026-06-06 15:23:17 -04:00
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>
This commit is contained in:
parent
7e05d37bfc
commit
a759182e90
14 changed files with 368 additions and 162 deletions
|
|
@ -52,6 +52,7 @@
|
|||
|
||||
<script lang="ts">
|
||||
import type { Node } from '@nextcloud/files'
|
||||
import type { FileSource } from '../types.ts'
|
||||
|
||||
import { basename } from 'path'
|
||||
import { defineComponent } from 'vue'
|
||||
|
|
@ -62,6 +63,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'
|
||||
|
|
@ -71,7 +73,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',
|
||||
|
|
@ -99,6 +100,7 @@ export default defineComponent({
|
|||
const pathsStore = usePathsStore()
|
||||
const selectionStore = useSelectionStore()
|
||||
const uploaderStore = useUploaderStore()
|
||||
const { currentView } = useNavigation()
|
||||
|
||||
return {
|
||||
draggingStore,
|
||||
|
|
@ -106,14 +108,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
|
||||
|
|
@ -167,15 +167,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)
|
||||
},
|
||||
|
|
@ -187,6 +187,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'
|
||||
|
|
|
|||
|
|
@ -44,16 +44,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',
|
||||
|
|
@ -64,11 +66,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,
|
||||
|
|
@ -76,10 +86,6 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
computed: {
|
||||
currentView() {
|
||||
return this.$navigation.active
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if the current folder has create permissions
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -103,9 +103,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'
|
||||
|
|
@ -157,12 +158,16 @@ export default defineComponent({
|
|||
const filesStore = useFilesStore()
|
||||
const renamingStore = useRenamingStore()
|
||||
const selectionStore = useSelectionStore()
|
||||
const { currentView } = useNavigation()
|
||||
|
||||
return {
|
||||
actionsMenuStore,
|
||||
draggingStore,
|
||||
filesStore,
|
||||
renamingStore,
|
||||
selectionStore,
|
||||
|
||||
currentView,
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -196,21 +201,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))`,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,13 +93,13 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType, ShallowRef } from 'vue'
|
||||
import type { FileAction, Node, View } from '@nextcloud/files'
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
import { showError, showSuccess } from '@nextcloud/dialogs'
|
||||
import { DefaultType, NodeStatus, getFileActions } from '@nextcloud/files'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import Vue, { defineComponent } from 'vue'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
|
||||
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
|
||||
|
|
@ -108,6 +108,7 @@ 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 { useNavigation } from '../../composables/useNavigation'
|
||||
import CustomElementRender from '../CustomElementRender.vue'
|
||||
import logger from '../../logger.js'
|
||||
|
||||
|
|
@ -150,6 +151,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,
|
||||
|
|
@ -161,9 +171,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
|
||||
},
|
||||
|
|
@ -287,7 +294,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)
|
||||
|
||||
|
|
@ -307,7 +314,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) {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Node } from '@nextcloud/files'
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
|
|
@ -66,6 +67,7 @@ import Vue 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'
|
||||
|
||||
|
|
@ -106,8 +108,12 @@ export default Vue.extend({
|
|||
},
|
||||
|
||||
setup() {
|
||||
const { currentView } = useNavigation()
|
||||
const renamingStore = useRenamingStore()
|
||||
|
||||
return {
|
||||
currentView,
|
||||
|
||||
renamingStore,
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -77,6 +77,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'
|
||||
|
|
@ -110,12 +111,16 @@ export default defineComponent({
|
|||
const filesStore = useFilesStore()
|
||||
const renamingStore = useRenamingStore()
|
||||
const selectionStore = useSelectionStore()
|
||||
const { currentView } = useNavigation()
|
||||
|
||||
return {
|
||||
actionsMenuStore,
|
||||
draggingStore,
|
||||
filesStore,
|
||||
renamingStore,
|
||||
selectionStore,
|
||||
|
||||
currentView,
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -65,10 +65,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')
|
||||
|
|
|
|||
|
|
@ -71,17 +71,21 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
|
||||
import { defineComponent, type PropType } from 'vue'
|
||||
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 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',
|
||||
|
|
@ -117,17 +121,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) {
|
||||
|
|
|
|||
98
apps/files/src/composables/useNavigation.spec.ts
Normal file
98
apps/files/src/composables/useNavigation.spec.ts
Normal file
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
})
|
||||
46
apps/files/src/composables/useNavigation.ts
Normal file
46
apps/files/src/composables/useNavigation.ts
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
1
apps/files/src/eventbus.d.ts
vendored
1
apps/files/src/eventbus.d.ts
vendored
|
|
@ -6,6 +6,7 @@ declare module '@nextcloud/event-bus' {
|
|||
'files:favorites:removed': Node
|
||||
'files:favorites:added': Node
|
||||
'files:node:renamed': Node
|
||||
'nextcloud:unified-search.search': { query: string }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
type="tertiary"
|
||||
@click="openSharingSidebar">
|
||||
<template #icon>
|
||||
<LinkIcon v-if="shareButtonType === Type.SHARE_TYPE_LINK" />
|
||||
<LinkIcon v-if="shareButtonType === ShareType.Link" />
|
||||
<AccountPlusIcon v-else :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
|
|
@ -116,21 +116,23 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Route } from 'vue-router'
|
||||
import type { ContentsWithRoot } from '@nextcloud/files'
|
||||
import type { Upload } from '@nextcloud/upload'
|
||||
import type { CancelablePromise } from 'cancelable-promise'
|
||||
import type { ComponentInstance } from 'vue'
|
||||
import type { Route } from 'vue-router'
|
||||
import type { UserConfig } from '../types.ts'
|
||||
import type { View, ContentsWithRoot } from '@nextcloud/files'
|
||||
|
||||
import { getCapabilities } from '@nextcloud/capabilities'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
|
||||
import { Folder, Node, Permission } from '@nextcloud/files'
|
||||
import { getCapabilities } from '@nextcloud/capabilities'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
||||
import { ShareType } from '@nextcloud/sharing'
|
||||
import { UploadPicker } from '@nextcloud/upload'
|
||||
import { join, dirname } from 'path'
|
||||
import { Parser } from 'xml2js'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { translate, translatePlural } from '@nextcloud/l10n'
|
||||
import { Type } from '@nextcloud/sharing'
|
||||
import { UploadPicker } from '@nextcloud/upload'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import LinkIcon from 'vue-material-design-icons/Link.vue'
|
||||
|
|
@ -145,6 +147,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'
|
||||
|
|
@ -194,10 +197,15 @@ 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,
|
||||
n,
|
||||
t,
|
||||
|
||||
filesStore,
|
||||
pathsStore,
|
||||
selectionStore,
|
||||
|
|
@ -205,6 +213,8 @@ export default defineComponent({
|
|||
userConfigStore,
|
||||
viewConfigStore,
|
||||
enableGridView,
|
||||
|
||||
ShareType,
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -212,10 +222,9 @@ export default defineComponent({
|
|||
return {
|
||||
filterText: '',
|
||||
loading: true,
|
||||
promise: null,
|
||||
Type,
|
||||
promise: null as Promise<ContentsWithRoot> | CancelablePromise<ContentsWithRoot> | null,
|
||||
|
||||
_unsubscribeStore: () => {},
|
||||
unsubscribeStoreCallback: () => {},
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -224,10 +233,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 ?? this.t('files', 'Files')
|
||||
},
|
||||
|
|
@ -384,22 +389,22 @@ export default defineComponent({
|
|||
return this.t('files', 'Share')
|
||||
}
|
||||
|
||||
if (this.shareButtonType === Type.SHARE_TYPE_LINK) {
|
||||
if (this.shareButtonType === ShareType.Link) {
|
||||
return this.t('files', 'Shared by link')
|
||||
}
|
||||
return this.t('files', 'Shared')
|
||||
},
|
||||
shareButtonType(): Type | null {
|
||||
shareButtonType(): ShareType | null {
|
||||
if (!this.shareAttributes) {
|
||||
return null
|
||||
}
|
||||
|
||||
// If all types are links, show the link icon
|
||||
if (this.shareAttributes.some(type => type === Type.SHARE_TYPE_LINK)) {
|
||||
return Type.SHARE_TYPE_LINK
|
||||
if (this.shareAttributes.some(type => type === ShareType.Link)) {
|
||||
return ShareType.Link
|
||||
}
|
||||
|
||||
return Type.SHARE_TYPE_USER
|
||||
return ShareType.User
|
||||
},
|
||||
|
||||
gridViewButtonLabel() {
|
||||
|
|
@ -431,6 +436,18 @@ export default defineComponent({
|
|||
return isSharingEnabled
|
||||
&& this.currentFolder && (this.currentFolder.permissions & Permission.SHARE) !== 0
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle search event from unified search.
|
||||
*
|
||||
* @return {(searchEvent: {query: string}) => void}
|
||||
*/
|
||||
onSearch() {
|
||||
return debounce((searchEvent: { query: string }) => {
|
||||
console.debug('Files app handling search event from unified search...', searchEvent)
|
||||
this.filterText = searchEvent.query
|
||||
}, 500)
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
|
@ -453,8 +470,9 @@ export default defineComponent({
|
|||
this.fetchContent()
|
||||
|
||||
// Scroll to top, force virtual scroller to re-render
|
||||
if (this.$refs?.filesListVirtual?.$el) {
|
||||
this.$refs.filesListVirtual.$el.scrollTop = 0
|
||||
const filesListVirtual = this.$refs?.filesListVirtual as ComponentInstance | undefined
|
||||
if (filesListVirtual?.$el) {
|
||||
filesListVirtual.$el.scrollTop = 0
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -470,18 +488,18 @@ 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._unsubscribeStore = this.userConfigStore.$subscribe(() => this.fetchContent(), { deep: true })
|
||||
this.unsubscribeStoreCallback = this.userConfigStore.$subscribe(() => this.fetchContent(), { deep: true })
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
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)
|
||||
this._unsubscribeStore()
|
||||
unsubscribe('nextcloud:unified-search.reset', this.resetSearch)
|
||||
this.unsubscribeStoreCallback()
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
|
@ -496,7 +514,7 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
// If we have a cancellable promise ongoing, cancel it
|
||||
if (typeof this.promise?.cancel === 'function') {
|
||||
if (this.promise && 'cancel' in this.promise) {
|
||||
this.promise.cancel()
|
||||
logger.debug('Cancelled previous ongoing fetch')
|
||||
}
|
||||
|
|
@ -531,7 +549,7 @@ export default defineComponent({
|
|||
// Update paths store
|
||||
const folders = contents.filter(node => node.type === 'folder')
|
||||
folders.forEach(node => {
|
||||
this.pathsStore.addPath({ service: currentView.id, fileid: node.fileid, path: join(dir, node.basename) })
|
||||
this.pathsStore.addPath({ service: currentView.id, source: node.source, path: join(dir, node.basename) })
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error while fetching content', { error })
|
||||
|
|
@ -642,20 +660,14 @@ export default defineComponent({
|
|||
this.fetchContent()
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handle search event from unified search.
|
||||
*
|
||||
* @param searchEvent is event object.
|
||||
*/
|
||||
onSearch: debounce(function(searchEvent) {
|
||||
console.debug('Files app handling search event from unified search...', searchEvent)
|
||||
this.filterText = searchEvent.query
|
||||
}, 500),
|
||||
|
||||
/**
|
||||
* Reset the search query
|
||||
*/
|
||||
resetSearch() {
|
||||
// Reset debounced calls to not set the query again
|
||||
this.onSearch.clear()
|
||||
// Reset filter query
|
||||
this.filterText = ''
|
||||
},
|
||||
|
||||
|
|
@ -668,14 +680,11 @@ 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)
|
||||
},
|
||||
|
||||
t: translate,
|
||||
n: translatePlural,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,40 @@
|
|||
import FolderSvg from '@mdi/svg/svg/folder.svg'
|
||||
import ShareSvg from '@mdi/svg/svg/share-variant.svg'
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import type { Navigation } from '@nextcloud/files'
|
||||
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
|
||||
|
||||
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', {
|
||||
|
|
@ -40,29 +61,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, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({
|
||||
createSpy: cy.spy,
|
||||
})],
|
||||
},
|
||||
router,
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
createSpy: cy.spy,
|
||||
}),
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('[data-cy-files-navigation]').should('be.visible')
|
||||
|
|
@ -72,21 +95,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')
|
||||
|
|
@ -96,22 +114,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')
|
||||
|
|
@ -139,23 +152,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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,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>
|
||||
|
|
@ -75,24 +75,29 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
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 { useViewConfigStore } from '../store/viewConfig.ts'
|
||||
import logger from '../logger.js'
|
||||
import type { View } from '@nextcloud/files'
|
||||
import NavigationQuota from '../components/NavigationQuota.vue'
|
||||
import SettingsModal from './Settings.vue'
|
||||
|
||||
export default {
|
||||
import { useNavigation } from '../composables/useNavigation'
|
||||
import { useViewConfigStore } from '../store/viewConfig.ts'
|
||||
import logger from '../logger.js'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Navigation',
|
||||
|
||||
components: {
|
||||
Cog,
|
||||
IconCog,
|
||||
|
||||
NavigationQuota,
|
||||
NcAppNavigation,
|
||||
NcAppNavigationItem,
|
||||
|
|
@ -102,7 +107,12 @@ export default {
|
|||
|
||||
setup() {
|
||||
const viewConfigStore = useViewConfigStore()
|
||||
const { currentView, views } = useNavigation()
|
||||
|
||||
return {
|
||||
currentView,
|
||||
views,
|
||||
|
||||
viewConfigStore,
|
||||
}
|
||||
},
|
||||
|
|
@ -114,18 +124,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
|
||||
|
|
@ -153,24 +158,27 @@ export default {
|
|||
},
|
||||
|
||||
watch: {
|
||||
currentView(view, oldView) {
|
||||
if (view.id !== oldView?.id) {
|
||||
this.$navigation.setActive(view)
|
||||
logger.debug('Navigation changed', { id: view.id, 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
|
||||
|
|
@ -181,9 +189,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)
|
||||
},
|
||||
|
|
@ -237,10 +249,8 @@ export default {
|
|||
onSettingsClose() {
|
||||
this.settingsOpened = false
|
||||
},
|
||||
|
||||
t: translate,
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
|
|
|||
Loading…
Reference in a new issue