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)" - /> + > + + -