aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src/components/FilesNavigationItem.vue
blob: f05ad389f50f8e11fd938dae78db8b966562a787 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
<!--
  - 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 { PropType } from 'vue'
import type { View } from '@nextcloud/files'

import { defineComponent } from 'vue'
import { Fragment } from 'vue-frag'

import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'

import { useNavigation } from '../composables/useNavigation.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 { currentView } = useNavigation()
		const viewConfigStore = useViewConfigStore()
		return {
			currentView,
			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 => view.params?.dir.startsWith(this.parent.params?.dir))
			}
			return this.views[this.parent.id] ?? [] // Root level views have `undefined` parent ids
		},

		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: {
		hasChildViews(view: View): boolean {
			if (this.level >= maxLevel) {
				return false
			}
			return 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>