diff --git a/__tests__/navigation.spec.ts b/__tests__/navigation.spec.ts
new file mode 100644
index 00000000..61a0c9ac
--- /dev/null
+++ b/__tests__/navigation.spec.ts
@@ -0,0 +1,124 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { describe, it, expect, vi } from 'vitest'
+import { Navigation, getNavigation } from '../lib/navigation/navigation'
+import { View } from '../lib/navigation/view'
+
+const mockView = (id = 'view', order = 1) => new View({ id, order, name: 'View', icon: '', getContents: () => Promise.reject(new Error()) })
+
+describe('getNavigation', () => {
+ it('creates a new navigation if needed', () => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ delete window._nc_navigation
+ const navigation = getNavigation()
+ expect(navigation).toBeInstanceOf(Navigation)
+ })
+
+ it('stores the navigation globally', () => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ delete window._nc_navigation
+ const navigation = getNavigation()
+ expect(navigation).toBeInstanceOf(Navigation)
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ expect(window._nc_navigation).toBeInstanceOf(Navigation)
+ })
+
+ it('reuses an existing navigation', () => {
+ const navigation = new Navigation()
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ window._nc_navigation = navigation
+ expect(getNavigation()).toBe(navigation)
+ })
+})
+
+describe('Navigation', () => {
+ it('Can register a view', async () => {
+ const navigation = new Navigation()
+ const view = mockView()
+ navigation.register(view)
+
+ expect(navigation.views).toEqual([view])
+ })
+
+ it('Throws when registering the same view twice', async () => {
+ const navigation = new Navigation()
+ const view = mockView()
+ navigation.register(view)
+ expect(() => navigation.register(view)).toThrow(/already registered/)
+ expect(navigation.views).toEqual([view])
+ })
+
+ it('Emits update event after registering a view', async () => {
+ const navigation = new Navigation()
+ const view = mockView()
+ const listener = vi.fn()
+
+ navigation.addEventListener('update', listener)
+ navigation.register(view)
+
+ expect(listener).toHaveBeenCalled()
+ expect(listener.mock.calls[0][0].type).toBe('update')
+ })
+
+ it('Can remove a view', async () => {
+ const navigation = new Navigation()
+ const view = mockView()
+ navigation.register(view)
+ expect(navigation.views).toEqual([view])
+ navigation.remove(view.id)
+ expect(navigation.views).toEqual([])
+ })
+
+ it('Emits update event after removing a view', async () => {
+ const navigation = new Navigation()
+ const view = mockView()
+ const listener = vi.fn()
+ navigation.register(view)
+ navigation.addEventListener('update', listener)
+
+ navigation.remove(view.id)
+ expect(listener).toHaveBeenCalled()
+ expect(listener.mock.calls[0][0].type).toBe('update')
+ })
+
+ it('does not emit an event when nothing was removed', async () => {
+ const navigation = new Navigation()
+ const listener = vi.fn()
+ navigation.addEventListener('update', listener)
+
+ navigation.remove('not-existing')
+ expect(listener).not.toHaveBeenCalled()
+ })
+
+ it('Can set a view as active', async () => {
+ const navigation = new Navigation()
+ const view = mockView()
+ navigation.register(view)
+
+ expect(navigation.active).toBe(null)
+
+ navigation.setActive(view)
+ expect(navigation.active).toEqual(view)
+ })
+
+ it('Emits event when setting a view as active', async () => {
+ const navigation = new Navigation()
+ const view = mockView()
+ navigation.register(view)
+
+ // add listener
+ const listener = vi.fn()
+ navigation.addEventListener('updateActive', listener)
+
+ navigation.setActive(view)
+ expect(listener).toHaveBeenCalledOnce()
+ // So it was called, we then expect the first argument of the first call to be the event with the view as the detail
+ expect(listener.mock.calls[0][0].detail).toBe(view)
+ })
+})
diff --git a/lib/navigation/navigation.ts b/lib/navigation/navigation.ts
index 79fbea9a..1a710cb6 100644
--- a/lib/navigation/navigation.ts
+++ b/lib/navigation/navigation.ts
@@ -3,42 +3,106 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { View } from './view'
+import { TypedEventTarget } from 'typescript-event-target'
import logger from '../utils/logger'
-export class Navigation {
+/**
+ * The event is emitted when the navigation view was updated.
+ * It contains the new active view in the `detail` attribute.
+ */
+interface UpdateActiveViewEvent extends CustomEvent {
+ type: 'updateActive'
+}
+
+/**
+ * This event is emitted when the list of registered views is changed
+ */
+interface UpdateViewsEvent extends CustomEvent {
+ type: 'update'
+}
+
+/**
+ * The files navigation manages the available and active views
+ *
+ * Custom views for the files app can be registered (examples are the favorites views or the shared-with-you view).
+ * It is also possible to listen on changes of the registered views or when the current active view is changed.
+ * @example
+ * ```js
+ * const navigation = getNavigation()
+ * navigation.addEventListener('update', () => {
+ * // This will be called whenever a new view is registered or a view is removed
+ * const viewNames = navigation.views.map((view) => view.name)
+ * console.warn('Registered views changed', viewNames)
+ * })
+ * // Or you can react to changes of the current active view
+ * navigation.addEventListener('updateActive', (event) => {
+ * // This will be called whenever the active view changed
+ * const newView = event.detail // you could also use `navigation.active`
+ * console.warn('Active view changed to ' + newView.name)
+ * })
+ * ```
+ */
+export class Navigation extends TypedEventTarget<{ updateActive: UpdateActiveViewEvent, update: UpdateViewsEvent }> {
private _views: View[] = []
private _currentView: View | null = null
- register(view: View) {
+ /**
+ * Register a new view on the navigation
+ * @param view The view to register
+ * @throws `Error` is thrown if a view with the same id is already registered
+ */
+ register(view: View): void {
if (this._views.find(search => search.id === view.id)) {
throw new Error(`View id ${view.id} is already registered`)
}
this._views.push(view)
+ this.dispatchTypedEvent('update', new CustomEvent('update') as UpdateViewsEvent)
}
- remove(id: string) {
+ /**
+ * Remove a registered view
+ * @param id The id of the view to remove
+ */
+ remove(id: string): void {
const index = this._views.findIndex(view => view.id === id)
if (index !== -1) {
this._views.splice(index, 1)
+ this.dispatchTypedEvent('update', new CustomEvent('update') as UpdateViewsEvent)
}
}
- get views(): View[] {
- return this._views
- }
-
- setActive(view: View | null) {
+ /**
+ * Set the currently active view
+ * @fires UpdateActiveViewEvent
+ * @param view New active view
+ */
+ setActive(view: View | null): void {
this._currentView = view
+ const event = new CustomEvent('updateActive', { detail: view })
+ this.dispatchTypedEvent('updateActive', event as UpdateActiveViewEvent)
}
+ /**
+ * The currently active files view
+ */
get active(): View | null {
return this._currentView
}
+ /**
+ * All registered views
+ */
+ get views(): View[] {
+ return this._views
+ }
+
}
+/**
+ * Get the current files navigation
+ */
export const getNavigation = function(): Navigation {
if (typeof window._nc_navigation === 'undefined') {
window._nc_navigation = new Navigation()
diff --git a/package-lock.json b/package-lock.json
index b5832888..14d4e2ad 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
"@nextcloud/router": "^3.0.1",
"cancelable-promise": "^4.3.1",
"is-svg": "^5.0.1",
+ "typescript-event-target": "^1.1.1",
"webdav": "^5.6.0"
},
"devDependencies": {
@@ -8237,6 +8238,11 @@
"node": ">=14.17"
}
},
+ "node_modules/typescript-event-target": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/typescript-event-target/-/typescript-event-target-1.1.1.tgz",
+ "integrity": "sha512-dFSOFBKV6uwaloBCCUhxlD3Pr/P1a/tJdcmPrTXCHlEFD3faj0mztjcGn6VBAhQ0/Bdy8K3VWrrqwbt/ffsYsg=="
+ },
"node_modules/ufo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.2.tgz",
diff --git a/package.json b/package.json
index 2e2176c1..3665eabd 100644
--- a/package.json
+++ b/package.json
@@ -73,6 +73,7 @@
"@nextcloud/router": "^3.0.1",
"cancelable-promise": "^4.3.1",
"is-svg": "^5.0.1",
+ "typescript-event-target": "^1.1.1",
"webdav": "^5.6.0"
}
}