Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[stable31] Allow to delete files without trashbin + add unit tests + some refactoring #51397

Merged
merged 6 commits into from
Mar 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 7 additions & 0 deletions __tests__/setup-global.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: CC0-1.0
*/
export function setup() {
process.env.TZ = 'UTC'
}
19 changes: 10 additions & 9 deletions apps/files/src/actions/deleteAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,19 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { FilesTrashbinConfigState } from '../../../files_trashbin/src/fileListActions/emptyTrashAction.ts'

import { loadState } from '@nextcloud/initial-state'
import { Permission, Node, View, FileAction } from '@nextcloud/files'
import { showInfo } from '@nextcloud/dialogs'
import { Permission, Node, View, FileAction } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import PQueue from 'p-queue'

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

import { TRASHBIN_VIEW_ID } from '../../../files_trashbin/src/files_views/trashbinView.ts'
import { askConfirmation, canDisconnectOnly, canUnshareOnly, deleteNode, displayName, isTrashbinEnabled } from './deleteUtils.ts'
import logger from '../logger.ts'
import { askConfirmation, canDisconnectOnly, canUnshareOnly, deleteNode, displayName, isTrashbinEnabled } from './deleteUtils'

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

Expand All @@ -36,10 +35,12 @@ export const action = new FileAction({
return TrashCanSvg
},

enabled(nodes: Node[]) {
const config = loadState<FilesTrashbinConfigState>('files_trashbin', 'config')
if (!config.allow_delete) {
return false
enabled(nodes: Node[], view: View): boolean {
if (view.id === TRASHBIN_VIEW_ID) {
const config = loadState('files_trashbin', 'config', { allow_delete: true })
if (config.allow_delete === false) {
return false
}
}

return nodes.length > 0 && nodes
Expand Down
37 changes: 7 additions & 30 deletions apps/files_trashbin/src/files-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,15 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

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'
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'

// Register restore action
import './actions/restoreAction'

import { emptyTrashAction } from './fileListActions/emptyTrashAction.ts'
import './trashbin.scss'

const Navigation = getNavigation()
Navigation.register(new View({
id: 'trashbin',
name: t('files_trashbin', 'Deleted files'),
caption: t('files_trashbin', 'List of files that have been deleted.'),

emptyTitle: t('files_trashbin', 'No deleted files'),
emptyCaption: t('files_trashbin', 'Files and folders you have deleted will show up here'),

icon: DeleteSvg,
order: 50,
sticky: true,

defaultSortKey: 'deleted',

columns,

getContents,
}))
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,
}))
})
Loading
Loading