diff --git a/__tests__/fileAction.spec.ts b/__tests__/fileAction.spec.ts index e4496169..6052d3a1 100644 --- a/__tests__/fileAction.spec.ts +++ b/__tests__/fileAction.spec.ts @@ -3,7 +3,7 @@ /* eslint-disable no-new */ import { beforeEach, describe, expect, test, vi } from 'vitest' -import { getFileActions, registerFileAction, FileAction } from '../lib/fileAction' +import { getFileActions, registerFileAction, FileAction, DefaultType } from '../lib/fileAction' import logger from '../lib/utils/logger' describe('FileActions init', () => { @@ -208,9 +208,9 @@ describe('FileActions creation', () => { execBatch: async () => [true], enabled: () => true, order: 100, - default: true, + default: DefaultType.DEFAULT, inline: () => true, - renderInline() { + renderInline: async () => { const span = document.createElement('span') span.textContent = 'test' return span @@ -218,14 +218,14 @@ describe('FileActions creation', () => { }) expect(action.id).toBe('test') - expect(action.displayName([], {})).toBe('Test') - expect(action.iconSvgInline([], {})).toBe('') - await expect(action.exec({} as any, {}, '/')).resolves.toBe(true) - await expect(action.execBatch?.([], {}, '/')).resolves.toStrictEqual([true]) - expect(action.enabled?.({} as any, {})).toBe(true) + expect(action.displayName([], {} as any)).toBe('Test') + expect(action.iconSvgInline([], {} as any)).toBe('') + await expect(action.exec({} as any, {} as any, '/')).resolves.toBe(true) + await expect(action.execBatch?.([], {} as any, '/')).resolves.toStrictEqual([true]) + expect(action.enabled?.({} as any, {} as any)).toBe(true) expect(action.order).toBe(100) - expect(action.default).toBe(true) - expect(action.inline?.({} as any, {})).toBe(true) - expect(action.renderInline?.({} as any, {}).outerHTML).toBe('test') + expect(action.default).toBe(DefaultType.DEFAULT) + expect(action.inline?.({} as any, {} as any)).toBe(true) + expect((await action.renderInline?.({} as any, {} as any))?.outerHTML).toBe('test') }) }) diff --git a/__tests__/newFileMenu.spec.ts b/__tests__/newFileMenu.spec.ts index 2595873c..3ac44104 100644 --- a/__tests__/newFileMenu.spec.ts +++ b/__tests__/newFileMenu.spec.ts @@ -2,7 +2,15 @@ import { describe, expect, test, vi } from 'vitest' import { NewFileMenu, getNewFileMenu, type Entry } from '../lib/newFileMenu' import logger from '../lib/utils/logger' -import { Folder, Permission } from '../lib' +import { Folder, Permission, View } from '../lib' + +const view = new View({ + id: 'files', + name: 'Files', + icon: '', + getContents: async () => ({ folder: {}, contents: [] }), + order: 1, +}) describe('NewFileMenu init', () => { test('Initializing NewFileMenu', () => { @@ -268,7 +276,7 @@ describe('NewFileMenu getEntries filter', () => { permissions: Permission.ALL, }) - const entries = newFileMenu.getEntries(context) + const entries = newFileMenu.getEntries(context, view) expect(entries).toHaveLength(2) expect(entries[0]).toBe(entry1) expect(entries[1]).toBe(entry2) @@ -304,7 +312,7 @@ describe('NewFileMenu getEntries filter', () => { permissions: Permission.READ, }) - const entries = newFileMenu.getEntries(context) + const entries = newFileMenu.getEntries(context, view) expect(entries).toHaveLength(0) }) @@ -338,7 +346,7 @@ describe('NewFileMenu getEntries filter', () => { root: '/files/admin', }) - const entries = newFileMenu.getEntries(context) + const entries = newFileMenu.getEntries(context, view) expect(entries).toHaveLength(1) expect(entries[0]).toBe(entry1) }) diff --git a/lib/fileAction.ts b/lib/fileAction.ts index 24e11fe3..2cc64e7a 100644 --- a/lib/fileAction.ts +++ b/lib/fileAction.ts @@ -1,5 +1,5 @@ /** - * @copyright Copyright (c) 2021 John Molakvoæ + * @copyright Copyright (c) 2023 John Molakvoæ * * @author John Molakvoæ * @@ -21,44 +21,59 @@ */ import { Node } from './files/node' +import { View } from './navigation/view' import logger from './utils/logger' +export enum DefaultType { + DEFAULT = 'default', + HIDDEN = 'hidden', +} + interface FileActionData { /** Unique ID */ id: string /** Translatable string displayed in the menu */ - displayName: (files: Node[], view) => string + displayName: (files: Node[], view: View) => string /** Svg as inline string. */ - iconSvgInline: (files: Node[], view) => string + iconSvgInline: (files: Node[], view: View) => string /** Condition wether this action is shown or not */ - enabled?: (files: Node[], view) => boolean + enabled?: (files: Node[], view: View) => boolean /** * Function executed on single file action * @return true if the action was executed successfully, * false otherwise and null if the action is silent/undefined. * @throws Error if the action failed */ - exec: (file: Node, view, dir: string) => Promise, + exec: (file: Node, view: View, dir: string) => Promise, /** * Function executed on multiple files action * @return true if the action was executed successfully, * false otherwise and null if the action is silent/undefined. * @throws Error if the action failed */ - execBatch?: (files: Node[], view, dir: string) => Promise<(boolean|null)[]> + execBatch?: (files: Node[], view: View, dir: string) => Promise<(boolean|null)[]> /** This action order in the list */ order?: number, - /** Make this action the default */ - default?: boolean, + + /** + * Make this action the default. + * If multiple actions are default, the first one + * will be used. The other ones will be put as first + * entries in the actions menu iff DefaultType.Hidden is not used. + * A DefaultType.Hidden action will never be shown + * in the actions menu even if another action takes + * its place as default. + */ + default?: DefaultType, /** * If true, the renderInline function will be called */ - inline?: (file: Node, view) => boolean, + inline?: (file: Node, view: View) => boolean, /** * If defined, the returned html element will be * appended before the actions menu. */ - renderInline?: (file: Node, view) => HTMLElement, + renderInline?: (file: Node, view: View) => Promise, } export class FileAction { @@ -140,7 +155,7 @@ export class FileAction { throw new Error('Invalid order') } - if ('default' in action && typeof action.default !== 'boolean') { + if (action.default && !Object.values(DefaultType).includes(action.default)) { throw new Error('Invalid default') } diff --git a/lib/fileListHeaders.ts b/lib/fileListHeaders.ts index 9c074ee4..b2e9a5f7 100644 --- a/lib/fileListHeaders.ts +++ b/lib/fileListHeaders.ts @@ -21,6 +21,7 @@ */ import { Folder } from './files/folder' +import { View } from './navigation/view' import logger from './utils/logger' export interface HeaderData { @@ -29,11 +30,11 @@ export interface HeaderData { /** Order */ order: number /** Condition wether this header is shown or not */ - enabled?: (folder: Folder, view) => boolean + enabled?: (folder: Folder, view: View) => boolean /** Executed when file list is initialized */ - render: (el: HTMLElement, folder: Folder, view) => void + render: (el: HTMLElement, folder: Folder, view: View) => void /** Executed when root folder changed */ - updated(folder: Folder, view) + updated(folder: Folder, view: View) } export class Header { diff --git a/lib/index.ts b/lib/index.ts index e5cde906..73389834 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -25,7 +25,7 @@ import { type Entry, getNewFileMenu } from './newFileMenu' import { Folder } from './files/folder' export { formatFileSize } from './humanfilesize' -export { FileAction, getFileActions, registerFileAction } from './fileAction' +export { FileAction, getFileActions, registerFileAction, DefaultType } from './fileAction' export { Header, getFileListHeaders, registerFileListHeaders } from './fileListHeaders' export { type Entry } from './newFileMenu' export { Permission } from './permissions' @@ -39,7 +39,9 @@ export { File } from './files/file' export { Folder } from './files/folder' export { Node } from './files/node' -// TODO: Add FileInfo type! +export * from './navigation/navigation' +export * from './navigation/column' +export * from './navigation/view' /** * Add a new menu entry to the upload manager menu diff --git a/lib/navigation/column.ts b/lib/navigation/column.ts new file mode 100644 index 00000000..7433691a --- /dev/null +++ b/lib/navigation/column.ts @@ -0,0 +1,102 @@ +/** + * @copyright Copyright (c) 2022 John Molakvoæ + * + * @author John Molakvoæ + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +import { View } from './view' +import { Node } from '../files/node' + +interface ColumnData { + /** Unique column ID */ + id: string + /** Translated column title */ + title: string + /** The content of the cell. The element will be appended within */ + render: (node: Node, view: View) => HTMLElement + /** Function used to sort Nodes between them */ + sort?: (nodeA: Node, nodeB: Node) => number + /** + * Custom summary of the column to display at the end of the list. + * Will not be displayed if nothing is provided + */ + summary?: (node: Node[], view: View) => string +} + +export class Column implements ColumnData { + + private _column: ColumnData + + constructor(column: ColumnData) { + isValidColumn(column) + this._column = column + } + + get id() { + return this._column.id + } + + get title() { + return this._column.title + } + + get render() { + return this._column.render + } + + get sort() { + return this._column.sort + } + + get summary() { + return this._column.summary + } + +} + +/** + * Typescript cannot validate an interface. + * Please keep in sync with the Column interface requirements. + * + * @param {ColumnData} column the column to check + * @return {boolean} true if the column is valid + */ +const isValidColumn = function(column: ColumnData): boolean { + if (!column.id || typeof column.id !== 'string') { + throw new Error('A column id is required') + } + + if (!column.title || typeof column.title !== 'string') { + throw new Error('A column title is required') + } + + if (!column.render || typeof column.render !== 'function') { + throw new Error('A render function is required') + } + + // Optional properties + if (column.sort && typeof column.sort !== 'function') { + throw new Error('Column sortFunction must be a function') + } + + if (column.summary && typeof column.summary !== 'function') { + throw new Error('Column summary must be a function') + } + + return true +} diff --git a/lib/navigation/navigation.ts b/lib/navigation/navigation.ts new file mode 100644 index 00000000..b3e1dee5 --- /dev/null +++ b/lib/navigation/navigation.ts @@ -0,0 +1,66 @@ +/** + * @copyright Copyright (c) 2022 John Molakvoæ + * + * @author John Molakvoæ + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +import { View } from './view' +import logger from '../utils/logger' + +export class Navigation { + + private _views: View[] = [] + private _currentView: View | null = null + + register(view: View) { + if (this._views.find(search => search.id === view.id)) { + throw new Error(`View id ${view.id} is already registered`) + } + + this._views.push(view) + } + + remove(id: string) { + const index = this._views.findIndex(view => view.id === id) + if (index !== -1) { + this._views.splice(index, 1) + } + } + + get views(): View[] { + return this._views + } + + setActive(view: View | null) { + this._currentView = view + } + + get active(): View | null { + return this._currentView + } + +} + +export const getNavigation = function(): Navigation { + if (typeof window._nc_navigation === 'undefined') { + window._nc_navigation = new Navigation() + logger.debug('Navigation service initialized') + } + + return window._nc_navigation +} diff --git a/lib/navigation/view.ts b/lib/navigation/view.ts new file mode 100644 index 00000000..8ea50d21 --- /dev/null +++ b/lib/navigation/view.ts @@ -0,0 +1,227 @@ +/** + * @copyright Copyright (c) 2022 John Molakvoæ + * + * @author John Molakvoæ + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +/* eslint-disable no-use-before-define */ +import type { Folder, Node } from '@nextcloud/files' +import isSvg from 'is-svg' + +import { Column } from './column.js' + +export type ContentsWithRoot = { + folder: Folder, + contents: Node[] +} + +interface ViewData { + /** Unique view ID */ + id: string + /** Translated view name */ + name: string + /** Translated accessible description of the view */ + caption?: string + + /** Translated title of the empty view */ + emptyTitle?: string + /** Translated description of the empty view */ + emptyCaption?: string + + /** + * Method return the content of the provided path + * This ideally should be a cancellable promise. + * promise.cancel(reason) will be called when the directory + * change and the promise is not resolved yet. + * You _must_ also return the current directory + * information alongside with its content. + */ + getContents: (path: string) => Promise + /** The view icon as an inline svg */ + icon: string + /** The view order */ + order: number + + /** + * Custom params to give to the router on click + * If defined, will be treated as a dummy view and + * will just redirect and not fetch any contents. + */ + params?: Record + + /** + * This view column(s). Name and actions are + * by default always included + */ + columns?: Column[] + /** The empty view element to render your empty content into */ + emptyView?: (div: HTMLDivElement) => void + /** The parent unique ID */ + parent?: string + /** This view is sticky (sent at the bottom) */ + sticky?: boolean + + /** + * This view has children and is expanded (by default) + * or not. This will be overridden by user config. + */ + expanded?: boolean + + /** + * Will be used as default if the user + * haven't customized their sorting column + */ + defaultSortKey?: string +} + +export class View implements ViewData { + + private _view: ViewData + + constructor(view: ViewData) { + isValidView(view) + this._view = view + } + + get id() { + return this._view.id + } + + get name() { + return this._view.name + } + + get caption() { + return this._view.caption + } + + get emptyTitle() { + return this._view.emptyTitle + } + + get emptyCaption() { + return this._view.emptyCaption + } + + get getContents() { + return this._view.getContents + } + + get icon() { + return this._view.icon + } + + get order() { + return this._view.order + } + + get params() { + return this._view.params + } + + get columns() { + return this._view.columns + } + + get emptyView() { + return this._view.emptyView + } + + get parent() { + return this._view.parent + } + + get sticky() { + return this._view.sticky + } + + get expanded() { + return this._view.expanded + } + + get defaultSortKey() { + return this._view.defaultSortKey + } + +} + +/** + * Typescript cannot validate an interface. + * Please keep in sync with the View interface requirements. + * + * @param {ViewData} view the view to check + * @return {boolean} true if the column is valid + * @throws {Error} if the view is not valid + */ +const isValidView = function(view: ViewData): boolean { + if (!view.id || typeof view.id !== 'string') { + throw new Error('View id is required and must be a string') + } + + if (!view.name || typeof view.name !== 'string') { + throw new Error('View name is required and must be a string') + } + + if (view.columns && view.columns.length > 0 + && (!view.caption || typeof view.caption !== 'string')) { + throw new Error('View caption is required for top-level views and must be a string') + } + + if (!view.getContents || typeof view.getContents !== 'function') { + throw new Error('View getContents is required and must be a function') + } + + if (!view.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) { + throw new Error('View icon is required and must be a valid svg string') + } + + if (!('order' in view) || typeof view.order !== 'number') { + throw new Error('View order is required and must be a number') + } + + // Optional properties + if (view.columns) { + view.columns.forEach((column) => { + if (!(column instanceof Column)) { + throw new Error('View columns must be an array of Column. Invalid column found') + } + }) + } + + if (view.emptyView && typeof view.emptyView !== 'function') { + throw new Error('View emptyView must be a function') + } + + if (view.parent && typeof view.parent !== 'string') { + throw new Error('View parent must be a string') + } + + if ('sticky' in view && typeof view.sticky !== 'boolean') { + throw new Error('View sticky must be a boolean') + } + + if ('expanded' in view && typeof view.expanded !== 'boolean') { + throw new Error('View expanded must be a boolean') + } + + if (view.defaultSortKey && typeof view.defaultSortKey !== 'string') { + throw new Error('View defaultSortKey must be a string') + } + + return true +} diff --git a/lib/newFileMenu.ts b/lib/newFileMenu.ts index 64a1324c..20b57179 100644 --- a/lib/newFileMenu.ts +++ b/lib/newFileMenu.ts @@ -20,7 +20,8 @@ * */ -import { Folder } from '.' +import { Folder } from './files/folder' +import { View } from './navigation/view' import logger from './utils/logger' export interface Entry { @@ -34,7 +35,7 @@ export interface Entry { * Condition wether this entry is shown or not * @param {Folder} context the creation context. Usually the current folder */ - if?: (context: Folder) => boolean + if?: (context: Folder, view: View) => boolean /** * Either iconSvgInline or iconClass must be defined * Svg as inline string. @@ -43,7 +44,7 @@ export interface Entry { /** Existing icon css class */ iconClass?: string /** Function to be run after creation */ - handler?: () => void + handler?: (context: Folder, view: View) => void } export class NewFileMenu { @@ -71,12 +72,13 @@ export class NewFileMenu { /** * Get the list of registered entries * - * @param {Folder} context the creation context. Usually the current folder FileInfo + * @param {Folder} context the creation context. Usually the current folder + * @param {View} view the current view */ - public getEntries(context?: Folder): Array { - if (context) { + public getEntries(context?: Folder, view?: View): Array { + if (context && view) { return this._entries - .filter(entry => typeof entry.if === 'function' ? entry.if(context) : true) + .filter(entry => typeof entry.if === 'function' ? entry.if(context, view) : true) } return this._entries } diff --git a/package-lock.json b/package-lock.json index ed92123c..ed2fe4a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@nextcloud/l10n": "^2.2.0", "@nextcloud/logger": "^2.5.0", "@nextcloud/router": "^2.1.2", + "is-svg": "^5.0.0", "webdav": "^5.2.3" }, "devDependencies": { @@ -6004,6 +6005,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-svg": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-5.0.0.tgz", + "integrity": "sha512-sRl7J0oX9yUNamSdc8cwgzh9KBLnQXNzGmW0RVHwg/jEYjGNYHC6UvnYD8+hAeut9WwxRvhG9biK7g/wDGxcMw==", + "dependencies": { + "fast-xml-parser": "^4.1.3" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-symbol": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", diff --git a/package.json b/package.json index f267d1d1..b083c37f 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@nextcloud/l10n": "^2.2.0", "@nextcloud/logger": "^2.5.0", "@nextcloud/router": "^2.1.2", + "is-svg": "^5.0.0", "webdav": "^5.2.3" } } diff --git a/tsconfig.json b/tsconfig.json index 3903dd49..807337f3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,5 +9,6 @@ "strict": true, "noImplicitAny": false, "outDir": "./dist", + "rootDir": "./lib", } } diff --git a/window.d.ts b/window.d.ts index e574a890..385e5cb4 100644 --- a/window.d.ts +++ b/window.d.ts @@ -1,5 +1,6 @@ /// +import type { Navigation } from './lib' import type { DavProperty } from './lib/dav/davProperties' import type { FileAction } from './lib/fileAction' import type { Header } from './lib/fileListHeaders' @@ -15,5 +16,6 @@ declare global { _nc_fileactions?: FileAction[] _nc_filelistheader?: Header[] _nc_newfilemenu?: NewFileMenu + _nc_navigation?: Navigation } }