From fcfe9a3a9bcfed7bfb125228908a41f9fc6eb03a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= Date: Fri, 18 Aug 2023 11:00:04 +0200 Subject: [PATCH] feat: migrate Navigation from server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: John Molakvoæ --- lib/fileAction.ts | 15 +-- lib/fileListHeaders.ts | 7 +- lib/index.ts | 4 +- lib/navigation/column.ts | 102 ++++++++++++++++ lib/navigation/navigation.ts | 66 ++++++++++ lib/navigation/view.ts | 227 +++++++++++++++++++++++++++++++++++ lib/newFileMenu.ts | 7 +- package-lock.json | 15 +++ package.json | 1 + window.d.ts | 2 + 10 files changed, 432 insertions(+), 14 deletions(-) create mode 100644 lib/navigation/column.ts create mode 100644 lib/navigation/navigation.ts create mode 100644 lib/navigation/view.ts diff --git a/lib/fileAction.ts b/lib/fileAction.ts index 24e11fe3..7f446402 100644 --- a/lib/fileAction.ts +++ b/lib/fileAction.ts @@ -21,31 +21,32 @@ */ import { Node } from './files/node' +import { View } from './navigation/view' import logger from './utils/logger' 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 */ @@ -53,12 +54,12 @@ interface FileActionData { /** * 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) => HTMLElement, } export class FileAction { 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..39b21ae0 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -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..0c7155e9 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 { diff --git a/package-lock.json b/package-lock.json index f8eb8000..a930e220 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 cdc6f0cb..2a1e1355 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/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 } }