Skip to content

feat(admin): bulk publish / set to draft / move to trash in the content list#1524

Open
swissky wants to merge 1 commit into
emdash-cms:mainfrom
swissky:feat/bulk-content-actions
Open

feat(admin): bulk publish / set to draft / move to trash in the content list#1524
swissky wants to merge 1 commit into
emdash-cms:mainfrom
swissky:feat/bulk-content-actions

Conversation

@swissky

@swissky swissky commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

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) via Promise.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 new onBulkPublish / onBulkUnpublish / onBulkDelete handlers. Selection persists across pagination and is pruned when rows leave the result set.

Discussion: #1523

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes — clean for the changed files. (A shallow clone surfaces 8 pre-existing RegistryPluginDetail.tsx implicit-any errors that also appear on main without this change; not introduced here.)
  • pnpm lint passes (oxlint --type-aware --deny-warnings, clean on the changed files)
  • pnpm test passes — 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 format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation. No messages.po changes included.
  • I have added a changeset (@emdash-cms/admin, minor)
  • New features link to a Discussion: Bulk actions in the content list (publish / set to draft / move to trash) #1523

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Cursor + Claude Opus 4.8

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.

@changeset-bot

changeset-bot Bot commented Jun 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 357ad20

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
@emdash-cms/admin Minor
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@github-actions github-actions Bot added area/admin size/L review/needs-review No maintainer or bot review yet labels Jun 17, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jun 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1524

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1524

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1524

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1524

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1524

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1524

emdash

npm i https://pkg.pr.new/emdash@1524

create-emdash

npm i https://pkg.pr.new/create-emdash@1524

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1524

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1524

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1524

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1524

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1524

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1524

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1524

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1524

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1524

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1524

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1524

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1524

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1524

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1524

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1524

commit: 357ad20

@emdashbot emdashbot Bot left a comment

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.

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's locale equals activeLocale; when i18n is off, activeLocale is undefined and the locale param is omitted. I traced the locale-switch race (transient state where activeLocale is new but items stale) and it self-resolves — useInfiniteQuery has no placeholderData, so items goes 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-item deleteMutation; the DELETE route resolves by id, so that's correct.
  • Partial-failure semantics: allSettled counts rejects, throws on failed > 0, and onSettled (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, returns prev when nothing changed (no render loop), and correctly drops ids that leave the result set. colSpan math ((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; no messages.po included (correct for a feature PR); new classes are RTL-safe logical Tailwind (gap-*, justify-end, px-*) with no dark: prefixes or raw colors; Kumo Checkbox/Dialog/Button reused; 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(

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

Comment on lines +294 to +298
const runBulk = (fn?: (ids: string[]) => void) => {
if (!fn || selectedCount === 0) return;
fn([...selectedIds]);
clearSelection();
};

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.

@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-review No maintainer or bot review yet labels Jun 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/admin review/approved Approved; no new commits since size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants