fix: show all object versions in dialog (#154)

This commit is contained in:
cxymds
2026-07-09 12:06:41 +08:00
committed by GitHub
parent 65b68de197
commit 25944866d5
3 changed files with 82 additions and 16 deletions
+25 -4
View File
@@ -6,6 +6,7 @@ import { RiFileCopyLine, RiEyeLine, RiDownloadCloud2Line, RiDeleteBin5Line, RiLo
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Spinner } from "@/components/ui/spinner"
import { DataTable } from "@/components/data-table/data-table"
import { useDataTable } from "@/hooks/use-data-table"
import { useObject } from "@/hooks/use-object"
@@ -55,6 +56,7 @@ export function ObjectVersions({
const [versions, setVersions] = React.useState<VersionRow[]>([])
const [loading, setLoading] = React.useState(false)
const [deletingVersionId, setDeletingVersionId] = React.useState<string | null>(null)
const fetchVersions = React.useCallback(async () => {
if (!objectKey) return
@@ -123,13 +125,16 @@ export function ObjectVersions({
async (row: VersionRow) => {
const versionId = row.VersionId
if (!versionId) return
setDeletingVersionId(versionId)
try {
await deleteObject(objectKey, versionId)
message.success(t("Delete Success"))
void fetchVersions()
await fetchVersions()
onRefreshParent()
} catch (err) {
message.error((err as Error)?.message ?? t("Delete Failed"))
} finally {
setDeletingVersionId(null)
}
},
[deleteObject, objectKey, message, t, fetchVersions, onRefreshParent],
@@ -251,10 +256,14 @@ export function ObjectVersions({
<Button
variant="destructive"
size="sm"
className="text-white"
disabled={deletingVersionId === row.original.VersionId}
onClick={() => deleteVersion(row.original)}
>
<RiDeleteBin5Line className="size-4" aria-hidden />
{deletingVersionId === row.original.VersionId ? (
<Spinner className="size-4" aria-hidden />
) : (
<RiDeleteBin5Line className="size-4" aria-hidden />
)}
{t("Delete")}
</Button>
) : null}
@@ -264,12 +273,24 @@ export function ObjectVersions({
meta: { minWidth: 360 },
},
],
[t, onPreview, copyVersionId, restoreVersion, downloadVersion, deleteVersion, canCapability, bucketName, objectKey],
[
t,
onPreview,
copyVersionId,
restoreVersion,
downloadVersion,
deleteVersion,
canCapability,
bucketName,
objectKey,
deletingVersionId,
],
)
const { table } = useDataTable<VersionRow>({
data: versions,
columns,
disablePagination: true,
})
return (
+31 -12
View File
@@ -4,13 +4,16 @@ import { useCallback } from "react"
import {
CopyObjectCommand,
DeleteObjectCommand,
type DeleteMarkerEntry,
GetObjectLegalHoldCommand,
GetObjectRetentionCommand,
GetObjectTaggingCommand,
GetObjectCommand,
HeadObjectCommand,
ListObjectVersionsCommand,
type ListObjectVersionsCommandOutput,
ListObjectsV2Command,
type ObjectVersion,
ObjectLockRetentionMode,
PutObjectCommand,
PutObjectLegalHoldCommand,
@@ -291,20 +294,36 @@ export function useObject(bucket: string) {
const listObjectVersions = useCallback(
async (key: string) => {
const res = await client.send(
new ListObjectVersionsCommand({
Bucket: bucket,
Prefix: key,
Delimiter: "/",
EncodingType: "url",
}),
)
decodeS3UrlEncodedObjectVersions(res)
const Versions = (res.Versions ?? []).filter((v) => v.Key === key)
const DeleteMarkers = (res.DeleteMarkers ?? []).filter((m) => m.Key === key)
const Versions: ObjectVersion[] = []
const DeleteMarkers: DeleteMarkerEntry[] = []
let keyMarker: string | undefined
let versionIdMarker: string | undefined
let isTruncated = true
let lastResponse: Partial<ListObjectVersionsCommandOutput> = {}
while (isTruncated) {
const res = await client.send(
new ListObjectVersionsCommand({
Bucket: bucket,
Prefix: key,
Delimiter: "/",
EncodingType: "url",
KeyMarker: keyMarker,
VersionIdMarker: versionIdMarker,
}),
)
decodeS3UrlEncodedObjectVersions(res)
lastResponse = res
Versions.push(...(res.Versions ?? []).filter((v) => v.Key === key))
DeleteMarkers.push(...(res.DeleteMarkers ?? []).filter((m) => m.Key === key))
isTruncated = res.IsTruncated ?? false
keyMarker = res.NextKeyMarker
versionIdMarker = res.NextVersionIdMarker
}
Versions.sort((a, b) => new Date(b.LastModified!).getTime() - new Date(a.LastModified!).getTime())
DeleteMarkers.sort((a, b) => new Date(b.LastModified!).getTime() - new Date(a.LastModified!).getTime())
return { ...res, Versions, DeleteMarkers }
return { ...lastResponse, Versions, DeleteMarkers, IsTruncated: false }
},
[client, bucket],
)
+26
View File
@@ -26,3 +26,29 @@ test("object versions dialog is wide, sortable by date and size, and shows summa
assert.match(source, /accessorFn: \(row\) => \(row\.LastModified \? new Date\(row\.LastModified\)\.getTime\(\) : 0\)/)
assert.match(source, /accessorFn: \(row\) => row\.Size \?\? 0/)
})
test("object versions dialog does not hide versions behind unreachable default pagination", () => {
const source = fs.readFileSync("components/object/versions.tsx", "utf8")
assert.match(source, /disablePagination: true/)
})
test("object versions delete button reflects the active delete request", () => {
const source = fs.readFileSync("components/object/versions.tsx", "utf8")
assert.match(source, /const \[deletingVersionId, setDeletingVersionId\] = React\.useState<string \| null>\(null\)/)
assert.match(source, /disabled=\{deletingVersionId === row\.original\.VersionId\}/)
assert.doesNotMatch(source, /variant="destructive"\s+size="sm"\s+className="text-white"/)
})
test("object version listing follows S3 version pagination markers", () => {
const source = fs.readFileSync("hooks/use-object.ts", "utf8")
assert.match(source, /let keyMarker: string \| undefined/)
assert.match(source, /let versionIdMarker: string \| undefined/)
assert.match(source, /KeyMarker: keyMarker/)
assert.match(source, /VersionIdMarker: versionIdMarker/)
assert.match(source, /keyMarker = res\.NextKeyMarker/)
assert.match(source, /versionIdMarker = res\.NextVersionIdMarker/)
assert.match(source, /while \(isTruncated\)/)
})