diff --git a/.eslintrc.json b/.eslintrc.json index 64afe8fb6..8a95224bc 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,5 +1,13 @@ { "extends": [ "@nextcloud/eslint-config/typescript" + ], + "overrides": [ + { + "files": ["**.spec.*"], + "rules": { + "no-console": "off" + } + } ] } diff --git a/__tests__/sidebar/sidebarTab.spec.ts b/__tests__/sidebar/sidebarTab.spec.ts new file mode 100644 index 000000000..503a907d3 --- /dev/null +++ b/__tests__/sidebar/sidebarTab.spec.ts @@ -0,0 +1,172 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IFolder, INode, IView } from '../../lib/index.ts' + +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest' +import { getSidebarTabs, ISidebarTab, registerSidebarTab } from '../../lib/sidebar' +// missing in JSDom but supported by every browser! +import 'css.escape' + +class SidebarTabMock extends HTMLElement { + + node?: INode + folder?: IFolder + view?: IView + + public setActive(active: boolean) { + console.log('setActive', active) + } + +} + +describe('Sidebar tabs', () => { + let getCustomElementsSpy: Mock + + beforeEach(() => { + vi.restoreAllMocks() + getCustomElementsSpy = vi.spyOn(window.customElements, 'get') + .mockImplementation(() => SidebarTabMock) + 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 tagName name', () => { + expect( + // @ts-expect-error mocking for testing + () => registerSidebarTab({ ...getExampleTab(), tagName: undefined }), + ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have the tagName name set]') + }) + + it('fails with invalid tagName name', () => { + expect(() => registerSidebarTab({ ...getExampleTab(), tagName: 'MyAppSidebarTab' })) + .toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs tagName name is invalid]') + }) + + it('fails with non registered element', () => { + getCustomElementsSpy.mockImplementationOnce(() => undefined) + expect(() => registerSidebarTab(getExampleTab())).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tab element not registered]') + }) + + it('fails with invalid custom element', () => { + getCustomElementsSpy.mockImplementationOnce(() => HTMLElement) + expect(() => registerSidebarTab(getExampleTab())).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tab elements must have the `setActive` method]') + }) + + 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(), iconSvgInline: undefined }), + ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an valid SVG icon]') + }) + + it('fails with invalid SVG icon', () => { + expect( + () => registerSidebarTab({ ...getExampleTab(), iconSvgInline: '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]') + }) + }) +}) + +/** + * Get a very basic mock of a sidebar tab + */ +function getExampleTab(): ISidebarTab { + return { + id: 'example-tab', + displayName: 'Example', + tagName: 'example_app-files-sidebar-tab', + enabled: vi.fn(), + iconSvgInline: '', + order: 0, + } +} diff --git a/__tests__/view.spec.ts b/__tests__/view.spec.ts index 04291c128..969c6a015 100644 --- a/__tests__/view.spec.ts +++ b/__tests__/view.spec.ts @@ -182,6 +182,9 @@ describe('View creation', () => { }) }) +/** + * Creates a mock View and its associated Folder for testing purposes. + */ export function mockView() { const folder = new Folder({ source: 'https://example.org/dav/files/admin/', diff --git a/lib/index.ts b/lib/index.ts index 6327ae270..69c32d997 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -12,4 +12,5 @@ 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' diff --git a/lib/sidebar/index.ts b/lib/sidebar/index.ts new file mode 100644 index 000000000..0ef3afe2f --- /dev/null +++ b/lib/sidebar/index.ts @@ -0,0 +1,163 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IView } from '../navigation/view.ts' +import type { IFolder, INode } from '../node/index.ts' + +import isSvg from 'is-svg' +import logger from '../utils/logger.ts' + +export interface ISidebarContext { + /** + * The active node in the sidebar + */ + node: INode + + /** + * The current open folder in the files app + */ + folder: IFolder + + /** + * The currently active view + */ + view: IView +} + +/** + * This component describes the custom web component that should be registered for a sidebar tab. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_components + * @see https://vuejs.org/guide/extras/web-components#building-custom-elements-with-vue + */ +export interface SidebarComponent extends HTMLElement, ISidebarContext { + /** + * This method is called by the files app if the sidebar tab state changes. + * + * @param active - The new active state + */ + setActive(active: boolean): Promise +} + +/** + * 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. + */ + iconSvgInline: string + + /** + * The order of this tab. + * Use a low number to make this tab ordered in front. + */ + order: number + + /** + * The tag name of the web component. + * The web component must already be registered under that tag name with `CustomElementRegistry.define()`. + * + * To avoid name clashes the name has to start with your appid (e.g. `your_app`). + * So in addition with the web component naming rules a good name would be `your_app-files-sidebar-tab`. + */ + tagName: string + + /** + * Callback to check if the sidebar tab should be shown for the selected node. + * + * @param context - The current context of the files app + */ + enabled: (context: ISidebarContext) => boolean +} + +/** + * 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.tagName || typeof tab.tagName !== 'string') { + throw new Error('Sidebar tabs need to have the tagName name set') + } + + if (!tab.tagName.match(/^[a-z][a-z0-9-_]+$/)) { + throw new Error('Sidebar tabs tagName name is invalid') + } + + if (!tab.displayName || typeof tab.displayName !== 'string') { + throw new Error('Sidebar tabs need to have a name set') + } + + if (typeof tab.iconSvgInline !== 'string' || !isSvg(tab.iconSvgInline)) { + 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') + } + + // now check the custom element constructor + const tagConstructor = window.customElements.get(tab.tagName) + if (!tagConstructor) { + throw new Error('Sidebar tab element not registered') + } + + if (!('setActive' in tagConstructor.prototype)) { + // we cannot check properties like `node` or `view` because those are not necessarily defined in the prototype. + throw new Error('Sidebar tab elements must have the `setActive` method') + } +} diff --git a/lib/window.d.ts b/lib/window.d.ts index 32bb6a0cf..84b1c5007 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 b4378598d..a2b961597 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@nextcloud/vite-config": "^2.5.2", "@types/node": "^25.0.0", "@vitest/coverage-istanbul": "^4.0.15", + "css.escape": "^1.5.1", "fast-xml-parser": "^5.3.2", "jsdom": "^27.3.0", "tslib": "^2.8.1", @@ -4705,6 +4706,13 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "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 e59fb7ee6..5f1f1dcd3 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@nextcloud/vite-config": "^2.5.2", "@types/node": "^25.0.0", "@vitest/coverage-istanbul": "^4.0.15", + "css.escape": "^1.5.1", "fast-xml-parser": "^5.3.2", "jsdom": "^27.3.0", "tslib": "^2.8.1",