diff --git a/AGENTS.md b/AGENTS.md
index ad8645c82b7..897c6d2db67 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,6 +16,7 @@ When new backend features are discovered, update `ai-context/core-features-refer
- A file's name MUST match at least one symbol it exports (keep the filename and the code in sync). e.g. `useScheduledActionsPresenter.ts` exports `useScheduledActionsPresenter`; `ContentEntriesPresenterSchedulingDecorator.ts` exports `ContentEntriesPresenterSchedulingDecorator`
- A React hook that returns a presenter carries the `Presenter` suffix, matching `useContentEntryFormPresenter` (e.g. `useScheduledActionsPresenter`). Resolve a presenter through such a dedicated hook — do not repeat inline `container.resolve(SomePresenter)` across components
- Do NOT define additional React components inline in a hook file (or any file whose primary export is not that component). Extract each component to its own file, named after it (e.g. a schedule dialog hook keeps `ReschedulingAlert`, `FormComponent`, etc. in separate files)
+- Do NOT use inline object types for function parameters or return values (e.g. `(data: { price: number; percent: number })`). Declare a named `interface` at the top of the file and reference it (e.g. `interface DiscountAppliedData { price: number; percent: number }` then `(data: DiscountAppliedData)`)
- When refactoring, we don't care about backwards compatibility, unless explicitly stated in the prompt
## Building
diff --git a/extensions/bulkActions/applyDiscount/ApplyDiscountExtension.tsx b/extensions/bulkActions/applyDiscount/ApplyDiscountExtension.tsx
index 689c96b6964..4fec3d1ee95 100644
--- a/extensions/bulkActions/applyDiscount/ApplyDiscountExtension.tsx
+++ b/extensions/bulkActions/applyDiscount/ApplyDiscountExtension.tsx
@@ -13,7 +13,9 @@ export const ApplyDiscountExtension = () => {
-
+
>
);
};
diff --git a/extensions/bulkActions/applyDiscount/README.md b/extensions/bulkActions/applyDiscount/README.md
index 8c1753db92b..3f238d75697 100644
--- a/extensions/bulkActions/applyDiscount/README.md
+++ b/extensions/bulkActions/applyDiscount/README.md
@@ -7,15 +7,13 @@ One artifact spans all three posts.
Organized by side (`api/` + `admin/`), with a full-stack entry component:
-| File | Role |
-| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
-| `ApplyDiscountExtension.tsx` | **Entry.** Full-stack component — wires up the API and Admin extensions below. |
-| `api/ApplyDiscountBulkAction.ts` | **API.** A custom `EntriesBulkAction`. Webiny auto-generates a background task from it. Emits a websocket message per processed entry. |
-| `admin/Extension.tsx` | **Admin entry.** Registers the bulk-action button + the websocket listener. |
-| `admin/ApplyDiscountAction.tsx` | **Admin.** The bulk-action button that triggers the task. |
-| `admin/DiscountAppliedEventHandler.ts` | **Admin.** Websocket listener that toasts when a discount is applied. |
-| `../models/ProductModel.ts` | The demo CMS model (`product`, with `price` + `onSale`). |
-| `../models/ProductCategoryModel.ts` | Referenced by Product; registered so the category picker isn't dangling. |
+| File | Role |
+| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `ApplyDiscountExtension.tsx` | **Entry.** Full-stack component — wires up the API and Admin extensions below. |
+| `api/ApplyDiscountBulkAction.ts` | **API.** A custom `EntriesBulkAction`. Webiny auto-generates a background task from it. Emits a websocket message per processed entry. |
+| `admin/ApplyDiscountBulkAction.tsx` | **Admin.** A single code-based `CmsBulkAction` class (button, confirm, trigger, notification), registered via `RegisterFeature`. The framework generates the toolbar button and the websocket toast handler — no hand-written React. |
+| `../models/ProductModel.ts` | The demo CMS model (`product`, with `price` + `onSale`). |
+| `../models/ProductCategoryModel.ts` | Referenced by Product; registered so the category picker isn't dangling. |
Registered in `webiny.config.tsx` as `` (plus the two model
`` entries under `extensions/models/`).
@@ -71,7 +69,7 @@ triggered the action (`WebsocketsSendToIdentityUseCase`), and the admin toasts i
```
[processData] → sendToIdentity({ action: "cms.product.discountApplied", data: { id, price, percent } })
▼
-[DiscountAppliedEventHandler] (WebsocketEventHandler) → notifications.success(...)
+[generated WebsocketEventHandler] (from CmsBulkAction.notifications) → notifications.success(...)
```
Mirrors the File Manager AI-enrichment pattern. Fires per processed entry, so toasts pop
diff --git a/extensions/bulkActions/applyDiscount/admin/ApplyDiscountAction.tsx b/extensions/bulkActions/applyDiscount/admin/ApplyDiscountAction.tsx
deleted file mode 100644
index c631eff6174..00000000000
--- a/extensions/bulkActions/applyDiscount/admin/ApplyDiscountAction.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import React from "react";
-import { observer } from "mobx-react-lite";
-import { ReactComponent as DiscountIcon } from "webiny/admin/icons/discount.svg";
-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";
-
-/**
- * The "Apply Discount" bulk action button, shown in the Products content entry list
- * whenever one or more entries are selected.
- *
- * `BulkActionFeature` resolves the `BulkActionUseCase`, whose `execute()` fires the
- * GraphQL mutation that triggers the background task Webiny generated from our
- * `ApplyDiscountBulkAction` (API side). The browser does NOT loop over entries — the
- * whole selection is handed to the API and processed server-side, in the background.
- * The user can navigate away while it runs and follow progress in the Background Tasks
- * screen.
- */
-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 current 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();
-
- // Confirm the task was kicked off. Per-product "Discount applied" toasts
- // then arrive over websockets as the background task processes the batch.
- showSuccessToast({
- title: "Discount task started",
- description: `Applying -${DISCOUNT_PERCENT}% in the background. You can keep working.`
- });
- }
- });
-
- return (
- }
- onClick={openDialog}
- />
- );
-});
diff --git a/extensions/bulkActions/applyDiscount/admin/ApplyDiscountBulkAction.tsx b/extensions/bulkActions/applyDiscount/admin/ApplyDiscountBulkAction.tsx
new file mode 100644
index 00000000000..5e2df242f2f
--- /dev/null
+++ b/extensions/bulkActions/applyDiscount/admin/ApplyDiscountBulkAction.tsx
@@ -0,0 +1,76 @@
+import React from "react";
+import { createFeature, RegisterFeature } from "webiny/admin";
+import { CmsBulkAction } from "webiny/admin/cms/entry/list";
+
+/**
+ * "Apply Discount" bulk action on Products — the code-based ("headless") version.
+ *
+ * A single `CmsBulkAction` class replaces the old hand-written button + websocket handler.
+ * The framework generates the toolbar button (from `button()`/`icon`), the confirmation
+ * dialog (`confirm()`), the background-task trigger (`buildData()` → `bulkActionProduct`),
+ * and the per-entry "Discount applied" toast (`notifications`). No React is written here.
+ *
+ * `name` PascalCases into the API action (`applyDiscount` → `ApplyDiscount`), matching the
+ * API-side `ApplyDiscountBulkAction`. `modelIds` restricts the button to the Products model.
+ */
+const DISCOUNT_PERCENT = 10;
+
+interface ApplyDiscountData {
+ percent: number;
+}
+
+// Payload of the `cms.product.discountApplied` websocket message (emitted per processed
+// entry by the API-side bulk action).
+interface DiscountAppliedData {
+ price: number;
+ percent: number;
+}
+
+class ApplyDiscountBulkActionImpl implements CmsBulkAction.Interface {
+ readonly name = "applyDiscount";
+ readonly modelIds = ["product"];
+ readonly icon = "discount";
+
+ button() {
+ return {
+ text: `Apply -${DISCOUNT_PERCENT}%`,
+ tooltip: `Apply ${DISCOUNT_PERCENT}% discount to the selected products`
+ };
+ }
+
+ confirm(ctx: CmsBulkAction.Ctx): CmsBulkAction.Confirm {
+ return {
+ title: "Apply discount",
+ message: `Apply a ${DISCOUNT_PERCENT}% discount to ${ctx.selection.label}? This runs as a background task, so you can keep working while it processes.`,
+ loadingLabel: `Processing ${ctx.selection.label}`
+ };
+ }
+
+ buildData(): ApplyDiscountData {
+ return { percent: DISCOUNT_PERCENT };
+ }
+
+ readonly notifications = {
+ "cms.product.discountApplied": (data: DiscountAppliedData): CmsBulkAction.Notification => ({
+ variant: "success",
+ title: "Discount applied",
+ description: `-${data.percent}% applied — new price ${data.price}.`
+ })
+ };
+}
+
+const ApplyDiscountBulkAction = CmsBulkAction.createImplementation({
+ implementation: ApplyDiscountBulkActionImpl,
+ dependencies: []
+});
+
+const ApplyDiscountFeature = createFeature({
+ name: "BulkActions/ApplyDiscount",
+ register(container) {
+ container.register(ApplyDiscountBulkAction);
+ }
+});
+
+export default () => {
+ return ;
+};
diff --git a/extensions/bulkActions/applyDiscount/admin/DiscountAppliedEventHandler.ts b/extensions/bulkActions/applyDiscount/admin/DiscountAppliedEventHandler.ts
deleted file mode 100644
index 403901212e6..00000000000
--- a/extensions/bulkActions/applyDiscount/admin/DiscountAppliedEventHandler.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { WebsocketEventHandler } from "webiny/admin/websockets";
-import { Notifications } from "webiny/admin";
-
-const DISCOUNT_APPLIED_ACTION = "cms.product.discountApplied";
-
-interface DiscountAppliedData {
- id: string;
- price: number;
- percent: number;
-}
-
-/**
- * Reacts to the `cms.product.discountApplied` websocket message emitted by the
- * ApplyDiscount bulk action (once per discounted product) and shows a toast.
- *
- * Registered via `createFeature` in this extension's `index.tsx`. The websockets runner
- * resolves every registered `WebsocketEventHandler` and calls `handle` for each incoming
- * message, so we filter by `action` here.
- */
-class DiscountAppliedEventHandlerImpl implements WebsocketEventHandler.Interface {
- constructor(private notifications: Notifications.Interface) {}
-
- async handle(event: WebsocketEventHandler.Event): Promise {
- const payload = event.payload as { action?: string; data?: DiscountAppliedData };
- if (payload.action !== DISCOUNT_APPLIED_ACTION || !payload.data) {
- return;
- }
-
- const { price, percent } = payload.data;
- this.notifications.success({
- title: "Discount applied",
- description: `-${percent}% applied — new price ${price}.`
- });
- }
-}
-
-export const DiscountAppliedEventHandler = WebsocketEventHandler.createImplementation({
- implementation: DiscountAppliedEventHandlerImpl,
- dependencies: [Notifications]
-});
diff --git a/extensions/bulkActions/applyDiscount/admin/Extension.tsx b/extensions/bulkActions/applyDiscount/admin/Extension.tsx
deleted file mode 100644
index a9cfc6d20df..00000000000
--- a/extensions/bulkActions/applyDiscount/admin/Extension.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import React from "react";
-import { createFeature, RegisterFeature } from "webiny/admin";
-import { ContentEntryListConfig } from "webiny/admin/cms/entry/list";
-import { ApplyDiscountAction } from "./ApplyDiscountAction.js";
-import { DiscountAppliedEventHandler } from "./DiscountAppliedEventHandler.js";
-
-const { Browser } = ContentEntryListConfig;
-
-/**
- * Registers a websocket event handler that toasts when a product discount is applied
- * (the backend emits `cms.product.discountApplied` per processed entry).
- */
-const ApplyDiscountFeature = createFeature({
- name: "BulkActions/ApplyDiscount",
- register(container) {
- container.register(DiscountAppliedEventHandler);
- }
-});
-
-/**
- * Registers the "Apply Discount" bulk action button in the content entry list, plus the
- * websocket listener above. `name` must match the backend `ApplyDiscountBulkAction`, and
- * `modelIds` restricts the button to the Products model.
- */
-export default () => {
- return (
- <>
-
-
- }
- modelIds={["product"]}
- />
-
- >
- );
-};
diff --git a/packages/app-admin/src/base/createRootContainer.ts b/packages/app-admin/src/base/createRootContainer.ts
index 281dae5c173..11177edb462 100644
--- a/packages/app-admin/src/base/createRootContainer.ts
+++ b/packages/app-admin/src/base/createRootContainer.ts
@@ -19,6 +19,7 @@ import { ToolsFeature } from "~/features/tools/feature.js";
import { TextToLexicalToolFeature } from "~/presentation/textToLexicalTool/feature.js";
import { ConfirmationFeature } from "~/features/confirmation/feature.js";
import { ClipboardFeature } from "~/features/clipboard/feature.js";
+import { IconRegistryFeature } from "~/features/icons/feature.js";
const isUndefined = (value: any) => [undefined, "undefined"].includes(value);
@@ -77,5 +78,7 @@ export function createRootContainer() {
ClipboardFeature.register(container);
+ IconRegistryFeature.register(container);
+
return container;
}
diff --git a/packages/app-admin/src/exports/admin.ts b/packages/app-admin/src/exports/admin.ts
index 19045f36cc5..0134c184c0d 100644
--- a/packages/app-admin/src/exports/admin.ts
+++ b/packages/app-admin/src/exports/admin.ts
@@ -10,4 +10,6 @@ export { AdminConfig } from "~/config/AdminConfig.js";
export { Routes } from "~/routes.js";
export { BulkActionButton, useBulkActionDialog } from "~/components/BulkActions/index.js";
export { Notifications } from "~/features/notifications/abstractions.js";
+export { IconRegistry, registerIcon } from "~/features/icons/index.js";
+export type { IIconRegistry, IconComponent } from "~/features/icons/index.js";
export { Command, CommandPalettePresenter } from "~/presentation/commandPalette/index.js";
diff --git a/packages/app-admin/src/features/icons/IconRegistry.ts b/packages/app-admin/src/features/icons/IconRegistry.ts
new file mode 100644
index 00000000000..81c61ec7ff4
--- /dev/null
+++ b/packages/app-admin/src/features/icons/IconRegistry.ts
@@ -0,0 +1,54 @@
+import { ReactComponent as AutoAwesomeIcon } from "@webiny/icons/auto_awesome.svg";
+import { ReactComponent as BoltIcon } from "@webiny/icons/bolt.svg";
+import { ReactComponent as CheckCircleIcon } from "@webiny/icons/check_circle.svg";
+import { ReactComponent as DeleteIcon } from "@webiny/icons/delete.svg";
+import { ReactComponent as DiscountIcon } from "@webiny/icons/discount.svg";
+import { ReactComponent as DownloadIcon } from "@webiny/icons/download.svg";
+import { ReactComponent as PublishIcon } from "@webiny/icons/publish.svg";
+import { ReactComponent as SellIcon } from "@webiny/icons/sell.svg";
+import { ReactComponent as SendIcon } from "@webiny/icons/send.svg";
+import { ReactComponent as StarIcon } from "@webiny/icons/star.svg";
+import { ReactComponent as VisibilityIcon } from "@webiny/icons/visibility.svg";
+import { IconRegistry as Abstraction, type IconComponent } from "./abstractions.js";
+
+/**
+ * Pre-seeded with a handful of `@webiny/icons` SVGs already used across the admin app, so
+ * common string keys resolve out of the box. Register more via `registerIcon(container, ...)`.
+ */
+class IconRegistryImpl implements Abstraction.Interface {
+ private readonly icons = new Map();
+
+ constructor() {
+ this.register("auto_awesome", AutoAwesomeIcon);
+ this.register("bolt", BoltIcon);
+ this.register("check_circle", CheckCircleIcon);
+ this.register("delete", DeleteIcon);
+ this.register("discount", DiscountIcon);
+ this.register("download", DownloadIcon);
+ this.register("publish", PublishIcon);
+ this.register("sell", SellIcon);
+ this.register("send", SendIcon);
+ this.register("star", StarIcon);
+ this.register("visibility", VisibilityIcon);
+ }
+
+ register(key: string, component: IconComponent): void {
+ this.icons.set(key, component);
+ }
+
+ get(key: string): IconComponent | undefined {
+ const component = this.icons.get(key);
+ if (!component && process.env.NODE_ENV !== "production") {
+ console.warn(
+ `[IconRegistry] No icon registered for key "${key}". ` +
+ `Register it via registerIcon(container, "${key}", Component) or pass a React element instead.`
+ );
+ }
+ return component;
+ }
+}
+
+export const IconRegistry = Abstraction.createImplementation({
+ implementation: IconRegistryImpl,
+ dependencies: []
+});
diff --git a/packages/app-admin/src/features/icons/abstractions.ts b/packages/app-admin/src/features/icons/abstractions.ts
new file mode 100644
index 00000000000..a4ff1b2b330
--- /dev/null
+++ b/packages/app-admin/src/features/icons/abstractions.ts
@@ -0,0 +1,22 @@
+import React from "react";
+import { createAbstraction } from "@webiny/feature/admin";
+
+export type IconComponent = React.ComponentType>;
+
+/**
+ * A small DI-backed registry that maps a string key to an icon React component. Features
+ * (and users) that only know an icon by name — e.g. a code-based `CmsBulkAction` declaring
+ * `icon = "discount"` — resolve the actual SVG component through this registry, instead of
+ * importing it directly. Unknown keys resolve to `undefined` (with a dev-only warning).
+ */
+export interface IIconRegistry {
+ register(key: string, component: IconComponent): void;
+ get(key: string): IconComponent | undefined;
+}
+
+export const IconRegistry = createAbstraction("IconRegistry");
+
+export namespace IconRegistry {
+ export type Interface = IIconRegistry;
+ export type Component = IconComponent;
+}
diff --git a/packages/app-admin/src/features/icons/feature.ts b/packages/app-admin/src/features/icons/feature.ts
new file mode 100644
index 00000000000..5efd081702e
--- /dev/null
+++ b/packages/app-admin/src/features/icons/feature.ts
@@ -0,0 +1,15 @@
+import { createFeature } from "@webiny/feature/admin";
+import { IconRegistry } from "./IconRegistry.js";
+import { IconRegistry as IconRegistryAbstraction } from "./abstractions.js";
+
+export const IconRegistryFeature = createFeature({
+ name: "IconRegistry",
+ register(container) {
+ container.register(IconRegistry).inSingletonScope();
+ },
+ resolve(container) {
+ return {
+ registry: container.resolve(IconRegistryAbstraction)
+ };
+ }
+});
diff --git a/packages/app-admin/src/features/icons/index.ts b/packages/app-admin/src/features/icons/index.ts
new file mode 100644
index 00000000000..1c83a16a3d5
--- /dev/null
+++ b/packages/app-admin/src/features/icons/index.ts
@@ -0,0 +1,3 @@
+export { IconRegistry, type IIconRegistry, type IconComponent } from "./abstractions.js";
+export { IconRegistryFeature } from "./feature.js";
+export { registerIcon } from "./registerIcon.js";
diff --git a/packages/app-admin/src/features/icons/registerIcon.ts b/packages/app-admin/src/features/icons/registerIcon.ts
new file mode 100644
index 00000000000..ae34a8b47a7
--- /dev/null
+++ b/packages/app-admin/src/features/icons/registerIcon.ts
@@ -0,0 +1,10 @@
+import { Container } from "@webiny/di";
+import { IconRegistry, type IconComponent } from "./abstractions.js";
+
+/**
+ * Registers (or overrides) an icon under `key` in the singleton `IconRegistry`. The registry
+ * must already be registered in the container (see `IconRegistryFeature`).
+ */
+export function registerIcon(container: Container, key: string, component: IconComponent): void {
+ container.resolve(IconRegistry).register(key, component);
+}
diff --git a/packages/app-headless-cms/package.json b/packages/app-headless-cms/package.json
index d1813650353..e19b984fa98 100644
--- a/packages/app-headless-cms/package.json
+++ b/packages/app-headless-cms/package.json
@@ -34,6 +34,7 @@
"@webiny/app-admin": "0.0.0",
"@webiny/app-graphql-playground": "0.0.0",
"@webiny/app-headless-cms-common": "0.0.0",
+ "@webiny/app-websockets": "0.0.0",
"@webiny/cms-sdk": "0.0.0",
"@webiny/di": "^1.0.2",
"@webiny/feature": "0.0.0",
diff --git a/packages/app-headless-cms/src/ContentEntriesModule.tsx b/packages/app-headless-cms/src/ContentEntriesModule.tsx
index 5ef76afd78b..2e3be23b572 100644
--- a/packages/app-headless-cms/src/ContentEntriesModule.tsx
+++ b/packages/app-headless-cms/src/ContentEntriesModule.tsx
@@ -35,6 +35,7 @@ import {
CellStatus
} from "~/admin/components/ContentEntries/Table/Cells/index.js";
import { IsModelPublishable } from "~/admin/components/IsModelPublishable.js";
+import { CmsBulkActionsRegistrar } from "~/features/contentEntry/bulkAction/CmsBulkAction/index.js";
import { FilterByStatus } from "~/admin/components/ContentEntries/FilterByStatus.js";
import { CmsTrashBin } from "~/presentation/contentEntries/trashBin/CmsTrashBin.js";
import { TrashEntryConfirmDialog } from "~/admin/components/Dialogs/TrashEntryConfirmDialog.js";
@@ -85,6 +86,8 @@ export const ContentEntriesModule = () => {
} />
} />
} />
+ {/* Code-based (headless) bulk actions registered via CmsBulkAction. */}
+
} />
} />
} />
diff --git a/packages/app-headless-cms/src/exports/admin/cms/entry/list.ts b/packages/app-headless-cms/src/exports/admin/cms/entry/list.ts
index d63504a7de8..579c970d0d9 100644
--- a/packages/app-headless-cms/src/exports/admin/cms/entry/list.ts
+++ b/packages/app-headless-cms/src/exports/admin/cms/entry/list.ts
@@ -11,3 +11,14 @@ export type { IGetEntryGraphQLFieldSelection } from "~/features/contentEntry/get
// on the API side (webiny/api/cms/entry).
export { BulkActionFeature } from "~/features/contentEntry/bulkAction/feature.js";
export { BulkActionUseCase } from "~/features/contentEntry/bulkAction/abstractions.js";
+
+// Code-based ("headless") bulk actions. Implement `CmsBulkAction` and register it with
+// `CmsBulkAction.createImplementation({ implementation, dependencies })` + ``;
+// the framework generates the toolbar button and websocket notification handlers.
+export { CmsBulkAction } from "~/features/contentEntry/bulkAction/CmsBulkAction/index.js";
+export type {
+ ICmsBulkAction,
+ BulkActionCtx,
+ ConfirmSpec,
+ NotificationSpec
+} from "~/features/contentEntry/bulkAction/CmsBulkAction/index.js";
diff --git a/packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/CmsBulkActionToolbarButton.tsx b/packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/CmsBulkActionToolbarButton.tsx
new file mode 100644
index 00000000000..d17abb6e607
--- /dev/null
+++ b/packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/CmsBulkActionToolbarButton.tsx
@@ -0,0 +1,114 @@
+import React from "react";
+import { observer } from "mobx-react-lite";
+import { useContainer, useFeature } from "@webiny/app";
+import {
+ BulkActionButton,
+ useBulkActionDialog
+} from "@webiny/app-admin/components/BulkActions/index.js";
+import { IconRegistry } from "@webiny/app-admin/features/icons/index.js";
+import { useToast } from "@webiny/admin-ui";
+import { useModel } from "~/admin/hooks/index.js";
+import { useContentEntriesPresenter } from "~/presentation/contentEntries/list/useContentEntriesPresenter.js";
+import { BulkActionFeature } from "../feature.js";
+import type { CmsBulkAction } from "./abstractions.js";
+
+/**
+ * PascalCases a `CmsBulkAction.name` into the API trigger action, mirroring how Webiny
+ * generates the `bulkAction(action: ...)` enum value (e.g. `applyDiscount` →
+ * `ApplyDiscount`).
+ */
+const toActionName = (name: string): string => name.charAt(0).toUpperCase() + name.slice(1);
+
+interface CmsBulkActionToolbarButtonProps {
+ action: CmsBulkAction.Interface;
+}
+
+/**
+ * The generated toolbar button rendered for a resolved `CmsBulkAction`. It builds the
+ * framework `BulkActionCtx`, resolves the icon (string key via `IconRegistry`, or a raw
+ * element), and on click either opens a confirmation dialog or triggers directly. The
+ * trigger runs the (built-in or custom) entries bulk action as a background task through
+ * `BulkActionFeature`.
+ */
+export const CmsBulkActionToolbarButton = observer(
+ ({ action }: CmsBulkActionToolbarButtonProps) => {
+ const container = useContainer();
+ 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))
+ .map(row => ({ id: row.id }));
+
+ // Scope the task to the selected entries. When "select all" (across pages) is active,
+ // omit the filter so the task processes everything matching the current view.
+ const where = selection.allSelected ? undefined : { id_in: [...selection.selectedIds] };
+
+ const ctx: CmsBulkAction.Ctx = {
+ model,
+ selection,
+ selectedItems,
+ where,
+ values: {} // TODO(phase 2): populated from the built form on submit.
+ };
+
+ const buttonSpec = action.button(ctx);
+ const confirmSpec = action.confirm?.(ctx);
+
+ const run = async () => {
+ await bulkAction.execute({
+ model,
+ action: toActionName(action.name),
+ where,
+ data: action.buildData(ctx)
+ });
+
+ presenter.list.actions.selection.deselectAll();
+
+ // Confirm the task was kicked off. Per-entry toasts then arrive over websockets
+ // (see the action's `notifications`), handled by the generated event handlers.
+ showSuccessToast({
+ title: `${buttonSpec.text} started`,
+ description: `Running for ${selection.label} in the background. You can keep working.`
+ });
+ };
+
+ const onClick = () => {
+ if (confirmSpec) {
+ showConfirmationDialog({
+ title: confirmSpec.title,
+ message: confirmSpec.message,
+ loadingLabel: confirmSpec.loadingLabel,
+ execute: run
+ });
+ return;
+ }
+
+ // TODO(phase 2): form path — open a form dialog (formTitle/buildForm), collect
+ // values into `ctx.values`, then run.
+
+ run();
+ };
+
+ let icon: React.ReactElement | undefined;
+ if (typeof action.icon === "string") {
+ const Component = container.resolve(IconRegistry).get(action.icon);
+ icon = Component ? : undefined;
+ } else {
+ icon = action.icon;
+ }
+
+ return (
+
+ );
+ }
+);
diff --git a/packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/CmsBulkActionsRegistrar.tsx b/packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/CmsBulkActionsRegistrar.tsx
new file mode 100644
index 00000000000..bea35681483
--- /dev/null
+++ b/packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/CmsBulkActionsRegistrar.tsx
@@ -0,0 +1,59 @@
+import React, { useEffect, useRef, useState } from "react";
+import { useContainer } from "@webiny/app";
+import { Browser } from "~/admin/config/contentEntries/list/Browser/index.js";
+import { CmsBulkAction } from "./abstractions.js";
+import { CmsBulkActionToolbarButton } from "./CmsBulkActionToolbarButton.js";
+import { createNotificationHandler } from "./createNotificationHandler.js";
+
+/**
+ * Resolves every registered `CmsBulkAction` implementation and wires it into the framework:
+ *
+ * - contributes a `` toolbar entry (name + modelIds + generated button)
+ * to the content entry list config, and
+ * - registers one generated `WebsocketEventHandler` per `notifications` entry, so the
+ * per-entry toasts arrive over websockets without any hand-written handler.
+ *
+ * Users only register their implementation (via `CmsBulkAction.createImplementation` +
+ * ``); this registrar is mounted once by the framework.
+ *
+ * Resolution runs in an effect (after the first commit) so it observes implementations
+ * registered anywhere in the initial render tree, regardless of render order relative to
+ * this component.
+ */
+export const CmsBulkActionsRegistrar = () => {
+ const container = useContainer();
+ const [actions, setActions] = useState([]);
+ const registeredNotifications = useRef(false);
+
+ useEffect(() => {
+ const resolved = container.resolveAll(CmsBulkAction);
+
+ if (!registeredNotifications.current) {
+ registeredNotifications.current = true;
+ resolved.forEach(action => {
+ const notifications = action.notifications;
+ if (!notifications) {
+ return;
+ }
+ Object.entries(notifications).forEach(([actionKey, build]) => {
+ container.register(createNotificationHandler(actionKey, build));
+ });
+ });
+ }
+
+ setActions(resolved);
+ }, [container]);
+
+ return (
+ <>
+ {actions.map(action => (
+ }
+ />
+ ))}
+ >
+ );
+};
diff --git a/packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/abstractions.ts b/packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/abstractions.ts
new file mode 100644
index 00000000000..e0c21a86f34
--- /dev/null
+++ b/packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/abstractions.ts
@@ -0,0 +1,75 @@
+import React from "react";
+import { createAbstraction } from "@webiny/feature/admin";
+import type { CmsModel } from "~/types.js";
+
+// ---------------------------------------------------------------------------
+// Framework-built context handed to every callback.
+// ---------------------------------------------------------------------------
+
+export interface BulkActionCtx {
+ model: CmsModel;
+ selection: {
+ selectedIds: Set;
+ selectedCount: number;
+ allSelected: boolean;
+ label: string;
+ };
+ selectedItems: { id: string }[];
+ // Standard scope the framework pre-computes (allSelected ? undefined : { id_in }).
+ where: Record | undefined;
+ // Values collected from the built form. Empty object for confirm/simple actions.
+ // TODO(phase 2): populated from the built FormModel on submit.
+ values: TData;
+}
+
+export interface NotificationSpec {
+ variant: "success" | "info" | "warning" | "danger";
+ title: string;
+ description?: string;
+}
+
+export interface ConfirmSpec {
+ title: string;
+ message: string;
+ loadingLabel?: string;
+}
+
+// ---------------------------------------------------------------------------
+// The admin-side bulk-action abstraction. Users implement it and register with
+// `createImplementation`, exactly like the API-side `EntriesBulkAction`.
+// ---------------------------------------------------------------------------
+
+export interface ICmsBulkAction {
+ // Matches the API `EntriesBulkAction.name`; PascalCased into the trigger action.
+ readonly name: string;
+ // Restrict the button to specific models (optional).
+ readonly modelIds?: string[];
+ // IconRegistry key (string) or a raw icon element (escape hatch).
+ readonly icon?: string | React.ReactElement;
+
+ button(ctx: BulkActionCtx): { text: string; tooltip?: string };
+
+ // Plain confirmation dialog. Omit for a direct (no-confirm) trigger.
+ confirm?(ctx: BulkActionCtx): ConfirmSpec;
+
+ // TODO(phase 2): form path — `formTitle(ctx)` + `buildForm(form, ctx)` (the FormModel
+ // `buildForm` builder convention). When present, the framework opens a form dialog and
+ // hands the collected values to `buildData` via `ctx.values`.
+
+ // The trigger payload (the "mapFromForm" equivalent): `ctx.values` holds the collected
+ // form data (empty object in phase 1).
+ buildData(ctx: BulkActionCtx): TData;
+
+ // Action-key → notification. The framework generates one WebsocketEventHandler per entry
+ // and shows the toast via `Notifications`.
+ readonly notifications?: Record NotificationSpec>;
+}
+
+export const CmsBulkAction = createAbstraction("Cms/BulkAction");
+
+export namespace CmsBulkAction {
+ export type Interface = ICmsBulkAction;
+ export type Ctx = BulkActionCtx;
+ export type Notification = NotificationSpec;
+ export type Confirm = ConfirmSpec;
+}
diff --git a/packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/createNotificationHandler.ts b/packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/createNotificationHandler.ts
new file mode 100644
index 00000000000..8aae3db6397
--- /dev/null
+++ b/packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/createNotificationHandler.ts
@@ -0,0 +1,46 @@
+import { WebsocketEventHandler } from "@webiny/app-websockets";
+import { Notifications } from "@webiny/app-admin/features/notifications/abstractions.js";
+import type { NotificationSpec } from "./abstractions.js";
+
+/**
+ * Generates a `WebsocketEventHandler` for a single `CmsBulkAction` notification entry. It
+ * mirrors the hand-written `DiscountAppliedEventHandler` pattern: filter incoming websocket
+ * messages by `payload.action`, build a `NotificationSpec` from `payload.data`, and surface
+ * it through `Notifications`.
+ */
+export function createNotificationHandler(
+ actionKey: string,
+ build: (data: any) => NotificationSpec
+) {
+ class GeneratedNotificationHandler implements WebsocketEventHandler.Interface {
+ constructor(readonly notifications: Notifications.Interface) {}
+
+ async handle(event: WebsocketEventHandler.Event): Promise {
+ const payload = event.payload as { action?: string; data?: any };
+ if (payload.action !== actionKey || !payload.data) {
+ return;
+ }
+
+ const spec = build(payload.data);
+ const input = { title: spec.title, description: spec.description };
+
+ switch (spec.variant) {
+ case "success":
+ this.notifications.success(input);
+ break;
+ case "warning":
+ case "danger":
+ this.notifications.warning(input);
+ break;
+ default:
+ this.notifications.notify(input);
+ break;
+ }
+ }
+ }
+
+ return WebsocketEventHandler.createImplementation({
+ implementation: GeneratedNotificationHandler,
+ dependencies: [Notifications]
+ });
+}
diff --git a/packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/index.ts b/packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/index.ts
new file mode 100644
index 00000000000..35914b221d6
--- /dev/null
+++ b/packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/index.ts
@@ -0,0 +1,8 @@
+export { CmsBulkAction } from "./abstractions.js";
+export type {
+ ICmsBulkAction,
+ BulkActionCtx,
+ ConfirmSpec,
+ NotificationSpec
+} from "./abstractions.js";
+export { CmsBulkActionsRegistrar } from "./CmsBulkActionsRegistrar.js";
diff --git a/packages/app-headless-cms/tsconfig.build.json b/packages/app-headless-cms/tsconfig.build.json
index 8d3c68e67df..dd47fbdaede 100644
--- a/packages/app-headless-cms/tsconfig.build.json
+++ b/packages/app-headless-cms/tsconfig.build.json
@@ -8,6 +8,7 @@
{ "path": "../app-admin/tsconfig.build.json" },
{ "path": "../app-graphql-playground/tsconfig.build.json" },
{ "path": "../app-headless-cms-common/tsconfig.build.json" },
+ { "path": "../app-websockets/tsconfig.build.json" },
{ "path": "../cms-sdk/tsconfig.build.json" },
{ "path": "../feature/tsconfig.build.json" },
{ "path": "../form/tsconfig.build.json" },
@@ -38,6 +39,8 @@
"@webiny/app-graphql-playground": ["../app-graphql-playground/src"],
"@webiny/app-headless-cms-common/*": ["../app-headless-cms-common/src/*"],
"@webiny/app-headless-cms-common": ["../app-headless-cms-common/src"],
+ "@webiny/app-websockets/*": ["../app-websockets/src/*"],
+ "@webiny/app-websockets": ["../app-websockets/src"],
"@webiny/cms-sdk/*": ["../cms-sdk/src/*"],
"@webiny/cms-sdk": ["../cms-sdk/src"],
"@webiny/feature/api": ["../feature/src/api/index.js"],
diff --git a/packages/app-headless-cms/tsconfig.json b/packages/app-headless-cms/tsconfig.json
index ccb5170a352..0a8920e465f 100644
--- a/packages/app-headless-cms/tsconfig.json
+++ b/packages/app-headless-cms/tsconfig.json
@@ -8,6 +8,7 @@
{ "path": "../app-admin" },
{ "path": "../app-graphql-playground" },
{ "path": "../app-headless-cms-common" },
+ { "path": "../app-websockets" },
{ "path": "../cms-sdk" },
{ "path": "../feature" },
{ "path": "../form" },
@@ -38,6 +39,8 @@
"@webiny/app-graphql-playground": ["../app-graphql-playground/src"],
"@webiny/app-headless-cms-common/*": ["../app-headless-cms-common/src/*"],
"@webiny/app-headless-cms-common": ["../app-headless-cms-common/src"],
+ "@webiny/app-websockets/*": ["../app-websockets/src/*"],
+ "@webiny/app-websockets": ["../app-websockets/src"],
"@webiny/cms-sdk/*": ["../cms-sdk/src/*"],
"@webiny/cms-sdk": ["../cms-sdk/src"],
"@webiny/feature/api": ["../feature/src/api/index.js"],
diff --git a/packages/webiny/package.json b/packages/webiny/package.json
index 2d35f08fdda..d634fd58ae0 100644
--- a/packages/webiny/package.json
+++ b/packages/webiny/package.json
@@ -151,7 +151,7 @@
"./extensions": "./extensions.js",
"./api/tenant-manager": "./api/tenant-manager.js"
},
- "exportGenerationHash": "69aa787127c45f42db2ecf8f5a4ba81ec7f7e19e5b0995c6d85b51e35430d73b",
+ "exportGenerationHash": "723a3200b537905ed660cfd3e8937be55e8127897f1c1e7dc203ad13a9a37642",
"webiny": {
"publishFrom": "dist"
}
diff --git a/packages/webiny/src/admin.ts b/packages/webiny/src/admin.ts
index 6e86732dd28..e3308a6d511 100644
--- a/packages/webiny/src/admin.ts
+++ b/packages/webiny/src/admin.ts
@@ -24,6 +24,8 @@ export {
useBulkActionDialog
} from "@webiny/app-admin/components/BulkActions/index.js";
export { Notifications } from "@webiny/app-admin/features/notifications/abstractions.js";
+export { IconRegistry, registerIcon } from "@webiny/app-admin/features/icons/index.js";
+export type { IIconRegistry, IconComponent } from "@webiny/app-admin/features/icons/index.js";
export {
Command,
CommandPalettePresenter
diff --git a/packages/webiny/src/admin/cms/entry/list.ts b/packages/webiny/src/admin/cms/entry/list.ts
index 76179449cac..b9cabb6778f 100644
--- a/packages/webiny/src/admin/cms/entry/list.ts
+++ b/packages/webiny/src/admin/cms/entry/list.ts
@@ -7,3 +7,10 @@ export { GetEntryGraphQLFieldSelection } from "@webiny/app-headless-cms/features
export type { IGetEntryGraphQLFieldSelection } from "@webiny/app-headless-cms/features/contentEntry/getEntry/abstractions.js";
export { BulkActionFeature } from "@webiny/app-headless-cms/features/contentEntry/bulkAction/feature.js";
export { BulkActionUseCase } from "@webiny/app-headless-cms/features/contentEntry/bulkAction/abstractions.js";
+export { CmsBulkAction } from "@webiny/app-headless-cms/features/contentEntry/bulkAction/CmsBulkAction/index.js";
+export type {
+ ICmsBulkAction,
+ BulkActionCtx,
+ ConfirmSpec,
+ NotificationSpec
+} from "@webiny/app-headless-cms/features/contentEntry/bulkAction/CmsBulkAction/index.js";
diff --git a/yarn.lock b/yarn.lock
index df663414c49..dbea1400d26 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -11881,6 +11881,7 @@ __metadata:
"@webiny/app-admin": "npm:0.0.0"
"@webiny/app-graphql-playground": "npm:0.0.0"
"@webiny/app-headless-cms-common": "npm:0.0.0"
+ "@webiny/app-websockets": "npm:0.0.0"
"@webiny/build-tools": "npm:0.0.0"
"@webiny/cms-sdk": "npm:0.0.0"
"@webiny/di": "npm:^1.0.2"