mirror of
https://github.com/nextcloud/server.git
synced 2026-02-19 02:38:40 -05:00
refactor(files): only load navigation views needed
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
parent
2f29ad8ff8
commit
69f2c17675
6 changed files with 365 additions and 377 deletions
|
|
@ -1,186 +0,0 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<Fragment>
|
||||
<NcAppNavigationItem
|
||||
v-for="view in currentViews"
|
||||
:key="view.id"
|
||||
class="files-navigation__item"
|
||||
allow-collapse
|
||||
:loading="view.loading"
|
||||
:data-cy-files-navigation-item="view.id"
|
||||
:exact="useExactRouteMatching(view)"
|
||||
:icon="view.iconClass"
|
||||
:name="view.name"
|
||||
:open="isExpanded(view)"
|
||||
:pinned="view.sticky"
|
||||
:to="generateToNavigation(view)"
|
||||
:style="style"
|
||||
@update:open="(open) => onOpen(open, view)">
|
||||
<template v-if="view.icon" #icon>
|
||||
<NcIconSvgWrapper :svg="view.icon" />
|
||||
</template>
|
||||
|
||||
<!-- Hack to force the collapse icon to be displayed -->
|
||||
<li v-if="view.loadChildViews && !view.loaded" style="display: none" />
|
||||
|
||||
<!-- Recursively nest child views -->
|
||||
<FilesNavigationItem
|
||||
v-if="hasChildViews(view)"
|
||||
:parent="view"
|
||||
:level="level + 1"
|
||||
:views="filterView(views, parent.id)" />
|
||||
</NcAppNavigationItem>
|
||||
</Fragment>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { View } from '@nextcloud/files'
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
import { defineComponent } from 'vue'
|
||||
import { Fragment } from 'vue-frag'
|
||||
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import { useActiveStore } from '../store/active.js'
|
||||
import { useViewConfigStore } from '../store/viewConfig.js'
|
||||
|
||||
const maxLevel = 7 // Limit nesting to not exceed max call stack size
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FilesNavigationItem',
|
||||
|
||||
components: {
|
||||
Fragment,
|
||||
NcAppNavigationItem,
|
||||
NcIconSvgWrapper,
|
||||
},
|
||||
|
||||
props: {
|
||||
parent: {
|
||||
type: Object as PropType<View>,
|
||||
default: () => ({}),
|
||||
},
|
||||
|
||||
level: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
|
||||
views: {
|
||||
type: Object as PropType<Record<string, View[]>>,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
const activeStore = useActiveStore()
|
||||
const viewConfigStore = useViewConfigStore()
|
||||
return {
|
||||
activeStore,
|
||||
viewConfigStore,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
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) => this.parent.params && view.params?.dir.startsWith(this.parent.params.dir))
|
||||
}
|
||||
return this.filterVisible(this.views[this.parent.id] ?? [])
|
||||
},
|
||||
|
||||
style() {
|
||||
if (this.level === 0 || this.level === 1 || this.level > maxLevel) { // Left-align deepest entry with center of app navigation, do not add any more visual indentation after this level
|
||||
return null
|
||||
}
|
||||
return {
|
||||
'padding-left': '16px',
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
filterVisible(views: View[]) {
|
||||
return views.filter(({ id, hidden }) => id === this.activeStore.activeView?.id || hidden !== true)
|
||||
},
|
||||
|
||||
hasChildViews(view: View): boolean {
|
||||
if (this.level >= maxLevel) {
|
||||
return false
|
||||
}
|
||||
return this.filterVisible(this.views[view.id] ?? []).length > 0
|
||||
},
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Like for the 'files' view this does not work because of optional 'fileid' param so /files and /files/1234 are both in the 'files' view
|
||||
*
|
||||
* @param view The view to check
|
||||
*/
|
||||
useExactRouteMatching(view: View): boolean {
|
||||
return this.hasChildViews(view)
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate the route to a view
|
||||
*
|
||||
* @param view View to generate "to" navigation for
|
||||
*/
|
||||
generateToNavigation(view: View) {
|
||||
if (view.params) {
|
||||
const { dir } = view.params
|
||||
return { name: 'filelist', params: { ...view.params }, query: { dir } }
|
||||
}
|
||||
return { name: 'filelist', params: { view: view.id } }
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a view is expanded by user config
|
||||
* or fallback to the default value.
|
||||
*
|
||||
* @param view View to check if expanded
|
||||
*/
|
||||
isExpanded(view: View): boolean {
|
||||
return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean'
|
||||
? this.viewConfigStore.getConfig(view.id).expanded === true
|
||||
: view.expanded === true
|
||||
},
|
||||
|
||||
/**
|
||||
* Expand/collapse a a view with children and permanently
|
||||
* save this setting in the server.
|
||||
*
|
||||
* @param open True if open
|
||||
* @param view View
|
||||
*/
|
||||
async onOpen(open: boolean, view: View) {
|
||||
// Invert state
|
||||
const isExpanded = this.isExpanded(view)
|
||||
// Update the view expanded state, might not be necessary
|
||||
view.expanded = !isExpanded
|
||||
this.viewConfigStore.update(view.id, 'expanded', !isExpanded)
|
||||
if (open && view.loadChildViews) {
|
||||
await view.loadChildViews(view)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the view map with the specified view id removed
|
||||
*
|
||||
* @param viewMap Map of views
|
||||
* @param id View id
|
||||
*/
|
||||
filterView(viewMap: Record<string, View[]>, id: string): Record<string, View[]> {
|
||||
return Object.fromEntries(Object.entries(viewMap)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
.filter(([viewId, _views]) => viewId !== id))
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
56
apps/files/src/components/FilesNavigationList.vue
Normal file
56
apps/files/src/components/FilesNavigationList.vue
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IView } from '@nextcloud/files'
|
||||
|
||||
import { getCanonicalLocale, getLanguage, t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList'
|
||||
import FilesNavigationListItem from './FilesNavigationListItem.vue'
|
||||
import { useVisibleViews } from '../composables/useViews.ts'
|
||||
|
||||
const views = useVisibleViews()
|
||||
const rootViews = computed(() => views.value
|
||||
.filter((view) => !view.parent)
|
||||
.sort(sortViews))
|
||||
|
||||
const collator = Intl.Collator(
|
||||
[getLanguage(), getCanonicalLocale()],
|
||||
{ numeric: true, usage: 'sort' },
|
||||
)
|
||||
|
||||
/**
|
||||
* Sort views by their order property if available, otherwise sort alphabetically by name.
|
||||
*
|
||||
* @param a - first view
|
||||
* @param b - second view
|
||||
*/
|
||||
function sortViews(a: IView, b: IView): number {
|
||||
if (a.order !== undefined && b.order === undefined) {
|
||||
return -1
|
||||
} else if (a.order === undefined && b.order !== undefined) {
|
||||
return 1
|
||||
}
|
||||
return collator.compare(a.name, b.name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcAppNavigationList
|
||||
:class="$style.filesNavigationList"
|
||||
:aria-label="t('files', 'Views')">
|
||||
<FilesNavigationListItem
|
||||
v-for="view in rootViews"
|
||||
:key="view.id"
|
||||
:view="view" />
|
||||
</NcAppNavigationList>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.filesNavigationList {
|
||||
height: 100%; /* Fill all available space for sticky views */
|
||||
}
|
||||
</style>
|
||||
162
apps/files/src/components/FilesNavigationListItem.vue
Normal file
162
apps/files/src/components/FilesNavigationListItem.vue
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IView } from '@nextcloud/files'
|
||||
|
||||
import { getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import { useVisibleViews } from '../composables/useViews.ts'
|
||||
import { folderTreeId } from '../services/FolderTree.ts'
|
||||
import { useViewConfigStore } from '../store/viewConfig.ts'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
view: IView
|
||||
level?: number
|
||||
}>(), {
|
||||
level: 0,
|
||||
})
|
||||
|
||||
/**
|
||||
* Load child views on mount if the view is expanded by default
|
||||
* but has no child views loaded yet.
|
||||
*/
|
||||
onMounted(() => {
|
||||
if (isExpanded.value && !hasChildViews.value) {
|
||||
loadChildViews()
|
||||
}
|
||||
})
|
||||
|
||||
const maxLevel = 6 // Limit nesting to not exceed max call stack size
|
||||
const viewConfigStore = useViewConfigStore()
|
||||
const viewConfig = computed(() => viewConfigStore.viewConfigs[props.view.id])
|
||||
const isExpanded = computed(() => viewConfig.value
|
||||
? (viewConfig.value.expanded === true)
|
||||
: (props.view.expanded === true))
|
||||
|
||||
const views = useVisibleViews()
|
||||
const childViews = computed(() => {
|
||||
if (props.level < maxLevel) {
|
||||
return views.value.filter((v) => v.parent === props.view.id)
|
||||
} else {
|
||||
return views.value.filter((v) => isDescendant(v, props.view.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a view is a descendant of another view by recursively traversing up the parent chain.
|
||||
*
|
||||
* @param view - The view to check
|
||||
* @param parent - The parent view id to check against
|
||||
*/
|
||||
function isDescendant(view: IView, parent: string): boolean {
|
||||
if (!view.parent) {
|
||||
return false
|
||||
} else if (view.parent === parent) {
|
||||
return true
|
||||
}
|
||||
|
||||
const parentView = views.value.find((v) => v.id === view.parent)
|
||||
return !!parentView && isDescendant(parentView, parent)
|
||||
}
|
||||
})
|
||||
const sortedChildViews = computed(() => childViews.value.slice().sort((a, b) => {
|
||||
if (a.order !== undefined && b.order === undefined) {
|
||||
return -1
|
||||
} else if (a.order === undefined && b.order !== undefined) {
|
||||
return 1
|
||||
}
|
||||
return collator.compare(a.name, b.name)
|
||||
}))
|
||||
const hasChildViews = computed(() => childViews.value.length > 0)
|
||||
|
||||
const navigationRoute = computed(() => {
|
||||
if (props.view.params) {
|
||||
const { dir } = props.view.params
|
||||
return { name: 'filelist', params: { ...props.view.params }, query: { dir } }
|
||||
}
|
||||
return { name: 'filelist', params: { view: props.view.id } }
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const childViewsLoaded = ref(false)
|
||||
|
||||
/**
|
||||
* Handle expanding/collapsing the navigation item.
|
||||
*
|
||||
* @param expanded - The expanded state
|
||||
*/
|
||||
async function onExpandCollapse(expanded: boolean) {
|
||||
if (viewConfig.value) {
|
||||
viewConfig.value.expanded = expanded
|
||||
} else if (expanded) {
|
||||
viewConfigStore.viewConfigs[props.view.id] = { expanded: true }
|
||||
}
|
||||
|
||||
// folder tree should only show current directory by default,
|
||||
// so we don't want to persist the expanded state in the store for its views
|
||||
if (!props.view.id.startsWith(`${folderTreeId}::`)) {
|
||||
viewConfigStore.update(props.view.id, 'expanded', expanded)
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
await loadChildViews()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load child views if a loader function is provided and child views haven't been loaded yet.
|
||||
*/
|
||||
async function loadChildViews() {
|
||||
if (props.view.loadChildViews && !childViewsLoaded.value) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await props.view.loadChildViews(props.view)
|
||||
childViewsLoaded.value = true
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
const collator = Intl.Collator(
|
||||
[getLanguage(), getCanonicalLocale()],
|
||||
{ numeric: true, usage: 'sort' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcAppNavigationItem
|
||||
class="files-navigation__item"
|
||||
allow-collapse
|
||||
:loading="isLoading"
|
||||
:data-cy-files-navigation-item="view.id"
|
||||
:exact="hasChildViews /* eslint-disable-line @nextcloud/vue/no-deprecated-props */"
|
||||
:name="view.name"
|
||||
:open="isExpanded"
|
||||
:pinned="view.sticky"
|
||||
:to="navigationRoute"
|
||||
@update:open="onExpandCollapse">
|
||||
<template v-if="view.icon" #icon>
|
||||
<NcIconSvgWrapper :svg="view.icon" />
|
||||
</template>
|
||||
|
||||
<!-- Hack to force the collapse icon to be displayed -->
|
||||
<li
|
||||
v-if="!hasChildViews && !childViewsLoaded && view.loadChildViews"
|
||||
v-show="false"
|
||||
role="presentation" />
|
||||
|
||||
<!-- Recursively nest child views -->
|
||||
<FilesNavigationListItem
|
||||
v-for="childView in sortedChildViews"
|
||||
:key="childView.id"
|
||||
:level="level + 1"
|
||||
:view="childView" />
|
||||
</NcAppNavigationItem>
|
||||
</template>
|
||||
|
|
@ -3,20 +3,19 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Navigation, View } from '@nextcloud/files'
|
||||
|
||||
import * as nextcloudFiles from '@nextcloud/files'
|
||||
import { getNavigation, View } from '@nextcloud/files'
|
||||
import { enableAutoDestroy, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { useViews } from './useViews.ts'
|
||||
import { useViews, useVisibleViews } from './useViews.ts'
|
||||
|
||||
// Just a wrapper so we can test the composable
|
||||
const TestComponent = defineComponent({
|
||||
template: '<div></div>',
|
||||
template: '<div />',
|
||||
setup() {
|
||||
return {
|
||||
views: useViews(),
|
||||
visibleViews: useVisibleViews(),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -24,40 +23,121 @@ const TestComponent = defineComponent({
|
|||
enableAutoDestroy(afterEach)
|
||||
|
||||
describe('Composables: useViews', () => {
|
||||
const spy = vi.spyOn(nextcloudFiles, 'getNavigation')
|
||||
let navigation: Navigation
|
||||
const navigation = getNavigation()
|
||||
|
||||
beforeEach(() => {
|
||||
navigation = new nextcloudFiles.Navigation()
|
||||
spy.mockImplementation(() => navigation)
|
||||
const views = [...navigation.views]
|
||||
for (const view of views) {
|
||||
navigation.remove(view.id)
|
||||
}
|
||||
})
|
||||
|
||||
it('should return empty array without registered views', () => {
|
||||
const wrapper = mount(TestComponent)
|
||||
expect((wrapper.vm as unknown as { views: View[] }).views).toStrictEqual([])
|
||||
expect(getViewsInWrapper(wrapper)).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 })
|
||||
const view = new 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])
|
||||
expect(getViewsInWrapper(wrapper)).toStrictEqual([view.id])
|
||||
})
|
||||
|
||||
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 })
|
||||
const view = new View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 })
|
||||
const view2 = new 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])
|
||||
expect(getViewsInWrapper(wrapper)).toStrictEqual([view.id])
|
||||
|
||||
// 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])
|
||||
expect(getViewsInWrapper(wrapper)).toStrictEqual([view.id, view2.id])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Composables: useVisibleViews', () => {
|
||||
const navigation = getNavigation()
|
||||
|
||||
beforeEach(() => {
|
||||
const views = [...navigation.views]
|
||||
for (const view of views) {
|
||||
navigation.remove(view.id)
|
||||
}
|
||||
})
|
||||
|
||||
it('should return empty array without registered views', () => {
|
||||
const wrapper = mount(TestComponent)
|
||||
expect(getVisibleViewsInWrapper(wrapper)).toStrictEqual([])
|
||||
})
|
||||
|
||||
it('should return already registered views', () => {
|
||||
const view = new 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(getVisibleViewsInWrapper(wrapper)).toStrictEqual([view.id])
|
||||
})
|
||||
|
||||
it('should ignore hidden views', () => {
|
||||
const view = new View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0, hidden: true })
|
||||
// register before mount
|
||||
navigation.register(view)
|
||||
// now mount and check that the view is listed
|
||||
const wrapper = mount(TestComponent)
|
||||
expect(getVisibleViewsInWrapper(wrapper)).toStrictEqual([])
|
||||
})
|
||||
|
||||
it('should ignore hidden views', () => {
|
||||
const hiddenView = new View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'hidden', name: 'My hidden view', order: 0, hidden: true })
|
||||
navigation.register(hiddenView)
|
||||
|
||||
const wrapper = mount(TestComponent)
|
||||
const visibleView = new View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 })
|
||||
navigation.register(visibleView)
|
||||
|
||||
expect(getVisibleViewsInWrapper(wrapper)).toStrictEqual([visibleView.id])
|
||||
})
|
||||
|
||||
it('should be reactive on registering new views', () => {
|
||||
const view = new View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 })
|
||||
const view2 = new 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(getVisibleViewsInWrapper(wrapper)).toStrictEqual([view.id])
|
||||
|
||||
// now register view 2 and check it is reactively added
|
||||
navigation.register(view2)
|
||||
expect(getVisibleViewsInWrapper(wrapper)).toStrictEqual([view.id, view2.id])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the view ids from the wrapper's component instance.
|
||||
*
|
||||
* @param wrapper - The wrapper
|
||||
*/
|
||||
function getViewsInWrapper(wrapper: ReturnType<typeof mount>) {
|
||||
const vm = wrapper.vm as unknown as InstanceType<typeof TestComponent>
|
||||
return vm.views.map((view) => view.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visible (non-hidden) view ids from the wrapper's component instance.
|
||||
*
|
||||
* @param wrapper - The wrapper
|
||||
*/
|
||||
function getVisibleViewsInWrapper(wrapper: ReturnType<typeof mount>) {
|
||||
const vm = wrapper.vm as unknown as InstanceType<typeof TestComponent>
|
||||
return vm.visibleViews.map((view) => view.id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,34 +3,38 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IView } from '@nextcloud/files'
|
||||
|
||||
import { getNavigation } from '@nextcloud/files'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { onUnmounted, shallowRef, triggerRef } from 'vue'
|
||||
import { computed, shallowRef } from 'vue'
|
||||
|
||||
const allViews = shallowRef<IView[]>([])
|
||||
const visibleViews = computed(() => allViews.value?.filter((view) => !view.hidden) ?? [])
|
||||
|
||||
let initialized = false
|
||||
|
||||
/**
|
||||
* Composable to get the currently available views
|
||||
* Get all currently registered views.
|
||||
* Unline `Navigation.views` this is reactive and will update when new views are added or existing views are removed.
|
||||
*/
|
||||
export const useViews = createSharedComposable(useInternalViews)
|
||||
export function useViews() {
|
||||
if (!initialized) {
|
||||
const navigation = getNavigation()
|
||||
navigation.addEventListener('update', () => {
|
||||
allViews.value = [...navigation.views]
|
||||
})
|
||||
|
||||
/**
|
||||
* 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)
|
||||
allViews.value = [...navigation.views]
|
||||
initialized = true
|
||||
}
|
||||
|
||||
navigation.addEventListener('update', onUpdateViews)
|
||||
onUnmounted(() => {
|
||||
navigation.removeEventListener('update', onUpdateViews)
|
||||
})
|
||||
|
||||
return views
|
||||
return allViews
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all non-hidden views.
|
||||
*/
|
||||
export function useVisibleViews() {
|
||||
useViews()
|
||||
return visibleViews
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,17 +11,13 @@
|
|||
<FilesNavigationSearch />
|
||||
</template>
|
||||
<template #default>
|
||||
<NcAppNavigationList
|
||||
class="files-navigation__list"
|
||||
:aria-label="t('files', 'Views')">
|
||||
<FilesNavigationItem :views="viewMap" />
|
||||
</NcAppNavigationList>
|
||||
<FilesNavigationList />
|
||||
|
||||
<!-- Settings modal-->
|
||||
<FilesAppSettings
|
||||
:open.sync="settingsOpened"
|
||||
data-cy-files-navigation-settings
|
||||
@close="onSettingsClose" />
|
||||
@close="settingsOpened = false" />
|
||||
</template>
|
||||
|
||||
<!-- Non-scrollable navigation bottom elements -->
|
||||
|
|
@ -34,7 +30,7 @@
|
|||
<NcAppNavigationItem
|
||||
:name="t('files', 'Files settings')"
|
||||
data-cy-files-navigation-settings-button
|
||||
@click.prevent.stop="openSettings">
|
||||
@click.prevent.stop="settingsOpened = true">
|
||||
<IconCog slot="icon" :size="20" />
|
||||
</NcAppNavigationItem>
|
||||
</ul>
|
||||
|
|
@ -42,163 +38,43 @@
|
|||
</NcAppNavigation>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { View } from '@nextcloud/files'
|
||||
import type { ViewConfig } from '../types.ts'
|
||||
|
||||
import { emit, subscribe } from '@nextcloud/event-bus'
|
||||
<script setup lang="ts">
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import { getNavigation } from '@nextcloud/files'
|
||||
import { getCanonicalLocale, getLanguage, t } from '@nextcloud/l10n'
|
||||
import { defineComponent } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { useRoute } from 'vue-router/composables'
|
||||
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
|
||||
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
|
||||
import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList'
|
||||
import IconCog from 'vue-material-design-icons/CogOutline.vue'
|
||||
import FilesNavigationItem from '../components/FilesNavigationItem.vue'
|
||||
import FilesNavigationList from '../components/FilesNavigationList.vue'
|
||||
import FilesNavigationSearch from '../components/FilesNavigationSearch.vue'
|
||||
import NavigationQuota from '../components/NavigationQuota.vue'
|
||||
import FilesAppSettings from './FilesAppSettings.vue'
|
||||
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'
|
||||
|
||||
const collator = Intl.Collator(
|
||||
[getLanguage(), getCanonicalLocale()],
|
||||
{
|
||||
numeric: true,
|
||||
usage: 'sort',
|
||||
},
|
||||
)
|
||||
const sidebar = useSidebarStore()
|
||||
const activeStore = useActiveStore()
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FilesNavigation',
|
||||
const settingsOpened = ref(false)
|
||||
|
||||
components: {
|
||||
IconCog,
|
||||
FilesAppSettings,
|
||||
FilesNavigationItem,
|
||||
FilesNavigationSearch,
|
||||
const allViews = useViews()
|
||||
|
||||
NavigationQuota,
|
||||
NcAppNavigation,
|
||||
NcAppNavigationItem,
|
||||
NcAppNavigationList,
|
||||
},
|
||||
|
||||
setup() {
|
||||
const sidebar = useSidebarStore()
|
||||
const activeStore = useActiveStore()
|
||||
const filtersStore = useFiltersStore()
|
||||
const viewConfigStore = useViewConfigStore()
|
||||
|
||||
return {
|
||||
t,
|
||||
|
||||
sidebar,
|
||||
activeStore,
|
||||
filtersStore,
|
||||
viewConfigStore,
|
||||
|
||||
views: useViews(),
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
settingsOpened: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* The current view ID from the route params
|
||||
*/
|
||||
currentViewId() {
|
||||
return this.$route?.params?.view || 'files'
|
||||
},
|
||||
|
||||
/**
|
||||
* Map of parent ids to views
|
||||
*/
|
||||
viewMap(): Record<string, View[]> {
|
||||
return this.views
|
||||
.reduce((map, view) => {
|
||||
map[view.parent!] = [...(map[view.parent!] || []), view]
|
||||
map[view.parent!].sort((a, b) => {
|
||||
if (typeof a.order === 'number' || typeof b.order === 'number') {
|
||||
return (a.order ?? 0) - (b.order ?? 0)
|
||||
}
|
||||
return collator.compare(a.name, b.name)
|
||||
})
|
||||
return map
|
||||
}, {} as Record<string, View[]>)
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
currentViewId(newView, oldView) {
|
||||
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
|
||||
this.showView(view)
|
||||
logger.debug(`Navigation changed from ${oldView} to ${newView}`, { to: view })
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
subscribe('files:folder-tree:initialized', this.loadExpandedViews)
|
||||
subscribe('files:folder-tree:expanded', this.loadExpandedViews)
|
||||
},
|
||||
|
||||
beforeMount() {
|
||||
// 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: {
|
||||
async loadExpandedViews() {
|
||||
const viewsToLoad: View[] = (Object.entries(this.viewConfigStore.viewConfigs) as Array<[string, ViewConfig]>)
|
||||
.filter(([, config]) => config.expanded === true)
|
||||
.map(([viewId]) => this.views.find((view) => view.id === viewId))
|
||||
.filter(Boolean as unknown as ((u: unknown) => u is View))
|
||||
.filter((view) => view.loadChildViews && !view.loaded)
|
||||
for (const view of viewsToLoad) {
|
||||
await view.loadChildViews(view)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the view as active on the navigation and handle internal state
|
||||
*
|
||||
* @param view View to set active
|
||||
*/
|
||||
showView(view: View) {
|
||||
this.sidebar.close()
|
||||
const route = useRoute()
|
||||
const currentViewId = computed(() => route?.params?.view || 'files')
|
||||
watchEffect(() => {
|
||||
if (currentViewId.value !== activeStore.activeView?.id) {
|
||||
logger.debug(`Route view id ${currentViewId.value} is different from active view id ${activeStore.activeView?.id}, updating active view...`)
|
||||
const view = allViews.value.find(({ id }) => id === currentViewId.value)!
|
||||
if (view) {
|
||||
sidebar.close()
|
||||
getNavigation().setActive(view.id)
|
||||
emit('files:navigation:changed', view)
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the settings modal
|
||||
*/
|
||||
openSettings() {
|
||||
this.settingsOpened = true
|
||||
},
|
||||
|
||||
/**
|
||||
* Close the settings modal
|
||||
*/
|
||||
onSettingsClose() {
|
||||
this.settingsOpened = false
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -223,10 +99,6 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
.files-navigation {
|
||||
&__list {
|
||||
height: 100%; // Fill all available space for sticky views
|
||||
}
|
||||
|
||||
:deep(.app-navigation__content > ul.app-navigation__list) {
|
||||
will-change: scroll-position;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue