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

feat(trashbin): Allow emptying trash #49171

Merged
merged 10 commits into from
Dec 12, 2024
81 changes: 66 additions & 15 deletions apps/files/src/views/FilesList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,24 +32,31 @@
multiple
@failed="onUploadFail"
@uploaded="onUpload" />

<NcActions :inline="1" force-name>
<NcActionButton v-for="action in enabledFileListActions"
:key="action.id"
close-after-click
@click="() => action.exec(currentView, dirContents, { folder: currentFolder })">
<template #icon>
<NcIconSvgWrapper :svg="action.iconSvgInline(currentView)" />
</template>
{{ action.displayName(currentView) }}
</NcActionButton>
</NcActions>
</template>
</BreadCrumbs>

<!-- Secondary loading indicator -->
<NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" />

<NcActions class="files-list__header-actions"
:inline="1"
type="tertiary"
force-name>
<NcActionButton v-for="action in enabledFileListActions"
:key="action.id"
:disabled="!!loadingAction"
:data-cy-files-list-action="action.id"
close-after-click
@click="execFileListAction(action)">
<template #icon>
<NcLoadingIcon v-if="loadingAction === action.id" :size="18" />
<NcIconSvgWrapper v-else-if="action.iconSvgInline !== undefined && currentView"
:svg="action.iconSvgInline(currentView)" />
</template>
{{ actionDisplayName(action) }}
</NcActionButton>
</NcActions>

<NcButton v-if="fileListWidth >= 512 && enableGridView"
:aria-label="gridViewButtonLabel"
:title="gridViewButtonLabel"
Expand Down Expand Up @@ -128,7 +135,7 @@
</template>

<script lang="ts">
import type { ContentsWithRoot, Folder, INode } from '@nextcloud/files'
import type { ContentsWithRoot, FileListAction, Folder, INode } from '@nextcloud/files'
import type { Upload } from '@nextcloud/upload'
import type { CancelablePromise } from 'cancelable-promise'
import type { ComponentPublicInstance } from 'vue'
Expand All @@ -140,7 +147,7 @@ import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { Node, Permission, sortNodes, getFileListActions } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { join, dirname, normalize } from 'path'
import { showError, showWarning } from '@nextcloud/dialogs'
import { showError, showSuccess, showWarning } from '@nextcloud/dialogs'
import { ShareType } from '@nextcloud/sharing'
import { UploadPicker, UploadStatus } from '@nextcloud/upload'
import { loadState } from '@nextcloud/initial-state'
Expand Down Expand Up @@ -254,6 +261,7 @@ export default defineComponent({
data() {
return {
loading: true,
loadingAction: null as string | null,
error: null as string | null,
promise: null as CancelablePromise<ContentsWithRoot> | Promise<ContentsWithRoot> | null,

Expand Down Expand Up @@ -433,6 +441,10 @@ export default defineComponent({
},

enabledFileListActions() {
if (!this.currentView || !this.currentFolder) {
return []
}

const actions = getFileListActions()
const enabledActions = actions
.filter(action => {
Expand All @@ -442,7 +454,7 @@ export default defineComponent({
return action.enabled(
this.currentView!,
this.dirContents,
{ folder: this.currentFolder! },
this.currentFolder as Folder,
)
})
.toSorted((a, b) => a.order - b.order)
Expand Down Expand Up @@ -710,6 +722,40 @@ export default defineComponent({
}
this.dirContentsFiltered = nodes
},

actionDisplayName(action: FileListAction): string {
let displayName = action.id
try {
displayName = action.displayName(this.currentView!)
} catch (error) {
logger.error('Error while getting action display name', { action, error })
}
return displayName
},

async execFileListAction(action: FileListAction) {
this.loadingAction = action.id

const displayName = this.actionDisplayName(action)
try {
const success = await action.exec(this.source, this.dirContents, this.currentDir)
// If the action returns null, we stay silent
if (success === null || success === undefined) {
return
}

if (success) {
showSuccess(t('files', '"{displayName}" action executed successfully', { displayName }))
return
}
showError(t('files', '"{displayName}" action failed', { displayName }))
} catch (error) {
logger.error('Error while executing action', { action, error })
showError(t('files', '"{displayName}" action failed', { displayName }))
} finally {
this.loadingAction = null
}
},
},
})
</script>
Expand Down Expand Up @@ -760,6 +806,11 @@ export default defineComponent({
color: var(--color-main-text) !important;
}
}

&-actions {
min-width: fit-content !important;
margin-inline: calc(var(--default-grid-baseline) * 2);
}
}

&__empty-view-wrapper {
Expand Down
81 changes: 81 additions & 0 deletions apps/files_trashbin/src/fileListActions/emptyTrashAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node, View, Folder } from '@nextcloud/files'

import axios from '@nextcloud/axios'
import { FileListAction } from '@nextcloud/files'
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'

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

displayName: () => t('files_trashbin', 'Empty deleted files'),
order: 0,

enabled(view: View, nodes: Node[], folder: Folder) {
if (view.id !== 'trashbin') {
return false
}
return nodes.length > 0 && folder.path === '/'
},

async exec(view: View, nodes: Node[]): Promise<void> {
const askConfirmation = new Promise<boolean>((resolve) => {
const dialog = getDialogBuilder(t('files_trashbin', 'Confirm permanent deletion'))
.setSeverity(DialogSeverity.Warning)
// TODO Add note for groupfolders
.setText(t('files_trashbin', 'Are you sure you want to permanently delete all files and folders in the trash? This cannot be undone.'))
.setButtons([
{
label: t('files_trashbin', 'Cancel'),
type: 'secondary',
callback: () => resolve(false),
},
{
label: t('files_trashbin', 'Empty deleted files'),
type: 'error',
callback: () => resolve(true),
},
])
.build()
dialog.show().then(() => {
resolve(false)
})
})

const result = await askConfirmation
if (result === true) {
await emptyTrash()
nodes.forEach((node) => emit('files:node:deleted', node))
return
}

showInfo(t('files_trashbin', 'Deletion cancelled'))
},
})
6 changes: 5 additions & 1 deletion apps/files_trashbin/src/files-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@
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 { View, getNavigation } from '@nextcloud/files'

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

const Navigation = getNavigation()
Navigation.register(new View({
Expand All @@ -34,3 +36,5 @@ Navigation.register(new View({

getContents,
}))

registerFileListAction(emptyTrashAction)
11 changes: 11 additions & 0 deletions apps/files_trashbin/src/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { getLoggerBuilder } from '@nextcloud/logger'

export const logger = getLoggerBuilder()
.setApp('files_trashbin')
.detectUser()
.build()
5 changes: 0 additions & 5 deletions build/psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1830,11 +1830,6 @@
<code><![CDATA[self::getGlobalCache()->getStorageInfo($storageId)]]></code>
</NullableReturnStatement>
</file>
<file src="lib/private/Files/Cache/Updater.php">
<RedundantCondition>
<code><![CDATA[$this->cache instanceof Cache]]></code>
</RedundantCondition>
</file>
<file src="lib/private/Files/Cache/Wrapper/CacheWrapper.php">
<LessSpecificImplementedReturnType>
<code><![CDATA[array]]></code>
Expand Down
32 changes: 28 additions & 4 deletions cypress/e2e/files/FilesUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { User } from "@nextcloud/cypress"

export const getRowForFileId = (fileid: number) => cy.get(`[data-cy-files-list-row-fileid="${fileid}"]`)
export const getRowForFile = (filename: string) => cy.get(`[data-cy-files-list-row-name="${CSS.escape(filename)}"]`)

Expand All @@ -13,16 +15,16 @@ export const getActionButtonForFileId = (fileid: number) => getActionsForFileId(
export const getActionButtonForFile = (filename: string) => getActionsForFile(filename).findByRole('button', { name: 'Actions' })

export const triggerActionForFileId = (fileid: number, actionId: string) => {
getActionButtonForFileId(fileid).click()
getActionButtonForFileId(fileid).click({ force: true })
// Getting the last button to avoid the one from popup fading out
cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).last()
.should('exist').click()
.should('exist').click({ force: true })
}
export const triggerActionForFile = (filename: string, actionId: string) => {
getActionButtonForFile(filename).click()
getActionButtonForFile(filename).click({ force: true })
// Getting the last button to avoid the one from popup fading out
cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).last()
.should('exist').click()
.should('exist').click({ force: true })
}

export const triggerInlineActionForFileId = (fileid: number, actionId: string) => {
Expand Down Expand Up @@ -184,3 +186,25 @@ export const haveValidity = (validity: string | RegExp) => {
}
return (el: JQuery<HTMLElement>) => expect((el.get(0) as HTMLInputElement).validationMessage).to.match(validity)
}

export const deleteFileWithRequest = (user: User, path: string) => {
// Ensure path starts with a slash and has no double slashes
path = `/${path}`.replace(/\/+/g, '/')

cy.request('/csrftoken').then(({ body }) => {
const requestToken = body.token
cy.request({
method: 'DELETE',
url: `${Cypress.env('baseUrl')}/remote.php/dav/files/${user.userId}` + path,
headers: {
requestToken,
},
retryOnStatusCodeFailure: true,
})
})
}

export const triggerFileListAction = (actionId: string) => {
cy.get(`button[data-cy-files-list-action="${CSS.escape(actionId)}"]`).last()
.should('exist').click({ force: true })
}
Loading
Loading