From 357ad20bf5b1af941dd21b5e07c9b5c4c4ce8944 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:55:02 +0200 Subject: [PATCH] feat(admin): bulk publish/draft/trash in the content list --- .changeset/bulk-content-actions.md | 5 + packages/admin/src/components/ContentList.tsx | 168 +++++++++++++++++- packages/admin/src/router.tsx | 77 ++++++++ 3 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 .changeset/bulk-content-actions.md diff --git a/.changeset/bulk-content-actions.md b/.changeset/bulk-content-actions.md new file mode 100644 index 000000000..7bdef0732 --- /dev/null +++ b/.changeset/bulk-content-actions.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": minor +--- + +Adds bulk actions to the content list. Select multiple entries with checkboxes (or the header "select all" box) and publish, set to draft, or move them to trash in one step. diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index 132f02328..17ab2c354 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -1,4 +1,14 @@ -import { Badge, Button, Dialog, Input, LinkButton, Loader, Select, Tabs } from "@cloudflare/kumo"; +import { + Badge, + Button, + Checkbox, + Dialog, + Input, + LinkButton, + Loader, + Select, + Tabs, +} from "@cloudflare/kumo"; import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { @@ -111,6 +121,14 @@ export interface ContentListProps { /** Controlled date-range filter state. */ dateFilter?: ContentDateFilter; onDateFilterChange?: (filter: ContentDateFilter) => void; + /** + * Bulk actions. Each is opt-in: the selection checkboxes only appear when at + * least one bulk handler is provided, and each toolbar button renders only + * when its handler is present. Handlers receive the selected entry ids. + */ + onBulkPublish?: (ids: string[]) => void; + onBulkUnpublish?: (ids: string[]) => void; + onBulkDelete?: (ids: string[]) => void; } type ViewTab = "all" | "trash"; @@ -162,11 +180,19 @@ export function ContentList({ onAuthorFilterChange, dateFilter = EMPTY_DATE_FILTER, onDateFilterChange, + onBulkPublish, + onBulkUnpublish, + onBulkDelete, }: ContentListProps) { const { t } = useLingui(); const [activeTab, setActiveTab] = React.useState("all"); const [searchQuery, setSearchQuery] = React.useState(""); const [page, setPage] = React.useState(0); + const [selectedIds, setSelectedIds] = React.useState>(() => new Set()); + + // Bulk selection is opt-in: the checkbox column + toolbar only render when + // the parent wired at least one bulk handler. + const bulkEnabled = !!(onBulkPublish || onBulkUnpublish || onBulkDelete); // Server-side search mode: the caller refetches based on the (debounced) // query, so `items`/`total` already reflect the filter and we must not @@ -231,6 +257,47 @@ export function ContentList({ } }, [clampedPage, filteredItems.length, hasMore, onLoadMore, searchQuery, serverSearch]); + // Drop selections for rows that left the current result set (filter/locale + // change, deletion) so a bulk action never targets a now-hidden id. + React.useEffect(() => { + setSelectedIds((prev) => { + if (prev.size === 0) return prev; + const present = new Set(items.map((i) => i.id)); + let changed = false; + const next = new Set(); + for (const id of prev) { + if (present.has(id)) next.add(id); + else changed = true; + } + return changed ? next : prev; + }); + }, [items]); + + const clearSelection = React.useCallback(() => setSelectedIds(new Set()), []); + const toggleOne = (id: string) => + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + const pageIds = paginatedItems.map((i) => i.id); + const allPageSelected = pageIds.length > 0 && pageIds.every((id) => selectedIds.has(id)); + const togglePage = () => + setSelectedIds((prev) => { + const next = new Set(prev); + if (allPageSelected) for (const id of pageIds) next.delete(id); + else for (const id of pageIds) next.add(id); + return next; + }); + const selectedCount = selectedIds.size; + const runBulk = (fn?: (ids: string[]) => void) => { + if (!fn || selectedCount === 0) return; + fn([...selectedIds]); + clearSelection(); + }; + const colSpan = (i18n ? 5 : 4) + (bulkEnabled ? 1 : 0); + return (
{/* Header */} @@ -310,11 +377,84 @@ export function ContentList({ /> )} + {/* Bulk action toolbar — appears once one or more rows are selected */} + {bulkEnabled && selectedCount > 0 && ( +
+ + {plural(selectedCount, { one: "# selected", other: "# selected" })} + +
+ {onBulkPublish && ( + + )} + {onBulkUnpublish && ( + + )} + {onBulkDelete && ( + + ( + + )} + /> + + {t`Move to Trash?`} + + {plural(selectedCount, { + one: "Move # item to trash? You can restore it later.", + other: "Move # items to trash? You can restore them later.", + })} + +
+ ( + + )} + /> + ( + + )} + /> +
+
+
+ )} + +
+
+ )} + {/* Table */}
+ {bulkEnabled && ( + + )} {isLoading && items.length === 0 ? ( - ) : items.length === 0 ? ( - ) : paginatedItems.length === 0 ? ( - @@ -392,6 +532,9 @@ export function ContentList({ onDuplicate={onDuplicate} showLocale={!!i18n} urlPattern={urlPattern} + selectable={bulkEnabled} + selected={selectedIds.has(item.id)} + onToggleSelect={toggleOne} /> )) )} @@ -757,6 +900,9 @@ interface ContentListItemProps { onDuplicate?: (id: string) => void; showLocale?: boolean; urlPattern?: string; + selectable?: boolean; + selected?: boolean; + onToggleSelect?: (id: string) => void; } function ContentListItem({ @@ -766,13 +912,25 @@ function ContentListItem({ onDuplicate, showLocale, urlPattern, + selectable, + selected, + onToggleSelect, }: ContentListItemProps) { const { t } = useLingui(); const title = getItemTitle(item); const date = new Date(item.updatedAt || item.createdAt); return ( - + + {selectable && ( + + )}
+ +
+ {t`Loading...`} @@ -358,7 +498,7 @@ export function ContentList({
+ {activeSearch ? ( t`No results for "${activeSearch}"` ) : ( @@ -378,7 +518,7 @@ export function ContentList({
+ {t`No results for "${activeSearch}"`}
+ onToggleSelect?.(item.id)} + aria-label={t`Select ${title}`} + /> + { + const results = await Promise.allSettled( + ids.map((id) => publishContent(collection, id, { locale: activeLocale })), + ); + const failed = results.filter((r) => r.status === "rejected").length; + if (failed > 0) throw new Error(t`${failed} of ${ids.length} could not be published`); + return ids.length; + }, + onSuccess: (count) => { + toastManager.add({ title: t`Published ${count} items`, type: "success" }); + }, + onError: (mutationError) => { + toastManager.add({ + title: t`Failed to publish`, + description: mutationError instanceof Error ? mutationError.message : t`An error occurred`, + type: "error", + }); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: ["content", collection] }); + }, + }); + + const bulkUnpublishMutation = useMutation({ + mutationFn: async (ids: string[]) => { + const results = await Promise.allSettled( + ids.map((id) => unpublishContent(collection, id, { locale: activeLocale })), + ); + const failed = results.filter((r) => r.status === "rejected").length; + if (failed > 0) throw new Error(t`${failed} of ${ids.length} could not be updated`); + return ids.length; + }, + onSuccess: (count) => { + toastManager.add({ title: t`Moved ${count} items to draft`, type: "success" }); + }, + onError: (mutationError) => { + toastManager.add({ + title: t`Failed to update`, + description: mutationError instanceof Error ? mutationError.message : t`An error occurred`, + type: "error", + }); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: ["content", collection] }); + }, + }); + + const bulkDeleteMutation = useMutation({ + mutationFn: async (ids: string[]) => { + const results = await Promise.allSettled(ids.map((id) => deleteContent(collection, id))); + const failed = results.filter((r) => r.status === "rejected").length; + if (failed > 0) throw new Error(t`${failed} of ${ids.length} could not be deleted`); + return ids.length; + }, + onSuccess: (count) => { + toastManager.add({ title: t`Moved ${count} items to trash`, type: "success" }); + }, + onError: (mutationError) => { + toastManager.add({ + title: t`Failed to delete`, + description: mutationError instanceof Error ? mutationError.message : t`An error occurred`, + type: "error", + }); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: ["content", collection] }); + void queryClient.invalidateQueries({ queryKey: ["content", collection, "trash"] }); + }, + }); + const items = React.useMemo(() => { return data?.pages.flatMap((page) => page.items) || []; }, [data]); @@ -507,6 +581,9 @@ function ContentListPage() { onAuthorFilterChange={setAuthorFilter} dateFilter={dateFilter} onDateFilterChange={setDateFilter} + onBulkPublish={(ids) => bulkPublishMutation.mutate(ids)} + onBulkUnpublish={(ids) => bulkUnpublishMutation.mutate(ids)} + onBulkDelete={(ids) => bulkDeleteMutation.mutate(ids)} /> ); }