Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>pull/58277/head
parent
17ed55ff56
commit
d55f1572a0
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
Loading…
Reference in new issue