fix(files_trashbin): return correct type in empty trash action

1. correctly return null instead of void in empty trash action
2. use constant instead of magic value for action ID
3. add unit tests for empty trash action
4. add unit tests for trashbin api

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2025-03-11 12:32:20 +01:00
parent ede015a424
commit 4b773f6dd7
No known key found for this signature in database
GPG key ID: 45FAE7268762B400
6 changed files with 263 additions and 37 deletions

View file

@ -3,21 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getNavigation, registerFileListAction } from '@nextcloud/files'
import { emptyTrashAction } from './files_actions/emptyTrashAction.ts'
import { trashbinView } from './files_views/trashbinView.ts'
import './trashbin.scss'
import { translate as t } from '@nextcloud/l10n'
import { View, getNavigation, registerFileListAction } from '@nextcloud/files'
import DeleteSvg from '@mdi/svg/svg/delete.svg?raw'
import { getContents } from './services/trashbin'
import { columns } from './columns.ts'
// Register restore action
import './actions/restoreAction'
import { emptyTrashAction } from './fileListActions/emptyTrashAction.ts'
const Navigation = getNavigation()
Navigation.register(trashbinView)

View file

@ -0,0 +1,178 @@
/**
* 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 { emptyTrashAction } from './emptyTrashAction.ts'
import { trashbinView } from '../files_views/trashbinView.ts'
import * as ncDialogs from '@nextcloud/dialogs'
import * as ncEventBus from '@nextcloud/event-bus'
import * as ncInitialState from '@nextcloud/initial-state'
import * as api from '../services/api.ts'
describe('files_trashbin: file list actions - empty trashbin', () => {
it('has id set', () => {
expect(emptyTrashAction.id).toBe('empty-trash')
})
it('has display name set', () => {
expect(emptyTrashAction.displayName(trashbinView)).toBe('Empty deleted files')
})
it('has order set', () => {
// expect highest priority!
expect(emptyTrashAction.order).toBe(0)
})
it('is enabled on trashbin view', () => {
const spy = vi.spyOn(ncInitialState, 'loadState').mockImplementationOnce(() => ({ allow_delete: true }))
const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' })
const nodes = [
new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }),
]
expect(emptyTrashAction.enabled).toBeTypeOf('function')
expect(emptyTrashAction.enabled!(trashbinView, nodes, root)).toBe(true)
expect(spy).toHaveBeenCalled()
expect(spy).toHaveBeenCalledWith('files_trashbin', 'config')
})
it('is not enabled on another view enabled', () => {
vi.spyOn(ncInitialState, 'loadState').mockImplementationOnce(() => ({ allow_delete: true }))
const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' })
const nodes = [
new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }),
]
const otherView = new Proxy(trashbinView, {
get(target, p) {
if (p === 'id') {
return 'other-view'
}
return target[p]
},
})
expect(emptyTrashAction.enabled).toBeTypeOf('function')
expect(emptyTrashAction.enabled!(otherView, nodes, root)).toBe(false)
})
it('is not enabled when deletion is forbidden', () => {
const spy = vi.spyOn(ncInitialState, 'loadState').mockImplementationOnce(() => ({ allow_delete: false }))
const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' })
const nodes = [
new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }),
]
expect(emptyTrashAction.enabled).toBeTypeOf('function')
expect(emptyTrashAction.enabled!(trashbinView, nodes, root)).toBe(false)
expect(spy).toHaveBeenCalled()
expect(spy).toHaveBeenCalledWith('files_trashbin', 'config')
})
it('is not enabled when not in trashbin root', () => {
vi.spyOn(ncInitialState, 'loadState').mockImplementationOnce(() => ({ allow_delete: true }))
const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/other-folder', root: '/trashbin/test/' })
const nodes = [
new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }),
]
expect(emptyTrashAction.enabled).toBeTypeOf('function')
expect(emptyTrashAction.enabled!(trashbinView, nodes, root)).toBe(false)
})
describe('execute', () => {
const root = new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/', root: '/trashbin/test/' })
const nodes = [
new Folder({ owner: 'test', source: 'https://example.com/remote.php/dav/trashbin/test/folder', root: '/trashbin/test/' }),
]
let dialogBuilder = {
setSeverity: vi.fn(),
setText: vi.fn(),
setButtons: vi.fn(),
build: vi.fn(),
}
beforeEach(() => {
dialogBuilder = {
setSeverity: vi.fn(() => dialogBuilder),
setText: vi.fn(() => dialogBuilder),
setButtons: vi.fn(() => dialogBuilder),
build: vi.fn(() => dialogBuilder),
}
vi.spyOn(ncDialogs, 'getDialogBuilder')
// @ts-expect-error This is a mock
.mockImplementationOnce(() => dialogBuilder)
})
it('can cancel the deletion by closing the dialog', async () => {
const apiSpy = vi.spyOn(api, 'emptyTrash')
const dialogSpy = vi.spyOn(ncDialogs, 'showInfo')
dialogBuilder.build.mockImplementationOnce(() => ({ show: async () => false }))
expect(await emptyTrashAction.exec(trashbinView, nodes, root)).toBe(null)
expect(apiSpy).not.toBeCalled()
expect(dialogSpy).toBeCalledWith('Deletion cancelled')
})
it('can cancel the deletion', async () => {
const apiSpy = vi.spyOn(api, 'emptyTrash')
const dialogSpy = vi.spyOn(ncDialogs, 'showInfo')
dialogBuilder.build.mockImplementationOnce(() => ({
show: async () => {
const buttons = dialogBuilder.setButtons.mock.calls[0][0]
const cancel = buttons.find(({ label }) => label === 'Cancel')
await cancel.callback()
},
}))
expect(await emptyTrashAction.exec(trashbinView, nodes, root)).toBe(null)
expect(apiSpy).not.toBeCalled()
expect(dialogSpy).toBeCalledWith('Deletion cancelled')
})
it('will trigger the API request if confirmed', async () => {
const apiSpy = vi.spyOn(api, 'emptyTrash').mockImplementationOnce(async () => true)
const dialogSpy = vi.spyOn(ncDialogs, 'showInfo')
const eventBusSpy = vi.spyOn(ncEventBus, 'emit')
dialogBuilder.build.mockImplementationOnce(() => ({
show: async () => {
const buttons = dialogBuilder.setButtons.mock.calls[0][0]
const cancel = buttons.find(({ label }) => label === 'Empty deleted files')
await cancel.callback()
},
}))
expect(await emptyTrashAction.exec(trashbinView, nodes, root)).toBe(null)
expect(apiSpy).toBeCalled()
expect(dialogSpy).not.toBeCalled()
expect(eventBusSpy).toBeCalledWith('files:node:deleted', nodes[0])
})
it('will not emit files deleted event if API request failed', async () => {
const apiSpy = vi.spyOn(api, 'emptyTrash').mockImplementationOnce(async () => false)
const dialogSpy = vi.spyOn(ncDialogs, 'showInfo')
const eventBusSpy = vi.spyOn(ncEventBus, 'emit')
dialogBuilder.build.mockImplementationOnce(() => ({
show: async () => {
const buttons = dialogBuilder.setButtons.mock.calls[0][0]
const cancel = buttons.find(({ label }) => label === 'Empty deleted files')
await cancel.callback()
},
}))
expect(await emptyTrashAction.exec(trashbinView, nodes, root)).toBe(null)
expect(apiSpy).toBeCalled()
expect(dialogSpy).not.toBeCalled()
expect(eventBusSpy).not.toBeCalled()
})
})
})

View file

@ -4,39 +4,22 @@
*/
import type { Node, View, Folder } from '@nextcloud/files'
import axios from '@nextcloud/axios'
import { emit } from '@nextcloud/event-bus'
import { FileListAction } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import {
DialogSeverity,
getDialogBuilder,
showError,
showInfo,
showSuccess,
} from '@nextcloud/dialogs'
import { logger } from '../logger.ts'
import { generateRemoteUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import { emit } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
import { emptyTrash } from '../services/api.ts'
import { TRASHBIN_VIEW_ID } from '../files_views/trashbinView.ts'
export type FilesTrashbinConfigState = {
allow_delete: boolean;
}
const emptyTrash = async (): Promise<boolean> => {
try {
await axios.delete(generateRemoteUrl('dav') + `/trashbin/${getCurrentUser()?.uid}/trash`)
showSuccess(t('files_trashbin', 'All files have been permanently deleted'))
return true
} catch (error) {
showError(t('files_trashbin', 'Failed to empty deleted files'))
logger.error('Failed to empty deleted files', { error })
return false
}
}
export const emptyTrashAction = new FileListAction({
id: 'empty-trash',
@ -44,7 +27,7 @@ export const emptyTrashAction = new FileListAction({
order: 0,
enabled(view: View, nodes: Node[], folder: Folder) {
if (view.id !== 'trashbin') {
if (view.id !== TRASHBIN_VIEW_ID) {
return false
}
@ -56,7 +39,7 @@ export const emptyTrashAction = new FileListAction({
return nodes.length > 0 && folder.path === '/'
},
async exec(view: View, nodes: Node[]): Promise<void> {
async exec(view: View, nodes: Node[]): Promise<null> {
const askConfirmation = new Promise<boolean>((resolve) => {
const dialog = getDialogBuilder(t('files_trashbin', 'Confirm permanent deletion'))
.setSeverity(DialogSeverity.Warning)
@ -85,9 +68,10 @@ export const emptyTrashAction = new FileListAction({
if (await emptyTrash()) {
nodes.forEach((node) => emit('files:node:deleted', node))
}
return
return null
}
showInfo(t('files_trashbin', 'Deletion cancelled'))
return null
},
})

View file

@ -9,8 +9,10 @@ import { getContents } from '../services/trashbin.ts'
import svgDelete from '@mdi/svg/svg/delete.svg?raw'
export const TRASHBIN_VIEW_ID = 'trashbin'
export const trashbinView = new View({
id: 'trashbin',
id: TRASHBIN_VIEW_ID,
name: t('files_trashbin', 'Deleted files'),
caption: t('files_trashbin', 'List of files that have been deleted.'),

View file

@ -0,0 +1,43 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { emptyTrash } from './api.ts'
import * as ncAuth from '@nextcloud/auth'
import * as ncDialogs from '@nextcloud/dialogs'
import * as logger from '../logger.ts'
const axiosMock = vi.hoisted(() => ({
delete: vi.fn(),
}))
vi.mock('@nextcloud/axios', () => ({ default: axiosMock }))
describe('files_trashbin: API - emptyTrash', () => {
beforeEach(() => {
vi.spyOn(ncAuth, 'getCurrentUser').mockImplementationOnce(() => ({
uid: 'test',
displayName: 'Test',
isAdmin: false,
}))
})
it('shows success', async () => {
const dialogSpy = vi.spyOn(ncDialogs, 'showSuccess')
expect(await emptyTrash()).toBe(true)
expect(axiosMock.delete).toBeCalled()
expect(dialogSpy).toBeCalledWith('All files have been permanently deleted')
})
it('shows failure', async () => {
axiosMock.delete.mockImplementationOnce(() => { throw new Error() })
const dialogSpy = vi.spyOn(ncDialogs, 'showError')
const loggerSpy = vi.spyOn(logger.logger, 'error').mockImplementationOnce(() => {})
expect(await emptyTrash()).toBe(false)
expect(axiosMock.delete).toBeCalled()
expect(dialogSpy).toBeCalledWith('Failed to empty deleted files')
expect(loggerSpy).toBeCalled()
})
})

View file

@ -0,0 +1,28 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getCurrentUser } from '@nextcloud/auth'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { defaultRemoteURL } from '@nextcloud/files/dav'
import { t } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import { logger } from '../logger.ts'
/**
* Send API request to empty the trashbin.
* Returns true if request succeeded - otherwise false is returned.
*/
export async function emptyTrash(): Promise<boolean> {
try {
await axios.delete(`${defaultRemoteURL}/trashbin/${getCurrentUser()!.uid}/trash`)
showSuccess(t('files_trashbin', 'All files have been permanently deleted'))
return true
} catch (error) {
showError(t('files_trashbin', 'Failed to empty deleted files'))
logger.error('Failed to empty deleted files', { error })
return false
}
}