Skills Development Developing Custom CMS Bulk Actions

Developing Custom CMS Bulk Actions

v20260825
webiny-cms-bulk-actions
A comprehensive guide for developers on creating custom bulk actions for a Headless CMS. Learn how to implement the `EntriesBulkAction` interface using `loadData` (for fetching batches) and `processData` (for performing updates). This allows developers to add administrative batch tasks, such as applying discounts or transforming entries, directly from the CMS admin UI, while managing background task convergence and complex field filtering.
Get Skill
328 downloads
Overview

Custom Headless CMS bulk actions

TL;DR

A bulk action is a class implementing EntriesBulkAction.Interface with two methods — loadData (which entries) and processData (what to do to each). Register it with export default EntriesBulkAction.createImplementation({...}) via <Api.Extension src>. For every registered bulk action, Webiny automatically generates a list background task, a process background task, and a GraphQL mutation. On the Admin side, add a ContentEntryListConfig.Browser.BulkAction button that calls BulkActionFeature's useCase.execute({ model, action, where, data }).

Available from Webiny 6.5.0 (webiny/api/cms/entry).

Backend — the bulk action

// extensions/myBulkAction/api/MyBulkAction.ts
import {
  EntriesBulkAction,
  ListLatestEntriesUseCase,
  UpdateEntryUseCase
} from "webiny/api/cms/entry";

class MyBulkActionImpl implements EntriesBulkAction.Interface {
  // PascalCased into the task ids + GraphQL enum value, so "applyDiscount" →
  // tasks hcmsBulk(List|Process)ApplyDiscountEntries and frontend action "ApplyDiscount".
  readonly name = "applyDiscount";
  // Optional: restrict which models get the mutation/button.
  readonly modelIds = ["product"];
  // Optional: entries processed per batch (defaults to the configured batchSize).
  // readonly batchSize = 50;

  constructor(
    private listEntries: ListLatestEntriesUseCase.Interface,
    private updateEntry: UpdateEntryUseCase.Interface
  ) {}

  // Runs in the "list" task, with pagination (params.where/search/after/limit).
  async loadData(model, params) {
    const result = await this.listEntries.execute(model, params);
    return result.value; // { entries, meta }
  }

  // Runs in the "process" task, once per entry, in batches.
  async processData(model, params) {
    // params.id is a revision id ("<entryId>#0001"); params.data carries whatever the
    // Admin action sent.
    // ...update / transform the entry here...
  }
}

export default EntriesBulkAction.createImplementation({
  implementation: MyBulkActionImpl,
  dependencies: [ListLatestEntriesUseCase, UpdateEntryUseCase]
});

loadData/processData are the background-task body. You never write scheduling, batching, retry, or timeout-resume code — the tasks system provides all of it. Webiny generates hcmsBulkList<Name>Entries, hcmsBulkProcess<Name>Entries, and the mutation bulkAction<SingularApiName>(action: <Name>, ...).

Convergence — the #1 gotcha

The engine calls loadData repeatedly until it returns zero entries — after each processing round it re-lists to check for more work. If loadData keeps returning the same entries, the task never converges: it re-processes them until it hits maxIterations and fails. So the filter MUST exclude already-processed entries.

  • State-transition actions converge naturally: Publish filters status_not: "published" and processData publishes; the next list is smaller. Built-in actions rely on this.
  • Actions with no natural "done" state need a marker:
    • A boolean flag: loadData excludes flag = true; processData sets it. Simple, but blocks re-running until you reset the flag.
    • A per-run token (re-runnable): the Admin action generates a fresh runId per click and filters "not stamped with this run"; processData stamps the entry with runId. The run converges once everything is stamped, but the next click uses a new token, so the same entries are eligible again — no manual reset.

Where filters — two layers, two formats

The bulk-action list path talks to storage directly, bypassing the GraphQL where-transform. Mind the difference:

  • GraphQL where (what the Admin action sends, typed as <Model>ListWhereInput): system fields are top-level (id_in, status_not, savedOn_lt, …); custom fields are nested under valueswhere: { values: { onSale_not: true } }. A dotted key like "values.onSale_not" is rejected by the typed input.
  • Storage where (what loadData passes to the list use case): custom fields are flat dotted{ "values.onSale_not": true }; system fields stay top-level. A bare onSale_not throws There is no field with the fieldId "onSale".

So if the Admin action sends a custom-field filter, flatten it in loadData:

async loadData(model, params) {
    const where = { ...params.where };
    if (where.values && typeof where.values === "object") {
        for (const [k, v] of Object.entries(where.values)) {
            where[`values.${k}`] = v;
        }
        delete where.values;
    }
    return (await this.listEntries.execute(model, { ...params, where })).value;
}

Alternatively, add a constant custom-field filter entirely in loadData (storage format) and send only system fields from the Admin (that's how the simplest actions work).

Note: only searchable custom fields appear in the GraphQL where input; a plain field may not be filterable via GraphQL, in which case add the filter backend-side in loadData.

Updating entries from processData

Use UpdateEntryUseCase; field values are nested under values, and pass { skipValidation: true } for targeted, system-driven field updates so an unrelated required/invalid field on the entry doesn't fail the operation:

await this.updateEntry.execute(
  model,
  entry.id,
  { values: { price: newPrice } },
  { skipValidation: true }
);

To read the current entry inside processData, inject GetLatestRevisionByEntryIdUseCase and call execute(model, { id: params.id.split("#")[0] }).

Admin — the button

// extensions/myBulkAction/admin/Extension.tsx
import { ContentEntryListConfig } from "webiny/admin/cms/entry/list";
const { Browser } = ContentEntryListConfig;
export default () => (
  <ContentEntryListConfig>
    <Browser.BulkAction name="applyDiscount" element={<MyActionButton />} modelIds={["product"]} />
  </ContentEntryListConfig>
);
// The button. `name` (here on the config) matches the backend action name.
import { observer } from "mobx-react-lite";
import { BulkActionButton, useBulkActionDialog, useFeature } from "webiny/admin";
import { useModel } from "webiny/admin/cms";
import { BulkActionFeature, useContentEntriesPresenter } from "webiny/admin/cms/entry/list";

export const MyActionButton = observer(() => {
  const { model } = useModel();
  const presenter = useContentEntriesPresenter();
  const { showConfirmationDialog } = useBulkActionDialog();
  const { useCase: bulkAction } = useFeature(BulkActionFeature);

  const selection = presenter.list.vm.selection;
  const rows = presenter.list.vm.rows.filter(r => selection.selectedIds.has(r.id));

  const run = () =>
    showConfirmationDialog({
      title: "Apply discount",
      message: `Apply to ${selection.label}? Runs as a background task.`,
      execute: async () => {
        // System-field scope (id_in) is valid GraphQL; custom-field filters go under `values`.
        const where = selection.allSelected ? undefined : { id_in: rows.map(r => r.id) };
        await bulkAction.execute({ model, action: "ApplyDiscount", where, data: { percent: 10 } });
        presenter.list.actions.selection.deselectAll();
      }
    });

  return <BulkActionButton text="Apply -10%" tooltipContent="Apply discount" onClick={run} />;
});

The browser never loops over entries — execute fires the mutation and the work runs server-side, in the background. Use observer (selection is MobX-observable). The bulk confirmation dialog only takes strings; for richer input (e.g. a picker) use DropdownMenu/Select from webiny/admin/ui.

Real-time progress (optional)

processData can emit a websocket message per entry via WebsocketsSendToIdentityUseCase (webiny/api) + IdentityContext (webiny/api/security); an admin WebsocketEventHandler (webiny/admin/websockets) then toasts via Notifications (webiny/admin). See the webiny-websocket-notifications skill.

Reference

  • Built-in actions live in @webiny/api-headless-cms-bulk-actions (Publish, Unpublish, Delete, Move, Restore) — good templates for loadData/processData.
  • Successful list/process tasks are private and self-clean; failed ones persist (visible in the Background Tasks screen).
Info
Category Development
Name webiny-cms-bulk-actions
Version v20260825
Size 8.68KB
Updated At 2026-09-06
Language