From 42226d75c582849935e8a8a304d8b53b5fce0f4b Mon Sep 17 00:00:00 2001 From: adrians5j Date: Wed, 22 Jul 2026 17:36:43 +0200 Subject: [PATCH 1/2] docs(headless-cms): add custom entries bulk action guides - New "Custom Entries Bulk Action" page: EntriesBulkAction (webiny/api/cms/entry) auto-run as a background task + GraphQL mutation, convergence, storage-level where. - New "Generate AI Summary Bulk Action" page: AI Power Ups delegation, persona/project dropdown, per-run convergence token, websocket notifications. - Cross-link from Background Tasks overview. - Register both pages in 6.x navigation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../6.x/background-tasks/about.mdx | 55 +- .../ai-summary-bulk-action.ai.txt | 22 + .../headless-cms/ai-summary-bulk-action.mdx | 401 +++++++++++++ .../custom-entries-bulk-action.ai.txt | 31 + .../custom-entries-bulk-action.mdx | 300 ++++++++++ docs/developer-docs/6.x/navigation.tsx | 560 ++++++++++-------- 6 files changed, 1101 insertions(+), 268 deletions(-) create mode 100644 docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.ai.txt create mode 100644 docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.mdx create mode 100644 docs/developer-docs/6.x/headless-cms/custom-entries-bulk-action.ai.txt create mode 100644 docs/developer-docs/6.x/headless-cms/custom-entries-bulk-action.mdx diff --git a/docs/developer-docs/6.x/background-tasks/about.mdx b/docs/developer-docs/6.x/background-tasks/about.mdx index 6ef6a852a..1b7a852b0 100644 --- a/docs/developer-docs/6.x/background-tasks/about.mdx +++ b/docs/developer-docs/6.x/background-tasks/about.mdx @@ -24,6 +24,12 @@ Tasks allow you to run long operations asynchronously without blocking your appl To create a background task, you implement the `TaskDefinition.Interface` and register it using `TaskDefinition.createImplementation()`. + + +Background tasks also power Headless CMS bulk actions: every custom [entries bulk action](/{version}/developer-docs/headless-cms/custom-entries-bulk-action) you register is automatically turned into a pair of background tasks (plus a GraphQL mutation) — so you get batching, resume-on-timeout, and monitoring for free, without defining a `TaskDefinition` yourself. + + + ## Example ```typescript @@ -61,7 +67,7 @@ class PublishEntriesBackgroundTask implements TaskDefinition.Interface { public constructor( private getModel: GetModelUseCase.Interface, - private publishEntry: PublishEntryUseCase.Interface + private publishEntry: PublishEntryUseCase.Interface, ) {} /** * On each task run, this method is called with the input parameters. @@ -69,8 +75,10 @@ class PublishEntriesBackgroundTask implements TaskDefinition.Interface { */ public async run({ input, - controller - }: TaskDefinition.RunParams): Promise> { + controller, + }: TaskDefinition.RunParams): Promise< + TaskDefinition.Result + > { const { model, list } = input; const modelResult = await this.getModel.execute(model); @@ -89,82 +97,95 @@ class PublishEntriesBackgroundTask implements TaskDefinition.Interface { } else if (controller.runtime.isCloseToTimeout()) { return controller.response.continue({ ...input, - last + last, }); } last = entryId; try { - const publishEntryResult = await this.publishEntry.execute(cmsModel, entryId); + const publishEntryResult = await this.publishEntry.execute( + cmsModel, + entryId, + ); if (publishEntryResult.isFail()) { results.push({ id: entryId, - error: publishEntryResult.error.message + error: publishEntryResult.error.message, }); continue; } results.push({ - id: entryId + id: entryId, }); } catch (ex) { results.push({ id: entryId, - error: ex.message + error: ex.message, }); } } return controller.response.done({ - list: results + list: results, }); } /** * Before the task is triggered, this method is called. * Users can do some preparation or checks here and break the execution by throwing an error. */ - public async onBeforeTrigger(params: TaskDefinition.BeforeTriggerParams): Promise { + public async onBeforeTrigger( + params: TaskDefinition.BeforeTriggerParams, + ): Promise { // } /** * When the task ends with a done status, this method is called. */ - public async onDone(params: TaskDefinition.LifecycleHookParams): Promise { + public async onDone( + params: TaskDefinition.LifecycleHookParams, + ): Promise { // } /** * When the task ends with an error status, this method is called. */ - public async onError(params: TaskDefinition.LifecycleHookParams): Promise { + public async onError( + params: TaskDefinition.LifecycleHookParams, + ): Promise { // } /** * When the task ends with an abort status, this method is called. */ - public async onAbort(params: TaskDefinition.LifecycleHookParams): Promise { + public async onAbort( + params: TaskDefinition.LifecycleHookParams, + ): Promise { // } /** * When the maximum number of iterations is reached, this method is called. */ public async onMaxIterations( - params: TaskDefinition.LifecycleHookParams + params: TaskDefinition.LifecycleHookParams, ): Promise { // } /** * A method to create input validation schema based on the input interface. */ - public createInputValidation({ validator }: TaskDefinition.CreateInputValidationParams) { + public createInputValidation({ + validator, + }: TaskDefinition.CreateInputValidationParams) { return validator.object({ model: validator.string().min(1), - list: validator.array(validator.string()).min(1).max(10000) + list: validator.array(validator.string()).min(1).max(10000), }); } } export default TaskDefinition.createImplementation({ implementation: PublishEntriesBackgroundTask, - dependencies: [GetModelUseCase, PublishEntryUseCase] + dependencies: [GetModelUseCase, PublishEntryUseCase], }); ``` diff --git a/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.ai.txt b/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.ai.txt new file mode 100644 index 000000000..49c2d5301 --- /dev/null +++ b/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.ai.txt @@ -0,0 +1,22 @@ +AI Context: Generate AI Summary Bulk Action (ai-summary-bulk-action.mdx) + +## Source of Information + +1. wby-next5 `extensions/bulkActions/aiContent/**` — the working "Generate AI summary" demo (API bulk action, dropdown Admin button, websocket handler, extension wiring). +2. Public APIs (Webiny 6.5.0): `EntriesBulkAction` (`webiny/api/cms/entry`), `CmsGenerateEntryContentUseCase` (`webiny/api/ai-powerups`), `GetSettingsFeature` (`webiny/admin/ai-powerups`), `WebsocketEventHandler` (`webiny/admin/websockets`), `WebsocketsSendToIdentityUseCase` + `IdentityContext` (`webiny/api`, `webiny/api/security`). + +## Key Documentation Decisions + +1. Explicitly a follow-on to custom-entries-bulk-action.mdx — reader is told to read that first. This page only covers the AI-specific deltas. +2. Delegates generation to AI Power Ups `CmsGenerateEntryContentUseCase` rather than a raw AI call — so it follows the editor's configured provider + persona/project. Do NOT show model/key handling. +3. `CmsGenerateEntryContentUseCase.execute(...)` returns `Result` with `value.output` = a JSON string of the generated entry. Doc parses `JSON.parse(result.value.output || "{}").aiSummary`. (Follow-up noted in PR to return structured `values` instead — if that lands, update this page.) +4. Re-runnable convergence via a per-run token: contrast with the discount page's permanent boolean flag. `runId` fresh per click; `processData` stamps `aiSummarizedRun`; `loadData` filters `aiSummarizedRun_not: runId`. Given its own section. +5. GraphQL where nests custom fields under `values: {}`; backend `loadData` flattens to `values.`. Shown on both sides (frontend builds nested, backend flattens). +6. Button is a `DropdownMenu` of AI contexts loaded from `GetSettingsFeature`: Projects, Writer Personas, Reader Personas, plus "Default (no context)". Graceful when AI Power Ups unconfigured (catch → only Default). +7. Websockets: API emits via `WebsocketsSendToIdentityUseCase` keyed to `IdentityContext.getIdentity()`, best-effort (never fail the task). Admin `WebsocketEventHandler` filters by `action` and toasts via `Notifications`. Registered with `createFeature` + `RegisterFeature`. +8. Requires AI Power Ups configured — info callout at top. + +## Links Used + +- Prereq/base: `/{version}/developer-docs/headless-cms/custom-entries-bulk-action` (+ #making-the-task-converge, #registering-the-bulk-action anchors) +- `/{version}/developer-docs/build-with-ai/ai-assisted-development` diff --git a/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.mdx b/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.mdx new file mode 100644 index 000000000..0f3a7326d --- /dev/null +++ b/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.mdx @@ -0,0 +1,401 @@ +--- +id: wsx4m9re +title: Generate AI Summary Bulk Action +description: A bulk action that generates AI content per entry using AI Power Ups, with real-time websocket notifications. +--- + +import { Alert } from "@/components/Alert"; + + + +- How to build a bulk action that generates AI content per entry +- How to delegate generation to AI Power Ups (configured provider + personas/projects) +- How to let the editor pick a Project or Persona from the button +- How to make an AI bulk action re-runnable (per-run convergence token) +- How to notify the user in real time over websockets + + + + + +This page builds on [Custom Entries Bulk Action](/{version}/developer-docs/headless-cms/custom-entries-bulk-action) — read that first. The APIs are available from **Webiny 6.5.0**. Generating content requires [AI Power Ups](/{version}/developer-docs/build-with-ai/ai-assisted-development) to be configured (a provider added in its settings). + + + +## Overview + +AI-per-entry is a natural background-task workload: it's slow, batched, and resumable — exactly what the bulk-action machinery already handles. This example generates a one-sentence marketing summary for each selected product and writes it back to an `aiSummary` field. + +Instead of calling an AI provider directly, `processData` delegates to AI Power Ups' `CmsGenerateEntryContentUseCase`. That use case uses the provider the editor configured in AI Power Ups settings and applies an optional **Writer Persona** (tone), **Reader Persona** (audience), or **Project** (bundled instructions + default personas). You don't pick models, decrypt keys, or hardcode a persona. + +## The API-Side Bulk Action + +```typescript extensions/aiContent/api/GenerateAiSummaryBulkAction.ts +import { + EntriesBulkAction, + GetLatestRevisionByEntryIdUseCase, + ListLatestEntriesUseCase, + UpdateEntryUseCase, +} from "webiny/api/cms/entry"; +import { CmsGenerateEntryContentUseCase } from "webiny/api/ai-powerups"; + +// The `data` payload the Admin action sends with each trigger. +interface GenerateAiSummaryData { + projectId?: string; + writerPersonaId?: string; + readerPersonaId?: string; + runId?: string; +} + +class GenerateAiSummaryBulkAction implements EntriesBulkAction.Interface { + name = "generateAiSummary"; + modelIds = ["product"]; + + constructor( + private readonly listEntries: ListLatestEntriesUseCase.Interface, + private readonly getRevision: GetLatestRevisionByEntryIdUseCase.Interface, + private readonly updateEntry: UpdateEntryUseCase.Interface, + private readonly generateContent: CmsGenerateEntryContentUseCase.Interface, + ) {} + + async loadData( + model: EntriesBulkAction.Model, + params: EntriesBulkAction.LoadDataParams, + ): Promise { + // The GraphQL `where` arrives with custom-field filters nested under `values`, but the + // storage layer wants flat dotted keys (`values.`), so we flatten here. + const where: Record = { ...params.where }; + if (where.values && typeof where.values === "object") { + for (const [key, value] of Object.entries( + where.values as Record, + )) { + where[`values.${key}`] = value; + } + delete where.values; + } + return (await this.listEntries.execute(model, { ...params, where })).value; + } + + async processData( + model: EntriesBulkAction.Model, + params: EntriesBulkAction.ProcessParams, + ): Promise { + const entryId = params.id.split("#")[0]; + const revision = await this.getRevision.execute(model, { id: entryId }); + if (revision.isFail()) { + throw revision.error; + } + const entry = revision.value; + + // Optional AI Power Ups context, forwarded from the Admin action's `data`. + const { + projectId, + writerPersonaId, + readerPersonaId, + runId = "", + } = params.data ?? {}; + + const result = await this.generateContent.execute({ + modelId: model.modelId, + prompt: `Write a concise, single-sentence marketing summary for the product "${entry.values.name}". Fill only the "aiSummary" field.`, + writerPersonaId, + readerPersonaId, + projectId, + }); + if (result.isFail()) { + throw result.error; + } + + // The use case returns the generated entry as a JSON string; take our field. + const generated = JSON.parse(result.value.output || "{}").aiSummary; + const aiSummary = typeof generated === "string" ? generated : ""; + + // Stamp the entry with the current run token so it drops out of the next `loadData`. + const updated = await this.updateEntry.execute( + model, + entry.id, + { values: { aiSummary, aiSummarizedRun: runId } }, + { skipValidation: true }, + ); + if (updated.isFail()) { + throw updated.error; + } + } +} + +export default EntriesBulkAction.createImplementation({ + implementation: GenerateAiSummaryBulkAction, + dependencies: [ + ListLatestEntriesUseCase, + GetLatestRevisionByEntryIdUseCase, + UpdateEntryUseCase, + CmsGenerateEntryContentUseCase, + ], +}); +``` + +### Re-Runnable Convergence: a Per-Run Token + +The [discount example](/{version}/developer-docs/headless-cms/custom-entries-bulk-action#making-the-task-converge) converges with a permanent boolean flag (`onSale`), so it runs once per entry. An AI summary is different — you'll want to regenerate it later. A permanent flag would make that impossible. + +Instead, each trigger carries a fresh **run token** (`runId`). `processData` stamps every processed entry with it (`aiSummarizedRun`), and `loadData` filters out entries already stamped with the current token. The task still converges (every targeted entry eventually carries this run's token), but the **next** trigger uses a **new** token, so the same entries qualify again and get re-summarized. + +## The Admin-Side Button + +The button is a dropdown of AI Power Ups contexts, loaded from AI Power Ups settings via `GetSettingsFeature`. Picking one triggers the task with that context; "Default" runs with none. + +```tsx extensions/aiContent/admin/GenerateAiSummaryAction.tsx +import React, { useEffect, useState } from "react"; +import { observer } from "mobx-react-lite"; +import { + BulkActionButton, + useBulkActionDialog, + useFeature, +} from "webiny/admin"; +import { DropdownMenu, useToast } from "webiny/admin/ui"; +import { useModel } from "webiny/admin/cms"; +import { + BulkActionFeature, + useContentEntriesPresenter, +} from "webiny/admin/cms/entry/list"; +import { GetSettingsFeature } from "webiny/admin/ai-powerups"; + +interface Preset { + id: string; + name: string; +} + +type ContextChoice = + | { projectId: string } + | { writerPersonaId: string } + | { readerPersonaId: string } + | undefined; + +export const GenerateAiSummaryAction = observer(() => { + const { model } = useModel(); + const presenter = useContentEntriesPresenter(); + const { showConfirmationDialog } = useBulkActionDialog(); + const { showSuccessToast } = useToast(); + const { useCase: bulkAction } = useFeature(BulkActionFeature); + const { useCase: getSettings } = useFeature(GetSettingsFeature); + + const [ctx, setCtx] = useState<{ + readers: Preset[]; + writers: Preset[]; + projects: Preset[]; + }>({ + readers: [], + writers: [], + projects: [], + }); + + useEffect(() => { + let active = true; + getSettings + .execute() + .then((settings) => { + if (!active) { + return; + } + setCtx({ + readers: settings.readerPersonas?.presets ?? [], + writers: settings.writerPersonas?.presets ?? [], + projects: settings.projects?.presets ?? [], + }); + }) + .catch(() => { + // AI Power Ups may not be configured — just show "Default". + }); + return () => { + active = false; + }; + }, [getSettings]); + + const selection = presenter.list.vm.selection; + const selectedItems = presenter.list.vm.rows.filter((row) => + selection.selectedIds.has(row.id), + ); + + const run = (choice: ContextChoice, label: string) => + showConfirmationDialog({ + title: "Generate AI summary", + message: `Generate an AI summary for ${selection.label} (${label})? This runs as a background task, so you can keep working while it processes.`, + loadingLabel: "Starting background task…", + execute: async () => { + // A fresh token per click makes the action re-runnable (see convergence, above). + const runId = + typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : String(Date.now()); + + // Custom-field filters go NESTED under `values` in the GraphQL where input; the + // backend flattens this to the storage form. + const scope = selection.allSelected + ? {} + : { id_in: selectedItems.map((item) => item.id) }; + const where = { ...scope, values: { aiSummarizedRun_not: runId } }; + + await bulkAction.execute({ + model, + action: "GenerateAiSummary", + where, + data: { ...(choice ?? {}), runId }, + }); + + presenter.list.actions.selection.deselectAll(); + + showSuccessToast({ + title: "AI summary task started", + description: + "Generating summaries in the background. You can keep working.", + }); + }, + }); + + return ( + + } + > + run(undefined, "default")} + /> + + {ctx.projects.length > 0 ? ( + <> + + + {ctx.projects.map((p) => ( + run({ projectId: p.id }, `project: ${p.name}`)} + /> + ))} + + ) : null} + + {ctx.writers.length > 0 ? ( + <> + + + {ctx.writers.map((p) => ( + + run({ writerPersonaId: p.id }, `writer: ${p.name}`) + } + /> + ))} + + ) : null} + + ); +}); +``` + +## Real-Time Notifications Over Websockets + +Emit a websocket message from `processData` after each entry, and toast it in the Admin app. On the API side, inject `IdentityContext` and `WebsocketsSendToIdentityUseCase` and send a message (best-effort — never fail the task on a websocket error): + +```typescript API side — inside processData, after the entry is updated +try { + const identity = this.identityContext.getIdentity(); + if (identity) { + await this.sendToIdentity.execute( + { id: identity.id }, + { + action: "cms.product.aiSummaryGenerated", + data: { id: entry.entryId, name: entry.values.name, aiSummary }, + }, + ); + } +} catch (ex) { + this.logger.warn(`[GenerateAiSummary] websocket notification failed`); +} +``` + +On the Admin side, implement a `WebsocketEventHandler` that filters by `action` and shows a toast: + +```typescript extensions/aiContent/admin/AiSummaryGeneratedEventHandler.ts +import { WebsocketEventHandler } from "webiny/admin/websockets"; +import { Notifications } from "webiny/admin"; + +const AI_SUMMARY_GENERATED_ACTION = "cms.product.aiSummaryGenerated"; + +interface AiSummaryGeneratedData { + id: string; + name: string; + aiSummary: string; +} + +class AiSummaryGeneratedEventHandlerImpl + implements WebsocketEventHandler.Interface +{ + constructor(private notifications: Notifications.Interface) {} + + async handle(event: WebsocketEventHandler.Event): Promise { + const payload = event.payload as { + action?: string; + data?: AiSummaryGeneratedData; + }; + if (payload.action !== AI_SUMMARY_GENERATED_ACTION || !payload.data) { + return; + } + + const { name } = payload.data; + this.notifications.success({ + title: "AI summary generated", + description: name ? `Summary ready for "${name}".` : "Summary ready.", + }); + } +} + +export const AiSummaryGeneratedEventHandler = + WebsocketEventHandler.createImplementation({ + implementation: AiSummaryGeneratedEventHandlerImpl, + dependencies: [Notifications], + }); +``` + +Register the handler alongside the button using `createFeature` + `RegisterFeature`. The websockets runner resolves every registered `WebsocketEventHandler` and calls `handle` for each incoming message, so each handler filters by its own `action`: + +```tsx extensions/aiContent/admin/Extension.tsx +import React from "react"; +import { createFeature, RegisterFeature } from "webiny/admin"; +import { ContentEntryListConfig } from "webiny/admin/cms/entry/list"; +import { GenerateAiSummaryAction } from "./GenerateAiSummaryAction.js"; +import { AiSummaryGeneratedEventHandler } from "./AiSummaryGeneratedEventHandler.js"; + +const { Browser } = ContentEntryListConfig; + +const AiContentFeature = createFeature({ + name: "BulkActions/AiContent", + register(container) { + container.register(AiSummaryGeneratedEventHandler); + }, +}); + +export default () => { + return ( + <> + + + } + modelIds={["product"]} + /> + + + ); +}; +``` + +Wiring the two sides into `webiny.config.tsx` follows the same full-stack extension pattern shown in [Custom Entries Bulk Action](/{version}/developer-docs/headless-cms/custom-entries-bulk-action#registering-the-bulk-action). diff --git a/docs/developer-docs/6.x/headless-cms/custom-entries-bulk-action.ai.txt b/docs/developer-docs/6.x/headless-cms/custom-entries-bulk-action.ai.txt new file mode 100644 index 000000000..d0a649faa --- /dev/null +++ b/docs/developer-docs/6.x/headless-cms/custom-entries-bulk-action.ai.txt @@ -0,0 +1,31 @@ +AI Context: Custom Entries Bulk Action (custom-entries-bulk-action.mdx) + +## Source of Information + +1. wby-next5 `extensions/bulkActions/applyDiscount/**` — the working "Apply Discount" demo this doc documents (API bulk action, Admin button, extension wiring, websocket handler). +2. Public API exposed in Webiny 6.5.0 via the `webiny` package (PR #5411): `EntriesBulkAction` from `webiny/api/cms/entry`. + +## Key Documentation Decisions + +1. New feature in 6.5.0 — includes a single "available from 6.5.0" info callout. No other version callouts. +2. Import path: `EntriesBulkAction` from `webiny/api/cms/entry` (NOT `webiny/api/cms/bulk-actions` — that alias was dropped during API review). +3. Central framing: an `EntriesBulkAction` is auto-turned into a pair of background tasks (list + process) plus a GraphQL mutation. The developer only describes what to do. +4. The convergence rule is the load-bearing concept — `loadData` is called repeatedly until it returns zero entries, so the filter MUST exclude already-processed entries. Given its own section + it's why `processData` sets a flag. +5. Storage-level `where`: custom fields are namespaced `values.` at storage; bulk-action list path bypasses the GraphQL where-transform. Documented as a warning callout (repro error: "There is no field with the fieldId onSale"). +6. `skipValidation: true` explained (targeted system update; human edits still validate). +7. Throw = entry failed, return = entry done. +8. Admin button uses `webiny/admin` (`BulkActionButton`, `useBulkActionDialog`, `useFeature`) + `webiny/admin/cms/entry/list` (`BulkActionFeature`, `useContentEntriesPresenter`, `ContentEntryListConfig`). `action` is the PascalCased API `name`. +9. `where` scoping: `{ id_in }` for explicit selection, `undefined` for select-all-across-pages. +10. Websocket notifications deferred to the sibling AI page to keep this page focused. + +## Key API Shapes + +- `EntriesBulkAction.Interface` — `name`, `modelIds?`, `batchSize?`, `loadData(model, params)`, `processData(model, params)`. +- Namespace types: `.Model`, `.LoadDataParams`, `.LoadDataResult`, `.ProcessParams`. +- `EntriesBulkAction.createImplementation({ implementation, dependencies })`, `export default`. +- Frontend trigger: `bulkAction.execute({ model, action, where, data })`. + +## Links Used + +- `/{version}/developer-docs/background-tasks/about` (+ #managing-tasks-via-graph-ql anchor) +- Sibling: `/{version}/developer-docs/headless-cms/ai-summary-bulk-action` diff --git a/docs/developer-docs/6.x/headless-cms/custom-entries-bulk-action.mdx b/docs/developer-docs/6.x/headless-cms/custom-entries-bulk-action.mdx new file mode 100644 index 000000000..3d731f390 --- /dev/null +++ b/docs/developer-docs/6.x/headless-cms/custom-entries-bulk-action.mdx @@ -0,0 +1,300 @@ +--- +id: bkq7f3d2 +title: Custom Entries Bulk Action +description: Learn how to create a custom Headless CMS entries bulk action that Webiny runs as a background task. +--- + +import { Alert } from "@/components/Alert"; + + + +- What a Headless CMS entries bulk action is +- How Webiny turns a bulk action into a background task and a GraphQL mutation +- How to implement the API-side bulk action (`loadData` / `processData`) +- How to add the Admin-side button that triggers it +- How to make the task converge and finish +- How to notify the user in real time as entries are processed + + + + + +The APIs on this page are available from **Webiny 6.5.0**. The bulk action itself is +imported from `webiny/api/cms/entry`. + + + +## Overview + +A bulk action lets an editor select multiple entries in the Headless CMS entry list and run a single operation over all of them — for example, apply a discount to every selected product. + +The important thing to understand is what Webiny does with a bulk action you register. For every `EntriesBulkAction`, the framework automatically generates: + +- a pair of **background tasks** (a "list" task that paginates the matching entries, and a "process" task that runs once per entry, in batches), and +- a **GraphQL mutation** that the Admin app calls to trigger those tasks. + +So you never schedule, chunk, retry, or resume-on-timeout anything yourself — that machinery comes from the [background tasks](/{version}/developer-docs/background-tasks/about) system. You only describe **what** to do: which entries to operate on, and what to do to each one. + +A full-stack bulk action has two parts: + +1. **API** — the `EntriesBulkAction` implementation (the background-task body). +2. **Admin** — a button in the entry list that triggers it. + +The example below implements an "Apply Discount" bulk action on a `product` model. + +## The API-Side Bulk Action + +You implement `EntriesBulkAction.Interface` and register it with `EntriesBulkAction.createImplementation()`. The interface has two methods: + +- `loadData(model, params)` — collect the entries to operate on. Runs in the "list" task. +- `processData(model, params)` — operate on a single entry. Runs in the "process" task. + +```typescript extensions/applyDiscount/api/ApplyDiscountBulkAction.ts +import { + EntriesBulkAction, + GetLatestRevisionByEntryIdUseCase, + ListLatestEntriesUseCase, + UpdateEntryUseCase, +} from "webiny/api/cms/entry"; + +const DEFAULT_DISCOUNT_PERCENT = 10; + +// The `data` payload the Admin action sends with each trigger. +interface ApplyDiscountData { + percent?: number; +} + +class ApplyDiscountBulkActionImpl implements EntriesBulkAction.Interface { + // The action name. Webiny PascalCases it into the task id + GraphQL enum value, so + // "applyDiscount" is triggered from the frontend with `action: "ApplyDiscount"`. + public readonly name = "applyDiscount"; + + // Only expose this bulk action on the "product" model. + public readonly modelIds = ["product"]; + + public constructor( + private readonly listLatestEntries: ListLatestEntriesUseCase.Interface, + private readonly getLatestRevision: GetLatestRevisionByEntryIdUseCase.Interface, + private readonly updateEntry: UpdateEntryUseCase.Interface, + ) {} + + public async loadData( + model: EntriesBulkAction.Model, + params: EntriesBulkAction.LoadDataParams, + ): Promise { + // `params.where` already carries the selection scope sent from the Admin UI. + const where: Record = { + ...params.where, + "values.onSale_not": true, + }; + const result = await this.listLatestEntries.execute(model, { + ...params, + where, + }); + return result.value; + } + + public async processData( + model: EntriesBulkAction.Model, + params: EntriesBulkAction.ProcessParams, + ): Promise { + const percent = params.data?.percent ?? DEFAULT_DISCOUNT_PERCENT; + + // `params.id` is a revision id (e.g. "#0001"); strip the version. + const entryId = params.id.split("#")[0]; + + const revision = await this.getLatestRevision.execute(model, { + id: entryId, + }); + if (revision.isFail()) { + throw revision.error; + } + + const entry = revision.value; + const currentPrice = Number(entry.values.price) || 0; + const newPrice = Math.round(currentPrice * (1 - percent / 100) * 100) / 100; + + const updated = await this.updateEntry.execute( + model, + entry.id, + { values: { price: newPrice, onSale: true } }, + { skipValidation: true }, + ); + if (updated.isFail()) { + throw updated.error; + } + } +} + +export default EntriesBulkAction.createImplementation({ + implementation: ApplyDiscountBulkActionImpl, + dependencies: [ + ListLatestEntriesUseCase, + GetLatestRevisionByEntryIdUseCase, + UpdateEntryUseCase, + ], +}); +``` + +### Making the Task Converge + +This is the single most important detail. The tasks engine calls `loadData` **repeatedly** — after each processing round it re-lists to check whether more work remains, and it stops only when `loadData` returns zero entries. + +That means your filter **must exclude entries that have already been processed**, otherwise the task never converges: it would re-discount the same products forever and eventually fail with a `maxIterations` error. + +In the example, `processData` sets `onSale: true`, and `loadData` filters those out with `"values.onSale_not": true`. Each round shrinks the working set until nothing is left, and the task finishes. + + + +The bulk-action list path talks to storage directly and bypasses the GraphQL where-transform. At the storage layer, custom fields are namespaced under `values.` (system fields like `id` stay top-level), so you write the filter as `"values.onSale_not"`. A bare `onSale_not` cannot be resolved and throws _"There is no field with the fieldId onSale"_. + + + +### `skipValidation` + +`processData` updates the entry with `{ skipValidation: true }` because this is a targeted, system-driven field update — it should not fail just because some unrelated field on the entry happens to be empty or invalid. Full validation still runs when a human edits the entry in the Admin app. + +### Success and Failure Per Entry + +Inside `processData`, **throwing** marks that single entry as _failed_ in the task report; **returning** marks it _done_. The rest of the batch keeps going either way. + +## The Admin-Side Button + +The button lives in the content entry list and triggers the background task. The browser does **not** loop over entries — it hands the whole selection to the API and the task processes it server-side. The user can navigate away while it runs. + +```tsx extensions/applyDiscount/admin/ApplyDiscountAction.tsx +import React from "react"; +import { observer } from "mobx-react-lite"; +import { + BulkActionButton, + useBulkActionDialog, + useFeature, +} from "webiny/admin"; +import { useToast } from "webiny/admin/ui"; +import { useModel } from "webiny/admin/cms"; +import { + BulkActionFeature, + useContentEntriesPresenter, +} from "webiny/admin/cms/entry/list"; + +const DISCOUNT_PERCENT = 10; + +export const ApplyDiscountAction = observer(() => { + const { model } = useModel(); + const presenter = useContentEntriesPresenter(); + const { showConfirmationDialog } = useBulkActionDialog(); + const { showSuccessToast } = useToast(); + const { useCase: bulkAction } = useFeature(BulkActionFeature); + + const selection = presenter.list.vm.selection; + const selectedItems = presenter.list.vm.rows.filter((row) => + selection.selectedIds.has(row.id), + ); + + const openDialog = () => + showConfirmationDialog({ + title: "Apply discount", + message: `Apply a ${DISCOUNT_PERCENT}% discount to ${selection.label}? This runs as a background task, so you can keep working while it processes.`, + loadingLabel: `Processing ${selection.label}`, + execute: async () => { + // Scope the task to the selected entries. When "select all" (across pages) is + // active, omit the filter so the task processes everything matching the view. + const where = selection.allSelected + ? undefined + : { id_in: selectedItems.map((item) => item.id) }; + + await bulkAction.execute({ + model, + action: "ApplyDiscount", + where, + data: { percent: DISCOUNT_PERCENT }, + }); + + presenter.list.actions.selection.deselectAll(); + + showSuccessToast({ + title: "Discount task started", + description: `Applying -${DISCOUNT_PERCENT}% in the background. You can keep working.`, + }); + }, + }); + + return ( + + ); +}); +``` + +A few things to note: + +- `useFeature(BulkActionFeature)` resolves the use case whose `execute()` fires the generated GraphQL mutation. +- `action: "ApplyDiscount"` is the PascalCased form of the API `name` (`"applyDiscount"`). +- `where` is the selection scope. Pass `{ id_in: [...] }` for an explicit selection, or `undefined` when the user chose "select all" across pages, so the task processes everything matching the current view. +- `data` is the arbitrary payload delivered to `processData` as `params.data` (here, the discount percentage). + +## Registering the Bulk Action + +On the Admin side, register the button in `ContentEntryListConfig` with a `Browser.BulkAction` whose `name` matches the API `name`: + +```tsx extensions/applyDiscount/admin/Extension.tsx +import React from "react"; +import { ContentEntryListConfig } from "webiny/admin/cms/entry/list"; +import { ApplyDiscountAction } from "./ApplyDiscountAction.js"; + +const { Browser } = ContentEntryListConfig; + +export default () => { + return ( + + } + modelIds={["product"]} + /> + + ); +}; +``` + +Then wire both sides into your project with a single extension component and register it in `webiny.config.tsx`: + +```tsx extensions/applyDiscount/ApplyDiscountExtension.tsx +import React from "react"; +import { Admin, Api } from "webiny/extensions"; + +export const ApplyDiscountExtension = () => { + return ( + <> + + + + ); +}; +``` + +```tsx webiny.config.tsx +import { ApplyDiscountExtension } from "@/extensions/applyDiscount/ApplyDiscountExtension.js"; + +export const Extensions = () => { + return ( + <> + {/* ...other extensions... */} + + + ); +}; +``` + +## Monitoring the Task + +Because a bulk action _is_ a background task, you monitor and troubleshoot it exactly like any other task — list runs, inspect input/output, read logs, or abort it. See [Background Tasks](/{version}/developer-docs/background-tasks/about#managing-tasks-via-graph-ql) for the available GraphQL queries and mutations. + +## Real-Time Notifications (Optional) + +Since processing happens in the background, it's nice to tell the user as each entry completes. You can emit a websocket message from `processData` and toast it in the Admin app. See [Generate AI Summary Bulk Action](/{version}/developer-docs/headless-cms/ai-summary-bulk-action) for a complete example that adds per-entry websocket notifications on top of the pattern shown here. diff --git a/docs/developer-docs/6.x/navigation.tsx b/docs/developer-docs/6.x/navigation.tsx index 1c3b3f37b..b449c9d5e 100644 --- a/docs/developer-docs/6.x/navigation.tsx +++ b/docs/developer-docs/6.x/navigation.tsx @@ -2,255 +2,313 @@ import React from "react"; import { Group, NavigationRoot, Page, Separator } from "@webiny/docs-generator"; export const Navigation = ({ children }: { children: React.ReactNode }) => { - return ( - - - ); + return ( + + + ); }; From 69da1c0cd9ed3fae9e3a98ead463c51938808de0 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Thu, 23 Jul 2026 08:22:33 +0200 Subject: [PATCH 2/2] docs(headless-cms): read structured values from CmsGenerateEntryContentUseCase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflects webiny-js#5477 — the use case returns `values` instead of a JSON `output` string, so the AI summary example reads `result.value.values.aiSummary` directly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../6.x/headless-cms/ai-summary-bulk-action.ai.txt | 2 +- .../6.x/headless-cms/ai-summary-bulk-action.mdx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.ai.txt b/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.ai.txt index 49c2d5301..0ea20866f 100644 --- a/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.ai.txt +++ b/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.ai.txt @@ -9,7 +9,7 @@ AI Context: Generate AI Summary Bulk Action (ai-summary-bulk-action.mdx) 1. Explicitly a follow-on to custom-entries-bulk-action.mdx — reader is told to read that first. This page only covers the AI-specific deltas. 2. Delegates generation to AI Power Ups `CmsGenerateEntryContentUseCase` rather than a raw AI call — so it follows the editor's configured provider + persona/project. Do NOT show model/key handling. -3. `CmsGenerateEntryContentUseCase.execute(...)` returns `Result` with `value.output` = a JSON string of the generated entry. Doc parses `JSON.parse(result.value.output || "{}").aiSummary`. (Follow-up noted in PR to return structured `values` instead — if that lands, update this page.) +3. `CmsGenerateEntryContentUseCase.execute(...)` returns `Result` with `value.values` = the generated entry as a structured object (keyed by fieldId). Doc reads `result.value.values.aiSummary` directly, no parse. (This is the reshaped API from webiny-js#5477, targeting release/6.5.0 alongside these docs; serialization now happens only at the transport edge — the task's websocket stream.) 4. Re-runnable convergence via a per-run token: contrast with the discount page's permanent boolean flag. `runId` fresh per click; `processData` stamps `aiSummarizedRun`; `loadData` filters `aiSummarizedRun_not: runId`. Given its own section. 5. GraphQL where nests custom fields under `values: {}`; backend `loadData` flattens to `values.`. Shown on both sides (frontend builds nested, backend flattens). 6. Button is a `DropdownMenu` of AI contexts loaded from `GetSettingsFeature`: Projects, Writer Personas, Reader Personas, plus "Default (no context)". Graceful when AI Power Ups unconfigured (catch → only Default). diff --git a/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.mdx b/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.mdx index 0f3a7326d..223942326 100644 --- a/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.mdx +++ b/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.mdx @@ -106,8 +106,8 @@ class GenerateAiSummaryBulkAction implements EntriesBulkAction.Interface