diff --git a/apps/files_trashbin/src/files-init.ts b/apps/files_trashbin/src/files-init.ts index b4526c97143..9af20693da6 100644 --- a/apps/files_trashbin/src/files-init.ts +++ b/apps/files_trashbin/src/files-init.ts @@ -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) diff --git a/apps/files_trashbin/src/files_listActions/emptyTrashAction.spec.ts b/apps/files_trashbin/src/files_listActions/emptyTrashAction.spec.ts new file mode 100644 index 00000000000..ba9697351e8 --- /dev/null +++ b/apps/files_trashbin/src/files_listActions/emptyTrashAction.spec.ts @@ -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() + }) + }) +}) diff --git a/apps/files_trashbin/src/fileListActions/emptyTrashAction.ts b/apps/files_trashbin/src/files_listActions/emptyTrashAction.ts similarity index 72% rename from apps/files_trashbin/src/fileListActions/emptyTrashAction.ts rename to apps/files_trashbin/src/files_listActions/emptyTrashAction.ts index f9cc3b301db..67d6c9c373b 100644 --- a/apps/files_trashbin/src/fileListActions/emptyTrashAction.ts +++ b/apps/files_trashbin/src/files_listActions/emptyTrashAction.ts @@ -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 => { - 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 { + async exec(view: View, nodes: Node[]): Promise { const askConfirmation = new Promise((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 }, }) diff --git a/apps/files_trashbin/src/files_views/trashbinView.ts b/apps/files_trashbin/src/files_views/trashbinView.ts index cb2a83368ea..5b547071cc7 100644 --- a/apps/files_trashbin/src/files_views/trashbinView.ts +++ b/apps/files_trashbin/src/files_views/trashbinView.ts @@ -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.'), diff --git a/apps/files_trashbin/src/services/api.spec.ts b/apps/files_trashbin/src/services/api.spec.ts new file mode 100644 index 00000000000..b50a53b8e07 --- /dev/null +++ b/apps/files_trashbin/src/services/api.spec.ts @@ -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() + }) +}) diff --git a/apps/files_trashbin/src/services/api.ts b/apps/files_trashbin/src/services/api.ts new file mode 100644 index 00000000000..b1f2e98b2d9 --- /dev/null +++ b/apps/files_trashbin/src/services/api.ts @@ -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 { + 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 + } +}