From 977907518b6f7237b9755bd7a0ef805aa2a151da Mon Sep 17 00:00:00 2001 From: Trezy Date: Tue, 24 Feb 2026 13:19:41 -0600 Subject: [PATCH] feat: add bulk collection record deletion to the dashboard --- web/package-lock.json | 28 +- web/src/app/(dashboard)/records/page.tsx | 341 +++++++++++++++++++++-- web/src/components/ui/radio-group.tsx | 45 +++ web/src/lib/api.ts | 11 + 4 files changed, 396 insertions(+), 29 deletions(-) create mode 100644 web/src/components/ui/radio-group.tsx diff --git a/web/package-lock.json b/web/package-lock.json index 39895ed..72c0774 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -111,6 +111,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -738,6 +739,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2008,6 +2010,7 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -4280,6 +4283,7 @@ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4290,6 +4294,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4300,6 +4305,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4316,8 +4322,7 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@types/unist": { "version": "3.0.3", @@ -4383,6 +4388,7 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -4909,6 +4915,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5295,6 +5302,7 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -5389,6 +5397,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6352,7 +6361,6 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -6691,6 +6699,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6831,6 +6840,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7299,6 +7309,7 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -8091,6 +8102,7 @@ "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -9510,7 +9522,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -11407,6 +11418,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11437,6 +11449,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11456,6 +11469,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -11594,7 +11608,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -12808,6 +12823,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13075,6 +13091,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13806,6 +13823,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/src/app/(dashboard)/records/page.tsx b/web/src/app/(dashboard)/records/page.tsx index 731b9ce..ae80d13 100644 --- a/web/src/app/(dashboard)/records/page.tsx +++ b/web/src/app/(dashboard)/records/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { type ColumnDef, + type RowSelectionState, type VisibilityState, getCoreRowModel, useReactTable, @@ -13,6 +14,7 @@ import { getStats, getAdminRecords, deleteRecord, + deleteCollectionRecords, type CollectionStat, type AdminRecord, } from "@/lib/api"; @@ -26,9 +28,16 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { Checkbox } from "@/components/ui/checkbox"; import { CodeBlock } from "@/components/code-block"; import { DataTable } from "@/components/data-table/data-table"; import { DataTableViewOptions } from "@/components/data-table/data-table-view-options"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { SiteHeader } from "@/components/site-header"; import { Button } from "@/components/ui/button"; import { @@ -44,7 +53,16 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { ChevronLeft, ChevronRight, Trash2 } from "lucide-react"; +import { + Field, + FieldContent, + FieldDescription, + FieldLabel, + FieldTitle, +} from "@/components/ui/field"; function parseAtUri(uri: string): { did: string; rkey: string } { const parts = uri.replace("at://", "").split("/"); @@ -71,8 +89,15 @@ export default function RecordsPage() { const [viewRecord, setViewRecord] = useState(null); const [deleting, setDeleting] = useState(false); const [deleteUri, setDeleteUri] = useState(null); + const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); + const [bulkDeleteMode, setBulkDeleteMode] = useState<"selected" | "all">( + "selected", + ); + const [bulkDeleteConfirm, setBulkDeleteConfirm] = useState(""); + const [deletingAll, setDeletingAll] = useState(false); const [columnVisibility, setColumnVisibility] = useState({}); + const [rowSelection, setRowSelection] = useState({}); useEffect(() => { getStats(getToken) @@ -122,6 +147,52 @@ export default function RecordsPage() { [getToken, selectedCollection, cursorStack, fetchRecords], ); + const handleDeleteAll = useCallback(async () => { + if (!selectedCollection) return; + setDeletingAll(true); + try { + await deleteCollectionRecords(getToken, selectedCollection); + setBulkDeleteOpen(false); + setBulkDeleteMode("selected"); + setBulkDeleteConfirm(""); + setRowSelection({}); + // Refresh stats and records + const stats = await getStats(getToken); + setCollections(stats.collections); + setCursorStack([]); + setNextCursor(undefined); + setRecords([]); + setSelectedCollection(""); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setDeletingAll(false); + } + }, [getToken, selectedCollection]); + + const handleBulkDelete = useCallback(async () => { + setDeleting(true); + try { + const selectedUris = Object.keys(rowSelection); + for (const uri of selectedUris) { + await deleteRecord(getToken, uri); + } + setRowSelection({}); + setBulkDeleteOpen(false); + if (selectedCollection) { + const currentCursor = + cursorStack.length > 0 + ? cursorStack[cursorStack.length - 1] + : undefined; + fetchRecords(selectedCollection, currentCursor); + } + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setDeleting(false); + } + }, [getToken, rowSelection, selectedCollection, cursorStack, fetchRecords]); + // Build columns dynamically from the union of all record keys const columns = useMemo[]>(() => { const keySet = new Set(); @@ -132,6 +203,31 @@ export default function RecordsPage() { } const cols: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + + table.toggleAllPageRowsSelected(!!value) + } + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + onClick={(e) => e.stopPropagation()} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + }, { id: "did", accessorFn: (row) => parseAtUri(row.uri).did, @@ -182,38 +278,20 @@ export default function RecordsPage() { }); } - cols.push({ - id: "actions", - header: "", - enableSorting: false, - enableHiding: false, - cell: ({ row }) => ( - - ), - }); - return cols; - }, [records, deleting]); + }, [records]); const table = useReactTable({ data: records, columns, state: { columnVisibility, - columnPinning: { right: ["actions"] }, + columnPinning: { left: ["select"] }, + rowSelection, }, onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + enableRowSelection: true, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.uri, }); @@ -223,6 +301,7 @@ export default function RecordsPage() { setCursorStack([]); setNextCursor(undefined); setColumnVisibility({}); + setRowSelection({}); fetchRecords(collection); } @@ -268,7 +347,30 @@ export default function RecordsPage() { ))} - +
+ + + + + + setBulkDeleteOpen(true)} + > + + Delete + + + + +
@@ -357,6 +459,197 @@ export default function RecordsPage() { + + { + if (!open) { + setBulkDeleteOpen(false); + setBulkDeleteMode("selected"); + setBulkDeleteConfirm(""); + } + }} + > + + {(() => { + const selectedCount = Object.keys(rowSelection).length; + const totalCount = + collections.find((c) => c.collection === selectedCollection) + ?.count ?? 0; + const allInCollection = + table.getIsAllPageRowsSelected() && + selectedCount >= totalCount; + + if (allInCollection) { + return ( + <> + + + Delete all records in collection? + + + This will permanently delete all {totalCount} record(s) + in{" "} + + {selectedCollection} + + . This action cannot be undone. + + +
+ + setBulkDeleteConfirm(e.target.value)} + placeholder={selectedCollection} + /> +
+ + + Cancel + + + {deletingAll ? "Deleting..." : "Delete"} + + + + ); + } + + if (!table.getIsAllPageRowsSelected()) { + return ( + <> + + + Delete {selectedCount} record(s)? + + + This action cannot be undone. + + + + + Cancel + + + {deleting ? "Deleting..." : "Delete"} + + + + ); + } + + return ( + <> + + Delete records? + + This action cannot be undone. + + + { + setBulkDeleteMode(v as "selected" | "all"); + setBulkDeleteConfirm(""); + }} + > + + + + + Delete selected only + + {`${selectedCount} items selected`} + + + + + + + + + + Delete all records + + + {selectedCollection} + + + + + + + {bulkDeleteMode === "all" && ( +
+ + setBulkDeleteConfirm(e.target.value)} + placeholder={selectedCollection} + /> +
+ )} + + + Cancel + + + {deleting || deletingAll ? "Deleting..." : "Delete"} + + + + ); + })()} +
+
); diff --git a/web/src/components/ui/radio-group.tsx b/web/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..ddae3d4 --- /dev/null +++ b/web/src/components/ui/radio-group.tsx @@ -0,0 +1,45 @@ +"use client" + +import * as React from "react" +import { CircleIcon } from "lucide-react" +import { RadioGroup as RadioGroupPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ff67bb7..e6ef77b 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -290,3 +290,14 @@ export function deleteRecord( { method: "DELETE" } ) } + +export function deleteCollectionRecords( + getToken: () => Promise, + collection: string, +) { + return apiFetch<{ deleted: number }>( + `/admin/records/collection?${new URLSearchParams({ collection })}`, + getToken, + { method: "DELETE" }, + ) +} -- 2.51.2