Skip to content
Closed
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
161 changes: 161 additions & 0 deletions __tests__/sidebar/sidebarTab.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getSidebarTabs, ISidebarTab, registerSidebarTab } from '../../lib/sidebar'

// missing in JSDom but supported by every browser!
import 'css.escape'

describe('Sidebar tabs', () => {
beforeEach(() => {
delete window._nc_files_sidebar_tabs
})

it('can register a tab', () => {
const tab = getExampleTab()
registerSidebarTab(tab)
expect(window._nc_files_sidebar_tabs).toBeInstanceOf(Map)
expect(window._nc_files_sidebar_tabs!.has(tab.id)).toBe(true)
expect(window._nc_files_sidebar_tabs!.get(tab.id)).toBe(tab)
})

it('can fetch empty list of sidebar tabs', () => {
expect(getSidebarTabs()).toBeInstanceOf(Array)
expect(getSidebarTabs()).toHaveLength(0)
})

it('can fetch list of sidebar tabs', () => {
registerSidebarTab(getExampleTab())
registerSidebarTab({ ...getExampleTab(), id: 'another-example' })

expect(getSidebarTabs()).toBeInstanceOf(Array)
expect(getSidebarTabs()).toHaveLength(2)
})

it('only registeres same id once', () => {
const consoleSpy = vi.spyOn(console, 'warn')
consoleSpy.mockImplementationOnce(() => {})

registerSidebarTab(getExampleTab())
registerSidebarTab(getExampleTab())
expect(consoleSpy).toHaveBeenCalledOnce()
expect(getSidebarTabs()).toHaveLength(1)
})

describe('Tab validation', () => {
it('fails with an invalid parameter', () => {
expect(
// @ts-expect-error mocking for testing
() => registerSidebarTab(getExampleTab),
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tab is not an object]')
})

it('fails with missing id', () => {
expect(
// @ts-expect-error mocking for testing
() => registerSidebarTab({ ...getExampleTab(), id: undefined }),
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an id conforming to the HTML id attribute specifications]')
})

it('fails with non conforming id', () => {
expect(
() => registerSidebarTab({ ...getExampleTab(), id: 'this is invalid' }),
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an id conforming to the HTML id attribute specifications]')
})

it('fails with missing name', () => {
expect(
// @ts-expect-error mocking for testing
() => registerSidebarTab({ ...getExampleTab(), displayName: undefined }),
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a name set]')
})

it('fails with invalid name', () => {
expect(
// @ts-expect-error mocking for testing
() => registerSidebarTab({ ...getExampleTab(), displayName: 1234 }),
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a name set]')
})

it('fails with missing icon', () => {
expect(
// @ts-expect-error mocking for testing
() => registerSidebarTab({ ...getExampleTab(), iconSvg: undefined }),
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an valid SVG icon]')
})

it('fails with invalid SVG icon', () => {
expect(
() => registerSidebarTab({ ...getExampleTab(), iconSvg: 'icon-group' }),
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an valid SVG icon]')
})

it('fails with missing order', () => {
expect(
// @ts-expect-error mocking for testing
() => registerSidebarTab({ ...getExampleTab(), order: undefined }),
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a numeric order set]')
})

it('fails with invalid order', () => {
expect(
// @ts-expect-error mocking for testing
() => registerSidebarTab({ ...getExampleTab(), order: '3' }),
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a numeric order set]')
})

it('fails with missing "enabled" method', () => {
expect(
// @ts-expect-error mocking for testing
() => registerSidebarTab({ ...getExampleTab(), enabled: undefined }),
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an "enabled" method]')
})

it('fails with missing "setActive" method', () => {
expect(
// @ts-expect-error mocking for testing
() => registerSidebarTab({ ...getExampleTab(), setActive: undefined }),
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a "setActive" method]')
})

it.for(['mount', 'unmount', 'update'])('fails with missing lifecylce methods', (method) => {
expect(
() => registerSidebarTab({ ...getExampleTab(), [method]: undefined }),
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tab is missing a required lifecycle method]')
})

it('works without specifying a scroll listener', () => {
expect(
() => registerSidebarTab({ ...getExampleTab(), onScrollBottomReached: undefined }),
).not.toThrow()
})

it('fails with an invalid scroll listener', () => {
expect(
// @ts-expect-error mocking for testing
() => registerSidebarTab({ ...getExampleTab(), onScrollBottomReached: 'not a method' }),
).toThrowErrorMatchingInlineSnapshot('[Error: "onScrollBottomReached" of the sidebar tab needs to be a function]')
})
})
})

/**
* Get a very basic mock of a sidebar tab
*/
function getExampleTab(): ISidebarTab {
return {
id: 'example-tab',
displayName: 'Example',
enabled: vi.fn(),
iconSvg: '<svg><circle r="45" cx="50" cy="50" fill="red" /></svg>',
order: 0,
mount: vi.fn(),
unmount: vi.fn(),
update: vi.fn(),
setActive: vi.fn(),
onScrollBottomReached: vi.fn(),
}
}
1 change: 1 addition & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './navigation/index.ts'
export * from './newMenu/index.ts'
export * from './node/index.ts'
export * from './permissions.ts'
export * from './sidebar/index.ts'
export * from './utils/index.ts'

// Legacy export of dav utils
Expand Down
165 changes: 165 additions & 0 deletions lib/sidebar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { IFolder, INode } from '../node/index.ts'

import isSvg from 'is-svg'
import logger from '../utils/logger.ts'

interface SidebarUpdateContext {
/**
* The currently selected node for which the sidebar is shown.
*/
node: INode

/**
* The parent of the current selected node.
* This is not necessarily the real parent folder of the node in means of the real filesystem tree,
* but rather the parent folder in the current view of the files app.
*/
parent: IFolder
}

/**
* Implementation of a custom sidebar tab within the files app.
*/
export interface ISidebarTab {
/**
* Unique id of the sidebar tab.
* This has to conform to the HTML id attribute specification.
*/
id: string

/**
* The localized name of the sidebar tab.
*/
displayName: string

/**
* The icon, as SVG, of the sidebar tab.
*/
iconSvg: string

/**
* The order of this tab.
* Use a low number to make this tab ordered in front.
*/
order: number

/**
* Callback to check if the sidebar tab should be shown for the selected node.
*
* @param node - The currently selected node
*/
enabled: (node: INode) => boolean

/**
* Called by the files app if this tab has become the active tab or was deactivated.
*
* @param active - The new active state of this tab
*/
setActive: (active: boolean) => void | Promise<void>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, we've been using events lately since you introduced them :)
Maybe it's time to make it a common pattern at Nextcloud?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this is the opposite direction - currently this is used by the sidebar to notify a tab that is was "navigated to".
So that it could e.g. refresh its content.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, yeah 🤦


/**
* The lifecylce method for mounting the sidebar tab onto the sidebar.
*
* @param el - The element to mount the sidebar tab to
* @param context - The current sidebar context
*/
mount: (el: HTMLElement, context: SidebarUpdateContext) => void | Promise<void>

/**
* The lifecycle method for updating the sidebar tab.
* This is called if the currently selected node changes.
*
* @param context - The current sidebar context
*/
update: (context: SidebarUpdateContext) => void | Promise<void>

/**
* The lifecycle method for unmounting the sidebar tab.
* This is called if the sidebar is unmounted from the files app and thus the sidebar tab needs to do its cleanup and unmounting.
*/
unmount: () => void | Promise<void>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

destroy maybe? I think unmounting is an odd edge case. We should maybe be clearer and let devs know the sidebar is actually destroyed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would work for me - no strong opinion. I just used the names of the Vue lifecyles (mount and unmount).

But it could also be that the tab is only unmounted but the sidebar is not destroyed (e.g. the current node is changed to something this tab is not enabled for).


/**
* Called when the bottom of the sidebar was reached during scrolling.
*/
onScrollBottomReached?: () => void | Promise<void>
}

/**
* Register a new sidebar tab for the files app.
*
* @param tab - The sidebar tab to register
* @throws If the provided tab is not a valid sidebar tab and thus cannot be registered.
*/
export function registerSidebarTab(tab: ISidebarTab): void {
validateSidebarTab(tab)

window._nc_files_sidebar_tabs ??= new Map<string, ISidebarTab>()
if (window._nc_files_sidebar_tabs.has(tab.id)) {
logger.warn(`Sidebar tab with id "${tab.id}" already registered. Skipping.`)
return
}
window._nc_files_sidebar_tabs.set(tab.id, tab)
logger.debug(`New sidebar tab with id "${tab.id}" registered.`)
}

/**
* Get all currently registered sidebar tabs.
*/
export function getSidebarTabs(): ISidebarTab[] {
if (window._nc_files_sidebar_tabs) {
return [...window._nc_files_sidebar_tabs.values()]
}
return []
}

/**
* Check if a given sidebar tab objects implements all necessary fields.
*
* @param tab - The sidebar tab to validate
*/
function validateSidebarTab(tab: ISidebarTab): void {
if (typeof tab !== 'object') {
throw new Error('Sidebar tab is not an object')
}

if (!tab.id || (typeof tab.id !== 'string') || tab.id !== CSS.escape(tab.id)) {
throw new Error('Sidebar tabs need to have an id conforming to the HTML id attribute specifications')
}

if (!tab.displayName || typeof tab.displayName !== 'string') {
throw new Error('Sidebar tabs need to have a name set')
}

if (typeof tab.iconSvg !== 'string' || !isSvg(tab.iconSvg)) {
throw new Error('Sidebar tabs need to have an valid SVG icon')
}

if (typeof tab.order !== 'number') {
throw new Error('Sidebar tabs need to have a numeric order set')
}

if (typeof tab.enabled !== 'function') {
throw new Error('Sidebar tabs need to have an "enabled" method')
}

if (typeof tab.setActive !== 'function') {
throw new Error('Sidebar tabs need to have a "setActive" method')
}

if (typeof tab.mount !== 'function'
|| typeof tab.update !== 'function'
|| typeof tab.unmount !== 'function'
) {
throw new Error('Sidebar tab is missing a required lifecycle method')
}

if (tab.onScrollBottomReached && typeof tab.onScrollBottomReached !== 'function') {
throw new Error('"onScrollBottomReached" of the sidebar tab needs to be a function')
}
}
6 changes: 3 additions & 3 deletions lib/window.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,8 @@ import type {
NewMenu,
} from './index.ts'

import type {
DavProperty,
} from './dav/index.ts'
import type { DavProperty } from './dav/index.ts'
import type { ISidebarTab } from './sidebar/index.ts'

export {}

Expand All @@ -30,6 +29,7 @@ declare global {
_nc_newfilemenu?: NewMenu
_nc_navigation?: Navigation
_nc_filelist_filters?: Map<string, IFileListFilter>
_nc_files_sidebar_tabs?: Map<string, ISidebarTab>

_oc_config?: {
forbidden_filenames_characters: string[]
Expand Down
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"@nextcloud/vite-config": "^2.4.0",
"@types/node": "^24.2.1",
"@vitest/coverage-istanbul": "^3.2.4",
"css.escape": "^1.5.1",
Comment thread
susnux marked this conversation as resolved.
"fast-xml-parser": "^5.2.5",
"jsdom": "^26.1.0",
"tslib": "^2.8.1",
Expand Down