Skip to content

Commit 3124c4e

Browse files
committed
feat(sidebar): provide public API to register a sidebar tab
This replaces the legacy `OCA.Files.Sidebar`. It also allows to define the order of the tab to prevent diffent order depending on the localized name like with the legacy tabs. Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent 88e9912 commit 3124c4e

6 files changed

Lines changed: 339 additions & 3 deletions

File tree

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { getSidebarTabs, ISidebarTab, registerSidebarTab } from '../../lib/sidebar'
8+
9+
// missing in JSDom but supported by every browser!
10+
import 'css.escape'
11+
12+
describe('Sidebar tabs', () => {
13+
beforeEach(() => {
14+
delete window._nc_files_sidebar_tabs
15+
})
16+
17+
it('can register a tab', () => {
18+
const tab = getExampleTab()
19+
registerSidebarTab(tab)
20+
expect(window._nc_files_sidebar_tabs).toBeInstanceOf(Map)
21+
expect(window._nc_files_sidebar_tabs!.has(tab.id)).toBe(true)
22+
expect(window._nc_files_sidebar_tabs!.get(tab.id)).toBe(tab)
23+
})
24+
25+
it('can fetch empty list of sidebar tabs', () => {
26+
expect(getSidebarTabs()).toBeInstanceOf(Array)
27+
expect(getSidebarTabs()).toHaveLength(0)
28+
})
29+
30+
it('can fetch list of sidebar tabs', () => {
31+
registerSidebarTab(getExampleTab())
32+
registerSidebarTab({ ...getExampleTab(), id: 'another-example' })
33+
34+
expect(getSidebarTabs()).toBeInstanceOf(Array)
35+
expect(getSidebarTabs()).toHaveLength(2)
36+
})
37+
38+
it('only registeres same id once', () => {
39+
const consoleSpy = vi.spyOn(console, 'warn')
40+
consoleSpy.mockImplementationOnce(() => {})
41+
42+
registerSidebarTab(getExampleTab())
43+
registerSidebarTab(getExampleTab())
44+
expect(consoleSpy).toHaveBeenCalledOnce()
45+
expect(getSidebarTabs()).toHaveLength(1)
46+
})
47+
48+
describe('Tab validation', () => {
49+
it('fails with an invalid parameter', () => {
50+
expect(
51+
// @ts-expect-error mocking for testing
52+
() => registerSidebarTab(getExampleTab),
53+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tab is not an object]')
54+
})
55+
56+
it('fails with missing id', () => {
57+
expect(
58+
// @ts-expect-error mocking for testing
59+
() => registerSidebarTab({ ...getExampleTab(), id: undefined }),
60+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an id conforming to the HTML id attribute specifications]')
61+
})
62+
63+
it('fails with non conforming id', () => {
64+
expect(
65+
() => registerSidebarTab({ ...getExampleTab(), id: 'this is invalid' }),
66+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an id conforming to the HTML id attribute specifications]')
67+
})
68+
69+
it('fails with missing name', () => {
70+
expect(
71+
// @ts-expect-error mocking for testing
72+
() => registerSidebarTab({ ...getExampleTab(), displayName: undefined }),
73+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a name set]')
74+
})
75+
76+
it('fails with invalid name', () => {
77+
expect(
78+
// @ts-expect-error mocking for testing
79+
() => registerSidebarTab({ ...getExampleTab(), displayName: 1234 }),
80+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a name set]')
81+
})
82+
83+
it('fails with missing icon', () => {
84+
expect(
85+
// @ts-expect-error mocking for testing
86+
() => registerSidebarTab({ ...getExampleTab(), iconSvg: undefined }),
87+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an valid SVG icon]')
88+
})
89+
90+
it('fails with invalid SVG icon', () => {
91+
expect(
92+
() => registerSidebarTab({ ...getExampleTab(), iconSvg: 'icon-group' }),
93+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an valid SVG icon]')
94+
})
95+
96+
it('fails with missing order', () => {
97+
expect(
98+
// @ts-expect-error mocking for testing
99+
() => registerSidebarTab({ ...getExampleTab(), order: undefined }),
100+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a numeric order set]')
101+
})
102+
103+
it('fails with invalid order', () => {
104+
expect(
105+
// @ts-expect-error mocking for testing
106+
() => registerSidebarTab({ ...getExampleTab(), order: '3' }),
107+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a numeric order set]')
108+
})
109+
110+
it('fails with missing "enabled" method', () => {
111+
expect(
112+
// @ts-expect-error mocking for testing
113+
() => registerSidebarTab({ ...getExampleTab(), enabled: undefined }),
114+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an "enabled" method]')
115+
})
116+
117+
it('fails with missing "setActive" method', () => {
118+
expect(
119+
// @ts-expect-error mocking for testing
120+
() => registerSidebarTab({ ...getExampleTab(), setActive: undefined }),
121+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a "setActive" method]')
122+
})
123+
124+
it.for(['mount', 'unmount', 'update'])('fails with missing lifecylce methods', (method) => {
125+
expect(
126+
() => registerSidebarTab({ ...getExampleTab(), [method]: undefined }),
127+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tab is missing a required lifecycle method]')
128+
})
129+
130+
it('works without specifying a scroll listener', () => {
131+
expect(
132+
() => registerSidebarTab({ ...getExampleTab(), onScrollBottomReached: undefined }),
133+
).not.toThrow()
134+
})
135+
136+
it('fails with an invalid scroll listener', () => {
137+
expect(
138+
// @ts-expect-error mocking for testing
139+
() => registerSidebarTab({ ...getExampleTab(), onScrollBottomReached: 'not a method' }),
140+
).toThrowErrorMatchingInlineSnapshot('[Error: "onScrollBottomReached" of the sidebar tab needs to be a function]')
141+
})
142+
})
143+
})
144+
145+
/**
146+
* Get a very basic mock of a sidebar tab
147+
*/
148+
function getExampleTab(): ISidebarTab {
149+
return {
150+
id: 'example-tab',
151+
displayName: 'Example',
152+
enabled: vi.fn(),
153+
iconSvg: '<svg><circle r="45" cx="50" cy="50" fill="red" /></svg>',
154+
order: 0,
155+
mount: vi.fn(),
156+
unmount: vi.fn(),
157+
update: vi.fn(),
158+
setActive: vi.fn(),
159+
onScrollBottomReached: vi.fn(),
160+
}
161+
}

lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export * from './navigation/index.ts'
1010
export * from './newMenu/index.ts'
1111
export * from './node/index.ts'
1212
export * from './permissions.ts'
13+
export * from './sidebar/index.ts'
1314
export * from './utils/index.ts'
1415

1516
// Legacy export of dav utils

lib/sidebar/index.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { IFolder, INode } from '../node/index.ts'
7+
8+
import isSvg from 'is-svg'
9+
import logger from '../utils/logger.ts'
10+
11+
interface SidebarUpdateContext {
12+
/**
13+
* The currently selected node for which the sidebar is shown.
14+
*/
15+
node: INode
16+
17+
/**
18+
* The parent of the current selected node.
19+
* This is not necessarily the real parent folder of the node in means of the real filesystem tree,
20+
* but rather the parent folder in the current view of the files app.
21+
*/
22+
parent: IFolder
23+
}
24+
25+
/**
26+
* Implementation of a custom sidebar tab within the files app.
27+
*/
28+
export interface ISidebarTab {
29+
/**
30+
* Unique id of the sidebar tab.
31+
* This has to conform to the HTML id attribute specification.
32+
*/
33+
id: string
34+
35+
/**
36+
* The localized name of the sidebar tab.
37+
*/
38+
displayName: string
39+
40+
/**
41+
* The icon, as SVG, of the sidebar tab.
42+
*/
43+
iconSvg: string
44+
45+
/**
46+
* The order of this tab.
47+
* Use a low number to make this tab ordered in front.
48+
*/
49+
order: number
50+
51+
/**
52+
* Callback to check if the sidebar tab should be shown for the selected node.
53+
*
54+
* @param node - The currently selected node
55+
*/
56+
enabled: (node: INode) => boolean
57+
58+
/**
59+
* Called by the files app if this tab has become the active tab or was deactivated.
60+
*
61+
* @param active - The new active state of this tab
62+
*/
63+
setActive: (active: boolean) => void | Promise<void>
64+
65+
/**
66+
* The lifecylce method for mounting the sidebar tab onto the sidebar.
67+
*
68+
* @param el - The element to mount the sidebar tab to
69+
* @param context - The current sidebar context
70+
*/
71+
mount: (el: HTMLElement, context: SidebarUpdateContext) => void | Promise<void>
72+
73+
/**
74+
* The lifecycle method for updating the sidebar tab.
75+
* This is called if the currently selected node changes.
76+
*
77+
* @param context - The current sidebar context
78+
*/
79+
update: (context: SidebarUpdateContext) => void | Promise<void>
80+
81+
/**
82+
* The lifecycle method for unmounting the sidebar tab.
83+
* This is called if the sidebar is unmounted from the files app and thus the sidebar tab needs to do its cleanup and unmounting.
84+
*/
85+
unmount: () => void | Promise<void>
86+
87+
/**
88+
* Called when the bottom of the sidebar was reached during scrolling.
89+
*/
90+
onScrollBottomReached?: () => void | Promise<void>
91+
}
92+
93+
/**
94+
* Register a new sidebar tab for the files app.
95+
*
96+
* @param tab - The sidebar tab to register
97+
* @throws If the provided tab is not a valid sidebar tab and thus cannot be registered.
98+
*/
99+
export function registerSidebarTab(tab: ISidebarTab): void {
100+
validateSidebarTab(tab)
101+
102+
window._nc_files_sidebar_tabs ??= new Map<string, ISidebarTab>()
103+
if (window._nc_files_sidebar_tabs.has(tab.id)) {
104+
logger.warn(`Sidebar tab with id "${tab.id}" already registered. Skipping.`)
105+
return
106+
}
107+
window._nc_files_sidebar_tabs.set(tab.id, tab)
108+
logger.debug(`New sidebar tab with id "${tab.id}" registered.`)
109+
}
110+
111+
/**
112+
* Get all currently registered sidebar tabs.
113+
*/
114+
export function getSidebarTabs(): ISidebarTab[] {
115+
if (window._nc_files_sidebar_tabs) {
116+
return [...window._nc_files_sidebar_tabs.values()]
117+
}
118+
return []
119+
}
120+
121+
/**
122+
* Check if a given sidebar tab objects implements all necessary fields.
123+
*
124+
* @param tab - The sidebar tab to validate
125+
*/
126+
function validateSidebarTab(tab: ISidebarTab): void {
127+
if (typeof tab !== 'object') {
128+
throw new Error('Sidebar tab is not an object')
129+
}
130+
131+
if (!tab.id || (typeof tab.id !== 'string') || tab.id !== CSS.escape(tab.id)) {
132+
throw new Error('Sidebar tabs need to have an id conforming to the HTML id attribute specifications')
133+
}
134+
135+
if (!tab.displayName || typeof tab.displayName !== 'string') {
136+
throw new Error('Sidebar tabs need to have a name set')
137+
}
138+
139+
if (typeof tab.iconSvg !== 'string' || !isSvg(tab.iconSvg)) {
140+
throw new Error('Sidebar tabs need to have an valid SVG icon')
141+
}
142+
143+
if (typeof tab.order !== 'number') {
144+
throw new Error('Sidebar tabs need to have a numeric order set')
145+
}
146+
147+
if (typeof tab.enabled !== 'function') {
148+
throw new Error('Sidebar tabs need to have an "enabled" method')
149+
}
150+
151+
if (typeof tab.setActive !== 'function') {
152+
throw new Error('Sidebar tabs need to have a "setActive" method')
153+
}
154+
155+
if (typeof tab.mount !== 'function'
156+
|| typeof tab.update !== 'function'
157+
|| typeof tab.unmount !== 'function'
158+
) {
159+
throw new Error('Sidebar tab is missing a required lifecycle method')
160+
}
161+
162+
if (tab.onScrollBottomReached && typeof tab.onScrollBottomReached !== 'function') {
163+
throw new Error('"onScrollBottomReached" of the sidebar tab needs to be a function')
164+
}
165+
}

lib/window.d.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,8 @@ import type {
1313
NewMenu,
1414
} from './index.ts'
1515

16-
import type {
17-
DavProperty,
18-
} from './dav/index.ts'
16+
import type { DavProperty } from './dav/index.ts'
17+
import type { ISidebarTab } from './sidebar/index.ts'
1918

2019
export {}
2120

@@ -30,6 +29,7 @@ declare global {
3029
_nc_newfilemenu?: NewMenu
3130
_nc_navigation?: Navigation
3231
_nc_filelist_filters?: Map<string, IFileListFilter>
32+
_nc_files_sidebar_tabs?: Map<string, ISidebarTab>
3333

3434
_oc_config?: {
3535
forbidden_filenames_characters: string[]

package-lock.json

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"@nextcloud/vite-config": "^2.4.0",
6969
"@types/node": "^24.2.1",
7070
"@vitest/coverage-istanbul": "^3.2.4",
71+
"css.escape": "^1.5.1",
7172
"fast-xml-parser": "^5.2.5",
7273
"jsdom": "^26.1.0",
7374
"tslib": "^2.8.1",

0 commit comments

Comments
 (0)