Skip to content

Commit 140b94a

Browse files
committed
fix(files): always ask for confirmation if trashbin app is disabled
Signed-off-by: skjnldsv <[email protected]> Signed-off-by: nextcloud-command <[email protected]> [skip ci]
1 parent d2a7a10 commit 140b94a

File tree

5 files changed

+354
-8
lines changed

5 files changed

+354
-8
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
import type { Capabilities } from '../../apps/files/src/types'
6+
7+
export const getCapabilities = (): Capabilities => {
8+
return {
9+
files: {
10+
bigfilechunking: true,
11+
blacklisted_files: [],
12+
forbidden_filename_basenames: [],
13+
forbidden_filename_characters: [],
14+
forbidden_filename_extensions: [],
15+
forbidden_filenames: [],
16+
undelete: true,
17+
version_deletion: true,
18+
version_labeling: true,
19+
versioning: true,
20+
},
21+
}
22+
}

apps/files/src/actions/deleteAction.spec.ts

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@
2222
import { action } from './deleteAction'
2323
import { expect } from '@jest/globals'
2424
import { File, Folder, Permission, View, FileAction } from '@nextcloud/files'
25-
import eventBus from '@nextcloud/event-bus'
25+
import * as capabilities from '@nextcloud/capabilities'
2626
import axios from '@nextcloud/axios'
27+
import eventBus from '@nextcloud/event-bus'
2728

2829
import logger from '../logger'
2930

@@ -111,6 +112,16 @@ describe('Delete action conditions tests', () => {
111112
expect(action.displayName([file], trashbinView)).toBe('Delete permanently')
112113
})
113114

115+
test('Trashbin disabled displayName', () => {
116+
jest.spyOn(capabilities, 'getCapabilities').mockImplementation(() => {
117+
return {
118+
files: {},
119+
}
120+
})
121+
expect(action.displayName([file], view)).toBe('Delete permanently')
122+
expect(capabilities.getCapabilities).toBeCalledTimes(1)
123+
})
124+
114125
test('Shared root node displayName', () => {
115126
expect(action.displayName([file2], view)).toBe('Leave this share')
116127
expect(action.displayName([folder2], view)).toBe('Leave this share')
@@ -181,6 +192,9 @@ describe('Delete action enabled tests', () => {
181192
})
182193

183194
describe('Delete action execute tests', () => {
195+
afterEach(() => {
196+
jest.restoreAllMocks()
197+
})
184198
test('Delete action', async () => {
185199
jest.spyOn(axios, 'delete')
186200
jest.spyOn(eventBus, 'emit')
@@ -235,9 +249,125 @@ describe('Delete action execute tests', () => {
235249
expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2)
236250
})
237251

252+
test('Delete action batch large set', async () => {
253+
jest.spyOn(axios, 'delete')
254+
jest.spyOn(eventBus, 'emit')
255+
256+
// Emulate the confirmation dialog to always confirm
257+
const confirmMock = jest.fn().mockImplementation((a, b, c, resolve) => resolve(true))
258+
// @ts-expect-error We only mock what needed
259+
window.OC = { dialogs: { confirmDestructive: confirmMock } }
260+
261+
const file1 = new File({
262+
id: 1,
263+
source: 'https://cloud.domain.com/remote.php/dav/files/test/foo.txt',
264+
owner: 'test',
265+
mime: 'text/plain',
266+
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
267+
})
268+
269+
const file2 = new File({
270+
id: 2,
271+
source: 'https://cloud.domain.com/remote.php/dav/files/test/bar.txt',
272+
owner: 'test',
273+
mime: 'text/plain',
274+
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
275+
})
276+
277+
const file3 = new File({
278+
id: 3,
279+
source: 'https://cloud.domain.com/remote.php/dav/files/test/baz.txt',
280+
owner: 'test',
281+
mime: 'text/plain',
282+
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
283+
})
284+
285+
const file4 = new File({
286+
id: 4,
287+
source: 'https://cloud.domain.com/remote.php/dav/files/test/qux.txt',
288+
owner: 'test',
289+
mime: 'text/plain',
290+
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
291+
})
292+
293+
const file5 = new File({
294+
id: 5,
295+
source: 'https://cloud.domain.com/remote.php/dav/files/test/quux.txt',
296+
owner: 'test',
297+
mime: 'text/plain',
298+
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
299+
})
300+
301+
const exec = await action.execBatch!([file1, file2, file3, file4, file5], view, '/')
302+
303+
// Enough nodes to trigger a confirmation dialog
304+
expect(confirmMock).toBeCalledTimes(1)
305+
306+
expect(exec).toStrictEqual([true, true, true, true, true])
307+
expect(axios.delete).toBeCalledTimes(5)
308+
expect(axios.delete).toHaveBeenNthCalledWith(1, 'https://cloud.domain.com/remote.php/dav/files/test/foo.txt')
309+
expect(axios.delete).toHaveBeenNthCalledWith(2, 'https://cloud.domain.com/remote.php/dav/files/test/bar.txt')
310+
expect(axios.delete).toHaveBeenNthCalledWith(3, 'https://cloud.domain.com/remote.php/dav/files/test/baz.txt')
311+
expect(axios.delete).toHaveBeenNthCalledWith(4, 'https://cloud.domain.com/remote.php/dav/files/test/qux.txt')
312+
expect(axios.delete).toHaveBeenNthCalledWith(5, 'https://cloud.domain.com/remote.php/dav/files/test/quux.txt')
313+
314+
expect(eventBus.emit).toBeCalledTimes(5)
315+
expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1)
316+
expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2)
317+
expect(eventBus.emit).toHaveBeenNthCalledWith(3, 'files:node:deleted', file3)
318+
expect(eventBus.emit).toHaveBeenNthCalledWith(4, 'files:node:deleted', file4)
319+
expect(eventBus.emit).toHaveBeenNthCalledWith(5, 'files:node:deleted', file5)
320+
})
321+
322+
test('Delete action batch trashbin disabled', async () => {
323+
jest.spyOn(axios, 'delete')
324+
jest.spyOn(eventBus, 'emit')
325+
jest.spyOn(capabilities, 'getCapabilities').mockImplementation(() => {
326+
return {
327+
files: {},
328+
}
329+
})
330+
331+
// Emulate the confirmation dialog to always confirm
332+
const confirmMock = jest.fn().mockImplementation((a, b, c, resolve) => resolve(true))
333+
// @ts-expect-error We only mock what needed
334+
window.OC = { dialogs: { confirmDestructive: confirmMock } }
335+
336+
const file1 = new File({
337+
id: 1,
338+
source: 'https://cloud.domain.com/remote.php/dav/files/test/foo.txt',
339+
owner: 'test',
340+
mime: 'text/plain',
341+
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
342+
})
343+
344+
const file2 = new File({
345+
id: 2,
346+
source: 'https://cloud.domain.com/remote.php/dav/files/test/bar.txt',
347+
owner: 'test',
348+
mime: 'text/plain',
349+
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
350+
})
351+
352+
const exec = await action.execBatch!([file1, file2], view, '/')
353+
354+
// Will trigger a confirmation dialog because trashbin app is disabled
355+
expect(confirmMock).toBeCalledTimes(1)
356+
357+
expect(exec).toStrictEqual([true, true])
358+
expect(axios.delete).toBeCalledTimes(2)
359+
expect(axios.delete).toHaveBeenNthCalledWith(1, 'https://cloud.domain.com/remote.php/dav/files/test/foo.txt')
360+
expect(axios.delete).toHaveBeenNthCalledWith(2, 'https://cloud.domain.com/remote.php/dav/files/test/bar.txt')
361+
362+
expect(eventBus.emit).toBeCalledTimes(2)
363+
expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1)
364+
expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2)
365+
})
366+
238367
test('Delete fails', async () => {
239368
jest.spyOn(axios, 'delete').mockImplementation(() => { throw new Error('Mock error') })
240369
jest.spyOn(logger, 'error').mockImplementation(() => jest.fn())
370+
jest.spyOn(eventBus, 'emit')
241371

242372
const file = new File({
243373
id: 1,
@@ -256,4 +386,36 @@ describe('Delete action execute tests', () => {
256386
expect(eventBus.emit).toBeCalledTimes(0)
257387
expect(logger.error).toBeCalledTimes(1)
258388
})
389+
390+
test('Delete is cancelled', async () => {
391+
jest.spyOn(axios, 'delete')
392+
jest.spyOn(eventBus, 'emit')
393+
jest.spyOn(capabilities, 'getCapabilities').mockImplementation(() => {
394+
return {
395+
files: {},
396+
}
397+
})
398+
399+
// Emulate the confirmation dialog to always confirm
400+
const confirmMock = jest.fn().mockImplementation((a, b, c, resolve) => resolve(false))
401+
// @ts-expect-error We only mock what needed
402+
window.OC = { dialogs: { confirmDestructive: confirmMock } }
403+
404+
const file1 = new File({
405+
id: 1,
406+
source: 'https://cloud.domain.com/remote.php/dav/files/test/foo.txt',
407+
owner: 'test',
408+
mime: 'text/plain',
409+
permissions: Permission.READ | Permission.UPDATE | Permission.DELETE,
410+
})
411+
412+
const exec = await action.execBatch!([file1], view, '/')
413+
414+
expect(confirmMock).toBeCalledTimes(1)
415+
416+
expect(exec).toStrictEqual([null])
417+
expect(axios.delete).toBeCalledTimes(0)
418+
419+
expect(eventBus.emit).toBeCalledTimes(0)
420+
})
259421
})

apps/files/src/actions/deleteAction.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,20 @@ export const action = new FileAction({
142142

143143
async exec(node: Node, view: View, dir: string) {
144144
try {
145-
await axios.delete(node.encodedSource)
145+
let confirm = true
146146

147-
// Let's delete even if it's moved to the trashbin
148-
// since it has been removed from the current view
149-
// and changing the view will trigger a reload anyway.
150-
emit('files:node:deleted', node)
147+
// If trashbin is disabled, we need to ask for confirmation
148+
if (!isTrashbinEnabled()) {
149+
confirm = await askConfirmation([node], view)
150+
}
151+
152+
// If the user cancels the deletion, we don't want to do anything
153+
if (confirm === false) {
154+
showInfo(t('files', 'Deletion cancelled'))
155+
return null
156+
}
157+
158+
await deleteNode(node)
151159

152160
return true
153161
} catch (error) {
@@ -161,8 +169,13 @@ export const action = new FileAction({
161169
// Create a promise that resolves with the result of exec(node)
162170
const promise = new Promise<boolean>(resolve => {
163171
queue.add(async () => {
164-
const result = await this.exec(node, view, dir)
165-
resolve(result !== null ? result : false)
172+
try {
173+
await deleteNode(node)
174+
resolve(true)
175+
} catch (error) {
176+
logger.error('Error while deleting a file', { error, source: node.source, node })
177+
resolve(false)
178+
}
166179
})
167180
})
168181
return promise

0 commit comments

Comments
 (0)