Skip to content

Commit

Permalink
Merge pull request #49171 from nextcloud/feat/empty-trash
Browse files Browse the repository at this point in the history
feat(trashbin): Allow emptying trash
  • Loading branch information
Pytal authored Dec 12, 2024
2 parents c21888e + ad29dd3 commit c8741b4
Show file tree
Hide file tree
Showing 139 changed files with 530 additions and 375 deletions.
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

0 comments on commit c8741b4

Please sign in to comment.