Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions __tests__/ui/sidebar/sidebar.spec.ts
Original file line number Diff line number Diff line change
@@ -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<ISidebar, 'available' | 'mount' | 'registerTab' | 'registerAction'>

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)
})
})
44 changes: 42 additions & 2 deletions lib/ui/sidebar/Sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<ISidebar, 'available' | 'registerTab' | 'registerAction'> | undefined {
get #impl(): Omit<ISidebar, 'available' | 'mount' | 'registerTab' | 'registerAction'> | undefined {
return window.OCA?.Files?._sidebar?.()
}

Expand Down Expand Up @@ -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)
}
Expand Down
14 changes: 10 additions & 4 deletions lib/ui/sidebar/SidebarTab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down
Loading