Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
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 <[email protected]>
  • Loading branch information
susnux committed Mar 13, 2025
commit b15ce12f2819e7cb14360e1b48c19886466fab0c
6 changes: 4 additions & 2 deletions apps/files_trashbin/src/files-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -13,3 +14,4 @@ const Navigation = getNavigation()
Navigation.register(trashbinView)

registerFileListAction(emptyTrashAction)
registerFileAction(restoreAction)
145 changes: 145 additions & 0 deletions apps/files_trashbin/src/files_actions/restoreAction.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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,
}))
})