From 4d0a9409413189239bc9c3d0cc3a1dd5cfd00b19 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 28 Dec 2025 12:45:15 +0100 Subject: [PATCH] feat(sidebar): properly implement sidebar Proxy for Nextcloud 33+ Signed-off-by: Ferdinand Thiessen --- __tests__/sidebar/sidebarTab.spec.ts | 29 +------ lib/fileListFilters.ts | 2 +- lib/sidebar/Sidebar.ts | 107 ++++++++++++++++++++----- lib/sidebar/SidebarAction.ts | 112 +++++++++++++++++++++++++++ lib/sidebar/SidebarTab.ts | 11 --- lib/sidebar/index.ts | 3 +- lib/window.d.ts | 2 + 7 files changed, 204 insertions(+), 62 deletions(-) create mode 100644 lib/sidebar/SidebarAction.ts diff --git a/__tests__/sidebar/sidebarTab.spec.ts b/__tests__/sidebar/sidebarTab.spec.ts index 0c2dcdcef..2da93b15f 100644 --- a/__tests__/sidebar/sidebarTab.spec.ts +++ b/__tests__/sidebar/sidebarTab.spec.ts @@ -3,32 +3,15 @@ * 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 { beforeEach, describe, expect, it, vi } from 'vitest' import { getSidebarTabs, ISidebarTab, registerSidebarTab } from '../../lib/sidebar/SidebarTab.ts' // 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 }) @@ -97,16 +80,6 @@ describe('Sidebar tabs', () => { .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 diff --git a/lib/fileListFilters.ts b/lib/fileListFilters.ts index 92b730fb9..b0ec84416 100644 --- a/lib/fileListFilters.ts +++ b/lib/fileListFilters.ts @@ -31,7 +31,7 @@ export interface IFileListFilterChip { /** * Handler to be called on click */ - onclick: () => void + onclick(): void } /** diff --git a/lib/sidebar/Sidebar.ts b/lib/sidebar/Sidebar.ts index ab33bb762..27b06e5a6 100644 --- a/lib/sidebar/Sidebar.ts +++ b/lib/sidebar/Sidebar.ts @@ -3,9 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { INode } from '../node/node.ts' +import type { ISidebarAction } from './SidebarAction.ts' import type { ISidebarContext, ISidebarTab } from './SidebarTab.ts' -import { getSidebarTabs, registerSidebarTab } from './SidebarTab.ts' +import { registerSidebarAction } from './SidebarAction.ts' +import { registerSidebarTab } from './SidebarTab.ts' export interface ISidebar { /** @@ -17,17 +20,47 @@ export interface ISidebar { /** * The current open state of the sidebar */ - readonly open: boolean + readonly isOpen: boolean /** - * Open or close the sidebar + * The currently active sidebar tab id + */ + readonly activeTab?: string + + /** + * The currently opened node in the sidebar + */ + readonly node?: INode + + /** + * Open the sidebar for a specific node. + * + * When the sidebar is fully opened the `files:sidebar:opened` event is emitted, + * see also `@nextcloud/event-bus`. + * + * @param node - The node to open the sidebar for + * @param tab - The tab to open by default + */ + open(node: INode, tab?: string): void + + /** + * Close the sidebar. * - * @param open - The new open state + * When the sidebar is fully closed the `files:sidebar:closed` event is emitted, + * see also `@nextcloud/event-bus`. */ - setOpen(open: boolean): void + close(): void /** - * Register a new sidebar tab + * Set the active sidebar tab + * + * @param tabId - The tab to set active + */ + setActiveTab(tabId: string): void + + /** + * Register a new sidebar tab. + * This should ideally be done on app initialization using Nextcloud init scripts. * * @param tab - The sidebar tab to register */ @@ -38,6 +71,22 @@ export interface ISidebar { * If a node is passed only the enabled tabs are retrieved. */ getTabs(context?: ISidebarContext): ISidebarTab[] + + /** + * Get all registered sidebar actions. + * + * If a context is provided only the enabled actions are returned. + * + * @param context - The context + */ + getActions(context?: ISidebarContext): ISidebarAction[] + + /** + * Register a new sidebar action. + * + * @param action - The action to register + */ + registerAction(action: ISidebarAction): void } /** @@ -50,24 +99,36 @@ export interface ISidebar { */ class SidebarProxy implements ISidebar { + get #impl(): Omit | undefined { + return window.OCA?.Files?._sidebar?.() + } + get available(): boolean { - return !!window.OCA?.Files?.Sidebar + return !!this.#impl + } + + get isOpen(): boolean { + return this.#impl?.isOpen ?? false + } + + get activeTab(): string | undefined { + return this.#impl?.activeTab + } + + get node(): INode | undefined { + return this.#impl?.node } - get open(): boolean { - return !!window.OCA?.Files?.Sidebar?.state.file + open(node: INode, tab?: string): void { + this.#impl?.open(node, tab) } - setOpen(open: boolean): void { - if (open) { - window.OCA?.Files?.Sidebar?.open() - } else { - window.OCA?.Files?.Sidebar?.close() - } + close(): void { + this.#impl?.close() } setActiveTab(tabId: string): void { - window.OCA?.Files?.Sidebar?.setActiveTab(tabId) + this.#impl?.setActiveTab(tabId) } registerTab(tab: ISidebarTab): void { @@ -75,11 +136,15 @@ class SidebarProxy implements ISidebar { } getTabs(context?: ISidebarContext): ISidebarTab[] { - const tabs = getSidebarTabs() - if (context) { - return tabs.filter((tab) => tab.enabled(context)) - } - return tabs + return this.#impl?.getTabs(context) ?? [] + } + + getActions(context?: ISidebarContext): ISidebarAction[] { + return this.#impl?.getActions(context) ?? [] + } + + registerAction(action: ISidebarAction): void { + registerSidebarAction(action) } } diff --git a/lib/sidebar/SidebarAction.ts b/lib/sidebar/SidebarAction.ts new file mode 100644 index 000000000..fe7bda9cb --- /dev/null +++ b/lib/sidebar/SidebarAction.ts @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ISidebarContext } from './SidebarTab.ts' + +import logger from '../utils/logger.ts' + +/** + * Implementation of a custom sidebar tab within the files app. + */ +export interface ISidebarAction { + /** + * Unique id of the sidebar tab. + * This has to conform to the HTML id attribute specification. + */ + id: string + + /** + * The order of this tab. + * Use a low number to make this tab ordered in front. + */ + order: number + + /** + * The localized name of the sidebar tab. + * + * @param context - The current context of the files app + */ + displayName(context: ISidebarContext): string + + /** + * The icon, as SVG, of the sidebar tab. + * + * @param context - The current context of the files app + */ + iconSvgInline(context: ISidebarContext): 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 + + /** + * Handle the sidebar action. + * + * @param context - The current context of the files app + */ + onClick(context: ISidebarContext): void +} + +/** + * Register a new sidebar action. + * + * @param action - The sidebar action to register + * @throws If the provided action is not a valid sidebar action and thus cannot be registered. + */ +export function registerSidebarAction(action: ISidebarAction): void { + validateSidebarAction(action) + + window._nc_files_sidebar_actions ??= new Map() + if (window._nc_files_sidebar_actions.has(action.id)) { + logger.warn(`Sidebar action with id "${action.id}" already registered. Skipping.`) + return + } + window._nc_files_sidebar_actions.set(action.id, action) + logger.debug(`New sidebar action with id "${action.id}" registered.`) +} + +/** + * Get all currently registered sidebar actions. + */ +export function getSidebarActions(): ISidebarAction[] { + if (window._nc_files_sidebar_actions) { + return [...window._nc_files_sidebar_actions.values()] + } + return [] +} + +/** + * Check if a given sidebar action object implements all necessary fields. + * + * @param action - The sidebar action to validate + */ +function validateSidebarAction(action: ISidebarAction): void { + if (typeof action !== 'object') { + throw new Error('Sidebar action is not an object') + } + + if (!action.id || (typeof action.id !== 'string') || action.id !== CSS.escape(action.id)) { + throw new Error('Sidebar actions need to have an id conforming to the HTML id attribute specifications') + } + + if (!action.displayName || typeof action.displayName !== 'function') { + throw new Error('Sidebar actions need to have a displayName function') + } + + if (!action.iconSvgInline || typeof action.iconSvgInline !== 'function') { + throw new Error('Sidebar actions need to have a iconSvgInline function') + } + + if (!action.enabled || typeof action.enabled !== 'function') { + throw new Error('Sidebar actions need to have an enabled function') + } + + if (!action.onClick || typeof action.onClick !== 'function') { + throw new Error('Sidebar actions need to have an onClick function') + } +} diff --git a/lib/sidebar/SidebarTab.ts b/lib/sidebar/SidebarTab.ts index 0ef3afe2f..2fd81c016 100644 --- a/lib/sidebar/SidebarTab.ts +++ b/lib/sidebar/SidebarTab.ts @@ -149,15 +149,4 @@ function validateSidebarTab(tab: ISidebarTab): void { 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/sidebar/index.ts b/lib/sidebar/index.ts index 8b5b93b6b..b4a07b8f3 100644 --- a/lib/sidebar/index.ts +++ b/lib/sidebar/index.ts @@ -3,5 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -export type { ISidebarContext, ISidebarTab, SidebarComponent } from './SidebarTab.ts' export * from './Sidebar.ts' +export * from './SidebarAction.ts' +export * from './SidebarTab.ts' diff --git a/lib/window.d.ts b/lib/window.d.ts index b7765aa31..4a4bd2c6d 100644 --- a/lib/window.d.ts +++ b/lib/window.d.ts @@ -15,6 +15,7 @@ import type { import type { DavProperty } from './dav/index.ts' import type { ISidebarTab } from './sidebar/index.ts' +import type { ISidebarAction } from './sidebar/SidebarAction.ts' export {} @@ -31,6 +32,7 @@ declare global { _nc_newfilemenu?: NewMenu _nc_navigation?: Navigation _nc_filelist_filters?: Map + _nc_files_sidebar_actions?: Map _nc_files_sidebar_tabs?: Map _oc_config?: {