diff --git a/packages/design-system/src/components/OcNotificationMessage/OcNotificationMessage.vue b/packages/design-system/src/components/OcNotificationMessage/OcNotificationMessage.vue
index ece96ce9da..63444b2333 100644
--- a/packages/design-system/src/components/OcNotificationMessage/OcNotificationMessage.vue
+++ b/packages/design-system/src/components/OcNotificationMessage/OcNotificationMessage.vue
@@ -6,7 +6,7 @@
-
+
{{ title }}
@@ -32,6 +32,7 @@
+
@@ -66,6 +67,11 @@ export interface Props {
* @default 5
*/
timeout?: number
+ /**
+ * @docs Whether to show an info icon prior to the title.
+ * @default true
+ */
+ showInfoIcon?: boolean
}
export interface Emits {
@@ -75,9 +81,24 @@ export interface Emits {
(e: 'close'): void
}
-const { title, errorLogContent, message, status = 'passive', timeout = 5 } = defineProps()
+export interface Slots {
+ /**
+ * @docs Slot for action buttons.
+ */
+ actions?: () => unknown
+}
+
+const {
+ title,
+ errorLogContent,
+ message,
+ status = 'passive',
+ timeout = 5,
+ showInfoIcon = true
+} = defineProps()
const emit = defineEmits()
+defineSlots()
const showErrorLog = ref(false)
diff --git a/packages/design-system/src/directives/OcTooltip.ts b/packages/design-system/src/directives/OcTooltip.ts
index 2898ff8816..dd0dac2d54 100644
--- a/packages/design-system/src/directives/OcTooltip.ts
+++ b/packages/design-system/src/directives/OcTooltip.ts
@@ -77,6 +77,7 @@ const initOrUpdate = (
if (!el.tooltip) {
el.tooltip = tippy(el, {
...props,
+ zIndex: 10000,
plugins: [hideOnEsc, customProps]
})
return
diff --git a/packages/web-pkg/src/composables/actions/files/index.ts b/packages/web-pkg/src/composables/actions/files/index.ts
index ed6be009f2..ce8d315537 100644
--- a/packages/web-pkg/src/composables/actions/files/index.ts
+++ b/packages/web-pkg/src/composables/actions/files/index.ts
@@ -26,3 +26,4 @@ export * from './useFileActionsOpenShortcut'
export * from './useFileActionsCreateLink'
export * from './useFileActionsOpenWithApp'
export * from './useFileActionsSaveAs'
+export * from './useFileActionsUndoDelete'
diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsRestore.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsRestore.ts
index 091eb9854b..db80b68456 100644
--- a/packages/web-pkg/src/composables/actions/files/useFileActionsRestore.ts
+++ b/packages/web-pkg/src/composables/actions/files/useFileActionsRestore.ts
@@ -23,7 +23,13 @@ import type { FileAction, FileActionOptions } from '../types'
import { useMessages, useSpacesStore, useUserStore, useResourcesStore } from '../../piniaStores'
import { useRestoreWorker } from '../../webWorkers/restoreWorker'
-export const useFileActionsRestore = () => {
+export const useFileActionsRestore = ({
+ showSuccessMessage = true,
+ onRestoreComplete
+}: {
+ showSuccessMessage?: boolean
+ onRestoreComplete?: (result: FileActionOptions) => void
+} = {}) => {
const { showMessage, showErrorMessage } = useMessages()
const userStore = useUserStore()
const router = useRouter()
@@ -130,7 +136,10 @@ export const useFileActionsRestore = () => {
resourceCount: successful.length.toString()
})
}
- showMessage({ title })
+
+ if (showSuccessMessage) {
+ showMessage({ title })
+ }
// user hasn't navigated to another location meanwhile
if (
@@ -149,6 +158,7 @@ export const useFileActionsRestore = () => {
field: 'spaceQuota',
value: updatedSpace.spaceQuota
})
+ onRestoreComplete?.({ space, resources: successful })
}
if (failed.length) {
diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsUndoDelete.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsUndoDelete.ts
new file mode 100644
index 0000000000..0bc972408e
--- /dev/null
+++ b/packages/web-pkg/src/composables/actions/files/useFileActionsUndoDelete.ts
@@ -0,0 +1,86 @@
+import { computed, unref } from 'vue'
+import { useGettext } from 'vue3-gettext'
+import type { Action, FileActionOptions } from '../types'
+import { useMessages, useResourcesStore } from '../../piniaStores'
+import { useFileActionsRestore } from './useFileActionsRestore'
+import { storeToRefs } from 'pinia'
+import { useCapabilityStore, useClientService } from '../../'
+import { isPersonalSpaceResource, isProjectSpaceResource } from '@opencloud-eu/web-client'
+import { isMacOs } from '../../../helpers'
+
+type UndoActionOptions = FileActionOptions & { callback?: () => void }
+
+export const useFileActionsUndoDelete = () => {
+ const { $gettext } = useGettext()
+ const { showErrorMessage } = useMessages()
+ const { webdav } = useClientService()
+ const capabilityStore = useCapabilityStore()
+ const resourcesStore = useResourcesStore()
+ const { currentFolder } = storeToRefs(resourcesStore)
+
+ const { actions: restoreActions } = useFileActionsRestore({
+ showSuccessMessage: false,
+ onRestoreComplete: async ({ space, resources }) => {
+ if (unref(currentFolder)?.id === resources[0].parentFolderId) {
+ // update local folder
+ const { children } = await webdav.listFiles(space, { path: unref(currentFolder).path })
+ resourcesStore.upsertResources(
+ children.filter(({ id }) => resources.some((s) => s.id === transformToTrashId(id)))
+ )
+ }
+ }
+ })
+
+ const transformToTrashId = (id: string) => {
+ // deleted files only have the "fileId" without the "storageId$driveId!" prefix
+ return id.includes('!') ? id.split('!')[1] : id
+ }
+
+ const undoDeleteHandler = async ({ space, resources, callback }: UndoActionOptions) => {
+ const resourcesToRestore = resources.map((res) => ({
+ ...res,
+ id: transformToTrashId(res.id)
+ }))
+
+ try {
+ const restoreAction = unref(restoreActions)[0]
+ await restoreAction.handler({ space, resources: resourcesToRestore })
+ callback()
+ } catch (e) {
+ console.error(e)
+ showErrorMessage({
+ title: $gettext('Failed to restore files'),
+ errors: [e]
+ })
+ }
+ }
+
+ const shortcutString = computed(() => {
+ if (isMacOs()) {
+ return $gettext('⌘ + Z')
+ }
+ return $gettext('Ctrl + Z')
+ })
+
+ const actions = computed[]>(() => {
+ return [
+ {
+ name: 'undoDelete',
+ icon: 'arrow-go-back',
+ shortcut: unref(shortcutString),
+ isVisible: ({ space }) => {
+ if (!capabilityStore.davTrashbin) {
+ return false
+ }
+ return isProjectSpaceResource(space) || isPersonalSpaceResource(space)
+ },
+ label: () => $gettext('Undo'),
+ handler: undoDeleteHandler
+ }
+ ]
+ })
+
+ return {
+ actions
+ }
+}
diff --git a/packages/web-pkg/src/composables/actions/helpers/useFileActionsDeleteResources.ts b/packages/web-pkg/src/composables/actions/helpers/useFileActionsDeleteResources.ts
index af641b9cd2..924ea87879 100644
--- a/packages/web-pkg/src/composables/actions/helpers/useFileActionsDeleteResources.ts
+++ b/packages/web-pkg/src/composables/actions/helpers/useFileActionsDeleteResources.ts
@@ -22,10 +22,12 @@ import {
import { storeToRefs } from 'pinia'
import { useDeleteWorker } from '../../webWorkers'
import { useEventBus } from '../../eventBus'
+import { useFileActionsUndoDelete } from '../files'
+import { Key, Modifier, useKeyboardActions } from '../../keyboardActions'
export const useFileActionsDeleteResources = () => {
const configStore = useConfigStore()
- const messageStore = useMessages()
+ const { showMessage, showErrorMessage, removeMessage } = useMessages()
const router = useRouter()
const language = useGettext()
const { getMatchingSpace } = useGetMatchingSpace()
@@ -34,10 +36,13 @@ export const useFileActionsDeleteResources = () => {
const { dispatchModal } = useModals()
const spacesStore = useSpacesStore()
const eventBus = useEventBus()
+ const { bindKeyAction, removeKeyAction } = useKeyboardActions()
const { startWorker } = useDeleteWorker({
concurrentRequests: configStore.options.concurrentRequests.resourceBatchActions
})
+ const { actions: undoActions } = useFileActionsUndoDelete()
+
const resourcesStore = useResourcesStore()
const { currentFolder } = storeToRefs(resourcesStore)
@@ -57,6 +62,58 @@ export const useFileActionsDeleteResources = () => {
return cloneStateObject(unref(resourcesToDelete))
})
+ const showSuccessMessage = ({
+ space,
+ filesToDelete,
+ deletedFiles
+ }: {
+ space: SpaceResource
+ filesToDelete: Resource[]
+ deletedFiles: Resource[]
+ }) => {
+ const title =
+ deletedFiles.length === 1 && filesToDelete.length === 1
+ ? $gettext('"%{item}" was moved to trash bin', { item: deletedFiles[0].name })
+ : $ngettext(
+ '%{itemCount} item was moved to trash bin',
+ '%{itemCount} items were moved to trash bin',
+ deletedFiles.length,
+ { itemCount: deletedFiles.length.toString() },
+ true
+ )
+
+ const messageTimeout = 7 // in seconds
+ const undoAction = unref(undoActions)[0]
+ const undoAvailable = undoAction.isVisible({ space, resources: deletedFiles })
+
+ const message = showMessage({
+ title,
+ timeout: messageTimeout,
+ actions: [undoAction],
+ actionOptions: {
+ space,
+ resources: deletedFiles,
+ callback: () => {
+ removeMessage(message)
+ }
+ }
+ })
+
+ if (undoAvailable) {
+ const keyActionId = bindKeyAction({ primary: Key.Z, modifier: Modifier.Ctrl }, () => {
+ removeKeyAction(keyActionId)
+ return undoAction.handler({
+ space,
+ resources: deletedFiles,
+ callback: () => {
+ removeMessage(message)
+ }
+ })
+ })
+ setTimeout(() => removeKeyAction(keyActionId), messageTimeout * 1000)
+ }
+ }
+
const dialogTitle = computed(() => {
const currentResources = unref(resources)
const isFolder = currentResources[0].type === 'folder'
@@ -130,12 +187,12 @@ export const useFileActionsDeleteResources = () => {
true
)
- messageStore.showMessage({ title })
+ showMessage({ title })
}
failed.forEach(({ resource }) => {
const title = $gettext('Failed to delete "%{item}"', { item: resource.name })
- messageStore.showErrorMessage({ title, errors: [new Error()] })
+ showErrorMessage({ title, errors: [new Error()] })
})
// user hasn't navigated to another location meanwhile
@@ -181,18 +238,11 @@ export const useFileActionsDeleteResources = () => {
{ topic: 'fileListDelete', space: spaceForDeletion, resources: resourcesForDeletion },
async ({ successful, failed }) => {
if (successful.length) {
- const title =
- successful.length === 1 && resources.length === 1
- ? $gettext('"%{item}" was moved to trash bin', { item: successful[0].name })
- : $ngettext(
- '%{itemCount} item was moved to trash bin',
- '%{itemCount} items were moved to trash bin',
- successful.length,
- { itemCount: successful.length.toString() },
- true
- )
-
- messageStore.showMessage({ title })
+ showSuccessMessage({
+ space: spaceForDeletion,
+ filesToDelete: resourcesForDeletion,
+ deletedFiles: successful
+ })
eventBus.publish('runtime.resource.deleted', successful)
}
@@ -207,7 +257,7 @@ export const useFileActionsDeleteResources = () => {
})
}
- messageStore.showErrorMessage({ title, errors: [error] })
+ showErrorMessage({ title, errors: [error] })
})
// user hasn't navigated to another location meanwhile
diff --git a/packages/web-pkg/src/composables/keyboardActions/useKeyboardActions.ts b/packages/web-pkg/src/composables/keyboardActions/useKeyboardActions.ts
index e9242b6034..f5f8194d59 100644
--- a/packages/web-pkg/src/composables/keyboardActions/useKeyboardActions.ts
+++ b/packages/web-pkg/src/composables/keyboardActions/useKeyboardActions.ts
@@ -12,6 +12,7 @@ export enum Key {
X = 'x',
A = 'a',
S = 's',
+ Z = 'z',
Plus = '+',
Minus = '-',
Space = ' ',
diff --git a/packages/web-pkg/src/composables/piniaStores/messages.ts b/packages/web-pkg/src/composables/piniaStores/messages.ts
index 5037679cfb..3c12e9df28 100644
--- a/packages/web-pkg/src/composables/piniaStores/messages.ts
+++ b/packages/web-pkg/src/composables/piniaStores/messages.ts
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { v4 as uuidV4 } from 'uuid'
import { ref, unref } from 'vue'
import { HttpError } from '@opencloud-eu/web-client'
+import { Action, ActionOptions } from '../actions'
type MessageError = Error | HttpError
@@ -13,6 +14,8 @@ export interface Message {
errorLogContent?: string
timeout?: number
status?: 'passive' | 'primary' | 'success' | 'warning' | 'danger'
+ actions?: Action[]
+ actionOptions?: ActionOptions
}
export const useMessages = defineStore('messages', () => {
diff --git a/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsUndoDelete.spec.ts b/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsUndoDelete.spec.ts
new file mode 100644
index 0000000000..dcc3103765
--- /dev/null
+++ b/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsUndoDelete.spec.ts
@@ -0,0 +1,118 @@
+import {
+ FileAction,
+ useFileActionsRestore,
+ useFileActionsUndoDelete
+} from '../../../../../src/composables/actions'
+import { mock } from 'vitest-mock-extended'
+import { defaultComponentMocks, getComposableWrapper } from '@opencloud-eu/web-test-helpers'
+import { CapabilityStore, Message } from '../../../../../src/composables/piniaStores'
+import { computed, unref } from 'vue'
+import { Resource } from '@opencloud-eu/web-client'
+import { SpaceResource } from '@opencloud-eu/web-client'
+
+vi.mock('../../../../../src/composables/actions/files/useFileActionsRestore')
+
+describe('undoDelete', () => {
+ describe('isVisible', () => {
+ it.each(['project', 'personal'])(
+ 'is true for %s spaces if trash bins are enabled via capabilities',
+ (driveType) => {
+ getWrapper({
+ setup: ({ actions }) => {
+ const space = mock({ driveType })
+ const resources = [mock({ id: '1' })]
+ const isVisible = unref(actions)[0].isVisible({ space, resources })
+
+ expect(isVisible).toBeTruthy()
+ }
+ })
+ }
+ )
+ it('is false when trash bins are disabled via capabilities', () => {
+ getWrapper({
+ setup: ({ actions }) => {
+ const space = mock({ driveType: 'project' })
+ const resources = [mock({ id: '1' })]
+ const isVisible = unref(actions)[0].isVisible({ space, resources })
+
+ expect(isVisible).toBeFalsy()
+ },
+ trashBinEnabled: false
+ })
+ })
+ it.each(['share', 'public'])('is false for %s spaces', (driveType) => {
+ getWrapper({
+ setup: ({ actions }) => {
+ const space = mock({ driveType })
+ const resources = [mock({ id: '1' })]
+ const isVisible = unref(actions)[0].isVisible({ space, resources })
+
+ expect(isVisible).toBeFalsy()
+ }
+ })
+ })
+ })
+ describe('handler', () => {
+ it('calls the restore file action and transforms the ids before', () => {
+ getWrapper({
+ setup: async ({ actions }, { restoreHandlerMock }) => {
+ const space = mock()
+ const callback = vi.fn()
+ const resources = [{ id: '1$2!3' } as Resource]
+ await unref(actions)[0].handler({ space, resources, callback })
+
+ expect(restoreHandlerMock).toHaveBeenCalledWith({ space, resources: [{ id: '3' }] })
+ expect(callback).toHaveBeenCalled()
+ }
+ })
+ })
+ })
+})
+
+function getWrapper({
+ deleteMessage = undefined,
+ trashBinEnabled = true,
+ setup
+}: {
+ deleteMessage?: Message
+ trashBinEnabled?: boolean
+ setup: (
+ instance: ReturnType,
+ mocks: {
+ restoreHandlerMock: FileAction['handler']
+ }
+ ) => void
+}) {
+ const restoreHandlerMock = vi.fn()
+ const useFileActionsRestoreMock = mock>()
+ useFileActionsRestoreMock.actions = computed(() => [
+ mock({ handler: restoreHandlerMock })
+ ])
+ vi.mocked(useFileActionsRestore).mockReturnValue(useFileActionsRestoreMock)
+
+ const mocks = defaultComponentMocks()
+ const capabilities = {
+ dav: { trashbin: trashBinEnabled ? '1.0' : undefined }
+ } satisfies Partial
+
+ return {
+ mocks,
+ wrapper: getComposableWrapper(
+ () => {
+ const instance = useFileActionsUndoDelete()
+ setup(instance, {
+ restoreHandlerMock
+ })
+ },
+ {
+ mocks,
+ provide: mocks,
+ pluginOptions: {
+ piniaOptions: {
+ capabilityState: { capabilities }
+ }
+ }
+ }
+ )
+ }
+}
diff --git a/packages/web-runtime/src/components/MessageBar.vue b/packages/web-runtime/src/components/MessageBar.vue
index 18a562d735..01f11c5da1 100644
--- a/packages/web-runtime/src/components/MessageBar.vue
+++ b/packages/web-runtime/src/components/MessageBar.vue
@@ -8,29 +8,41 @@
:status="item.status"
:timeout="item.timeout"
:error-log-content="item.errorLogContent"
+ :show-info-icon="false"
@close="deleteMessage(item)"
- />
+ >
+
+
+
+
+
+
-