diff --git a/__tests__/fileAction.spec.ts b/__tests__/fileAction.spec.ts new file mode 100644 index 00000000..5ad43880 --- /dev/null +++ b/__tests__/fileAction.spec.ts @@ -0,0 +1,217 @@ +import { getFileActions, registerFileAction, FileAction } from '../lib/fileAction' +import logger from '../lib/utils/logger'; + +declare global { + interface Window { + OC: any; + _nc_fileactions: FileAction[]; + } +} + +describe('FileActions init', () => { + test('Getting empty uninitialized FileActions', () => { + logger.debug = jest.fn() + const fileActions = getFileActions() + expect(window._nc_fileactions).toBeUndefined() + expect(fileActions).toHaveLength(0) + expect(logger.debug).toHaveBeenCalledTimes(0) + }) + + test('Initializing FileActions', () => { + logger.debug = jest.fn() + const action = new FileAction({ + id: 'test', + displayName: () => 'Test', + iconSvgInline: () => '', + exec: async () => true, + }) + + expect(action.id).toBe('test') + expect(action.displayName([])).toBe('Test') + expect(action.iconSvgInline([])).toBe('') + + registerFileAction(action) + + expect(window._nc_fileactions).toHaveLength(1) + expect(getFileActions()).toHaveLength(1) + expect(getFileActions()[0]).toStrictEqual(action) + expect(logger.debug).toHaveBeenCalled() + }) + + test('Duplicate FileAction gets rejected', () => { + logger.error = jest.fn() + const action = new FileAction({ + id: 'test', + displayName: () => 'Test', + iconSvgInline: () => '', + exec: async () => true, + }) + + registerFileAction(action) + expect(getFileActions()).toHaveLength(1) + expect(getFileActions()[0]).toStrictEqual(action) + + const action2 = new FileAction({ + id: 'test', + displayName: () => 'Test 2', + iconSvgInline: () => '', + exec: async () => true, + }) + + registerFileAction(action2) + expect(getFileActions()).toHaveLength(1) + expect(getFileActions()[0]).toStrictEqual(action) + expect(logger.error).toHaveBeenCalledWith('FileAction test already registered', { action: action2 }) + }) +}) + +describe('Invalid FileAction creation', () => { + test('Invalid id', () => { + expect(() => { + new FileAction({ + displayName: () => 'Test', + iconSvgInline: () => '', + exec: async () => true, + } as any as FileAction) + }).toThrowError('Invalid id') + }) + test('Invalid displayName', () => { + expect(() => { + new FileAction({ + id: 'test', + displayName: 'Test', + iconSvgInline: () => '', + exec: async () => true, + } as any as FileAction) + }).toThrowError('Invalid displayName function') + }) + test('Invalid iconSvgInline', () => { + expect(() => { + new FileAction({ + id: 'test', + displayName: () => 'Test', + iconSvgInline: '', + exec: async () => true, + } as any as FileAction) + }).toThrowError('Invalid iconSvgInline function') + }) + test('Invalid exec', () => { + expect(() => { + new FileAction({ + id: 'test', + displayName: () => 'Test', + iconSvgInline: () => '', + exec: false, + } as any as FileAction) + }).toThrowError('Invalid exec function') + }) + test('Invalid enabled', () => { + expect(() => { + new FileAction({ + id: 'test', + displayName: () => 'Test', + iconSvgInline: () => '', + exec: async () => true, + enabled: false, + } as any as FileAction) + }).toThrowError('Invalid enabled function') + }) + test('Invalid execBatch', () => { + expect(() => { + new FileAction({ + id: 'test', + displayName: () => 'Test', + iconSvgInline: () => '', + exec: async () => true, + execBatch: false, + } as any as FileAction) + }).toThrowError('Invalid execBatch function') + }) + test('Invalid order', () => { + expect(() => { + new FileAction({ + id: 'test', + displayName: () => 'Test', + iconSvgInline: () => '', + exec: async () => true, + order: 'invalid', + } as any as FileAction) + }).toThrowError('Invalid order') + }) + test('Invalid default', () => { + expect(() => { + new FileAction({ + id: 'test', + displayName: () => 'Test', + iconSvgInline: () => '', + exec: async () => true, + default: 'invalid', + } as any as FileAction) + }).toThrowError('Invalid default') + }) + test('Invalid inline', () => { + expect(() => { + new FileAction({ + id: 'test', + displayName: () => 'Test', + iconSvgInline: () => '', + exec: async () => true, + inline: true, + } as any as FileAction) + }).toThrowError('Invalid inline function') + + expect(() => { + new FileAction({ + id: 'test', + displayName: () => 'Test', + iconSvgInline: () => '', + exec: async () => true, + inline: () => true, + } as any as FileAction) + }).toThrowError('renderInline is required when inline is defined') + + expect(() => { + new FileAction({ + id: 'test', + displayName: () => 'Test', + iconSvgInline: () => '', + exec: async () => true, + inline: () => true, + renderInline: false + } as any as FileAction) + }).toThrowError('Invalid renderInline function') + }) +}) + +describe('FileActions creation', () => { + test('create valid FileAction', async () => { + logger.debug = jest.fn() + const action = new FileAction({ + id: 'test', + displayName: () => 'Test', + iconSvgInline: () => '', + exec: async () => true, + execBatch: async () => [true], + enabled: () => true, + order: 100, + default: true, + inline: () => true, + renderInline() { + const span = document.createElement('span') + span.textContent = 'test' + return span + }, + }) + + 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.order).toBe(100) + expect(action.default).toBe(true) + expect(action.inline?.({} as any)).toBe(true) + expect(action.renderInline?.({} as any).outerHTML).toBe('test') + }) +}) \ No newline at end of file diff --git a/lib/fileAction.ts b/lib/fileAction.ts new file mode 100644 index 00000000..c32160ca --- /dev/null +++ b/lib/fileAction.ts @@ -0,0 +1,144 @@ +/** + * @copyright Copyright (c) 2021 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 { Node } from "./files/node" +import logger from "./utils/logger" + +interface FileActionData { + /** Unique ID */ + id: string + /** Translatable string displayed in the menu */ + displayName: (files: Node[]) => string + /** Svg as inline string. */ + iconSvgInline: (files: Node[]) => string + /** Condition wether this action is shown or not */ + enabled?: (files: Node[], view) => boolean + /** + * Function executed on single file action + * @returns true if the action was executed, false otherwise + * @throws Error if the action failed + */ + exec: (file: Node) => Promise, + /** + * Function executed on multiple files action + * @returns true if the action was executed, false otherwise + * @throws Error if the action failed + */ + execBatch?: (files: Node[]) => Promise + /** This action order in the list */ + order?: number, + /** Make this action the default */ + default?: boolean, + /** + * If true, the renderInline function will be called + */ + inline?: (file: Node) => boolean, + /** + * If defined, the returned html element will be + * appended before the actions menu. + */ + renderInline?: (file: Node) => HTMLElement, +} + +// Allow class/interface merging and proxying +export interface FileAction extends FileActionData {} +export class FileAction { + private _action: FileActionData + + constructor(action: FileActionData) { + this.validateAction(action) + this._action = action + + // Forward any getter to action data + return new Proxy(this, { + get(target, property: string) { + return Reflect.get(target._action, property) + } + }) + } + + private validateAction(action: FileActionData) { + if (!action.id || typeof action.id !== 'string') { + throw new Error('Invalid id') + } + + if (!action.displayName || typeof action.displayName !== 'function') { + throw new Error('Invalid displayName function') + } + + if (!action.iconSvgInline || typeof action.iconSvgInline !== 'function') { + throw new Error('Invalid iconSvgInline function') + } + + if (!action.exec || typeof action.exec !== 'function') { + throw new Error('Invalid exec function') + } + + // Optional properties -------------------------------------------- + if ('enabled' in action && typeof action.execBatch !== 'function') { + throw new Error('Invalid enabled function') + } + + if ('execBatch' in action && typeof action.execBatch !== 'function') { + throw new Error('Invalid execBatch function') + } + + if ('order' in action && typeof action.order !== 'number') { + throw new Error('Invalid order') + } + + if ('default' in action && typeof action.default !== 'boolean') { + throw new Error('Invalid default') + } + + if ('inline' in action && typeof action.inline !== 'function') { + throw new Error('Invalid inline function') + } + + if ('renderInline' in action && typeof action.renderInline !== 'function') { + throw new Error('Invalid renderInline function') + } + + if ('inline' in action && !('renderInline' in action)) { + throw new Error('renderInline is required when inline is defined') + } + } +} + +export const registerFileAction = function(action: FileAction): void { + if (typeof window._nc_fileactions === 'undefined') { + window._nc_fileactions = [] + logger.debug('FileActions initialized') + } + + // Check duplicates + if (window._nc_fileactions.find(search => search.id === action.id)) { + logger.error(`FileAction ${action.id} already registered`, { action }) + return + } + + window._nc_fileactions.push(action) +} + +export const getFileActions = function(): FileAction[] { + return window._nc_fileactions || [] +} diff --git a/lib/index.ts b/lib/index.ts index 8cd3be86..85ca825a 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -23,6 +23,7 @@ export { formatFileSize } from './humanfilesize' export { type Entry } from './newFileMenu' +import { FileAction } from './fileAction' import { type Entry, getNewFileMenu, NewFileMenu } from './newFileMenu' export { FileType } from './files/fileType' @@ -35,6 +36,7 @@ declare global { interface Window { OC: any; _nc_newfilemenu: NewFileMenu; + _nc_fileactions: FileAction[]; } }