Skip to content

Commit

Permalink
Merge pull request #732 from nextcloud-libraries/feat/new-menu-context
Browse files Browse the repository at this point in the history
  • Loading branch information
skjnldsv committed Aug 18, 2023
2 parents 9e2861f + 9b1d574 commit cd35f80
Show file tree
Hide file tree
Showing 13 changed files with 480 additions and 38 deletions.
22 changes: 11 additions & 11 deletions __tests__/fileAction.spec.ts
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -208,24 +208,24 @@ 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
},
})

expect(action.id).toBe('test')
expect(action.displayName([], {})).toBe('Test')
expect(action.iconSvgInline([], {})).toBe('<svg></svg>')
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('<svg></svg>')
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('<span>test</span>')
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('<span>test</span>')
})
})
16 changes: 12 additions & 4 deletions __tests__/newFileMenu.spec.ts
Expand Up @@ -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: '<svg></svg>',
getContents: async () => ({ folder: {}, contents: [] }),
order: 1,
})

describe('NewFileMenu init', () => {
test('Initializing NewFileMenu', () => {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
})

Expand Down Expand Up @@ -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)
})
Expand Down
37 changes: 26 additions & 11 deletions lib/fileAction.ts
@@ -1,5 +1,5 @@
/**
* @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com>
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
Expand All @@ -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. <svg><path fill="..." /></svg> */
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<boolean|null>,
exec: (file: Node, view: View, dir: string) => Promise<boolean|null>,
/**
* 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<HTMLElement | null>,
}

export class FileAction {
Expand Down Expand Up @@ -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')
}

Expand Down
7 changes: 4 additions & 3 deletions lib/fileListHeaders.ts
Expand Up @@ -21,6 +21,7 @@
*/

import { Folder } from './files/folder'
import { View } from './navigation/view'
import logger from './utils/logger'

export interface HeaderData {
Expand All @@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions lib/index.ts
Expand Up @@ -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'
Expand All @@ -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
Expand Down
102 changes: 102 additions & 0 deletions lib/navigation/column.ts
@@ -0,0 +1,102 @@
/**
* @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
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
}
66 changes: 66 additions & 0 deletions lib/navigation/navigation.ts
@@ -0,0 +1,66 @@
/**
* @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
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
}

0 comments on commit cd35f80

Please sign in to comment.