-
Notifications
You must be signed in to change notification settings - Fork 12
feat(sidebar): provide public API to register a sidebar tab #1305
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| /** | ||
| * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors | ||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||
| */ | ||
|
|
||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | ||
| import { getSidebarTabs, ISidebarTab, registerSidebarTab } from '../../lib/sidebar' | ||
|
|
||
| // missing in JSDom but supported by every browser! | ||
| import 'css.escape' | ||
|
|
||
| describe('Sidebar tabs', () => { | ||
| beforeEach(() => { | ||
| delete window._nc_files_sidebar_tabs | ||
| }) | ||
|
|
||
| it('can register a tab', () => { | ||
| const tab = getExampleTab() | ||
| registerSidebarTab(tab) | ||
| expect(window._nc_files_sidebar_tabs).toBeInstanceOf(Map) | ||
| expect(window._nc_files_sidebar_tabs!.has(tab.id)).toBe(true) | ||
| expect(window._nc_files_sidebar_tabs!.get(tab.id)).toBe(tab) | ||
| }) | ||
|
|
||
| it('can fetch empty list of sidebar tabs', () => { | ||
| expect(getSidebarTabs()).toBeInstanceOf(Array) | ||
| expect(getSidebarTabs()).toHaveLength(0) | ||
| }) | ||
|
|
||
| it('can fetch list of sidebar tabs', () => { | ||
| registerSidebarTab(getExampleTab()) | ||
| registerSidebarTab({ ...getExampleTab(), id: 'another-example' }) | ||
|
|
||
| expect(getSidebarTabs()).toBeInstanceOf(Array) | ||
| expect(getSidebarTabs()).toHaveLength(2) | ||
| }) | ||
|
|
||
| it('only registeres same id once', () => { | ||
| const consoleSpy = vi.spyOn(console, 'warn') | ||
| consoleSpy.mockImplementationOnce(() => {}) | ||
|
|
||
| registerSidebarTab(getExampleTab()) | ||
| registerSidebarTab(getExampleTab()) | ||
| expect(consoleSpy).toHaveBeenCalledOnce() | ||
| expect(getSidebarTabs()).toHaveLength(1) | ||
| }) | ||
|
|
||
| describe('Tab validation', () => { | ||
| it('fails with an invalid parameter', () => { | ||
| expect( | ||
| // @ts-expect-error mocking for testing | ||
| () => registerSidebarTab(getExampleTab), | ||
| ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tab is not an object]') | ||
| }) | ||
|
|
||
| it('fails with missing id', () => { | ||
| expect( | ||
| // @ts-expect-error mocking for testing | ||
| () => registerSidebarTab({ ...getExampleTab(), id: undefined }), | ||
| ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an id conforming to the HTML id attribute specifications]') | ||
| }) | ||
|
|
||
| it('fails with non conforming id', () => { | ||
| expect( | ||
| () => registerSidebarTab({ ...getExampleTab(), id: 'this is invalid' }), | ||
| ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an id conforming to the HTML id attribute specifications]') | ||
| }) | ||
|
|
||
| it('fails with missing name', () => { | ||
| expect( | ||
| // @ts-expect-error mocking for testing | ||
| () => registerSidebarTab({ ...getExampleTab(), displayName: undefined }), | ||
| ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a name set]') | ||
| }) | ||
|
|
||
| it('fails with invalid name', () => { | ||
| expect( | ||
| // @ts-expect-error mocking for testing | ||
| () => registerSidebarTab({ ...getExampleTab(), displayName: 1234 }), | ||
| ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a name set]') | ||
| }) | ||
|
|
||
| it('fails with missing icon', () => { | ||
| expect( | ||
| // @ts-expect-error mocking for testing | ||
| () => registerSidebarTab({ ...getExampleTab(), iconSvg: undefined }), | ||
| ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an valid SVG icon]') | ||
| }) | ||
|
|
||
| it('fails with invalid SVG icon', () => { | ||
| expect( | ||
| () => registerSidebarTab({ ...getExampleTab(), iconSvg: 'icon-group' }), | ||
| ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an valid SVG icon]') | ||
| }) | ||
|
|
||
| it('fails with missing order', () => { | ||
| expect( | ||
| // @ts-expect-error mocking for testing | ||
| () => registerSidebarTab({ ...getExampleTab(), order: undefined }), | ||
| ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a numeric order set]') | ||
| }) | ||
|
|
||
| it('fails with invalid order', () => { | ||
| expect( | ||
| // @ts-expect-error mocking for testing | ||
| () => registerSidebarTab({ ...getExampleTab(), order: '3' }), | ||
| ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a numeric order set]') | ||
| }) | ||
|
|
||
| it('fails with missing "enabled" method', () => { | ||
| expect( | ||
| // @ts-expect-error mocking for testing | ||
| () => registerSidebarTab({ ...getExampleTab(), enabled: undefined }), | ||
| ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an "enabled" method]') | ||
| }) | ||
|
|
||
| it('fails with missing "setActive" method', () => { | ||
| expect( | ||
| // @ts-expect-error mocking for testing | ||
| () => registerSidebarTab({ ...getExampleTab(), setActive: undefined }), | ||
| ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a "setActive" method]') | ||
| }) | ||
|
|
||
| it.for(['mount', 'unmount', 'update'])('fails with missing lifecylce methods', (method) => { | ||
| expect( | ||
| () => registerSidebarTab({ ...getExampleTab(), [method]: undefined }), | ||
| ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tab is missing a required lifecycle method]') | ||
| }) | ||
|
|
||
| it('works without specifying a scroll listener', () => { | ||
| expect( | ||
| () => registerSidebarTab({ ...getExampleTab(), onScrollBottomReached: undefined }), | ||
| ).not.toThrow() | ||
| }) | ||
|
|
||
| it('fails with an invalid scroll listener', () => { | ||
| expect( | ||
| // @ts-expect-error mocking for testing | ||
| () => registerSidebarTab({ ...getExampleTab(), onScrollBottomReached: 'not a method' }), | ||
| ).toThrowErrorMatchingInlineSnapshot('[Error: "onScrollBottomReached" of the sidebar tab needs to be a function]') | ||
| }) | ||
| }) | ||
| }) | ||
|
|
||
| /** | ||
| * Get a very basic mock of a sidebar tab | ||
| */ | ||
| function getExampleTab(): ISidebarTab { | ||
| return { | ||
| id: 'example-tab', | ||
| displayName: 'Example', | ||
| enabled: vi.fn(), | ||
| iconSvg: '<svg><circle r="45" cx="50" cy="50" fill="red" /></svg>', | ||
| order: 0, | ||
| mount: vi.fn(), | ||
| unmount: vi.fn(), | ||
| update: vi.fn(), | ||
| setActive: vi.fn(), | ||
| onScrollBottomReached: vi.fn(), | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| /* | ||
| * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors | ||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||
| */ | ||
|
|
||
| import type { IFolder, INode } from '../node/index.ts' | ||
|
|
||
| import isSvg from 'is-svg' | ||
| import logger from '../utils/logger.ts' | ||
|
|
||
| interface SidebarUpdateContext { | ||
| /** | ||
| * The currently selected node for which the sidebar is shown. | ||
| */ | ||
| node: INode | ||
|
|
||
| /** | ||
| * The parent of the current selected node. | ||
| * This is not necessarily the real parent folder of the node in means of the real filesystem tree, | ||
| * but rather the parent folder in the current view of the files app. | ||
| */ | ||
| parent: IFolder | ||
| } | ||
|
|
||
| /** | ||
| * Implementation of a custom sidebar tab within the files app. | ||
| */ | ||
| export interface ISidebarTab { | ||
| /** | ||
| * Unique id of the sidebar tab. | ||
| * This has to conform to the HTML id attribute specification. | ||
| */ | ||
| id: string | ||
|
|
||
| /** | ||
| * The localized name of the sidebar tab. | ||
| */ | ||
| displayName: string | ||
|
|
||
| /** | ||
| * The icon, as SVG, of the sidebar tab. | ||
| */ | ||
| iconSvg: string | ||
|
|
||
| /** | ||
| * The order of this tab. | ||
| * Use a low number to make this tab ordered in front. | ||
| */ | ||
| order: number | ||
|
|
||
| /** | ||
| * Callback to check if the sidebar tab should be shown for the selected node. | ||
| * | ||
| * @param node - The currently selected node | ||
| */ | ||
| enabled: (node: INode) => boolean | ||
|
|
||
| /** | ||
| * Called by the files app if this tab has become the active tab or was deactivated. | ||
| * | ||
| * @param active - The new active state of this tab | ||
| */ | ||
| setActive: (active: boolean) => void | Promise<void> | ||
|
|
||
| /** | ||
| * The lifecylce method for mounting the sidebar tab onto the sidebar. | ||
| * | ||
| * @param el - The element to mount the sidebar tab to | ||
| * @param context - The current sidebar context | ||
| */ | ||
| mount: (el: HTMLElement, context: SidebarUpdateContext) => void | Promise<void> | ||
|
|
||
| /** | ||
| * The lifecycle method for updating the sidebar tab. | ||
| * This is called if the currently selected node changes. | ||
| * | ||
| * @param context - The current sidebar context | ||
| */ | ||
| update: (context: SidebarUpdateContext) => void | Promise<void> | ||
|
|
||
| /** | ||
| * The lifecycle method for unmounting the sidebar tab. | ||
| * This is called if the sidebar is unmounted from the files app and thus the sidebar tab needs to do its cleanup and unmounting. | ||
| */ | ||
| unmount: () => void | Promise<void> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. destroy maybe? I think unmounting is an odd edge case. We should maybe be clearer and let devs know the sidebar is actually destroyed.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would work for me - no strong opinion. I just used the names of the Vue lifecyles (mount and unmount). But it could also be that the tab is only unmounted but the sidebar is not destroyed (e.g. the current node is changed to something this tab is not enabled for). |
||
|
|
||
| /** | ||
| * Called when the bottom of the sidebar was reached during scrolling. | ||
| */ | ||
| onScrollBottomReached?: () => void | Promise<void> | ||
| } | ||
|
|
||
| /** | ||
| * Register a new sidebar tab for the files app. | ||
| * | ||
| * @param tab - The sidebar tab to register | ||
| * @throws If the provided tab is not a valid sidebar tab and thus cannot be registered. | ||
| */ | ||
| export function registerSidebarTab(tab: ISidebarTab): void { | ||
| validateSidebarTab(tab) | ||
|
|
||
| window._nc_files_sidebar_tabs ??= new Map<string, ISidebarTab>() | ||
| if (window._nc_files_sidebar_tabs.has(tab.id)) { | ||
| logger.warn(`Sidebar tab with id "${tab.id}" already registered. Skipping.`) | ||
| return | ||
| } | ||
| window._nc_files_sidebar_tabs.set(tab.id, tab) | ||
| logger.debug(`New sidebar tab with id "${tab.id}" registered.`) | ||
| } | ||
|
|
||
| /** | ||
| * Get all currently registered sidebar tabs. | ||
| */ | ||
| export function getSidebarTabs(): ISidebarTab[] { | ||
| if (window._nc_files_sidebar_tabs) { | ||
| return [...window._nc_files_sidebar_tabs.values()] | ||
| } | ||
| return [] | ||
| } | ||
|
|
||
| /** | ||
| * Check if a given sidebar tab objects implements all necessary fields. | ||
| * | ||
| * @param tab - The sidebar tab to validate | ||
| */ | ||
| function validateSidebarTab(tab: ISidebarTab): void { | ||
| if (typeof tab !== 'object') { | ||
| throw new Error('Sidebar tab is not an object') | ||
| } | ||
|
|
||
| if (!tab.id || (typeof tab.id !== 'string') || tab.id !== CSS.escape(tab.id)) { | ||
| throw new Error('Sidebar tabs need to have an id conforming to the HTML id attribute specifications') | ||
| } | ||
|
|
||
| if (!tab.displayName || typeof tab.displayName !== 'string') { | ||
| throw new Error('Sidebar tabs need to have a name set') | ||
| } | ||
|
|
||
| if (typeof tab.iconSvg !== 'string' || !isSvg(tab.iconSvg)) { | ||
| throw new Error('Sidebar tabs need to have an valid SVG icon') | ||
| } | ||
|
|
||
| if (typeof tab.order !== 'number') { | ||
| throw new Error('Sidebar tabs need to have a numeric order set') | ||
| } | ||
|
|
||
| if (typeof tab.enabled !== 'function') { | ||
| throw new Error('Sidebar tabs need to have an "enabled" method') | ||
| } | ||
|
|
||
| if (typeof tab.setActive !== 'function') { | ||
| throw new Error('Sidebar tabs need to have a "setActive" method') | ||
| } | ||
|
|
||
| if (typeof tab.mount !== 'function' | ||
| || typeof tab.update !== 'function' | ||
| || typeof tab.unmount !== 'function' | ||
| ) { | ||
| throw new Error('Sidebar tab is missing a required lifecycle method') | ||
| } | ||
|
|
||
| if (tab.onScrollBottomReached && typeof tab.onScrollBottomReached !== 'function') { | ||
| throw new Error('"onScrollBottomReached" of the sidebar tab needs to be a function') | ||
| } | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, we've been using events lately since you introduced them :)
Maybe it's time to make it a common pattern at Nextcloud?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But this is the opposite direction - currently this is used by the sidebar to notify a tab that is was "navigated to".
So that it could e.g. refresh its content.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, yeah 🤦