Skip to content

Commit 8dbc781

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 287421e commit 8dbc781

7 files changed

Lines changed: 348 additions & 3 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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, IView } from '../../lib'
7+
8+
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'
9+
import { getSidebarTabs, ISidebarTab, registerSidebarTab } from '../../lib/sidebar'
10+
// missing in JSDom but supported by every browser!
11+
import 'css.escape'
12+
13+
class SidebarTabMock extends HTMLElement {
14+
node?: INode
15+
folder?: IFolder
16+
view?: IView
17+
18+
public setActive(active: boolean) {
19+
//
20+
}
21+
}
22+
23+
describe('Sidebar tabs', () => {
24+
let getCustomElementsSpy: Mock
25+
26+
beforeEach(() => {
27+
vi.restoreAllMocks()
28+
getCustomElementsSpy = vi.spyOn(window.customElements, 'get')
29+
.mockImplementation(() => SidebarTabMock)
30+
delete window._nc_files_sidebar_tabs
31+
})
32+
33+
it('can register a tab', () => {
34+
const tab = getExampleTab()
35+
36+
registerSidebarTab(tab)
37+
expect(window._nc_files_sidebar_tabs).toBeInstanceOf(Map)
38+
expect(window._nc_files_sidebar_tabs!.has(tab.id)).toBe(true)
39+
expect(window._nc_files_sidebar_tabs!.get(tab.id)).toBe(tab)
40+
})
41+
42+
it('can fetch empty list of sidebar tabs', () => {
43+
expect(getSidebarTabs()).toBeInstanceOf(Array)
44+
expect(getSidebarTabs()).toHaveLength(0)
45+
})
46+
47+
it('can fetch list of sidebar tabs', () => {
48+
registerSidebarTab(getExampleTab())
49+
registerSidebarTab({ ...getExampleTab(), id: 'another-example' })
50+
51+
expect(getSidebarTabs()).toBeInstanceOf(Array)
52+
expect(getSidebarTabs()).toHaveLength(2)
53+
})
54+
55+
it('only registeres same id once', () => {
56+
const consoleSpy = vi.spyOn(console, 'warn')
57+
consoleSpy.mockImplementationOnce(() => {})
58+
59+
registerSidebarTab(getExampleTab())
60+
registerSidebarTab(getExampleTab())
61+
expect(consoleSpy).toHaveBeenCalledOnce()
62+
expect(getSidebarTabs()).toHaveLength(1)
63+
})
64+
65+
describe('Tab validation', () => {
66+
it('fails with an invalid parameter', () => {
67+
expect(
68+
// @ts-expect-error mocking for testing
69+
() => registerSidebarTab(getExampleTab),
70+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tab is not an object]')
71+
})
72+
73+
it('fails with missing id', () => {
74+
expect(
75+
// @ts-expect-error mocking for testing
76+
() => registerSidebarTab({ ...getExampleTab(), id: undefined }),
77+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an id conforming to the HTML id attribute specifications]')
78+
})
79+
80+
it('fails with non conforming id', () => {
81+
expect(
82+
() => registerSidebarTab({ ...getExampleTab(), id: 'this is invalid' }),
83+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an id conforming to the HTML id attribute specifications]')
84+
})
85+
86+
it('fails with missing tagName name', () => {
87+
expect(
88+
// @ts-expect-error mocking for testing
89+
() => registerSidebarTab({ ...getExampleTab(), tagName: undefined }),
90+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have the tagName name set]')
91+
})
92+
93+
it('fails with invalid tagName name', () => {
94+
expect(() => registerSidebarTab({ ...getExampleTab(), tagName: 'MyAppSidebarTab' }))
95+
.toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs tagName name is invalid]')
96+
})
97+
98+
it('fails with non registered element', () => {
99+
getCustomElementsSpy.mockImplementationOnce(() => undefined)
100+
expect(() => registerSidebarTab(getExampleTab())).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tab element not registered]')
101+
})
102+
103+
it('fails with invalid custom element', () => {
104+
getCustomElementsSpy.mockImplementationOnce(() => HTMLElement)
105+
expect(() => registerSidebarTab(getExampleTab())).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tab elements must have the `setActive` method]')
106+
})
107+
108+
it('fails with missing name', () => {
109+
expect(
110+
// @ts-expect-error mocking for testing
111+
() => registerSidebarTab({ ...getExampleTab(), displayName: undefined }),
112+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a name set]')
113+
})
114+
115+
it('fails with invalid name', () => {
116+
expect(
117+
// @ts-expect-error mocking for testing
118+
() => registerSidebarTab({ ...getExampleTab(), displayName: 1234 }),
119+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a name set]')
120+
})
121+
122+
it('fails with missing icon', () => {
123+
expect(
124+
// @ts-expect-error mocking for testing
125+
() => registerSidebarTab({ ...getExampleTab(), iconSvgInline: undefined }),
126+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an valid SVG icon]')
127+
})
128+
129+
it('fails with invalid SVG icon', () => {
130+
expect(
131+
() => registerSidebarTab({ ...getExampleTab(), iconSvgInline: 'icon-group' }),
132+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an valid SVG icon]')
133+
})
134+
135+
it('fails with missing order', () => {
136+
expect(
137+
// @ts-expect-error mocking for testing
138+
() => registerSidebarTab({ ...getExampleTab(), order: undefined }),
139+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a numeric order set]')
140+
})
141+
142+
it('fails with invalid order', () => {
143+
expect(
144+
// @ts-expect-error mocking for testing
145+
() => registerSidebarTab({ ...getExampleTab(), order: '3' }),
146+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a numeric order set]')
147+
})
148+
149+
it('fails with missing "enabled" method', () => {
150+
expect(
151+
// @ts-expect-error mocking for testing
152+
() => registerSidebarTab({ ...getExampleTab(), enabled: undefined }),
153+
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an "enabled" method]')
154+
})
155+
})
156+
})
157+
158+
/**
159+
* Get a very basic mock of a sidebar tab
160+
*/
161+
function getExampleTab(): ISidebarTab {
162+
return {
163+
id: 'example-tab',
164+
displayName: 'Example',
165+
tagName: 'example_app-files-sidebar-tab',
166+
enabled: vi.fn(),
167+
iconSvgInline: '<svg><circle r="45" cx="50" cy="50" fill="red" /></svg>',
168+
order: 0,
169+
}
170+
}

lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ export * from './navigation/index.ts'
1212
export * from './newMenu/index.ts'
1313
export * from './node/index.ts'
1414
export * from './permissions.ts'
15+
export * from './sidebar/index.ts'
1516
export * from './utils/index.ts'

lib/navigation/view.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ interface ViewData {
9797
loadChildViews?: (view: View) => Promise<void>
9898
}
9999

100+
export type IView = ViewData
101+
100102
export class View implements ViewData {
101103

102104
private _view: ViewData

lib/sidebar/index.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { IView } from '../navigation/view.ts'
7+
import type { IFolder, INode } from '../node/index.ts'
8+
9+
import isSvg from 'is-svg'
10+
import logger from '../utils/logger.ts'
11+
12+
export interface ISidebarContext {
13+
/**
14+
* The active node in the sidebar
15+
*/
16+
node: INode
17+
18+
/**
19+
* The current open folder in the files app
20+
*/
21+
folder: IFolder
22+
23+
/**
24+
* The currently active view
25+
*/
26+
view: IView
27+
}
28+
29+
/**
30+
* This component describes the custom web component that should be registered for a sidebar tab.
31+
*
32+
* @see https://developer.mozilla.org/en-US/docs/Web/API/Web_components
33+
* @see https://vuejs.org/guide/extras/web-components#building-custom-elements-with-vue
34+
*/
35+
export interface SidebarComponent extends HTMLElement, ISidebarContext {
36+
/**
37+
* This method is called by the files app if the sidebar tab state changes.
38+
*
39+
* @param active - The new active state
40+
*/
41+
setActive(active: boolean): Promise<void>
42+
}
43+
44+
/**
45+
* Implementation of a custom sidebar tab within the files app.
46+
*/
47+
export interface ISidebarTab {
48+
/**
49+
* Unique id of the sidebar tab.
50+
* This has to conform to the HTML id attribute specification.
51+
*/
52+
id: string
53+
54+
/**
55+
* The localized name of the sidebar tab.
56+
*/
57+
displayName: string
58+
59+
/**
60+
* The icon, as SVG, of the sidebar tab.
61+
*/
62+
iconSvgInline: string
63+
64+
/**
65+
* The order of this tab.
66+
* Use a low number to make this tab ordered in front.
67+
*/
68+
order: number
69+
70+
/**
71+
* The tag name of the web component.
72+
* The web component must already be registered under that tag name with `CustomElementRegistry.define()`.
73+
*
74+
* To avoid name clashes the name has to start with your appid (e.g. `your_app`).
75+
* So in addition with the web component naming rules a good name would be `your_app-files-sidebar-tab`.
76+
*/
77+
tagName: string
78+
79+
/**
80+
* Callback to check if the sidebar tab should be shown for the selected node.
81+
*
82+
* @param context - The current context of the files app
83+
*/
84+
enabled: (context: ISidebarContext) => boolean
85+
}
86+
87+
/**
88+
* Register a new sidebar tab for the files app.
89+
*
90+
* @param tab - The sidebar tab to register
91+
* @throws If the provided tab is not a valid sidebar tab and thus cannot be registered.
92+
*/
93+
export function registerSidebarTab(tab: ISidebarTab): void {
94+
validateSidebarTab(tab)
95+
96+
window._nc_files_sidebar_tabs ??= new Map<string, ISidebarTab>()
97+
if (window._nc_files_sidebar_tabs.has(tab.id)) {
98+
logger.warn(`Sidebar tab with id "${tab.id}" already registered. Skipping.`)
99+
return
100+
}
101+
window._nc_files_sidebar_tabs.set(tab.id, tab)
102+
logger.debug(`New sidebar tab with id "${tab.id}" registered.`)
103+
}
104+
105+
/**
106+
* Get all currently registered sidebar tabs.
107+
*/
108+
export function getSidebarTabs(): ISidebarTab[] {
109+
if (window._nc_files_sidebar_tabs) {
110+
return [...window._nc_files_sidebar_tabs.values()]
111+
}
112+
return []
113+
}
114+
115+
/**
116+
* Check if a given sidebar tab objects implements all necessary fields.
117+
*
118+
* @param tab - The sidebar tab to validate
119+
*/
120+
function validateSidebarTab(tab: ISidebarTab): void {
121+
if (typeof tab !== 'object') {
122+
throw new Error('Sidebar tab is not an object')
123+
}
124+
125+
if (!tab.id || (typeof tab.id !== 'string') || tab.id !== CSS.escape(tab.id)) {
126+
throw new Error('Sidebar tabs need to have an id conforming to the HTML id attribute specifications')
127+
}
128+
129+
if (!tab.tagName || typeof tab.tagName !== 'string') {
130+
throw new Error('Sidebar tabs need to have the tagName name set')
131+
}
132+
133+
if (!tab.tagName.match(/^[a-z][a-z0-9-_]+$/)) {
134+
throw new Error('Sidebar tabs tagName name is invalid')
135+
}
136+
137+
if (!tab.displayName || typeof tab.displayName !== 'string') {
138+
throw new Error('Sidebar tabs need to have a name set')
139+
}
140+
141+
if (typeof tab.iconSvgInline !== 'string' || !isSvg(tab.iconSvgInline)) {
142+
throw new Error('Sidebar tabs need to have an valid SVG icon')
143+
}
144+
145+
if (typeof tab.order !== 'number') {
146+
throw new Error('Sidebar tabs need to have a numeric order set')
147+
}
148+
149+
if (typeof tab.enabled !== 'function') {
150+
throw new Error('Sidebar tabs need to have an "enabled" method')
151+
}
152+
153+
// now check the custom element constructor
154+
const tagConstructor = window.customElements.get(tab.tagName)
155+
if (!tagConstructor) {
156+
throw new Error('Sidebar tab element not registered')
157+
}
158+
159+
if (!('setActive' in tagConstructor.prototype)) {
160+
// we cannot check properties like `node` or `view` because those are not necessarily defined in the prototype.
161+
throw new Error('Sidebar tab elements must have the `setActive` method')
162+
}
163+
}

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
@@ -73,6 +73,7 @@
7373
"@nextcloud/vite-config": "^2.5.2",
7474
"@types/node": "^25.0.0",
7575
"@vitest/coverage-istanbul": "^4.0.15",
76+
"css.escape": "^1.5.1",
7677
"fast-xml-parser": "^5.3.2",
7778
"jsdom": "^27.3.0",
7879
"tslib": "^2.8.1",

0 commit comments

Comments
 (0)