feat(admin): bulk publish / set to draft / move to trash in the content list#1524
feat(admin): bulk publish / set to draft / move to trash in the content list#1524swissky wants to merge 1 commit into
Conversation
🦋 Changeset detectedLatest commit: 357ad20 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
Approach
This is the right change, solving a real gap, done in a way that fits EmDash. Bulk publish/draft/trash in the content list is a reasonable admin feature, and the PR implements it the idiomatic way: no backend changes, fanning out to the existing per-entry publishContent / unpublishContent / deleteContent endpoints so per-item authorization, locale handling, and cache invalidation are reused rather than reimplemented. Promise.allSettled keeps one failure from aborting the batch. The feature is opt-in at the component level (checkbox column + toolbar only render when a handler is wired), backward-compatible, and links to the required Discussion #1523. The selection model closely mirrors the existing CommentInbox bulk-selection pattern, which is good consistency.
What I checked
- Locale correctness: bulk publish/unpublish pass
locale: activeLocale. The list is always locale-filtered when i18n is on, so every listed item'slocaleequalsactiveLocale; when i18n is off,activeLocaleisundefinedand the locale param is omitted. I traced the locale-switch race (transient state whereactiveLocaleis new butitemsstale) and it self-resolves —useInfiniteQueryhas noplaceholderData, soitemsgoes empty on refetch and the pruning effect clears the selection before any bulk action could fire with a stale locale. Bulk delete omits locale, matching the existing single-itemdeleteMutation; the DELETE route resolves by id, so that's correct. - Partial-failure semantics:
allSettledcounts rejects, throws onfailed > 0, andonSettled(runs on both success and error) invalidates the list, so successful items are reflected even when some fail. - Selection state: the pruning effect depends on the memoized
items, returnsprevwhen nothing changed (no render loop), and correctly drops ids that leave the result set.colSpanmath ((i18n ? 5 : 4) + (bulkEnabled ? 1 : 0)) is correct for all empty/loading states. - Authorization: fans out to endpoints that each check ownership (
requireOwnerPerm), so there's no bypass. - Conventions: all new admin strings are wrapped with
t/plural; nomessages.poincluded (correct for a feature PR); new classes are RTL-safe logical Tailwind (gap-*,justify-end,px-*) with nodark:prefixes or raw colors; KumoCheckbox/Dialog/Buttonreused; changeset present and user-facing (@emdash-cms/admin, minor).
Conclusion
The implementation is clean and I'd sign off. No logic bugs, security gaps, authorization issues, or convention violations. The two findings below are optional design notes on the bulk-execution model (unbounded fan-out, and selection cleared before the batch resolves), neither of which blocks merge.
| // surfaced via the error toast, and the list is refetched either way. | ||
| const bulkPublishMutation = useMutation({ | ||
| mutationFn: async (ids: string[]) => { | ||
| const results = await Promise.allSettled( |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
I think the bot is right here: it would be good if it could use a queue
| const runBulk = (fn?: (ids: string[]) => void) => { | ||
| if (!fn || selectedCount === 0) return; | ||
| fn([...selectedIds]); | ||
| clearSelection(); | ||
| }; |
There was a problem hiding this comment.
[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.
What does this PR do?
Adds bulk actions to the admin content list (Pages, Posts, and every collection). Each row gets a selection checkbox plus a header "select all on this page" box; once one or more rows are selected, a toolbar appears with Publish, Set to draft, and Move to trash (with a confirmation dialog).
It's admin-only with no backend/API changes — the bulk handlers fan out to the existing per-entry endpoints (
publishContent/unpublishContent/deleteContent) viaPromise.allSettled, so a single failure doesn't abort the batch. The feature is fully opt-in and backward-compatible: the checkbox column and toolbar only render when the parent wires the newonBulkPublish/onBulkUnpublish/onBulkDeletehandlers. Selection persists across pagination and is pruned when rows leave the result set.Discussion: #1523
Type of change
Checklist
pnpm typecheckpasses — clean for the changed files. (A shallow clone surfaces 8 pre-existingRegistryPluginDetail.tsximplicit-any errors that also appear onmainwithout this change; not introduced here.)pnpm lintpasses (oxlint --type-aware --deny-warnings, clean on the changed files)pnpm testpasses — the admin suite runs in a Playwright browser-vitest harness that did not start in my local environment; relying on repo CI for the test run.pnpm formathas been runmessages.pochanges included.@emdash-cms/admin, minor)AI-generated code disclosure
Screenshots / test output
Behavioral summary: selecting rows reveals a toolbar (
N selected+ Publish / Set to draft / Move to trash / Clear). Publish/draft act immediately with a success toast; trash asks for confirmation. All actions invalidate the content query so the list reflects the new state, and clear the selection.