Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ export const ApplyDiscountExtension = () => {
<Api.Extension
src={"@/extensions/bulkActions/applyDiscount/api/ApplyDiscountBulkAction.ts"}
/>
<Admin.Extension src={"@/extensions/bulkActions/applyDiscount/admin/Extension.tsx"} />
<Admin.Extension
src={"@/extensions/bulkActions/applyDiscount/admin/ApplyDiscountBulkAction.tsx"}
/>
</>
);
};
18 changes: 8 additions & 10 deletions extensions/bulkActions/applyDiscount/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ApplyDiscountExtension />` (plus the two model
`<Api.Extension>` entries under `extensions/models/`).
Expand Down Expand Up @@ -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
Expand Down
71 changes: 0 additions & 71 deletions extensions/bulkActions/applyDiscount/admin/ApplyDiscountAction.tsx

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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<ApplyDiscountData> {
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<ApplyDiscountData>): 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 <RegisterFeature feature={ApplyDiscountFeature} />;
};

This file was deleted.

38 changes: 0 additions & 38 deletions extensions/bulkActions/applyDiscount/admin/Extension.tsx

This file was deleted.

3 changes: 3 additions & 0 deletions packages/app-admin/src/base/createRootContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -77,5 +78,7 @@ export function createRootContainer() {

ClipboardFeature.register(container);

IconRegistryFeature.register(container);

return container;
}
2 changes: 2 additions & 0 deletions packages/app-admin/src/exports/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
54 changes: 54 additions & 0 deletions packages/app-admin/src/features/icons/IconRegistry.ts
Original file line number Diff line number Diff line change
@@ -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<string, IconComponent>();

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: []
});
22 changes: 22 additions & 0 deletions packages/app-admin/src/features/icons/abstractions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from "react";
import { createAbstraction } from "@webiny/feature/admin";

export type IconComponent = React.ComponentType<React.SVGProps<SVGSVGElement>>;

/**
* 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<IIconRegistry>("IconRegistry");

export namespace IconRegistry {
export type Interface = IIconRegistry;
export type Component = IconComponent;
}
15 changes: 15 additions & 0 deletions packages/app-admin/src/features/icons/feature.ts
Original file line number Diff line number Diff line change
@@ -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)
};
}
});
Loading
Loading