diff --git a/__tests__/ui/sidebar/sidebar.spec.ts b/__tests__/ui/sidebar/sidebar.spec.ts new file mode 100644 index 00000000..abaa8edd --- /dev/null +++ b/__tests__/ui/sidebar/sidebar.spec.ts @@ -0,0 +1,133 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ISidebar, ISidebarContext } from '~/ui/index.ts' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { File } from '~/node/index.ts' +import { getSidebar } from '~/ui/index.ts' + +const node = new File({ + id: 1, + source: 'https://cloud.example.com/remote.php/dav/files/test/file.txt', + owner: 'test', + mime: 'text/plain', + root: '/files/test', +}) + +/** + * Get a mocked sidebar implementation as provided by the files app. + */ +function mockImplementation() { + const implementation = { + isOpen: true, + activeTab: 'sharing', + node, + open: vi.fn(), + close: vi.fn(), + setActiveTab: vi.fn(), + setFullScreenMode: vi.fn(), + getTabs: vi.fn(() => []), + getActions: vi.fn(() => []), + } satisfies Omit + + window.OCA = { Files: { _sidebar: () => implementation } } + return implementation +} + +describe('Sidebar', () => { + beforeEach(() => { + window.OCA = {} + }) + + afterEach(() => { + delete window.OCA.Files + }) + + it('is not available without an implementation', () => { + const sidebar = getSidebar() + + expect(sidebar.available).toBe(false) + expect(sidebar.isOpen).toBe(false) + expect(sidebar.activeTab).toBeUndefined() + expect(sidebar.node).toBeUndefined() + expect(sidebar.getTabs()).toEqual([]) + expect(sidebar.getActions()).toEqual([]) + }) + + it('does not fail without an implementation', () => { + const sidebar = getSidebar() + + expect(() => sidebar.open(node)).not.toThrow() + expect(() => sidebar.close()).not.toThrow() + expect(() => sidebar.setActiveTab('sharing')).not.toThrow() + expect(() => sidebar.setFullScreenMode(true)).not.toThrow() + }) + + describe('Rendering the sidebar within an app', () => { + it('renders the sidebar into the requested element', () => { + const mountSidebar = vi.fn() + window.OCA = { Files: { _mountSidebar: mountSidebar } } + const target = document.createElement('div') + + getSidebar().mount(target) + expect(mountSidebar).toHaveBeenCalledWith(target) + }) + + it('reports if the sidebar was not loaded for the page', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + expect(() => getSidebar().mount(document.createElement('div'))).not.toThrow() + expect(consoleSpy).toHaveBeenCalledOnce() + }) + + it('is not available until it is rendered', () => { + window.OCA = { Files: { _mountSidebar: vi.fn() } } + + expect(getSidebar().available).toBe(false) + }) + }) + + it('is available with an implementation', () => { + mockImplementation() + const sidebar = getSidebar() + + expect(sidebar.available).toBe(true) + expect(sidebar.isOpen).toBe(true) + expect(sidebar.activeTab).toBe('sharing') + expect(sidebar.node).toBe(node) + }) + + it('proxies the state to the implementation', () => { + const implementation = mockImplementation() + const sidebar = getSidebar() + + sidebar.open(node, 'sharing') + expect(implementation.open).toHaveBeenCalledWith(node, 'sharing') + + sidebar.setActiveTab('versions') + expect(implementation.setActiveTab).toHaveBeenCalledWith('versions') + + sidebar.setFullScreenMode(true) + expect(implementation.setFullScreenMode).toHaveBeenCalledWith(true) + + sidebar.close() + expect(implementation.close).toHaveBeenCalledOnce() + }) + + it('proxies the context of tabs and actions', () => { + const implementation = mockImplementation() + const sidebar = getSidebar() + + // the folder and the view only exist if the sidebar is rendered within the files app + const context: ISidebarContext = { node } + + sidebar.getTabs(context) + expect(implementation.getTabs).toHaveBeenCalledWith(context) + + sidebar.getActions(context) + expect(implementation.getActions).toHaveBeenCalledWith(context) + }) +}) diff --git a/lib/ui/sidebar/Sidebar.ts b/lib/ui/sidebar/Sidebar.ts index 173e742d..1aeacb43 100644 --- a/lib/ui/sidebar/Sidebar.ts +++ b/lib/ui/sidebar/Sidebar.ts @@ -7,12 +7,15 @@ import type { INode } from '../../node/node.ts' import type { ISidebarAction } from './SidebarAction.ts' import type { ISidebarContext, ISidebarTab } from './SidebarTab.ts' +import logger from '../../utils/logger.ts' import { registerSidebarAction } from './SidebarAction.ts' import { registerSidebarTab } from './SidebarTab.ts' export interface ISidebar { /** - * If the files sidebar can currently be accessed. + * If the files sidebar can currently be accessed, + * meaning it is rendered on the current page either by the files app + * or by an app rendering the sidebar within its own app. * Registering tabs also works if the sidebar is currently not available. */ readonly available: boolean @@ -58,6 +61,29 @@ export interface ISidebar { */ setActiveTab(tabId: string): void + /** + * Render the sidebar as a fullscreen overlay of the current page. + * + * @param isFullScreen - Whether to render the sidebar fullscreen + */ + setFullScreenMode(isFullScreen: boolean): void + + /** + * Render the sidebar into an element of the current app. + * + * The sidebar is rendered into the app content element as soon as the page is loaded, + * so this is only needed by apps that render the sidebar into a specific element + * or that set up their own layout after the page was loaded. + * Calling this again moves an already rendered sidebar into the requested element. + * + * Requires the app to request the sidebar for the current page, + * by dispatching the `OCA\Files\Event\LoadSidebar` event while rendering it. + * Within the files app the sidebar is part of the app layout, so this does nothing. + * + * @param target - The element to render the sidebar into + */ + mount(target: HTMLElement): void + /** * Register a new sidebar tab. * This should ideally be done on app initialization using Nextcloud init scripts. @@ -98,7 +124,7 @@ export interface ISidebar { * If we decide to do a breaking change we can either add compatibility wrappers in the implementation in the files app. */ class SidebarProxy implements ISidebar { - get #impl(): Omit | undefined { + get #impl(): Omit | undefined { return window.OCA?.Files?._sidebar?.() } @@ -130,6 +156,20 @@ class SidebarProxy implements ISidebar { this.#impl?.setActiveTab(tabId) } + setFullScreenMode(isFullScreen: boolean): void { + this.#impl?.setFullScreenMode(isFullScreen) + } + + mount(target: HTMLElement): void { + const mountSidebar = window.OCA?.Files?._mountSidebar + if (mountSidebar === undefined) { + logger.error('Cannot render the sidebar as it was not loaded for this page, see the `LoadSidebar` event.') + return + } + + mountSidebar(target) + } + registerTab(tab: ISidebarTab): void { registerSidebarTab(tab) } diff --git a/lib/ui/sidebar/SidebarTab.ts b/lib/ui/sidebar/SidebarTab.ts index b216a554..b5c98158 100644 --- a/lib/ui/sidebar/SidebarTab.ts +++ b/lib/ui/sidebar/SidebarTab.ts @@ -17,14 +17,20 @@ export interface ISidebarContext { node: INode /** - * The current open folder in the files app + * The current open folder in the files app. + * + * Only set if the sidebar is rendered within the files app, + * apps rendering the sidebar within their own app have no open folder. */ - folder: IFolder + folder?: IFolder /** - * The currently active view + * The currently active view. + * + * Only set if the sidebar is rendered within the files app, + * apps rendering the sidebar within their own app have no active view. */ - view: IView + view?: IView } /**