Skip to content

Commit

Permalink
feat(navigation): Allow to listen for active navigation changes
Browse files Browse the repository at this point in the history
This adds the native `EventTarget` class to the Navigation allowing to add listeners to it.
Allowing to dispatch the `updateActive` event when the active navigation changed.

The idea is to make it reactive in a native / framework agnostic way.

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
  • Loading branch information
susnux committed Jun 14, 2024
1 parent 2a13c48 commit c046d93
Show file tree
Hide file tree
Showing 4 changed files with 203 additions and 8 deletions.
124 changes: 124 additions & 0 deletions __tests__/navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -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: '<svg></svg>', 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)
})
})
80 changes: 72 additions & 8 deletions lib/navigation/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<View | null> {
type: 'updateActive'
}

/**
* This event is emitted when the list of registered views is changed
*/
interface UpdateViewsEvent extends CustomEvent<never> {
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<never>('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<View | null>('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()
Expand Down
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

0 comments on commit c046d93

Please sign in to comment.