Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bulk-content-actions.md
Original file line number Diff line number Diff line change
@@ -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.
168 changes: 163 additions & 5 deletions packages/admin/src/components/ContentList.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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<ViewTab>("all");
const [searchQuery, setSearchQuery] = React.useState("");
const [page, setPage] = React.useState(0);
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(() => 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
Expand Down Expand Up @@ -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<string>();
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();
};
Comment on lines +294 to +298

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] runBulk calls clearSelection() synchronously, before the fanned-out mutations resolve (fn([...selectedIds]) is fire-and-forget — mutate isn't awaited). On partial failure the user sees "3 of 10 could not be published", but the selection is already wiped, so they can't retry just the failed items — and the toast reports only the count, not which ids failed. Consider clearing only after the batch settles (or reporting the failed ids) so a partial failure is recoverable.

const colSpan = (i18n ? 5 : 4) + (bulkEnabled ? 1 : 0);

return (
<div className="space-y-4">
{/* Header */}
Expand Down Expand Up @@ -310,11 +377,84 @@ export function ContentList({
/>
)}

{/* Bulk action toolbar — appears once one or more rows are selected */}
{bulkEnabled && selectedCount > 0 && (
<div className="flex flex-wrap items-center gap-3 rounded-md border bg-kumo-tint/40 px-4 py-2">
<span className="text-sm font-medium">
{plural(selectedCount, { one: "# selected", other: "# selected" })}
</span>
<div className="flex flex-wrap items-center gap-2">
{onBulkPublish && (
<Button size="sm" variant="secondary" onClick={() => runBulk(onBulkPublish)}>
{t`Publish`}
</Button>
)}
{onBulkUnpublish && (
<Button size="sm" variant="secondary" onClick={() => runBulk(onBulkUnpublish)}>
{t`Set to draft`}
</Button>
)}
{onBulkDelete && (
<Dialog.Root disablePointerDismissal>
<Dialog.Trigger
render={(p) => (
<Button {...p} size="sm" variant="destructive" icon={<Trash />}>
{t`Move to trash`}
</Button>
)}
/>
<Dialog className="p-6" size="sm">
<Dialog.Title className="text-lg font-semibold">{t`Move to Trash?`}</Dialog.Title>
<Dialog.Description className="text-kumo-subtle">
{plural(selectedCount, {
one: "Move # item to trash? You can restore it later.",
other: "Move # items to trash? You can restore them later.",
})}
</Dialog.Description>
<div className="mt-6 flex justify-end gap-2">
<Dialog.Close
render={(p) => (
<Button {...p} variant="secondary">
{t`Cancel`}
</Button>
)}
/>
<Dialog.Close
render={(p) => (
<Button
{...p}
variant="destructive"
onClick={() => runBulk(onBulkDelete)}
>
{t`Move to Trash`}
</Button>
)}
/>
</div>
</Dialog>
</Dialog.Root>
)}
<Button size="sm" variant="ghost" icon={<X />} onClick={clearSelection}>
{t`Clear`}
</Button>
</div>
</div>
)}

{/* Table */}
<div className="rounded-md border bg-kumo-base overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-kumo-tint/50">
{bulkEnabled && (
<th scope="col" className="w-10 px-4 py-3">
<Checkbox
checked={allPageSelected}
onCheckedChange={togglePage}
aria-label={t`Select all on this page`}
/>
</th>
)}
<SortableTh
field="title"
sort={sort}
Expand Down Expand Up @@ -349,7 +489,7 @@ export function ContentList({
<tbody className="divide-y divide-kumo-line">
{isLoading && items.length === 0 ? (
<tr>
<td colSpan={i18n ? 5 : 4} className="px-4 py-8 text-center text-kumo-subtle">
<td colSpan={colSpan} className="px-4 py-8 text-center text-kumo-subtle">
<span className="inline-flex items-center gap-2">
<Loader size="sm" />
{t`Loading...`}
Expand All @@ -358,7 +498,7 @@ export function ContentList({
</tr>
) : items.length === 0 ? (
<tr>
<td colSpan={i18n ? 5 : 4} className="px-4 py-8 text-center text-kumo-subtle">
<td colSpan={colSpan} className="px-4 py-8 text-center text-kumo-subtle">
{activeSearch ? (
t`No results for "${activeSearch}"`
) : (
Expand All @@ -378,7 +518,7 @@ export function ContentList({
</tr>
) : paginatedItems.length === 0 ? (
<tr>
<td colSpan={i18n ? 5 : 4} className="px-4 py-8 text-center text-kumo-subtle">
<td colSpan={colSpan} className="px-4 py-8 text-center text-kumo-subtle">
{t`No results for "${activeSearch}"`}
</td>
</tr>
Expand All @@ -392,6 +532,9 @@ export function ContentList({
onDuplicate={onDuplicate}
showLocale={!!i18n}
urlPattern={urlPattern}
selectable={bulkEnabled}
selected={selectedIds.has(item.id)}
onToggleSelect={toggleOne}
/>
))
)}
Expand Down Expand Up @@ -757,6 +900,9 @@ interface ContentListItemProps {
onDuplicate?: (id: string) => void;
showLocale?: boolean;
urlPattern?: string;
selectable?: boolean;
selected?: boolean;
onToggleSelect?: (id: string) => void;
}

function ContentListItem({
Expand All @@ -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 (
<tr className="hover:bg-kumo-tint/25">
<tr className={cn("hover:bg-kumo-tint/25", selected && "bg-kumo-tint/40")}>
{selectable && (
<td className="px-4 py-3">
<Checkbox
checked={!!selected}
onCheckedChange={() => onToggleSelect?.(item.id)}
aria-label={t`Select ${title}`}
/>
</td>
)}
<td className="px-4 py-3">
<Link
to="/content/$collection/$id"
Expand Down
77 changes: 77 additions & 0 deletions packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,80 @@ function ContentListPage() {
},
});

// Bulk actions run the existing per-entry endpoints in parallel. allSettled
// keeps a single failure from aborting the rest; the count of failures is
// surfaced via the error toast, and the list is refetched either way.
const bulkPublishMutation = useMutation({
mutationFn: async (ids: string[]) => {
const results = await Promise.allSettled(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Each bulk mutation fans out one request per selected id with Promise.allSettled(ids.map(...)) and no concurrency cap or selection-size limit. Selection persists across pagination, so a user can page through and amass a large set, then trigger dozens-to-hundreds of parallel requests with no progress indication (the buttons just vanish via clearSelection while the batch runs). It works and is admin-only, so this is a forward-looking note rather than a blocker — but for large batches consider chunking/concurrency-limiting the fan-out (or a server-side bulk endpoint) and surfacing progress. Applies to all three bulk mutations here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the bot is right here: it would be good if it could use a queue

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]);
Expand Down Expand Up @@ -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)}
/>
);
}
Expand Down
Loading