Skip to content

Commit

Permalink
feat(FileAction): add file action support
Browse files Browse the repository at this point in the history
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
  • Loading branch information
skjnldsv committed Mar 22, 2023
1 parent 7d30829 commit 10cc6af
Show file tree
Hide file tree
Showing 3 changed files with 363 additions and 0 deletions.
217 changes: 217 additions & 0 deletions __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: () => '<svg></svg>',
exec: () => true,
})

expect(action.id).toBe('test')
expect(action.displayName([])).toBe('Test')
expect(action.iconSvgInline([])).toBe('<svg></svg>')

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: () => '<svg></svg>',
exec: () => true,
})

registerFileAction(action)
expect(getFileActions()).toHaveLength(1)
expect(getFileActions()[0]).toStrictEqual(action)

const action2 = new FileAction({
id: 'test',
displayName: () => 'Test 2',
iconSvgInline: () => '<svg></svg>',
exec: () => 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: () => '<svg></svg>',
exec: () => true,
} as any as FileAction)
}).toThrowError('Invalid id')
})
test('Invalid displayName', () => {
expect(() => {
new FileAction({
id: 'test',
displayName: 'Test',
iconSvgInline: () => '<svg></svg>',
exec: () => true,
} as any as FileAction)
}).toThrowError('Invalid displayName function')
})
test('Invalid iconSvgInline', () => {
expect(() => {
new FileAction({
id: 'test',
displayName: () => 'Test',
iconSvgInline: '<svg></svg>',
exec: () => true,
} as any as FileAction)
}).toThrowError('Invalid iconSvgInline function')
})
test('Invalid exec', () => {
expect(() => {
new FileAction({
id: 'test',
displayName: () => 'Test',
iconSvgInline: () => '<svg></svg>',
exec: false,
} as any as FileAction)
}).toThrowError('Invalid exec function')
})
test('Invalid enabled', () => {
expect(() => {
new FileAction({
id: 'test',
displayName: () => 'Test',
iconSvgInline: () => '<svg></svg>',
exec: () => true,
enabled: false,
} as any as FileAction)
}).toThrowError('Invalid enabled function')
})
test('Invalid execBatch', () => {
expect(() => {
new FileAction({
id: 'test',
displayName: () => 'Test',
iconSvgInline: () => '<svg></svg>',
exec: () => true,
execBatch: false,
} as any as FileAction)
}).toThrowError('Invalid execBatch function')
})
test('Invalid order', () => {
expect(() => {
new FileAction({
id: 'test',
displayName: () => 'Test',
iconSvgInline: () => '<svg></svg>',
exec: () => true,
order: 'invalid',
} as any as FileAction)
}).toThrowError('Invalid order')
})
test('Invalid default', () => {
expect(() => {
new FileAction({
id: 'test',
displayName: () => 'Test',
iconSvgInline: () => '<svg></svg>',
exec: () => true,
default: 'invalid',
} as any as FileAction)
}).toThrowError('Invalid default')
})
test('Invalid inline', () => {
expect(() => {
new FileAction({
id: 'test',
displayName: () => 'Test',
iconSvgInline: () => '<svg></svg>',
exec: () => true,
inline: true,
} as any as FileAction)
}).toThrowError('Invalid inline function')

expect(() => {
new FileAction({
id: 'test',
displayName: () => 'Test',
iconSvgInline: () => '<svg></svg>',
exec: () => true,
inline: () => true,
} as any as FileAction)
}).toThrowError('renderInline is required when inline is defined')

expect(() => {
new FileAction({
id: 'test',
displayName: () => 'Test',
iconSvgInline: () => '<svg></svg>',
exec: () => true,
inline: () => true,
renderInline: false
} as any as FileAction)
}).toThrowError('Invalid renderInline function')
})
})

describe('FileActions creation', () => {
test('create valid FileAction', () => {
logger.debug = jest.fn()
const action = new FileAction({
id: 'test',
displayName: () => 'Test',
iconSvgInline: () => '<svg></svg>',
exec: () => true,
execBatch: () => 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('<svg></svg>')
expect(action.exec({} as any)).toBe(true)
expect(action.execBatch?.([])).toBe(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('<span>test</span>')
})
})
144 changes: 144 additions & 0 deletions lib/fileAction.ts
@@ -0,0 +1,144 @@
/**
* @copyright Copyright (c) 2021 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 { 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. <svg><path fill="..." /></svg> */
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) => boolean,
/**
* Function executed on multiple files action
* @returns true if the action was executed, false otherwise
* @throws Error if the action failed
*/
execBatch?: (files: Node[]) => boolean
/** 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 || []
}
2 changes: 2 additions & 0 deletions lib/index.ts
Expand Up @@ -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'
Expand All @@ -35,6 +36,7 @@ declare global {
interface Window {
OC: any;
_nc_newfilemenu: NewFileMenu;
_nc_fileactions: FileAction[];
}
}

Expand Down

0 comments on commit 10cc6af

Please sign in to comment.