mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 02:45:07 +03:00
feat(storage): deleted files table — version selection, preview, mutations, expand/collapse all
- Version rows are now selectable via checkboxes for batch multi-selection, using composite keys (objectId::versionId) in the shared selection state - Clicking a version row opens the preview panel with version-specific metadata (version ID, action, created date, size) and a 'no preview available' placeholder - Aligned CornerDownRight icon with parent row chevron and version text with parent name text (both use gap-x-2 and size-14 icons) - Added expand/collapse all toggle button in the table header top right (ChevronsUpDown / ChevronsDownUp icons) - Restore and delete actions on version rows now perform real client-side effects: version-level mock store mutations + query cache invalidation - Removed the Expires column from both DeletedFilesList and TrashList - Extended DeletedFilesContext with selectedDeletedVersion state - Added version-level mutation functions to protection-mocks.ts - Added useTrashVersionRestoreMutation and useTrashVersionDeleteMutation hooks to bucket-trash-query.ts - Updated Trash page (standalone) with version-level mutation wiring Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiUEvmC84bRsteqsWXHY2p
This commit is contained in:
7 files changed
+614
-102
No files matched your search
+195
-42
@@ -11,6 +11,8 @@ import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import {
|
||||
useBucketTrashDeleteMutation,
|
||||
useBucketTrashRestoreMutation,
|
||||
useTrashVersionDeleteMutation,
|
||||
useTrashVersionRestoreMutation,
|
||||
} from '@/data/storage/protection/bucket-trash-query'
|
||||
import { type TrashObject } from '@/data/storage/protection/protection-mocks'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
@@ -18,9 +20,30 @@ import { BASE_PATH } from '@/lib/constants'
|
||||
import { formatBytes } from '@/lib/helpers'
|
||||
import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer'
|
||||
|
||||
import { useDeletedFilesContext } from './DeletedFilesContext'
|
||||
import { useDeletedFilesContext, type SelectedDeletedVersion } from './DeletedFilesContext'
|
||||
|
||||
export const DeletedFilePreviewPane = () => {
|
||||
const {
|
||||
selectedDeletedFile,
|
||||
setSelectedDeletedFile,
|
||||
selectedDeletedVersion,
|
||||
setSelectedDeletedVersion,
|
||||
} = useDeletedFilesContext()
|
||||
|
||||
if (selectedDeletedVersion) {
|
||||
return <VersionPreviewPane />
|
||||
}
|
||||
|
||||
if (selectedDeletedFile) {
|
||||
return <ObjectPreviewPane />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Object-level preview (delete-marker) ──────────────────────────────
|
||||
|
||||
const ObjectPreviewPane = () => {
|
||||
const { selectedDeletedFile: file, setSelectedDeletedFile } = useDeletedFilesContext()
|
||||
const { projectRef, selectedBucket } = useStorageExplorerStateSnapshot()
|
||||
const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
|
||||
@@ -36,7 +59,7 @@ export const DeletedFilePreviewPane = () => {
|
||||
|
||||
const { mutate: deleteObjects, isPending: isDeleting } = useBucketTrashDeleteMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Version permanently deleted')
|
||||
toast.success('File permanently deleted')
|
||||
setShowDeleteConfirm(false)
|
||||
setSelectedDeletedFile(undefined)
|
||||
},
|
||||
@@ -72,7 +95,7 @@ export const DeletedFilePreviewPane = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DeletedFilePreview file={file} />
|
||||
<FilePreviewPlaceholder />
|
||||
|
||||
<div className="w-full space-y-3 mt-4">
|
||||
<div className="space-y-1">
|
||||
@@ -87,9 +110,162 @@ export const DeletedFilePreviewPane = () => {
|
||||
/>
|
||||
<DeletedFileDetail label="Deleted by" value={file.deletedBy} />
|
||||
<DeletedFileDetail label="Size" value={formatBytes(file.size)} />
|
||||
{file.noncurrentVersions && file.noncurrentVersions.length > 0 && (
|
||||
<DeletedFileDetail
|
||||
label="Noncurrent versions"
|
||||
value={`${file.noncurrentVersions.length}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-t border-overlay p-4">
|
||||
<ButtonTooltip
|
||||
variant="default"
|
||||
icon={<RotateCcw size={14} />}
|
||||
loading={isRestoring}
|
||||
disabled={!canUpdateFiles}
|
||||
onClick={handleRestore}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canUpdateFiles
|
||||
? 'You need additional permissions to restore versions'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Restore
|
||||
</ButtonTooltip>
|
||||
<ButtonTooltip
|
||||
variant="danger"
|
||||
icon={<Trash2 size={14} />}
|
||||
disabled={!canUpdateFiles}
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canUpdateFiles
|
||||
? 'You need additional permissions to delete versions'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Delete permanently
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
variant="destructive"
|
||||
visible={showDeleteConfirm}
|
||||
title="Permanently delete file"
|
||||
confirmLabel="Delete permanently"
|
||||
confirmLabelLoading="Deleting..."
|
||||
loading={isDeleting}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
onConfirm={handleDeletePermanently}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">
|
||||
{file.name} will be permanently deleted and can no longer be restored. This action cannot
|
||||
be undone.
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Version-level preview ─────────────────────────────────────────────
|
||||
|
||||
const VersionPreviewPane = () => {
|
||||
const { selectedDeletedVersion, setSelectedDeletedVersion } = useDeletedFilesContext()
|
||||
const { projectRef, selectedBucket } = useStorageExplorerStateSnapshot()
|
||||
const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
|
||||
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
const { mutate: restoreVersion, isPending: isRestoring } = useTrashVersionRestoreMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Version restored`)
|
||||
setSelectedDeletedVersion(undefined)
|
||||
},
|
||||
})
|
||||
|
||||
const { mutate: deleteVersion, isPending: isDeleting } = useTrashVersionDeleteMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Version permanently deleted')
|
||||
setShowDeleteConfirm(false)
|
||||
setSelectedDeletedVersion(undefined)
|
||||
},
|
||||
})
|
||||
|
||||
if (!selectedDeletedVersion) return null
|
||||
|
||||
const { parentObject, version } = selectedDeletedVersion
|
||||
const shortId = version.versionId.slice(0, 8)
|
||||
|
||||
const handleRestore = () => {
|
||||
if (!projectRef || !selectedBucket?.id) return
|
||||
restoreVersion({
|
||||
projectRef,
|
||||
bucketId: selectedBucket.id,
|
||||
objectId: parentObject.id,
|
||||
versionId: version.versionId,
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeletePermanently = () => {
|
||||
if (!projectRef || !selectedBucket?.id) return
|
||||
deleteVersion({
|
||||
projectRef,
|
||||
bucketId: selectedBucket.id,
|
||||
objectId: parentObject.id,
|
||||
versionId: version.versionId,
|
||||
})
|
||||
}
|
||||
|
||||
const width = 450
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
key={`${parentObject.id}::${version.versionId}`}
|
||||
className="absolute inset-y-0 right-0 z-10 flex flex-col border-l border-overlay bg-surface-100 shadow-lg"
|
||||
style={{ width }}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="flex w-full justify-end text-foreground-lighter transition-colors hover:text-foreground">
|
||||
<X
|
||||
className="cursor-pointer"
|
||||
size={14}
|
||||
onClick={() => setSelectedDeletedVersion(undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="my-4 flex h-56 w-full items-center justify-center rounded border border-dashed border-overlay 2xl:h-72">
|
||||
<p className="text-sm text-foreground-muted">No preview available for this version</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-3 mt-4">
|
||||
<div className="space-y-1">
|
||||
<h5 className="wrap-break-word text-base text-foreground">{parentObject.name}</h5>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Noncurrent version ·{' '}
|
||||
<span className="font-mono text-foreground-muted">{shortId}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DeletedFileDetail label="Version ID" value={shortId} />
|
||||
<DeletedFileDetail label="Action" value={version.action} />
|
||||
<DeletedFileDetail
|
||||
label="Expires"
|
||||
value={file.expiresAt ? dayjs(file.expiresAt).format('MMM D, YYYY') : 'Never'}
|
||||
label="Created"
|
||||
value={dayjs(version.createdAt).format('MMM D, YYYY HH:mm')}
|
||||
/>
|
||||
<DeletedFileDetail label="Size" value={formatBytes(version.size)} />
|
||||
<DeletedFileDetail label="Original location" value={parentObject.originalPath} />
|
||||
<DeletedFileDetail
|
||||
label="Deleted at"
|
||||
value={dayjs(parentObject.deletedAt).format('MMM D, YYYY HH:mm')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,51 +318,28 @@ export const DeletedFilePreviewPane = () => {
|
||||
onConfirm={handleDeletePermanently}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">
|
||||
{file.name} will be permanently deleted and can no longer be restored. This action cannot
|
||||
be undone.
|
||||
Version <span className="font-mono text-foreground">{shortId}</span> of{' '}
|
||||
{parentObject.name} will be permanently deleted. This action cannot be undone.
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const DeletedFilePreview = ({ file }: { file: TrashObject }) => {
|
||||
const mimeType = file.name.match(/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i)
|
||||
? 'image'
|
||||
: file.name.match(/\.(mp4|webm|ogg|mov)$/i)
|
||||
? 'video'
|
||||
: file.name.match(/\.(mp3|wav|ogg|aac|flac)$/i)
|
||||
? 'audio'
|
||||
: null
|
||||
// ── Shared helpers ────────────────────────────────────────────────────
|
||||
|
||||
if (!mimeType) {
|
||||
return (
|
||||
<div className="my-4 border border-overlay">
|
||||
<div className="flex h-56 w-full items-center justify-center 2xl:h-72">
|
||||
<SVG
|
||||
src={`${BASE_PATH}/img/file-filled.svg`}
|
||||
preProcessor={(code: string) =>
|
||||
code.replace(/svg/, 'svg class="mx-auto w-32 h-32 text-color-inherit opacity-75"')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-4 border border-overlay">
|
||||
<div className="flex h-56 w-full items-center justify-center 2xl:h-72">
|
||||
<SVG
|
||||
src={`${BASE_PATH}/img/file-filled.svg`}
|
||||
preProcessor={(code: string) =>
|
||||
code.replace(/svg/, 'svg class="mx-auto w-32 h-32 text-color-inherit opacity-75"')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
const FilePreviewPlaceholder = () => (
|
||||
<div className="my-4 border border-overlay">
|
||||
<div className="flex h-56 w-full items-center justify-center 2xl:h-72">
|
||||
<SVG
|
||||
src={`${BASE_PATH}/img/file-filled.svg`}
|
||||
preProcessor={(code: string) =>
|
||||
code.replace(/svg/, 'svg class="mx-auto w-32 h-32 text-color-inherit opacity-75"')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
|
||||
const DeletedFileDetail = ({ label, value }: { label: string; value: string }) => (
|
||||
<div>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
import { type TrashObject } from '@/data/storage/protection/protection-mocks'
|
||||
import {
|
||||
type DeletedObjectVersion,
|
||||
type TrashObject,
|
||||
} from '@/data/storage/protection/protection-mocks'
|
||||
|
||||
export interface SelectedDeletedVersion {
|
||||
parentObject: TrashObject
|
||||
version: DeletedObjectVersion
|
||||
}
|
||||
|
||||
interface DeletedFilesContextValue {
|
||||
isEnabled: boolean
|
||||
@@ -9,6 +17,8 @@ interface DeletedFilesContextValue {
|
||||
setIsShowingDeleted: (value: boolean) => void
|
||||
selectedDeletedFile: TrashObject | undefined
|
||||
setSelectedDeletedFile: (file: TrashObject | undefined) => void
|
||||
selectedDeletedVersion: SelectedDeletedVersion | undefined
|
||||
setSelectedDeletedVersion: (version: SelectedDeletedVersion | undefined) => void
|
||||
selectedDeletedIds: string[]
|
||||
setSelectedDeletedIds: (ids: string[]) => void
|
||||
lastToggledId: string | null
|
||||
@@ -22,6 +32,8 @@ const DeletedFilesContext = createContext<DeletedFilesContextValue>({
|
||||
setIsShowingDeleted: () => {},
|
||||
selectedDeletedFile: undefined,
|
||||
setSelectedDeletedFile: () => {},
|
||||
selectedDeletedVersion: undefined,
|
||||
setSelectedDeletedVersion: () => {},
|
||||
selectedDeletedIds: [],
|
||||
setSelectedDeletedIds: () => {},
|
||||
lastToggledId: null,
|
||||
@@ -42,6 +54,9 @@ export const DeletedFilesProvider = ({ enabled, children }: DeletedFilesProvider
|
||||
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
|
||||
)
|
||||
const [selectedDeletedFile, setSelectedDeletedFile] = useState<TrashObject>()
|
||||
const [selectedDeletedVersion, setSelectedDeletedVersion] = useState<
|
||||
SelectedDeletedVersion | undefined
|
||||
>()
|
||||
const [selectedDeletedIds, setSelectedDeletedIds] = useState<string[]>([])
|
||||
const [lastToggledId, setLastToggledId] = useState<string | null>(null)
|
||||
|
||||
@@ -58,6 +73,8 @@ export const DeletedFilesProvider = ({ enabled, children }: DeletedFilesProvider
|
||||
setIsShowingDeleted,
|
||||
selectedDeletedFile,
|
||||
setSelectedDeletedFile,
|
||||
selectedDeletedVersion,
|
||||
setSelectedDeletedVersion,
|
||||
selectedDeletedIds,
|
||||
setSelectedDeletedIds,
|
||||
lastToggledId,
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import dayjs from 'dayjs'
|
||||
import { ChevronDown, ChevronRight, CornerDownRight, RotateCcw, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
CornerDownRight,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -27,6 +35,8 @@ import {
|
||||
useBucketTrashDeleteMutation,
|
||||
useBucketTrashQuery,
|
||||
useBucketTrashRestoreMutation,
|
||||
useTrashVersionDeleteMutation,
|
||||
useTrashVersionRestoreMutation,
|
||||
} from '@/data/storage/protection/bucket-trash-query'
|
||||
import {
|
||||
type DeletedObjectVersion,
|
||||
@@ -38,6 +48,9 @@ import { formatBytes } from '@/lib/helpers'
|
||||
import { toggleSelectAll, toggleSelection } from '../Trash/Trash.utils'
|
||||
import { useDeletedFilesContext } from './DeletedFilesContext'
|
||||
|
||||
/** Composite key for a version row — unambiguous vs. top-level object ids. */
|
||||
const versionKey = (objectId: string, versionId: string) => `${objectId}::${versionId}`
|
||||
|
||||
interface DeletedFilesListProps {
|
||||
bucketId: string
|
||||
searchString: string
|
||||
@@ -48,6 +61,8 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
|
||||
const {
|
||||
selectedDeletedFile,
|
||||
setSelectedDeletedFile,
|
||||
selectedDeletedVersion,
|
||||
setSelectedDeletedVersion,
|
||||
selectedDeletedIds,
|
||||
setSelectedDeletedIds,
|
||||
lastToggledId,
|
||||
@@ -56,6 +71,10 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
|
||||
const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
|
||||
|
||||
const [fileToDelete, setFileToDelete] = useState<TrashObject>()
|
||||
const [versionToDelete, setVersionToDelete] = useState<{
|
||||
parentObject: TrashObject
|
||||
version: DeletedObjectVersion
|
||||
}>()
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const {
|
||||
@@ -66,6 +85,8 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
|
||||
isSuccess,
|
||||
} = useBucketTrashQuery({ projectRef: ref, bucketId })
|
||||
|
||||
// ── Object-level mutations ──────────────────────────────────────────
|
||||
|
||||
const { mutate: restoreObjects, isPending: isRestoring } = useBucketTrashRestoreMutation({
|
||||
onSuccess: (_data, variables) => {
|
||||
const count = variables.objectIds.length
|
||||
@@ -89,6 +110,36 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
|
||||
},
|
||||
})
|
||||
|
||||
// ── Version-level mutations ─────────────────────────────────────────
|
||||
|
||||
const { mutate: restoreVersion, isPending: isRestoringVersion } = useTrashVersionRestoreMutation({
|
||||
onSuccess: (_data, variables) => {
|
||||
toast.success(`Version ${variables.versionId.slice(0, 8)} restored`)
|
||||
// Clear version preview if we just restored the previewed version
|
||||
if (selectedDeletedVersion?.version.versionId === variables.versionId) {
|
||||
setSelectedDeletedVersion(undefined)
|
||||
}
|
||||
// Remove from selection
|
||||
const key = versionKey(variables.objectId, variables.versionId)
|
||||
setSelectedDeletedIds(selectedDeletedIds.filter((id) => id !== key))
|
||||
},
|
||||
})
|
||||
|
||||
const { mutate: deleteVersionPermanently, isPending: isDeletingVersion } =
|
||||
useTrashVersionDeleteMutation({
|
||||
onSuccess: (_data, variables) => {
|
||||
toast.success(`Version ${variables.versionId.slice(0, 8)} permanently deleted`)
|
||||
setVersionToDelete(undefined)
|
||||
if (selectedDeletedVersion?.version.versionId === variables.versionId) {
|
||||
setSelectedDeletedVersion(undefined)
|
||||
}
|
||||
const key = versionKey(variables.objectId, variables.versionId)
|
||||
setSelectedDeletedIds(selectedDeletedIds.filter((id) => id !== key))
|
||||
},
|
||||
})
|
||||
|
||||
// ── Loading / error states ──────────────────────────────────────────
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
@@ -126,9 +177,21 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
|
||||
)
|
||||
}
|
||||
|
||||
const orderedIds = filtered.map((o) => o.id)
|
||||
// ── Flat ordered ID list (objects + expanded versions) for shift-select ──
|
||||
|
||||
const orderedIds = filtered.flatMap((o) => {
|
||||
const ids = [o.id]
|
||||
if (expandedIds.has(o.id) && o.noncurrentVersions) {
|
||||
for (const v of o.noncurrentVersions) {
|
||||
ids.push(versionKey(o.id, v.versionId))
|
||||
}
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
const topLevelIds = filtered.map((o) => o.id)
|
||||
const isAllSelected =
|
||||
orderedIds.length > 0 && orderedIds.every((id) => selectedDeletedIds.includes(id))
|
||||
topLevelIds.length > 0 && topLevelIds.every((id) => selectedDeletedIds.includes(id))
|
||||
const isSomeSelected = selectedDeletedIds.length > 0 && !isAllSelected
|
||||
|
||||
const handleToggle = (id: string, isShiftHeld: boolean) => {
|
||||
@@ -144,7 +207,7 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
|
||||
}
|
||||
|
||||
const handleToggleAll = () => {
|
||||
const newIds = toggleSelectAll(selectedDeletedIds, orderedIds)
|
||||
const newIds = toggleSelectAll(selectedDeletedIds, topLevelIds)
|
||||
setSelectedDeletedIds(newIds)
|
||||
setLastToggledId(null)
|
||||
}
|
||||
@@ -154,6 +217,27 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
|
||||
restoreObjects({ projectRef: ref, bucketId, objectIds: [object.id] })
|
||||
}
|
||||
|
||||
const handleVersionRestore = (parent: TrashObject, version: DeletedObjectVersion) => {
|
||||
if (!ref) return
|
||||
restoreVersion({
|
||||
projectRef: ref,
|
||||
bucketId,
|
||||
objectId: parent.id,
|
||||
versionId: version.versionId,
|
||||
})
|
||||
}
|
||||
|
||||
const handleVersionDelete = (parent: TrashObject, version: DeletedObjectVersion) => {
|
||||
setVersionToDelete({ parentObject: parent, version })
|
||||
}
|
||||
|
||||
// ── Expand / collapse all ───────────────────────────────────────────
|
||||
|
||||
const expandableIds = filtered
|
||||
.filter((o) => o.noncurrentVersions && o.noncurrentVersions.length > 0)
|
||||
.map((o) => o.id)
|
||||
const isAllExpanded = expandableIds.length > 0 && expandableIds.every((id) => expandedIds.has(id))
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -166,10 +250,25 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
|
||||
})
|
||||
}
|
||||
|
||||
const expandAll = () => setExpandedIds(new Set(expandableIds))
|
||||
const collapseAll = () => setExpandedIds(new Set())
|
||||
|
||||
const totalVersionCount = (object: TrashObject) => {
|
||||
return 1 + (object.noncurrentVersions?.length ?? 0)
|
||||
}
|
||||
|
||||
// ── Preview handlers ────────────────────────────────────────────────
|
||||
|
||||
const handleSelectObject = (object: TrashObject) => {
|
||||
setSelectedDeletedFile(object)
|
||||
setSelectedDeletedVersion(undefined)
|
||||
}
|
||||
|
||||
const handleSelectVersion = (parent: TrashObject, version: DeletedObjectVersion) => {
|
||||
setSelectedDeletedVersion({ parentObject: parent, version })
|
||||
setSelectedDeletedFile(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table>
|
||||
@@ -187,14 +286,34 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
|
||||
<TableHead>Original location</TableHead>
|
||||
<TableHead>Deleted</TableHead>
|
||||
<TableHead className="text-right">Size</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead />
|
||||
<TableHead className="text-right">
|
||||
{expandableIds.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-x-1">
|
||||
<ButtonTooltip
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className="px-1"
|
||||
icon={
|
||||
isAllExpanded ? <ChevronsDownUp size={14} /> : <ChevronsUpDown size={14} />
|
||||
}
|
||||
onClick={isAllExpanded ? collapseAll : expandAll}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: isAllExpanded ? 'Collapse all versions' : 'Expand all versions',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((object) => {
|
||||
const isChecked = selectedDeletedIds.includes(object.id)
|
||||
const isPreviewed = selectedDeletedFile?.id === object.id
|
||||
const isPreviewed =
|
||||
selectedDeletedFile?.id === object.id && selectedDeletedVersion === undefined
|
||||
const hasVersions =
|
||||
object.noncurrentVersions !== undefined && object.noncurrentVersions.length > 0
|
||||
const isExpanded = expandedIds.has(object.id)
|
||||
@@ -206,7 +325,7 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
|
||||
'group cursor-pointer',
|
||||
isPreviewed && 'bg-selection hover:bg-selection'
|
||||
)}
|
||||
onClick={() => setSelectedDeletedFile(object)}
|
||||
onClick={() => handleSelectObject(object)}
|
||||
>
|
||||
<TableCell className="px-4 py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
@@ -256,13 +375,6 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
|
||||
<TableCell className="px-4 py-2 text-right text-foreground-light tabular-nums">
|
||||
{formatBytes(object.size)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2">
|
||||
{object.expiresAt ? (
|
||||
<span className="text-warning-600">{dayjs(object.expiresAt).fromNow()}</span>
|
||||
) : (
|
||||
<span className="text-foreground-lighter">Never</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-x-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<ButtonTooltip
|
||||
@@ -302,30 +414,36 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
|
||||
|
||||
{hasVersions &&
|
||||
isExpanded &&
|
||||
object.noncurrentVersions!.map((version) => (
|
||||
<NoncurrentVersionRow
|
||||
key={version.versionId}
|
||||
version={version}
|
||||
parentObject={object}
|
||||
canUpdateFiles={canUpdateFiles}
|
||||
onRestore={() => {
|
||||
toast.success(
|
||||
`Restoring version ${version.versionId.slice(0, 8)} to current`
|
||||
)
|
||||
}}
|
||||
onDelete={() => {
|
||||
toast.success(
|
||||
`Permanently deleted version ${version.versionId.slice(0, 8)}`
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
object.noncurrentVersions!.map((version) => {
|
||||
const key = versionKey(object.id, version.versionId)
|
||||
const isVersionChecked = selectedDeletedIds.includes(key)
|
||||
const isVersionPreviewed =
|
||||
selectedDeletedVersion?.version.versionId === version.versionId &&
|
||||
selectedDeletedVersion?.parentObject.id === object.id
|
||||
|
||||
return (
|
||||
<NoncurrentVersionRow
|
||||
key={version.versionId}
|
||||
version={version}
|
||||
parentObject={object}
|
||||
isChecked={isVersionChecked}
|
||||
isPreviewed={isVersionPreviewed}
|
||||
canUpdateFiles={canUpdateFiles}
|
||||
isRestoring={isRestoringVersion}
|
||||
onToggleSelect={(isShiftHeld) => handleToggle(key, isShiftHeld)}
|
||||
onClick={() => handleSelectVersion(object, version)}
|
||||
onRestore={() => handleVersionRestore(object, version)}
|
||||
onDelete={() => handleVersionDelete(object, version)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Confirm delete for top-level object */}
|
||||
<ConfirmationModal
|
||||
variant="destructive"
|
||||
visible={fileToDelete !== undefined}
|
||||
@@ -347,6 +465,35 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
|
||||
will be permanently deleted and can no longer be restored. This action cannot be undone.
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
|
||||
{/* Confirm delete for individual version */}
|
||||
<ConfirmationModal
|
||||
variant="destructive"
|
||||
visible={versionToDelete !== undefined}
|
||||
title="Permanently delete version"
|
||||
confirmLabel="Delete permanently"
|
||||
confirmLabelLoading="Deleting..."
|
||||
loading={isDeletingVersion}
|
||||
onCancel={() => setVersionToDelete(undefined)}
|
||||
onConfirm={() => {
|
||||
if (!ref || !versionToDelete) return
|
||||
deleteVersionPermanently({
|
||||
projectRef: ref,
|
||||
bucketId,
|
||||
objectId: versionToDelete.parentObject.id,
|
||||
versionId: versionToDelete.version.versionId,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Version{' '}
|
||||
<span className="font-mono text-foreground">
|
||||
{versionToDelete?.version.versionId.slice(0, 8)}
|
||||
</span>{' '}
|
||||
of {versionToDelete?.parentObject.name} will be permanently deleted. This action cannot be
|
||||
undone.
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -354,7 +501,12 @@ export const DeletedFilesList = ({ bucketId, searchString }: DeletedFilesListPro
|
||||
interface NoncurrentVersionRowProps {
|
||||
version: DeletedObjectVersion
|
||||
parentObject: TrashObject
|
||||
isChecked: boolean
|
||||
isPreviewed: boolean
|
||||
canUpdateFiles: boolean
|
||||
isRestoring: boolean
|
||||
onToggleSelect: (isShiftHeld: boolean) => void
|
||||
onClick: () => void
|
||||
onRestore: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
@@ -362,17 +514,37 @@ interface NoncurrentVersionRowProps {
|
||||
const NoncurrentVersionRow = ({
|
||||
version,
|
||||
canUpdateFiles,
|
||||
isChecked,
|
||||
isPreviewed,
|
||||
isRestoring,
|
||||
onToggleSelect,
|
||||
onClick,
|
||||
onRestore,
|
||||
onDelete,
|
||||
}: NoncurrentVersionRowProps) => {
|
||||
const shortId = `${version.versionId.slice(0, 8)}`
|
||||
|
||||
return (
|
||||
<TableRow className="bg-surface-100/50 group">
|
||||
<TableCell />
|
||||
<TableRow
|
||||
className={cn(
|
||||
'bg-surface-100/50 group cursor-pointer',
|
||||
isPreviewed && 'bg-selection hover:bg-selection'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<TableCell className="px-4 py-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
className={cn(
|
||||
isChecked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus:opacity-100'
|
||||
)}
|
||||
onClick={(event) => onToggleSelect(event.nativeEvent.shiftKey)}
|
||||
aria-label={`Select version ${shortId}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-1.5" colSpan={2}>
|
||||
<div className="flex items-center gap-1.5 pl-6">
|
||||
<CornerDownRight size={12} className="text-foreground-muted shrink-0" />
|
||||
<div className="flex items-center gap-x-2">
|
||||
<CornerDownRight size={14} className="text-foreground-muted shrink-0" />
|
||||
<span className="text-foreground-lighter font-mono text-xs">{shortId}</span>
|
||||
<span className="text-foreground-muted text-xs">({version.action})</span>
|
||||
</div>
|
||||
@@ -383,13 +555,13 @@ const NoncurrentVersionRow = ({
|
||||
<TableCell className="px-4 py-1.5 text-right text-foreground-lighter tabular-nums text-xs">
|
||||
{formatBytes(version.size)}
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
<TableCell className="px-4 py-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-x-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<ButtonTooltip
|
||||
variant="default"
|
||||
size="tiny"
|
||||
icon={<RotateCcw size={12} />}
|
||||
loading={isRestoring}
|
||||
disabled={!canUpdateFiles}
|
||||
onClick={onRestore}
|
||||
tooltip={{
|
||||
|
||||
@@ -15,8 +15,13 @@ import {
|
||||
useBucketTrashDeleteMutation,
|
||||
useBucketTrashQuery,
|
||||
useBucketTrashRestoreMutation,
|
||||
useTrashVersionDeleteMutation,
|
||||
useTrashVersionRestoreMutation,
|
||||
} from '@/data/storage/protection/bucket-trash-query'
|
||||
import { type TrashObject } from '@/data/storage/protection/protection-mocks'
|
||||
import {
|
||||
type DeletedObjectVersion,
|
||||
type TrashObject,
|
||||
} from '@/data/storage/protection/protection-mocks'
|
||||
|
||||
import { toggleSelectAll, toggleSelection } from './Trash.utils'
|
||||
import { TrashList } from './TrashList'
|
||||
@@ -32,6 +37,10 @@ export const Trash = ({ bucketId }: TrashProps) => {
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [lastToggledId, setLastToggledId] = useState<string | null>(null)
|
||||
const [objectToDelete, setObjectToDelete] = useState<TrashObject>()
|
||||
const [versionToDelete, setVersionToDelete] = useState<{
|
||||
parent: TrashObject
|
||||
version: DeletedObjectVersion
|
||||
}>()
|
||||
const [showBulkDelete, setShowBulkDelete] = useState(false)
|
||||
const [showDeleteAll, setShowDeleteAll] = useState(false)
|
||||
|
||||
@@ -61,6 +70,20 @@ export const Trash = ({ bucketId }: TrashProps) => {
|
||||
},
|
||||
})
|
||||
|
||||
const { mutate: restoreVersion } = useTrashVersionRestoreMutation({
|
||||
onSuccess: (_data, variables) => {
|
||||
toast.success(`Version ${variables.versionId.slice(0, 8)} restored`)
|
||||
},
|
||||
})
|
||||
|
||||
const { mutate: deleteVersionPermanently, isPending: isDeletingVersion } =
|
||||
useTrashVersionDeleteMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Version permanently deleted')
|
||||
setVersionToDelete(undefined)
|
||||
},
|
||||
})
|
||||
|
||||
const orderedIds = (objects ?? []).map((object) => object.id)
|
||||
|
||||
const handleToggleSelect = (id: string, isShiftHeld: boolean) => {
|
||||
@@ -118,6 +141,18 @@ export const Trash = ({ bucketId }: TrashProps) => {
|
||||
onToggleSelectAll={() => setSelectedIds(toggleSelectAll(selectedIds, orderedIds))}
|
||||
onRestore={(object) => handleRestore([object.id])}
|
||||
onDeleteForever={setObjectToDelete}
|
||||
onRestoreVersion={(parent, version) => {
|
||||
if (!ref) return
|
||||
restoreVersion({
|
||||
projectRef: ref,
|
||||
bucketId,
|
||||
objectId: parent.id,
|
||||
versionId: version.versionId,
|
||||
})
|
||||
}}
|
||||
onDeleteVersionForever={(parent, version) =>
|
||||
setVersionToDelete({ parent, version })
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
@@ -167,6 +202,34 @@ export const Trash = ({ bucketId }: TrashProps) => {
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
|
||||
<ConfirmationModal
|
||||
variant="destructive"
|
||||
visible={versionToDelete !== undefined}
|
||||
title="Permanently delete version"
|
||||
confirmLabel="Delete permanently"
|
||||
confirmLabelLoading="Deleting..."
|
||||
loading={isDeletingVersion}
|
||||
onCancel={() => setVersionToDelete(undefined)}
|
||||
onConfirm={() => {
|
||||
if (!ref || !versionToDelete) return
|
||||
deleteVersionPermanently({
|
||||
projectRef: ref,
|
||||
bucketId,
|
||||
objectId: versionToDelete.parent.id,
|
||||
versionId: versionToDelete.version.versionId,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Version{' '}
|
||||
<span className="font-mono text-foreground">
|
||||
{versionToDelete?.version.versionId.slice(0, 8)}
|
||||
</span>{' '}
|
||||
of {versionToDelete?.parent.name} will be permanently deleted. This action cannot be
|
||||
undone.
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
|
||||
<TextConfirmModal
|
||||
variant="destructive"
|
||||
visible={showDeleteAll}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { ChevronDown, ChevronRight, CornerDownRight, RotateCcw, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
CornerDownRight,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -32,6 +39,8 @@ interface TrashListProps {
|
||||
onToggleSelectAll: () => void
|
||||
onRestore: (object: TrashObject) => void
|
||||
onDeleteForever: (object: TrashObject) => void
|
||||
onRestoreVersion?: (parent: TrashObject, version: DeletedObjectVersion) => void
|
||||
onDeleteVersionForever?: (parent: TrashObject, version: DeletedObjectVersion) => void
|
||||
}
|
||||
|
||||
export const TrashList = ({
|
||||
@@ -42,6 +51,8 @@ export const TrashList = ({
|
||||
onToggleSelectAll,
|
||||
onRestore,
|
||||
onDeleteForever,
|
||||
onRestoreVersion,
|
||||
onDeleteVersionForever,
|
||||
}: TrashListProps) => {
|
||||
const isAllSelected = objects.length > 0 && objects.every((o) => selectedIds.includes(o.id))
|
||||
const isSomeSelected = selectedIds.length > 0 && !isAllSelected
|
||||
@@ -60,6 +71,14 @@ export const TrashList = ({
|
||||
})
|
||||
}
|
||||
|
||||
const expandableIds = objects
|
||||
.filter((o) => o.noncurrentVersions && o.noncurrentVersions.length > 0)
|
||||
.map((o) => o.id)
|
||||
const isAllExpanded = expandableIds.length > 0 && expandableIds.every((id) => expandedIds.has(id))
|
||||
|
||||
const expandAll = () => setExpandedIds(new Set(expandableIds))
|
||||
const collapseAll = () => setExpandedIds(new Set())
|
||||
|
||||
const totalVersionCount = (object: TrashObject) => {
|
||||
return 1 + (object.noncurrentVersions?.length ?? 0)
|
||||
}
|
||||
@@ -80,8 +99,25 @@ export const TrashList = ({
|
||||
<TableHead>Original location</TableHead>
|
||||
<TableHead>Deleted</TableHead>
|
||||
<TableHead className="text-right">Size</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead />
|
||||
<TableHead className="text-right">
|
||||
{expandableIds.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-x-1">
|
||||
<ButtonTooltip
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className="px-1"
|
||||
icon={isAllExpanded ? <ChevronsDownUp size={14} /> : <ChevronsUpDown size={14} />}
|
||||
onClick={isAllExpanded ? collapseAll : expandAll}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: isAllExpanded ? 'Collapse all versions' : 'Expand all versions',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -139,13 +175,6 @@ export const TrashList = ({
|
||||
<TableCell className="text-right text-foreground-light tabular-nums">
|
||||
{formatBytes(object.size)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{object.expiresAt ? (
|
||||
<span className="text-warning-600">{dayjs(object.expiresAt).fromNow()}</span>
|
||||
) : (
|
||||
<span className="text-foreground-lighter">Never</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<Button
|
||||
@@ -168,12 +197,8 @@ export const TrashList = ({
|
||||
<NoncurrentVersionRow
|
||||
key={version.versionId}
|
||||
version={version}
|
||||
onRestore={() => {
|
||||
toast.success(`Restoring version ${version.versionId.slice(0, 8)} to current`)
|
||||
}}
|
||||
onDelete={() => {
|
||||
toast.success(`Permanently deleted version ${version.versionId.slice(0, 8)}`)
|
||||
}}
|
||||
onRestore={() => onRestoreVersion?.(object, version)}
|
||||
onDelete={() => onDeleteVersionForever?.(object, version)}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
@@ -197,8 +222,8 @@ const NoncurrentVersionRow = ({ version, onRestore, onDelete }: NoncurrentVersio
|
||||
<TableRow className="bg-surface-100/50 group">
|
||||
<TableCell />
|
||||
<TableCell colSpan={2}>
|
||||
<div className="flex items-center gap-1.5 pl-6">
|
||||
<CornerDownRight size={12} className="text-foreground-muted shrink-0" />
|
||||
<div className="flex items-center gap-x-2">
|
||||
<CornerDownRight size={14} className="text-foreground-muted shrink-0" />
|
||||
<span className="text-foreground-lighter font-mono text-xs">{shortId}</span>
|
||||
<span className="text-foreground-muted text-xs">({version.action})</span>
|
||||
</div>
|
||||
@@ -209,7 +234,6 @@ const NoncurrentVersionRow = ({ version, onRestore, onDelete }: NoncurrentVersio
|
||||
<TableCell className="text-right text-foreground-lighter tabular-nums text-xs">
|
||||
{formatBytes(version.size)}
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-x-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<ButtonTooltip
|
||||
|
||||
@@ -3,9 +3,11 @@ import { toast } from 'sonner'
|
||||
|
||||
import { storageKeys } from '../keys'
|
||||
import {
|
||||
deleteNoncurrentVersionPermanently,
|
||||
deleteMockTrashObjectsPermanently,
|
||||
getMockTrashObjects,
|
||||
mockDelay,
|
||||
restoreNoncurrentVersion,
|
||||
restoreMockTrashObjects,
|
||||
type TrashObject,
|
||||
} from './protection-mocks'
|
||||
@@ -83,3 +85,60 @@ export const useBucketTrashDeleteMutation = ({
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
type TrashVersionVariables = {
|
||||
projectRef: string
|
||||
bucketId: string
|
||||
objectId: string
|
||||
versionId: string
|
||||
}
|
||||
|
||||
export const useTrashVersionRestoreMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: UseMutationOptions<void, Error, TrashVersionVariables> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation<void, Error, TrashVersionVariables>({
|
||||
mutationFn: async (variables) => {
|
||||
restoreNoncurrentVersion(variables.objectId, variables.versionId)
|
||||
await mockDelay(undefined, 400)
|
||||
},
|
||||
async onSuccess(data, variables, context) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: storageKeys.trash(variables.projectRef, variables.bucketId),
|
||||
})
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
onError(error, variables, context) {
|
||||
if (onError === undefined) toast.error(`Failed to restore version: ${error.message}`)
|
||||
else onError(error, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export const useTrashVersionDeleteMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: UseMutationOptions<void, Error, TrashVersionVariables> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation<void, Error, TrashVersionVariables>({
|
||||
mutationFn: async (variables) => {
|
||||
deleteNoncurrentVersionPermanently(variables.objectId, variables.versionId)
|
||||
await mockDelay(undefined, 400)
|
||||
},
|
||||
async onSuccess(data, variables, context) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: storageKeys.trash(variables.projectRef, variables.bucketId),
|
||||
})
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
onError(error, variables, context) {
|
||||
if (onError === undefined) toast.error(`Failed to delete version: ${error.message}`)
|
||||
else onError(error, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -390,6 +390,30 @@ export const deleteMockTrashObjectsPermanently = (objectIds?: string[]): TrashOb
|
||||
return [...trashObjects]
|
||||
}
|
||||
|
||||
/** Permanently deletes a single noncurrent version from a trash object. */
|
||||
export const deleteNoncurrentVersionPermanently = (
|
||||
objectId: string,
|
||||
versionId: string
|
||||
): TrashObject[] => {
|
||||
trashObjects = trashObjects.map((obj) => {
|
||||
if (obj.id !== objectId) return obj
|
||||
return {
|
||||
...obj,
|
||||
noncurrentVersions: (obj.noncurrentVersions ?? []).filter((v) => v.versionId !== versionId),
|
||||
}
|
||||
})
|
||||
return [...trashObjects]
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a noncurrent version from trash. In a real implementation this
|
||||
* would promote it to the current version of the live object. For the
|
||||
* prototype we simply remove it from the trash entry.
|
||||
*/
|
||||
export const restoreNoncurrentVersion = (objectId: string, versionId: string): TrashObject[] => {
|
||||
return deleteNoncurrentVersionPermanently(objectId, versionId)
|
||||
}
|
||||
|
||||
export interface BucketRetentionSummary {
|
||||
bucket: string
|
||||
live: number
|
||||
|
||||
Reference in new issue
Block a user