diff --git a/__tests__/sidebar/sidebarTab.spec.ts b/__tests__/sidebar/sidebarTab.spec.ts new file mode 100644 index 000000000..4a1dd461e --- /dev/null +++ b/__tests__/sidebar/sidebarTab.spec.ts @@ -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: '', + order: 0, + mount: vi.fn(), + unmount: vi.fn(), + update: vi.fn(), + setActive: vi.fn(), + onScrollBottomReached: vi.fn(), + } +} diff --git a/lib/index.ts b/lib/index.ts index cd16c35d9..3f97ec971 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -10,6 +10,7 @@ export * from './navigation/index.ts' export * from './newMenu/index.ts' export * from './node/index.ts' export * from './permissions.ts' +export * from './sidebar/index.ts' export * from './utils/index.ts' // Legacy export of dav utils diff --git a/lib/sidebar/index.ts b/lib/sidebar/index.ts new file mode 100644 index 000000000..3ca7d2bf1 --- /dev/null +++ b/lib/sidebar/index.ts @@ -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 + + /** + * 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 + + /** + * 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 + + /** + * 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 + + /** + * Called when the bottom of the sidebar was reached during scrolling. + */ + onScrollBottomReached?: () => void | Promise +} + +/** + * 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() + 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') + } +} diff --git a/lib/window.d.ts b/lib/window.d.ts index 0af2eb4ea..fbd357f05 100644 --- a/lib/window.d.ts +++ b/lib/window.d.ts @@ -13,9 +13,8 @@ import type { NewMenu, } from './index.ts' -import type { - DavProperty, -} from './dav/index.ts' +import type { DavProperty } from './dav/index.ts' +import type { ISidebarTab } from './sidebar/index.ts' export {} @@ -30,6 +29,7 @@ declare global { _nc_newfilemenu?: NewMenu _nc_navigation?: Navigation _nc_filelist_filters?: Map + _nc_files_sidebar_tabs?: Map _oc_config?: { forbidden_filenames_characters: string[] diff --git a/package-lock.json b/package-lock.json index aa6719a16..2a6a2053b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@nextcloud/vite-config": "^2.4.0", "@types/node": "^24.2.1", "@vitest/coverage-istanbul": "^3.2.4", + "css.escape": "^1.5.1", "fast-xml-parser": "^5.2.5", "jsdom": "^26.1.0", "tslib": "^2.8.1", @@ -4406,6 +4407,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", diff --git a/package.json b/package.json index a2c370203..eacd024eb 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@nextcloud/vite-config": "^2.4.0", "@types/node": "^24.2.1", "@vitest/coverage-istanbul": "^3.2.4", + "css.escape": "^1.5.1", "fast-xml-parser": "^5.2.5", "jsdom": "^26.1.0", "tslib": "^2.8.1",