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
29 changes: 1 addition & 28 deletions __tests__/sidebar/sidebarTab.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,15 @@
* 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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { getSidebarTabs, ISidebarTab, registerSidebarTab } from '../../lib/sidebar/SidebarTab.ts'
// 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
})

Expand Down Expand Up @@ -97,16 +80,6 @@ describe('Sidebar tabs', () => {
.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
Expand Down
2 changes: 1 addition & 1 deletion lib/fileListFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export interface IFileListFilterChip {
/**
* Handler to be called on click
*/
onclick: () => void
onclick(): void
}

/**
Expand Down
107 changes: 86 additions & 21 deletions lib/sidebar/Sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { INode } from '../node/node.ts'
import type { ISidebarAction } from './SidebarAction.ts'
import type { ISidebarContext, ISidebarTab } from './SidebarTab.ts'

import { getSidebarTabs, registerSidebarTab } from './SidebarTab.ts'
import { registerSidebarAction } from './SidebarAction.ts'
import { registerSidebarTab } from './SidebarTab.ts'

export interface ISidebar {
/**
Expand All @@ -17,17 +20,47 @@ export interface ISidebar {
/**
* The current open state of the sidebar
*/
readonly open: boolean
readonly isOpen: boolean

/**
* Open or close the sidebar
* The currently active sidebar tab id
*/
readonly activeTab?: string

/**
* The currently opened node in the sidebar
*/
readonly node?: INode

/**
* Open the sidebar for a specific node.
*
* When the sidebar is fully opened the `files:sidebar:opened` event is emitted,
* see also `@nextcloud/event-bus`.
*
* @param node - The node to open the sidebar for
* @param tab - The tab to open by default
*/
open(node: INode, tab?: string): void

/**
* Close the sidebar.
*
* @param open - The new open state
* When the sidebar is fully closed the `files:sidebar:closed` event is emitted,
* see also `@nextcloud/event-bus`.
*/
setOpen(open: boolean): void
close(): void

/**
* Register a new sidebar tab
* Set the active sidebar tab
*
* @param tabId - The tab to set active
*/
setActiveTab(tabId: string): void

/**
* Register a new sidebar tab.
* This should ideally be done on app initialization using Nextcloud init scripts.
*
* @param tab - The sidebar tab to register
*/
Expand All @@ -38,6 +71,22 @@ export interface ISidebar {
* If a node is passed only the enabled tabs are retrieved.
*/
getTabs(context?: ISidebarContext): ISidebarTab[]

/**
* Get all registered sidebar actions.
*
* If a context is provided only the enabled actions are returned.
*
* @param context - The context
*/
getActions(context?: ISidebarContext): ISidebarAction[]

/**
* Register a new sidebar action.
*
* @param action - The action to register
*/
registerAction(action: ISidebarAction): void
}

/**
Expand All @@ -50,36 +99,52 @@ export interface ISidebar {
*/
class SidebarProxy implements ISidebar {

get #impl(): Omit<ISidebar, 'available' | 'registerTab' | 'registerAction'> | undefined {
return window.OCA?.Files?._sidebar?.()
}

get available(): boolean {
return !!window.OCA?.Files?.Sidebar
return !!this.#impl
}

get isOpen(): boolean {
return this.#impl?.isOpen ?? false
}

get activeTab(): string | undefined {
return this.#impl?.activeTab
}

get node(): INode | undefined {
return this.#impl?.node
}

get open(): boolean {
return !!window.OCA?.Files?.Sidebar?.state.file
open(node: INode, tab?: string): void {
this.#impl?.open(node, tab)
}

setOpen(open: boolean): void {
if (open) {
window.OCA?.Files?.Sidebar?.open()
} else {
window.OCA?.Files?.Sidebar?.close()
}
close(): void {
this.#impl?.close()
}

setActiveTab(tabId: string): void {
window.OCA?.Files?.Sidebar?.setActiveTab(tabId)
this.#impl?.setActiveTab(tabId)
}

registerTab(tab: ISidebarTab): void {
registerSidebarTab(tab)
}

getTabs(context?: ISidebarContext): ISidebarTab[] {
const tabs = getSidebarTabs()
if (context) {
return tabs.filter((tab) => tab.enabled(context))
}
return tabs
return this.#impl?.getTabs(context) ?? []
}

getActions(context?: ISidebarContext): ISidebarAction[] {
return this.#impl?.getActions(context) ?? []
}

registerAction(action: ISidebarAction): void {
registerSidebarAction(action)
}

}
Expand Down
112 changes: 112 additions & 0 deletions lib/sidebar/SidebarAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { ISidebarContext } from './SidebarTab.ts'

import logger from '../utils/logger.ts'

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

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

/**
* The localized name of the sidebar tab.
*
* @param context - The current context of the files app
*/
displayName(context: ISidebarContext): string

/**
* The icon, as SVG, of the sidebar tab.
*
* @param context - The current context of the files app
*/
iconSvgInline(context: ISidebarContext): 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

/**
* Handle the sidebar action.
*
* @param context - The current context of the files app
*/
onClick(context: ISidebarContext): void
}

/**
* Register a new sidebar action.
*
* @param action - The sidebar action to register
* @throws If the provided action is not a valid sidebar action and thus cannot be registered.
*/
export function registerSidebarAction(action: ISidebarAction): void {
validateSidebarAction(action)

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

/**
* Get all currently registered sidebar actions.
*/
export function getSidebarActions(): ISidebarAction[] {
if (window._nc_files_sidebar_actions) {
return [...window._nc_files_sidebar_actions.values()]
}
return []
}

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

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

if (!action.displayName || typeof action.displayName !== 'function') {
throw new Error('Sidebar actions need to have a displayName function')
}

if (!action.iconSvgInline || typeof action.iconSvgInline !== 'function') {
throw new Error('Sidebar actions need to have a iconSvgInline function')
}

if (!action.enabled || typeof action.enabled !== 'function') {
throw new Error('Sidebar actions need to have an enabled function')
}

if (!action.onClick || typeof action.onClick !== 'function') {
throw new Error('Sidebar actions need to have an onClick function')
}
}
11 changes: 0 additions & 11 deletions lib/sidebar/SidebarTab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,15 +149,4 @@ function validateSidebarTab(tab: ISidebarTab): void {
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')
}
}
3 changes: 2 additions & 1 deletion lib/sidebar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

export type { ISidebarContext, ISidebarTab, SidebarComponent } from './SidebarTab.ts'
export * from './Sidebar.ts'
export * from './SidebarAction.ts'
export * from './SidebarTab.ts'
2 changes: 2 additions & 0 deletions lib/window.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {

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

export {}

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

_oc_config?: {
Expand Down