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
8 changes: 8 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
{
"extends": [
"@nextcloud/eslint-config/typescript"
],
"overrides": [
{
"files": ["**.spec.*"],
"rules": {
"no-console": "off"
}
}
]
}
172 changes: 172 additions & 0 deletions __tests__/sidebar/sidebarTab.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

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

import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'
import { getSidebarTabs, ISidebarTab, registerSidebarTab } from '../../lib/sidebar'
// missing in JSDom but supported by every browser!
import 'css.escape'

class SidebarTabMock extends HTMLElement {

node?: INode
folder?: IFolder
view?: IView

public setActive(active: boolean) {
console.log('setActive', active)
}

}

describe('Sidebar tabs', () => {
let getCustomElementsSpy: Mock

beforeEach(() => {
vi.restoreAllMocks()
getCustomElementsSpy = vi.spyOn(window.customElements, 'get')
.mockImplementation(() => SidebarTabMock)
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 tagName name', () => {
expect(
// @ts-expect-error mocking for testing
() => registerSidebarTab({ ...getExampleTab(), tagName: undefined }),
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have the tagName name set]')
})

it('fails with invalid tagName name', () => {
expect(() => registerSidebarTab({ ...getExampleTab(), tagName: 'MyAppSidebarTab' }))
.toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs tagName name is invalid]')
})

it('fails with non registered element', () => {
getCustomElementsSpy.mockImplementationOnce(() => undefined)
expect(() => registerSidebarTab(getExampleTab())).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tab element not registered]')
})

it('fails with invalid custom element', () => {
getCustomElementsSpy.mockImplementationOnce(() => HTMLElement)
expect(() => registerSidebarTab(getExampleTab())).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tab elements must have the `setActive` method]')
})

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(), iconSvgInline: undefined }),
).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an valid SVG icon]')
})

it('fails with invalid SVG icon', () => {
expect(
() => registerSidebarTab({ ...getExampleTab(), iconSvgInline: '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]')
})
})
})

/**
* Get a very basic mock of a sidebar tab
*/
function getExampleTab(): ISidebarTab {
return {
id: 'example-tab',
displayName: 'Example',
tagName: 'example_app-files-sidebar-tab',
enabled: vi.fn(),
iconSvgInline: '<svg><circle r="45" cx="50" cy="50" fill="red" /></svg>',
order: 0,
}
}
3 changes: 3 additions & 0 deletions __tests__/view.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ describe('View creation', () => {
})
})

/**
* Creates a mock View and its associated Folder for testing purposes.
*/
export function mockView() {
const folder = new Folder({
source: 'https://example.org/dav/files/admin/',
Expand Down
1 change: 1 addition & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ 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'
163 changes: 163 additions & 0 deletions lib/sidebar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

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

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

export interface ISidebarContext {
/**
* The active node in the sidebar
*/
node: INode

/**
* The current open folder in the files app
*/
folder: IFolder

/**
* The currently active view
*/
view: IView
}

/**
* This component describes the custom web component that should be registered for a sidebar tab.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Web_components
* @see https://vuejs.org/guide/extras/web-components#building-custom-elements-with-vue
*/
export interface SidebarComponent extends HTMLElement, ISidebarContext {
/**
* This method is called by the files app if the sidebar tab state changes.
*
* @param active - The new active state
*/
setActive(active: boolean): Promise<void>
}

/**
* 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.
*/
iconSvgInline: string

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

/**
* The tag name of the web component.
* The web component must already be registered under that tag name with `CustomElementRegistry.define()`.
*
* To avoid name clashes the name has to start with your appid (e.g. `your_app`).
* So in addition with the web component naming rules a good name would be `your_app-files-sidebar-tab`.
*/
tagName: string

/**
* Callback to check if the sidebar tab should be shown for the selected node.
*
* @param context - The current context of the files app
*/
enabled: (context: ISidebarContext) => boolean
}

/**
* 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.tagName || typeof tab.tagName !== 'string') {
throw new Error('Sidebar tabs need to have the tagName name set')
}

if (!tab.tagName.match(/^[a-z][a-z0-9-_]+$/)) {
throw new Error('Sidebar tabs tagName name is invalid')
}

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

if (typeof tab.iconSvgInline !== 'string' || !isSvg(tab.iconSvgInline)) {
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')
}

// now check the custom element constructor
const tagConstructor = window.customElements.get(tab.tagName)
if (!tagConstructor) {
throw new Error('Sidebar tab element not registered')
}

if (!('setActive' in tagConstructor.prototype)) {
// we cannot check properties like `node` or `view` because those are not necessarily defined in the prototype.
throw new Error('Sidebar tab elements must have the `setActive` method')
}
}
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
Loading