aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorFerdinand Thiessen <opensource@fthiessen.de>2025-03-11 13:20:47 +0100
committerFerdinand Thiessen <opensource@fthiessen.de>2025-03-13 21:30:43 +0100
commitb15ce12f2819e7cb14360e1b48c19886466fab0c (patch)
tree1f2bfdf99ced94a3ff2ad78bdfd0f07e844816b7
parent4b773f6dd7dda363660081a2f6dacded150b90a1 (diff)
downloadnextcloud-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.ts6
-rw-r--r--apps/files_trashbin/src/files_actions/restoreAction.spec.ts145
-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,
-}))
+})