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:
Claude committed 2026-08-07 12:55:51 +00:00
1 parent 7c2b5bbce3
commit 4b3960cbc4
7 files changed
+614 -102

No files matched your search

@@ -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