[data-reveal]{opacity:1!important;transform:none!important}
Building

Actions & reactivity

The behavioral side of the component DSL — permissioned actions, the events components emit, action targets, and the watch-actions that make components react to each other from backend configuration.

A component description is not just props. Every builder also carries behavior: named actions that become permissions, events the rendered component emits, action targets that describe what a button does, and watch-actions that make one component react to another — all declared on the backend, as data.

The behavioral API applies to every component: every cms-base builder (Form, TableView, Tree, …) returns a ComponentBuilder — the same class you get from CustomComponent(name) or ComponentBuilder(componentName) directly.

Everything here is the backend declaration side. The frontend machinery that executes it — the window event bus, useWatch, and registering your own function handlers — is Component events.

The ComponentBuilder API

ComponentBuilder comes from cms/component. Its fluent methods fall into three groups:

GroupMethodPurpose
Structure.options(opts)Set the component's props.
.mergeOptions(partial)Shallow-merge into already-set options.
.child(id, component, { slot? })Nest a child component, optionally in a named slot.
.meta({ name, icon?, description? })Set the metadata that titles the component's permission.
Behavior.action(id, definition)Register a permissioned action.
.watch(event, functionId, opts?)React to the component's own events (watch-actions).
.watchOn(childId, event, functionId, opts?)Attach a watch to a named child component.
.watchFilter(cb)Filter the watch list per user at serialize time.
Advanced.onCreated(cb(page))Run a callback when the owning page registers (below).
.onFilter(cb(permissions, options, permissionId))Rewrite the options per user at serialize time.

Structure methods are covered in Pages & components. The rest of this page covers the behavior and advanced groups.

Actions and their permissions

.action(id, definition) declares a named, permission-gated capability on a component. The definition shapes the permission that gets registered:

src/pages/reports.ts
static report = CustomComponent("MyReportPanel")
  .meta({ name: "Report panel" })
  .action("regenerate", {
    title: "$my_app.report.perm.regenerate", // i18n key — add to every locale file
    icon: "i-ph-arrows-clockwise",
    description: "Regenerate the report from source data",
    defaultGranted: false,
  });

The example shows every ActionDefinition field — only title is required. The action's permission id derives from its component: <componentPermissionId>.<actionId>, where the component permission id is the page's fullId plus every field/child id in that component position's path (see Auth & permissions). The permission registers together with the page, when @RegisterPage runs.

Two places consume an Action:

  • Route gating@AuthUserWithPermission(...) accepts an Action, so the backend route that performs the work checks the same permission the UI displays.
  • Page/permission options — a page's permission option also accepts an Action.

TableView uses this exact mechanism internally: every table route registers its own action (list, add, edit, delete, …), which is why table permissions look like pages.tasks.table.delete (Tables).

Action targets

An ActionTarget (from cms-base/types/action-target) describes what a button does when clicked. It is the payload of a table's customButtons and custom row actions, and the shape to know when you build clickable affordances of your own:

typeFieldsEffect
"drawer"component, title?, description?Opens a side drawer rendering the given component.
"modal"component, size?, title?, description?Opens a centered modal rendering the component.
"page"urlNavigates to an internal route.
"external"url, newTab?Navigates to an external URL, optionally in a new tab.
"api"url, method?, successMessage, confirm?Calls an endpoint; shows a toast on success; optional confirm dialog first.
"exportJob"url, method?, statusUrl?, downloadUrl?, labels?, confirm?Starts an async export job, polls its status, then downloads the result.

A confirm object carries a title, a description, and an optional confirmColor (primary, error, or warning). The exportJob labels let you relabel the progress toast (title, exporting, downloading, successTitle, successMessage, errorTitle, retry); the job endpoint itself is usually built with the reusable export engine (Backend services).

The component of a drawer/modal target is any component builder — the same vocabulary as the rest of the page.

A target in the wild — the custom array below sits inside a TableView's rowActions (Tables):

src/pages/tasks.ts — a custom row action
custom: [
  {
    label: "Mark complete",
    icon: "i-ph-check-circle",
    target: {
      type: "api",
      url: "/api/task/complete",
      method: "POST",
      successMessage: "Task completed",
      confirm: { title: "Complete task?", description: "This cannot be undone." },
    },
  },
],
Quick actions use their own, smaller target union (navigate / openForm / event) — don't confuse the two.

Events components emit

Rendered components broadcast their lifecycle and interactions as events on a window-level bus. Watch-actions subscribe to these; so can your own frontend code (Component events). Two events are universal: ComponentEvents.LOAD / UNLOAD (cms/component), emitted automatically on mount and unmount.

Every other event belongs to a component. Each builder exports its event names as a constants namespace next to it (FormEvents for Form, and so on), importable from the same subpath — the component's own page in the Components section lists its events.

Event payloads are event-specific — FIELD_CHANGE carries the fieldId, value, and formValues, TAB_CHANGE the selected tab, and so on. The payload is what onParam conditions match against (below).

Watch-actions

A watch-action is the declarative form of "when event E fires and its payload matches, run function F". You declare it on the backend; the frontend's useWatch engine executes it. The serialized shape every component can carry (via BaseComponentProps):

WatchAction
interface WatchAction {
  component: string; // which component's events to listen to
  event: string; // the event name (see the table above)
  functionId: string; // the registered frontend function to run
  params?: Record<string, unknown>; // static arguments for the function
  onParam?: WatchActionCondition | WatchActionCondition[]; // payload conditions
  requirePermission?: string; // drop the watch for users without this permission
}

You rarely write that object by hand — the builder methods produce it.

.watch() — react to your own events

.watch(event, functionId, opts?) registers a watch with component: ComponentId.SELF ("$component:self"), so the component reacts to the events it emits itself. This is how a form disables one field when another changes:

src/pages/profile.ts
import { Form, FormEvents, FormFunctions } from "@antelopejs-private/cms/interfaces/cms-base/form";

static form = Form({
  fields: [
    { id: "isOwner", label: "Is Owner", type: new DefaultDataTypes.BooleanType() },
    { id: "displayName", label: "Display Name", type: new DefaultDataTypes.StringType() },
  ],
}).watch(FormEvents.FIELD_CHANGE, FormFunctions.SET_FIELD_DISABLED, {
  params: { targetField: "displayName", setDisabled: true },
  onParam: [
    { key: "fieldId", value: "isOwner" },
    { key: "value", value: true },
  ],
});

.watchOn() — attach a watch to a child

.watchOn(childId, event, functionId, opts?) targets a named child instead: at serialize time the watch is spliced into that child's options (still with component: SELF), so the child reacts to its own events. Use it when a parent container composes children it doesn't build itself and wants to wire their behavior in one place.

Two rules keep it working:

  • Call .child(childId, …) first — an unknown childId logs a warning and the watch never reaches a component.
  • Target the direct child that emits the eventSELF resolves to that child, not to components nested deeper.
src/pages/workspace.ts
static panel = VStack({ spacing: "1rem" })
  .child("form", Form({ fields, submitUrl: "/api/workspace" }))
  .watchOn("form", FormEvents.SUBMIT_SUCCESS, "app.workspace.OnSaved");

WatchOptions

Both methods take the same options object:

  • params — static arguments passed to the function. For built-in function ids the shape is typed: WatchFunctionParamMap is a declaration-merged map, so a built-in id like FormFunctions.SET_FIELD_DISABLED gets its exact params type-checked at compile time. Unknown ids fall back to Record<string, unknown>.
  • onParam — one condition or an ANDed array of them, each pairing a key and a value. The key is a dotted path walked through the event's payload; the value must match strictly. This is how a single FIELD_CHANGE stream drives different reactions per field.
  • requirePermission — a permission id; users who don't hold it get the watch stripped from their serialized page.

.watchFilter() — per-user filtering

For logic beyond a single permission id, .watchFilter((permissions, watches, permissionId) => watches) runs at serialize time and returns the watch list to keep for that user. Combine it with .onFilter() when the options themselves must also change.

Built-in function ids

Some components ship function ids with registered frontend handlers — a <Component>Functions namespace exported next to the builder; the component's page lists its ids and their params.

Any other functionId is yours to define: register a handler under the same string from a plugin in your frontend layer — the full pattern, including payload shapes and the executing engine, is in Component events.

Declaring watchActions inside .options({...}) has no effect — the builder silently strips the key from caller-supplied options. .watch() / .watchOn() are the supported way to declare watches.

Advanced hooks

Two builder methods cover the rare cases where a component needs to participate in page registration or per-user serialization:

  • .onCreated(callback) — runs when the owning page registers, receiving the page's PageMetadata. The built-in builders use it for page-scoped side effects: charts register their realtimeTopics as page topics, and TableView registers its hidden new/edit/view sub-pages.
  • .onFilter(callback)(permissions, options, permissionId) => options runs per user when the page layout is served, letting you rewrite the component's options based on what the caller may see. Return the (possibly modified) options.

Page-level reactivity

Reactivity can also hang off the page rather than a component: the page option setupId names a registered frontend function that runs when the page mounts, with an event-bus on/emit context — useful for orchestrating several components at once. See Pages & components for the option and Page context for the function contract.