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 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/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
}
}