Skip to content

Commit c34c8ed

Browse files
authored
Merge pull request #51397 from nextcloud/backport/51394/stable31
[stable31] Allow to delete files without trashbin + add unit tests + some refactoring
2 parents b2f2bf7 + da8e210 commit c34c8ed

33 files changed

+936
-163
lines changed

__tests__/setup-global.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: CC0-1.0
4+
*/
5+
export function setup() {
6+
process.env.TZ = 'UTC'
7+
}

apps/files/src/actions/deleteAction.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,19 @@
22
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
5-
import type { FilesTrashbinConfigState } from '../../../files_trashbin/src/fileListActions/emptyTrashAction.ts'
6-
7-
import { loadState } from '@nextcloud/initial-state'
8-
import { Permission, Node, View, FileAction } from '@nextcloud/files'
95
import { showInfo } from '@nextcloud/dialogs'
6+
import { Permission, Node, View, FileAction } from '@nextcloud/files'
7+
import { loadState } from '@nextcloud/initial-state'
108
import { translate as t } from '@nextcloud/l10n'
119
import PQueue from 'p-queue'
1210

1311
import CloseSvg from '@mdi/svg/svg/close.svg?raw'
1412
import NetworkOffSvg from '@mdi/svg/svg/network-off.svg?raw'
1513
import TrashCanSvg from '@mdi/svg/svg/trash-can.svg?raw'
1614

15+
import { TRASHBIN_VIEW_ID } from '../../../files_trashbin/src/files_views/trashbinView.ts'
16+
import { askConfirmation, canDisconnectOnly, canUnshareOnly, deleteNode, displayName, isTrashbinEnabled } from './deleteUtils.ts'
1717
import logger from '../logger.ts'
18-
import { askConfirmation, canDisconnectOnly, canUnshareOnly, deleteNode, displayName, isTrashbinEnabled } from './deleteUtils'
1918

2019
const queue = new PQueue({ concurrency: 5 })
2120

@@ -36,10 +35,12 @@ export const action = new FileAction({
3635
return TrashCanSvg
3736
},
3837

39-
enabled(nodes: Node[]) {
40-
const config = loadState<FilesTrashbinConfigState>('files_trashbin', 'config')
41-
if (!config.allow_delete) {
42-
return false
38+
enabled(nodes: Node[], view: View): boolean {
39+
if (view.id === TRASHBIN_VIEW_ID) {
40+
const config = loadState('files_trashbin', 'config', { allow_delete: true })
41+
if (config.allow_delete === false) {
42+
return false
43+
}
4344
}
4445

4546
return nodes.length > 0 && nodes

apps/files_trashbin/src/files-init.ts

Lines changed: 7 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,38 +3,15 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
import './trashbin.scss'
7-
8-
import { translate as t } from '@nextcloud/l10n'
9-
import { View, getNavigation, registerFileListAction } from '@nextcloud/files'
10-
import DeleteSvg from '@mdi/svg/svg/delete.svg?raw'
11-
12-
import { getContents } from './services/trashbin'
13-
import { columns } from './columns.ts'
6+
import { getNavigation, registerFileAction, registerFileListAction } from '@nextcloud/files'
7+
import { restoreAction } from './files_actions/restoreAction.ts'
8+
import { emptyTrashAction } from './files_listActions/emptyTrashAction.ts'
9+
import { trashbinView } from './files_views/trashbinView.ts'
1410

15-
// Register restore action
16-
import './actions/restoreAction'
17-
18-
import { emptyTrashAction } from './fileListActions/emptyTrashAction.ts'
11+
import './trashbin.scss'
1912

2013
const Navigation = getNavigation()
21-
Navigation.register(new View({
22-
id: 'trashbin',
23-
name: t('files_trashbin', 'Deleted files'),
24-
caption: t('files_trashbin', 'List of files that have been deleted.'),
25-
26-
emptyTitle: t('files_trashbin', 'No deleted files'),
27-
emptyCaption: t('files_trashbin', 'Files and folders you have deleted will show up here'),
28-
29-
icon: DeleteSvg,
30-
order: 50,
31-
sticky: true,
32-
33-
defaultSortKey: 'deleted',
34-
35-
columns,
36-
37-
getContents,
38-
}))
14+
Navigation.register(trashbinView)
3915

4016
registerFileListAction(emptyTrashAction)
17+
registerFileAction(restoreAction)
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { Folder } from '@nextcloud/files'
7+
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
import * as ncEventBus from '@nextcloud/event-bus'
9+
import isSvg from 'is-svg'
10+
11+
import { trashbinView } from '../files_views/trashbinView.ts'
12+
import { restoreAction } from './restoreAction.ts'
13+
import { PERMISSION_ALL, PERMISSION_NONE } from '../../../../core/src/OC/constants.js'
14+
15+
const axiosMock = vi.hoisted(() => ({
16+
request: vi.fn(),
17+
}))
18+
vi.mock('@nextcloud/axios', () => ({ default: axiosMock }))
19+
vi.mock('@nextcloud/auth')
20+
21+
describe('files_trashbin: file actions - restore action', () => {
22+
it('has id set', () => {
23+
expect(restoreAction.id).toBe('restore')
24+
})
25+
26+
it('has order set', () => {
27+
// very high priority!
28+
expect(restoreAction.order).toBe(1)
29+
})
30+
31+
it('is an inline action', () => {
32+
const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' })
33+
34+
expect(restoreAction.inline).toBeTypeOf('function')
35+
expect(restoreAction.inline!(node, trashbinView)).toBe(true)
36+
})
37+
38+
it('has the display name set', () => {
39+
const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' })
40+
41+
expect(restoreAction.displayName([node], trashbinView)).toBe('Restore')
42+
})
43+
44+
it('has an icon set', () => {
45+
const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' })
46+
47+
const icon = restoreAction.iconSvgInline([node], trashbinView)
48+
expect(icon).toBeTypeOf('string')
49+
expect(isSvg(icon)).toBe(true)
50+
})
51+
52+
it('is enabled for trashbin view', () => {
53+
const nodes = [
54+
new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL }),
55+
]
56+
57+
expect(restoreAction.enabled).toBeTypeOf('function')
58+
expect(restoreAction.enabled!(nodes, trashbinView)).toBe(true)
59+
})
60+
61+
it('is not enabled when permissions are missing', () => {
62+
const nodes = [
63+
new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_NONE }),
64+
]
65+
66+
expect(restoreAction.enabled).toBeTypeOf('function')
67+
expect(restoreAction.enabled!(nodes, trashbinView)).toBe(false)
68+
})
69+
70+
it('is not enabled when no nodes are selected', () => {
71+
expect(restoreAction.enabled).toBeTypeOf('function')
72+
expect(restoreAction.enabled!([], trashbinView)).toBe(false)
73+
})
74+
75+
it('is not enabled for other views', () => {
76+
const nodes = [
77+
new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL }),
78+
]
79+
80+
const otherView = new Proxy(trashbinView, {
81+
get(target, p) {
82+
if (p === 'id') {
83+
return 'other-view'
84+
}
85+
return target[p]
86+
},
87+
})
88+
89+
expect(restoreAction.enabled).toBeTypeOf('function')
90+
expect(restoreAction.enabled!(nodes, otherView)).toBe(false)
91+
})
92+
93+
describe('execute', () => {
94+
beforeEach(() => {
95+
axiosMock.request.mockReset()
96+
})
97+
98+
it('send restore request', async () => {
99+
const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL })
100+
101+
expect(await restoreAction.exec(node, trashbinView, '/')).toBe(true)
102+
expect(axiosMock.request).toBeCalled()
103+
expect(axiosMock.request.mock.calls[0][0].method).toBe('MOVE')
104+
expect(axiosMock.request.mock.calls[0][0].url).toBe(node.encodedSource)
105+
expect(axiosMock.request.mock.calls[0][0].headers.destination).toContain('/restore/')
106+
})
107+
108+
it('deletes node from current view after successfull request', async () => {
109+
const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL })
110+
111+
const emitSpy = vi.spyOn(ncEventBus, 'emit')
112+
113+
expect(await restoreAction.exec(node, trashbinView, '/')).toBe(true)
114+
expect(axiosMock.request).toBeCalled()
115+
expect(emitSpy).toBeCalled()
116+
expect(emitSpy).toBeCalledWith('files:node:deleted', node)
117+
})
118+
119+
it('does not delete node from view if reuest failed', async () => {
120+
const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL })
121+
122+
axiosMock.request.mockImplementationOnce(() => { throw new Error() })
123+
const emitSpy = vi.spyOn(ncEventBus, 'emit')
124+
125+
expect(await restoreAction.exec(node, trashbinView, '/')).toBe(false)
126+
expect(axiosMock.request).toBeCalled()
127+
expect(emitSpy).not.toBeCalled()
128+
})
129+
130+
it('batch: only returns success if all requests worked', async () => {
131+
const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL })
132+
133+
expect(await restoreAction.execBatch!([node, node], trashbinView, '/')).toStrictEqual([true, true])
134+
expect(axiosMock.request).toBeCalledTimes(2)
135+
})
136+
137+
it('batch: only returns success if all requests worked - one failed', async () => {
138+
const node = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/', permissions: PERMISSION_ALL })
139+
140+
axiosMock.request.mockImplementationOnce(() => { throw new Error() })
141+
expect(await restoreAction.execBatch!([node, node], trashbinView, '/')).toStrictEqual([false, true])
142+
expect(axiosMock.request).toBeCalledTimes(2)
143+
})
144+
})
145+
})

apps/files_trashbin/src/actions/restoreAction.ts renamed to apps/files_trashbin/src/files_actions/restoreAction.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,44 @@
22
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
5+
import { getCurrentUser } from '@nextcloud/auth'
56
import { emit } from '@nextcloud/event-bus'
7+
import { Permission, Node, View, FileAction } from '@nextcloud/files'
8+
import { t } from '@nextcloud/l10n'
69
import { encodePath } from '@nextcloud/paths'
710
import { generateRemoteUrl } from '@nextcloud/router'
8-
import { getCurrentUser } from '@nextcloud/auth'
9-
import { Permission, Node, View, registerFileAction, FileAction } from '@nextcloud/files'
10-
import { translate as t } from '@nextcloud/l10n'
1111
import axios from '@nextcloud/axios'
12-
import History from '@mdi/svg/svg/history.svg?raw'
12+
import svgHistory from '@mdi/svg/svg/history.svg?raw'
1313

14+
import { TRASHBIN_VIEW_ID } from '../files_views/trashbinView.ts'
1415
import logger from '../../../files/src/logger.ts'
1516

16-
registerFileAction(new FileAction({
17+
export const restoreAction = new FileAction({
1718
id: 'restore',
19+
1820
displayName() {
1921
return t('files_trashbin', 'Restore')
2022
},
21-
iconSvgInline: () => History,
23+
24+
iconSvgInline: () => svgHistory,
2225

2326
enabled(nodes: Node[], view) {
2427
// Only available in the trashbin view
25-
if (view.id !== 'trashbin') {
28+
if (view.id !== TRASHBIN_VIEW_ID) {
2629
return false
2730
}
2831

2932
// Only available if all nodes have read permission
30-
return nodes.length > 0 && nodes
31-
.map(node => node.permissions)
32-
.every(permission => (permission & Permission.READ) !== 0)
33+
return nodes.length > 0
34+
&& nodes
35+
.map((node) => node.permissions)
36+
.every((permission) => Boolean(permission & Permission.READ))
3337
},
3438

3539
async exec(node: Node) {
3640
try {
37-
const destination = generateRemoteUrl(encodePath(`dav/trashbin/${getCurrentUser()?.uid}/restore/${node.basename}`))
38-
await axios({
41+
const destination = generateRemoteUrl(encodePath(`dav/trashbin/${getCurrentUser()!.uid}/restore/${node.basename}`))
42+
await axios.request({
3943
method: 'MOVE',
4044
url: node.encodedSource,
4145
headers: {
@@ -48,14 +52,16 @@ registerFileAction(new FileAction({
4852
emit('files:node:deleted', node)
4953
return true
5054
} catch (error) {
51-
logger.error(error)
55+
logger.error('Failed to restore node', { error, node })
5256
return false
5357
}
5458
},
59+
5560
async execBatch(nodes: Node[], view: View, dir: string) {
5661
return Promise.all(nodes.map(node => this.exec(node, view, dir)))
5762
},
5863

5964
order: 1,
65+
6066
inline: () => true,
61-
}))
67+
})

0 commit comments

Comments
 (0)