diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2025-03-11 13:20:47 +0100 |
---|---|---|
committer | Ferdinand Thiessen <opensource@fthiessen.de> | 2025-03-13 21:30:43 +0100 |
commit | b15ce12f2819e7cb14360e1b48c19886466fab0c (patch) | |
tree | 1f2bfdf99ced94a3ff2ad78bdfd0f07e844816b7 | |
parent | 4b773f6dd7dda363660081a2f6dacded150b90a1 (diff) | |
download | nextcloud-server-b15ce12f2819e7cb14360e1b48c19886466fab0c.tar.gz nextcloud-server-b15ce12f2819e7cb14360e1b48c19886466fab0c.zip |
refactor(files_trashbin): restore action refactoring
1. do not rely on magic string but use constant ID for trashbin view
2. add unit tests for restore action
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
-rw-r--r-- | apps/files_trashbin/src/files-init.ts | 6 | ||||
-rw-r--r-- | apps/files_trashbin/src/files_actions/restoreAction.spec.ts | 145 | ||||
-rw-r--r-- | apps/files_trashbin/src/files_actions/restoreAction.ts (renamed from apps/files_trashbin/src/actions/restoreAction.ts) | 34 |
3 files changed, 169 insertions, 16 deletions
diff --git a/apps/files_trashbin/src/files-init.ts b/apps/files_trashbin/src/files-init.ts index 9af20693da6..edb09027804 100644 --- a/apps/files_trashbin/src/files-init.ts +++ b/apps/files_trashbin/src/files-init.ts @@ -3,8 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { getNavigation, registerFileListAction } from '@nextcloud/files' -import { emptyTrashAction } from './files_actions/emptyTrashAction.ts' +import { getNavigation, registerFileAction, registerFileListAction } from '@nextcloud/files' +import { restoreAction } from './files_actions/restoreAction.ts' +import { emptyTrashAction } from './files_listActions/emptyTrashAction.ts' import { trashbinView } from './files_views/trashbinView.ts' import './trashbin.scss' @@ -13,3 +14,4 @@ const Navigation = getNavigation() Navigation.register(trashbinView) registerFileListAction(emptyTrashAction) +registerFileAction(restoreAction) diff --git a/apps/files_trashbin/src/files_actions/restoreAction.spec.ts b/apps/files_trashbin/src/files_actions/restoreAction.spec.ts new file mode 100644 index 00000000000..4863eb6d00a --- /dev/null +++ b/apps/files_trashbin/src/files_actions/restoreAction.spec.ts @@ -0,0 +1,145 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { Folder } from '@nextcloud/files' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as ncEventBus from '@nextcloud/event-bus' +import isSvg from 'is-svg' + +import { trashbinView } from '../files_views/trashbinView.ts' +import { restoreAction } from './restoreAction.ts' +import { PERMISSION_ALL, PERMISSION_NONE } from '../../../../core/src/OC/constants.js' + +const axiosMock = vi.hoisted(() => ({ + request: vi.fn(), +})) +vi.mock('@nextcloud/axios', () => ({ default: axiosMock })) +vi.mock('@nextcloud/auth') + +describe('files_trashbin: file actions - restore action', () => { + it('has id set', () => { + expect(restoreAction.id).toBe('restore') + }) + + it('has order set', () => { + // very high priority! + expect(restoreAction.order).toBe(1) + }) + + it('is an inline action', () => { + const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }) + + expect(restoreAction.inline).toBeTypeOf('function') + expect(restoreAction.inline!(node, trashbinView)).toBe(true) + }) + + it('has the display name set', () => { + const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }) + + expect(restoreAction.displayName([node], trashbinView)).toBe('Restore') + }) + + it('has an icon set', () => { + const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }) + + const icon = restoreAction.iconSvgInline([node], trashbinView) + expect(icon).toBeTypeOf('string') + expect(isSvg(icon)).toBe(true) + }) + + it('is enabled for trashbin view', () => { + const nodes = [ + new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL }), + ] + + expect(restoreAction.enabled).toBeTypeOf('function') + expect(restoreAction.enabled!(nodes, trashbinView)).toBe(true) + }) + + it('is not enabled when permissions are missing', () => { + const nodes = [ + new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_NONE }), + ] + + expect(restoreAction.enabled).toBeTypeOf('function') + expect(restoreAction.enabled!(nodes, trashbinView)).toBe(false) + }) + + it('is not enabled when no nodes are selected', () => { + expect(restoreAction.enabled).toBeTypeOf('function') + expect(restoreAction.enabled!([], trashbinView)).toBe(false) + }) + + it('is not enabled for other views', () => { + const nodes = [ + new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL }), + ] + + const otherView = new Proxy(trashbinView, { + get(target, p) { + if (p === 'id') { + return 'other-view' + } + return target[p] + }, + }) + + expect(restoreAction.enabled).toBeTypeOf('function') + expect(restoreAction.enabled!(nodes, otherView)).toBe(false) + }) + + describe('execute', () => { + beforeEach(() => { + axiosMock.request.mockReset() + }) + + it('send restore request', async () => { + const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL }) + + expect(await restoreAction.exec(node, trashbinView, '/')).toBe(true) + expect(axiosMock.request).toBeCalled() + expect(axiosMock.request.mock.calls[0][0].method).toBe('MOVE') + expect(axiosMock.request.mock.calls[0][0].url).toBe(node.encodedSource) + expect(axiosMock.request.mock.calls[0][0].headers.destination).toContain('/restore/') + }) + + it('deletes node from current view after successfull request', async () => { + const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL }) + + const emitSpy = vi.spyOn(ncEventBus, 'emit') + + expect(await restoreAction.exec(node, trashbinView, '/')).toBe(true) + expect(axiosMock.request).toBeCalled() + expect(emitSpy).toBeCalled() + expect(emitSpy).toBeCalledWith('files:node:deleted', node) + }) + + it('does not delete node from view if reuest failed', async () => { + const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL }) + + axiosMock.request.mockImplementationOnce(() => { throw new Error() }) + const emitSpy = vi.spyOn(ncEventBus, 'emit') + + expect(await restoreAction.exec(node, trashbinView, '/')).toBe(false) + expect(axiosMock.request).toBeCalled() + expect(emitSpy).not.toBeCalled() + }) + + it('batch: only returns success if all requests worked', async () => { + const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL }) + + expect(await restoreAction.execBatch!([node, node], trashbinView, '/')).toStrictEqual([true, true]) + expect(axiosMock.request).toBeCalledTimes(2) + }) + + it('batch: only returns success if all requests worked - one failed', async () => { + const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL }) + + axiosMock.request.mockImplementationOnce(() => { throw new Error() }) + expect(await restoreAction.execBatch!([node, node], trashbinView, '/')).toStrictEqual([false, true]) + expect(axiosMock.request).toBeCalledTimes(2) + }) + }) +}) diff --git a/apps/files_trashbin/src/actions/restoreAction.ts b/apps/files_trashbin/src/files_actions/restoreAction.ts index 31160183d83..9af0a13a2b0 100644 --- a/apps/files_trashbin/src/actions/restoreAction.ts +++ b/apps/files_trashbin/src/files_actions/restoreAction.ts @@ -2,40 +2,44 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { getCurrentUser } from '@nextcloud/auth' import { emit } from '@nextcloud/event-bus' +import { Permission, Node, View, FileAction } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' import { encodePath } from '@nextcloud/paths' import { generateRemoteUrl } from '@nextcloud/router' -import { getCurrentUser } from '@nextcloud/auth' -import { Permission, Node, View, registerFileAction, FileAction } from '@nextcloud/files' -import { translate as t } from '@nextcloud/l10n' import axios from '@nextcloud/axios' -import History from '@mdi/svg/svg/history.svg?raw' +import svgHistory from '@mdi/svg/svg/history.svg?raw' +import { TRASHBIN_VIEW_ID } from '../files_views/trashbinView.ts' import logger from '../../../files/src/logger.ts' -registerFileAction(new FileAction({ +export const restoreAction = new FileAction({ id: 'restore', + displayName() { return t('files_trashbin', 'Restore') }, - iconSvgInline: () => History, + + iconSvgInline: () => svgHistory, enabled(nodes: Node[], view) { // Only available in the trashbin view - if (view.id !== 'trashbin') { + if (view.id !== TRASHBIN_VIEW_ID) { return false } // Only available if all nodes have read permission - return nodes.length > 0 && nodes - .map(node => node.permissions) - .every(permission => (permission & Permission.READ) !== 0) + return nodes.length > 0 + && nodes + .map((node) => node.permissions) + .every((permission) => Boolean(permission & Permission.READ)) }, async exec(node: Node) { try { - const destination = generateRemoteUrl(encodePath(`dav/trashbin/${getCurrentUser()?.uid}/restore/${node.basename}`)) - await axios({ + const destination = generateRemoteUrl(encodePath(`dav/trashbin/${getCurrentUser()!.uid}/restore/${node.basename}`)) + await axios.request({ method: 'MOVE', url: node.encodedSource, headers: { @@ -48,14 +52,16 @@ registerFileAction(new FileAction({ emit('files:node:deleted', node) return true } catch (error) { - logger.error(error) + logger.error('Failed to restore node', { error, node }) return false } }, + async execBatch(nodes: Node[], view: View, dir: string) { return Promise.all(nodes.map(node => this.exec(node, view, dir))) }, order: 1, + inline: () => true, -})) +}) |