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" } }