summaryrefslogtreecommitdiffstats
path: root/apps/files/src/services/Navigation.ts
blob: 01b6e701c7223e86edd785fcf49d384b616c8e2c (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
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
/**
 * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
 *
 * @author John Molakvoæ <skjnldsv@protonmail.com>
 *
 * @license AGPL-3.0-or-later
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 *
 */
/* eslint-disable */
import type { Folder, Node } from '@nextcloud/files'
import isSvg from 'is-svg'

import logger from '../logger.js'

export type ContentsWithRoot = {
	folder: Folder,
	contents: Node[]
}

export interface Column {
	/** Unique column ID */
	id: string
	/** Translated column title */
	title: string
	/**
	 * Property key from Node main or additional attributes.
	 * Will be used if no custom sort function is provided.
	 * Sorting will be done by localCompare
	 */
	property: string
	/** Special function used to sort Nodes between them */
	sortFunction?: (nodeA: Node, nodeB: Node) => number;
	/** Custom summary of the column to display at the end of the list.
	 Will not be displayed if  nothing is provided */
	summary?: (node: Node[]) => string
}

export interface Navigation {
	/** Unique view ID */
	id: string
	/** Translated view name */
	name: string
	/**
	 * Method return the content of the  provided path
	 * This ideally should be a cancellable promise.
	 * promise.cancel(reason) will be called when the directory
	 * change and the promise is not resolved yet.
	 * You _must_ also return the current directory
	 * information alongside with its content.
	 */
	getContents: (path: string) => Promise<ContentsWithRoot[]>
	/** The view icon as an inline svg */
	icon: string
	/** The view order */
	order: number
	/** This view column(s). Name and actions are
	by default always included */
	columns?: Column[]
	/** The empty view element to render your empty content into */
	emptyView?: (div: HTMLDivElement) => void
	/** The parent unique ID */
	parent?: string
	/** This view is sticky (sent at the bottom) */
	sticky?: boolean
	/** This view has children and is expanded or not */
	expanded?: boolean

	/**
	 * This view is sticky a legacy view.
	 * Here until all the views are migrated to Vue.
	 * @deprecated It will be removed in a near future
	 */
	legacy?: boolean
	/**
	 * An icon class. 
	 * @deprecated It will be removed in a near future
	 */
	iconClass?: string
}

export default class {

	private _views: Navigation[] = []
	private _currentView: Navigation | null = null

	constructor() {
		logger.debug('Navigation service initialized')
	}

	register(view: Navigation) {
		try {
			isValidNavigation(view)
			isUniqueNavigation(view, this._views)
		} catch (e) {
			if (e instanceof Error) {
				logger.error(e.message, { view })
			}
			throw e
		}

		if (view.legacy) {
			logger.warn('Legacy view detected, please migrate to Vue')
		}

		if (view.iconClass) {
			view.legacy = true
		}

		this._views.push(view)
	}

	get views(): Navigation[] {
		return this._views
	}

	setActive(view: Navigation | null) {
		this._currentView = view
	}

	get active(): Navigation | null {
		return this._currentView
	}

}

/**
 * Make sure the given view is unique
 * and not already registered.
 */
const isUniqueNavigation = function(view: Navigation, views: Navigation[]): boolean {
	if (views.find(search => search.id === view.id)) {
		throw new Error(`Navigation id ${view.id} is already registered`)
	}
	return true
}

/**
 * Typescript cannot validate an interface.
 * Please keep in sync with the Navigation interface requirements.
 */
const isValidNavigation = function(view: Navigation): boolean {
	if (!view.id || typeof view.id !== 'string') {
		throw new Error('Navigation id is required and must be a string')
	}

	if (!view.name || typeof view.name !== 'string') {
		throw new Error('Navigation name is required and must be a string')
	}

	/**
	 * Legacy handle their content and icon differently
	 * TODO: remove when support for legacy views is removed
	 */
	if (!view.legacy) {
		if (!view.getContents || typeof view.getContents !== 'function') {
			throw new Error('Navigation getContents is required and must be a function')
		}

		if (!view.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) {
			throw new Error('Navigation icon is required and must be a valid svg string')
		}
	}

	if (!('order' in view) || typeof view.order !== 'number') {
		throw new Error('Navigation order is required and must be a number')
	}

	// Optional properties
	if (view.columns) {
		view.columns.forEach(isValidColumn)
	}

	if (view.emptyView && typeof view.emptyView !== 'function') {
		throw new Error('Navigation emptyView must be a function')
	}

	if (view.parent && typeof view.parent !== 'string') {
		throw new Error('Navigation parent must be a string')
	}

	if ('sticky' in view && typeof view.sticky !== 'boolean') {
		throw new Error('Navigation sticky must be a boolean')
	}

	if ('expanded' in view && typeof view.expanded !== 'boolean') {
		throw new Error('Navigation expanded must be a boolean')
	}

	return true
}

/**
 * Typescript cannot validate an interface.
 * Please keep in sync with the Column interface requirements.
 */
const isValidColumn = function(column: Column): boolean {
	if (!column.id || typeof column.id !== 'string') {
		throw new Error('Column id is required')
	}

	if (!column.title || typeof column.title !== 'string') {
		throw new Error('Column title is required')
	}

	if (!column.property || typeof column.property !== 'string') {
		throw new Error('Column property is required')
	}

	// Optional properties
	if (column.sortFunction && typeof column.sortFunction !== 'function') {
		throw new Error('Column sortFunction must be a function')
	}

	if (column.summary && typeof column.summary !== 'function') {
		throw new Error('Column summary must be a function')
	}

	return true
}