From ae8b1209a4671049fa25486d8a84f516a2b4bccf Mon Sep 17 00:00:00 2001 From: Csaky Date: Fri, 13 Dec 2024 15:32:43 -0800 Subject: [PATCH] Soft-delete/restore Feature delete/restore versions from File details page soft-delete and restore files Deleted files view for each folder Uses new COMS copyVersion feature --- frontend/src/assets/main.scss | 7 + .../components/object/DeleteObjectButton.vue | 51 ++- .../components/object/ObjectFileDetails.vue | 56 ++- frontend/src/components/object/ObjectList.vue | 8 +- .../components/object/ObjectListDeleted.vue | 99 +++++ .../components/object/ObjectProperties.vue | 2 +- .../src/components/object/ObjectTable.vue | 3 +- .../components/object/ObjectTableDeleted.vue | 367 ++++++++++++++++++ .../src/components/object/ObjectVersion.vue | 54 +-- .../components/object/RestoreObjectButton.vue | 106 +++++ frontend/src/components/object/index.ts | 3 + frontend/src/router/index.ts | 7 + frontend/src/services/objectService.ts | 16 + frontend/src/store/objectStore.ts | 56 ++- frontend/src/store/versionStore.ts | 6 +- frontend/src/utils/constants.ts | 1 + .../src/views/detail/DetailObjectsView.vue | 2 +- .../src/views/list/ListObjectsDeletedView.vue | 74 ++++ frontend/tests/unit/store/objectStore.spec.ts | 6 +- .../tests/unit/store/versionStore.spec.ts | 3 +- 20 files changed, 853 insertions(+), 74 deletions(-) create mode 100644 frontend/src/components/object/ObjectListDeleted.vue create mode 100644 frontend/src/components/object/ObjectTableDeleted.vue create mode 100644 frontend/src/components/object/RestoreObjectButton.vue create mode 100644 frontend/src/views/list/ListObjectsDeletedView.vue diff --git a/frontend/src/assets/main.scss b/frontend/src/assets/main.scss index f41b9182..797ef5bb 100644 --- a/frontend/src/assets/main.scss +++ b/frontend/src/assets/main.scss @@ -121,6 +121,10 @@ div:focus-visible { box-shadow: 0 6px 6px -1px rgb(145, 145, 145); } +.p-tooltip{ + max-width: 400px !important; +} + /* layout */ .layout-main { margin: 1rem; @@ -231,6 +235,9 @@ div:focus-visible { &.selected-row { background: $bcbox-highlight-background !important; } + &.deleted-row td:not(.action-buttons) { + opacity: 0.6 !important; + } } } diff --git a/frontend/src/components/object/DeleteObjectButton.vue b/frontend/src/components/object/DeleteObjectButton.vue index 2c8d9b5d..110086fe 100644 --- a/frontend/src/components/object/DeleteObjectButton.vue +++ b/frontend/src/components/object/DeleteObjectButton.vue @@ -1,6 +1,6 @@ diff --git a/frontend/src/components/object/ObjectListDeleted.vue b/frontend/src/components/object/ObjectListDeleted.vue new file mode 100644 index 00000000..08d99dd8 --- /dev/null +++ b/frontend/src/components/object/ObjectListDeleted.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/frontend/src/components/object/ObjectProperties.vue b/frontend/src/components/object/ObjectProperties.vue index fb995b3e..f369a9e4 100644 --- a/frontend/src/components/object/ObjectProperties.vue +++ b/frontend/src/components/object/ObjectProperties.vue @@ -28,7 +28,7 @@ const { getUser } = storeToRefs(userStore); const object: Ref = computed((): any => getObject.value(props.objectId)); const bucket: Ref = computed(() => getBucket.value(object.value?.bucketId as string)); -const createdBy: Ref = computed(() => getUser.value(object.value.createdBy)?.fullName); +const createdBy: Ref = computed(() => getUser.value(object.value?.createdBy)?.fullName); const updatedBy: Ref = computed(() => getUser.value(object.value?.updatedBy)?.fullName); onMounted(() => { diff --git a/frontend/src/components/object/ObjectTable.vue b/frontend/src/components/object/ObjectTable.vue index 39a42b39..f789586b 100644 --- a/frontend/src/components/object/ObjectTable.vue +++ b/frontend/src/components/object/ObjectTable.vue @@ -254,7 +254,7 @@ const selectedFilters = (payload: any) => { v-if="!loading" class="flex justify-content-center" > -

There are no objects associated with your account in this bucket.

+

There are no files associated with your account in this folder.

diff --git a/frontend/src/components/object/RestoreObjectButton.vue b/frontend/src/components/object/RestoreObjectButton.vue new file mode 100644 index 00000000..2124ac1d --- /dev/null +++ b/frontend/src/components/object/RestoreObjectButton.vue @@ -0,0 +1,106 @@ + + + diff --git a/frontend/src/components/object/index.ts b/frontend/src/components/object/index.ts index f0781066..b65f7c00 100644 --- a/frontend/src/components/object/index.ts +++ b/frontend/src/components/object/index.ts @@ -1,9 +1,11 @@ export { default as DeleteObjectButton } from './DeleteObjectButton.vue'; export { default as DownloadObjectButton } from './DownloadObjectButton.vue'; +export { default as RestoreObjectButton } from './RestoreObjectButton.vue'; export { default as ObjectAccess } from './ObjectAccess.vue'; export { default as ObjectFileDetails } from './ObjectFileDetails.vue'; export { default as ObjectFilters } from './ObjectFilters.vue'; export { default as ObjectList } from './ObjectList.vue'; +export { default as ObjectListDeleted } from './ObjectListDeleted.vue'; export { default as ObjectMetadata } from './ObjectMetadata.vue'; export { default as ObjectMetadataTagForm } from './ObjectMetadataTagForm.vue'; export { default as ObjectPermission } from './ObjectPermission.vue'; @@ -12,6 +14,7 @@ export { default as ObjectProperties } from './ObjectProperties.vue'; export { default as ObjectPublicToggle } from './ObjectPublicToggle.vue'; export { default as ObjectSidebar } from './ObjectSidebar.vue'; export { default as ObjectTable } from './ObjectTable.vue'; +export { default as ObjectTableDeleted } from './ObjectTableDeleted.vue'; export { default as ObjectTag } from './ObjectTag.vue'; export { default as ObjectUpload } from './ObjectUpload.vue'; export { default as ObjectUploadBasic } from './ObjectUploadBasic.vue'; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b25368c2..0dfc215a 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -58,6 +58,13 @@ const routes: Array = [ name: RouteNames.LIST_OBJECTS, component: () => import('@/views/list/ListObjectsView.vue'), meta: { requiresAuth: true, breadcrumb: '__listObjectsDynamic', title: 'My Objects' }, + props: createProps, + }, + { + path: 'deleted', + name: RouteNames.LIST_OBJECTS_DELETED, + component: () => import('@/views/list/ListObjectsDeletedView.vue'), + meta: { requiresAuth: true, breadcrumb: '__listObjectsDeletedDynamic', title: 'My Deleted Objects' }, props: createProps } ] diff --git a/frontend/src/services/objectService.ts b/frontend/src/services/objectService.ts index bd831c00..31f3e14b 100644 --- a/frontend/src/services/objectService.ts +++ b/frontend/src/services/objectService.ts @@ -148,6 +148,22 @@ export default { return comsAxios().head(`${PATH}/${objectId}`); }, + /** + * @function copyObjectVersion + * Copies a previous version of an object and places on top of the version 'stack'. + * If no version is provided to copy, the latest existing version will be copied. + * @param {string} objectId The id for the object to get + * @param {string} versionId An optional versionId + * @returns {Promise} An axios response + */ + copyObjectVersion(objectId: string, versionId: string) { + return comsAxios().put(`${PATH}/${objectId}/version`, undefined, { + params: { + versionId: versionId + } + }); + }, + /** * @function listObjectVersion * Returns the object version history diff --git a/frontend/src/store/objectStore.ts b/frontend/src/store/objectStore.ts index 6c40322a..b059fa7b 100644 --- a/frontend/src/store/objectStore.ts +++ b/frontend/src/store/objectStore.ts @@ -3,7 +3,7 @@ import { computed, ref } from 'vue'; import { useToast } from '@/lib/primevue'; import { objectService } from '@/services'; -import { useAppStore, useAuthStore, usePermissionStore } from '@/store'; +import { useAppStore, useAuthStore, usePermissionStore, useVersionStore } from '@/store'; import { partition } from '@/utils/utils'; import type { AxiosRequestConfig } from 'axios'; @@ -22,6 +22,7 @@ export const useObjectStore = defineStore('object', () => { // Store const appStore = useAppStore(); const permissionStore = usePermissionStore(); + const versionStore = useVersionStore(); const { getUserId } = storeToRefs(useAuthStore()); // State @@ -87,14 +88,30 @@ export const useObjectStore = defineStore('object', () => { } } - async function deleteObject(objectId: string, versionId?: string) { + async function deleteObject(objectId: string, versionId?: string, hard = false) { const bucketId = getters.getObject.value(objectId)?.bucketId; - try { appStore.beginIndeterminateLoading(); - await objectService.deleteObject(objectId, versionId); + + + + if(!versionId) { + if(hard) { + // await versionStore.fetchVersions({ objectId: objectId }); + const versions = await versionStore.getVersionsByObjectId(objectId); + for (const v of versions) { + await objectService.deleteObject(objectId, v.id); + } + } else{ + await objectService.deleteObject(objectId, versionId); + } + toast.success('File deleted'); + } + else{ + await objectService.deleteObject(objectId, versionId); + toast.success('Version deleted'); + } removeSelectedObject(objectId); - toast.success('Object deleted'); } catch (error: any) { toast.error('deleting object.'); throw error; @@ -104,6 +121,28 @@ export const useObjectStore = defineStore('object', () => { } } + async function restoreObject(objectId: string, versionId?: string) { + const bucketId = getters.getObject.value(objectId)?.bucketId; + try { + appStore.beginIndeterminateLoading(); + // if restoring a specific version.. copy and make latest + if(versionId) { + await objectService.copyObjectVersion(objectId, versionId); + } + const versions = await versionStore.getVersionsByObjectId(objectId); + versions + .filter(v => v.deleteMarker) + .forEach(v => objectService.deleteObject(objectId, v.id)); + toast.success('File restored'); + } catch (error: any) { + toast.error('restoring file'); + throw error; + } finally { + fetchObjects({ bucketId: bucketId, userId: getUserId.value, bucketPerms: true }); + appStore.endIndeterminateLoading(); + } + } + async function getObjectUrl(objectId: string, versionId?: string) { try { appStore.beginIndeterminateLoading(); @@ -155,8 +194,8 @@ export const useObjectStore = defineStore('object', () => { // Added to allow deletion of objects before versioning implementation // TODO: Verify if needed after versioning implemented - deleteMarker: false, - latest: true + // deleteMarker: false, + // latest: true }, headers ) @@ -197,7 +236,7 @@ export const useObjectStore = defineStore('object', () => { // Return full response as data will always be No Content return await objectService.headObject(objectId); } catch (error: any) { - toast.error('Fetching head', error); + // toast.error('Fetching head', error); } finally { appStore.endIndeterminateLoading(); } @@ -284,6 +323,7 @@ export const useObjectStore = defineStore('object', () => { // Actions createObject, deleteObject, + restoreObject, getObjectUrl, fetchObjects, headObject, diff --git a/frontend/src/store/versionStore.ts b/frontend/src/store/versionStore.ts index 0876ed1d..8ce2f0b6 100644 --- a/frontend/src/store/versionStore.ts +++ b/frontend/src/store/versionStore.ts @@ -39,6 +39,9 @@ export const useVersionStore = defineStore('version', () => { getVersionsByObjectId: computed( () => (objectId: string) => state.versions.value.filter((x: Version) => x.objectId === objectId) ), + getIsDeleted: computed(() => (objectId: string) => state.versions.value.find((x: Version) => + ((x.objectId === objectId) && (x.isLatest) && (x.deleteMarker))) ? true : false + ), getLatestVersionIdByObjectId: computed( () => (objectId: string) => state.versions.value.find((x: Version) => x.objectId === objectId && x.isLatest)?.id ), @@ -94,7 +97,8 @@ export const useVersionStore = defineStore('version', () => { appStore.beginIndeterminateLoading(); state.versions.value = (await objectService.listObjectVersion(params.objectId)).data; } catch (error: any) { - toast.error('Fetching versions', error); + // toast.error('Fetching versions', error); + state.versions.value = []; } finally { appStore.endIndeterminateLoading(); } diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index 8d1ab0ad..b2668021 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -46,6 +46,7 @@ export const RouteNames = Object.freeze({ INVITE: 'invite', LIST_BUCKETS: 'listBuckets', LIST_OBJECTS: 'listObjects', + LIST_OBJECTS_DELETED: 'listObjectsDeleted', LOGIN: 'login', LOGOUT: 'logout' }); diff --git a/frontend/src/views/detail/DetailObjectsView.vue b/frontend/src/views/detail/DetailObjectsView.vue index 9ef1560d..4db40ed6 100644 --- a/frontend/src/views/detail/DetailObjectsView.vue +++ b/frontend/src/views/detail/DetailObjectsView.vue @@ -49,7 +49,7 @@ onBeforeMount(async () => { :version-id="versionId" />
-

No object or version provided

+

File not found

diff --git a/frontend/src/views/list/ListObjectsDeletedView.vue b/frontend/src/views/list/ListObjectsDeletedView.vue new file mode 100644 index 00000000..4c32eed4 --- /dev/null +++ b/frontend/src/views/list/ListObjectsDeletedView.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/frontend/tests/unit/store/objectStore.spec.ts b/frontend/tests/unit/store/objectStore.spec.ts index 25351022..5109d69a 100644 --- a/frontend/tests/unit/store/objectStore.spec.ts +++ b/frontend/tests/unit/store/objectStore.spec.ts @@ -189,8 +189,7 @@ describe('Object Store', () => { { bucketId: ['000'], objectId: ['000'], - deleteMarker: false, - latest: true + tagset: undefined }, {} ); @@ -216,8 +215,7 @@ describe('Object Store', () => { { bucketId: ['000'], objectId: ['000'], - deleteMarker: false, - latest: true + tagset: undefined }, {} ); diff --git a/frontend/tests/unit/store/versionStore.spec.ts b/frontend/tests/unit/store/versionStore.spec.ts index 7c7668fc..1004b8b2 100644 --- a/frontend/tests/unit/store/versionStore.spec.ts +++ b/frontend/tests/unit/store/versionStore.spec.ts @@ -163,8 +163,7 @@ describe('Version Store', () => { expect(beginIndeterminateLoadingSpy).toHaveBeenCalledTimes(1); expect(getVersionsSpy).toHaveBeenCalledTimes(1); expect(getVersionsSpy).toHaveBeenCalledWith('000'); - expect(mockToast).toHaveBeenCalledTimes(1); - expect(mockToast).toHaveBeenCalledWith('Fetching versions', new Error()); + expect(mockToast).toHaveBeenCalledTimes(0); expect(endIndeterminateLoadingSpy).toHaveBeenCalledTimes(1); expect(versionStore.getTagging).toStrictEqual([]); });