From 65f105b7d0132137848296c5595395033564d369 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Thu, 30 Jul 2026 10:12:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(app-headless-cms):=20headless=20(code-base?= =?UTF-8?q?d)=20CMS=20bulk=20actions=20=E2=80=94=20phase=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `CmsBulkAction` admin abstraction so a custom entry-list bulk action is declared as one DI class (implement + createImplementation), mirroring the API-side `EntriesBulkAction` — no hand-written React button/dialog/handler. Phase 1 (non-form path): - `CmsBulkAction` abstraction: name, modelIds, icon, button(), confirm(), buildData(), notifications map. Form path (formTitle/buildForm) is TODO(p2). - `IconRegistry` DI feature + `registerIcon` (string key → @webiny/icons React component), exported from `webiny/admin`. - Framework renders the toolbar button (icon → confirm → trigger → deselect → toast) and generates one WebsocketEventHandler per notification key. - Export `CmsBulkAction` from `webiny/admin/cms/entry/list`. - Migrate the applyDiscount demo: 3 admin files → 1 class. - AGENTS.md: no inline object types for params/returns — use a named interface. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 1 + .../applyDiscount/ApplyDiscountExtension.tsx | 4 +- .../bulkActions/applyDiscount/README.md | 18 ++- .../admin/ApplyDiscountAction.tsx | 71 ----------- .../admin/ApplyDiscountBulkAction.tsx | 76 ++++++++++++ .../admin/DiscountAppliedEventHandler.ts | 40 ------ .../applyDiscount/admin/Extension.tsx | 38 ------ .../app-admin/src/base/createRootContainer.ts | 3 + packages/app-admin/src/exports/admin.ts | 2 + .../src/features/icons/IconRegistry.ts | 54 +++++++++ .../src/features/icons/abstractions.ts | 22 ++++ .../app-admin/src/features/icons/feature.ts | 15 +++ .../app-admin/src/features/icons/index.ts | 3 + .../src/features/icons/registerIcon.ts | 10 ++ packages/app-headless-cms/package.json | 1 + .../src/ContentEntriesModule.tsx | 3 + .../src/exports/admin/cms/entry/list.ts | 11 ++ .../CmsBulkActionToolbarButton.tsx | 114 ++++++++++++++++++ .../CmsBulkAction/CmsBulkActionsRegistrar.tsx | 59 +++++++++ .../bulkAction/CmsBulkAction/abstractions.ts | 75 ++++++++++++ .../createNotificationHandler.ts | 46 +++++++ .../bulkAction/CmsBulkAction/index.ts | 8 ++ packages/app-headless-cms/tsconfig.build.json | 3 + packages/app-headless-cms/tsconfig.json | 3 + packages/webiny/package.json | 2 +- packages/webiny/src/admin.ts | 2 + packages/webiny/src/admin/cms/entry/list.ts | 7 ++ yarn.lock | 1 + 28 files changed, 531 insertions(+), 161 deletions(-) delete mode 100644 extensions/bulkActions/applyDiscount/admin/ApplyDiscountAction.tsx create mode 100644 extensions/bulkActions/applyDiscount/admin/ApplyDiscountBulkAction.tsx delete mode 100644 extensions/bulkActions/applyDiscount/admin/DiscountAppliedEventHandler.ts delete mode 100644 extensions/bulkActions/applyDiscount/admin/Extension.tsx create mode 100644 packages/app-admin/src/features/icons/IconRegistry.ts create mode 100644 packages/app-admin/src/features/icons/abstractions.ts create mode 100644 packages/app-admin/src/features/icons/feature.ts create mode 100644 packages/app-admin/src/features/icons/index.ts create mode 100644 packages/app-admin/src/features/icons/registerIcon.ts create mode 100644 packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/CmsBulkActionToolbarButton.tsx create mode 100644 packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/CmsBulkActionsRegistrar.tsx create mode 100644 packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/abstractions.ts create mode 100644 packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/createNotificationHandler.ts create mode 100644 packages/app-headless-cms/src/features/contentEntry/bulkAction/CmsBulkAction/index.ts 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"