[data-reveal]{opacity:1!important;transform:none!important}
AntelopeJS DMS
DocumentationActions and reactivity

Actions and reactivity

The behaviour 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 carries behaviour as well as props. Every builder declares named actions that become permissions, events the rendered component emits, action targets that say what a button does, and watch-actions that make one component react to another. The backend declares all of them as data.

The behaviour API applies to every component. Every @antelopejs/interface-dms/base builder (Form, TableView, Tab, and the others) returns a ComponentBuilder. CustomComponent(name) and new ComponentBuilder(componentName) return the same class.

This page covers the backend declarations. Component events covers the frontend side that runs them: the window event bus, useWatch, and the registration of your own function handlers.

The ComponentBuilder API

ComponentBuilder comes from @antelopejs/interface-dms/component. Its chainable methods fall into three groups:

GroupMethodPurpose
Structure.options(opts)Set the component's props.
.mergeOptions(partial)Shallow-merge into the options already set.
.child(id, component, { slot?, ...metadata })Nest a child component, in a named slot when slot is set. Other keys pass through to the serialized child.
.meta({ name?, icon?, description? })Merge the metadata that titles the component's permission.
Behaviour.action(id, definition)Register a permissioned action.
.button(id, { permission?, permissionId? })Register a named button that is not an action, so a quick action can expose it and inherit its permission.
.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).
.transformOptions(cb(options))Rewrite the options once, when the page registers.
.onFilter(cb(permissions, options, permissionId, context))Rewrite the options per request at serialize time.
.navBadge({ page?, count })Publish a count as the navigation badge of a page.

Pages and components covers the structure methods. The rest of this page covers the behaviour and advanced groups.

Actions and their permissions

.action(id, definition) declares a named capability on a component, gated by a permission. The definition shapes the permission that the DMS registers:

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

ActionDefinition has five fields, and only title is required. The example shows four. The fifth, dependencies, names other actions of the same component by id. The roles editor grants them with this action, and a saved role stores them with it. A table view's edit depends on its view this way, because its form loads the row through the view route.

The action's permission id is <componentPermissionId>.<actionId>. The component permission id is the page's permission id followed by every field and child id on the path to that component. The page's permission id is its fullId, unless the page declares permission.id. See Auth and permissions. The permission registers together with the page, when @RegisterPage runs.

Two places take an Action:

  • Route gating. @AuthUserWithPermission(...) accepts an Action, so the backend route that does the work checks the same permission the UI shows.
  • Page options. A page's permission option accepts an Action as well as a Permission.

TableView uses the same mechanism. It registers list and select, then add, edit, view, delete, archive, restore and export when the table can perform them. Table permissions therefore read like pages.tasks.table.delete (see Tables).

Action targets

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

typeFieldsEffect
"drawer"component, title?, description?, direction?, size?, rowHeader?, rowTitle?, positionLabel?, deepLink?, notFound?Opens a drawer that renders the component. It slides in from the bottom, or from the direction edge. A "right" drawer is a side sheet, 600px wide by default, which the user resizes from lg. Use it for detail panels. On a row it takes the side panel's row options (see Row drawers).
"modal"component, size?, title?, description?Opens a centred modal that renders the component.
"sidePanel"component, title?, rowTitle?, positionLabel?, deepLink?, notFound?Opens the component on a row in a non-modal panel docked on the right of the page. The DMS draws its header with the title, the "x of y" position, previous and next buttons, and a close button (see Row drawers). It is meant for global panels that do not depend on the page. A detail panel is a drawer.
"page"urlNavigates to an internal route.
"external"url, newTab?Navigates to an external URL, in a new tab when newTab is set.
"download"url, filename?Fetches a file with the user's session and saves it. The name is filename, else the server's Content-Disposition name, else the URL's last segment.
"quickAction"idRuns a quick action, by its category:id key or its bare id. The DMS leaves the button out for a user the quick action is not served to.
"api"url, method?, body?, copy?, successMessageCalls an endpoint and shows a toast on success, or the response's warning when it carries one. copy names a response field to copy to the clipboard.
"exportJob"url, method?, statusUrl?, downloadUrl?, labels?Starts an async export job, polls its status, then downloads the result.

The confirmation belongs to the action, not to the target. A button or a row action takes confirm. Its value is a ConfirmDialog (title, description?, color?, icon?, confirmLabel?, impact?, blocked?, confirmText?, fields?, and more) or { from: url }, a dialog the server words for the row (see Tables).

The exportJob labels relabel the progress toast with title, exporting, downloading, successTitle, successMessage, errorTitle and retry. The reusable export engine usually builds the job endpoint itself (see Backend services).

The component of a drawer, modal or sidePanel target is any component builder, from the same vocabulary as the rest of the page.

The custom array below sits inside a TableView's rowActions (see Tables):

src/pages/tasks.ts, a custom row action ts
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 a smaller target union of their own: navigate, openForm, button and event. It is a different type from ActionTarget.

Events components emit

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

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

Each event has its own payload. FormEvents.FIELD_CHANGE carries { fieldId, value, initial }, once per field with the values the form starts from and then on each change. TabEvents.TAB_CHANGE carries the selected tab. onParam conditions match against the payload (see below).

Watch-actions

A watch-action says "when event E fires and its payload matches, run function F". You declare it on the backend, and the frontend's useWatch engine runs it. Every component can carry the serialized shape through BaseComponentProps:

WatchAction ts
import type { WatchActionCondition } from "@antelopejs/interface-dms/base/types";

export interface WatchAction {
  component: string; // which component's events to listen to
  event: string; // the event name
  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
}

The builder methods produce that object, so you rarely write it by hand.

.watch() reacts to the component's own events

.watch(event, functionId, opts?) registers a watch with component: ComponentId.SELF ("$component:self"). The component reacts to the events it emits itself. A form uses it to disable one field when another changes:

src/pages/profile.ts ts
import { Form, FormEvents, FormFunctions } from "@antelopejs/interface-dms/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() attaches a watch to a child

.watchOn(childId, event, functionId, opts?) targets a named child instead. At serialize time the builder splices the watch 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 does not build itself and wires their behaviour in one place.

Two rules apply:

  • Call .child(childId, ...) first. An unknown childId logs the warning watchOn: unknown child id "<id>" on component "<name>". Did you call .child() first?, and the watch never reaches a component.
  • Target the direct child that emits the event. SELF resolves to that child, not to components nested deeper.
src/pages/workspace.ts ts
static panel = VStack({ spacing: "1rem" })
  .child("form", Form({ fields, submitUrl: "/api/workspace" }))
  .watchOn("form", FormEvents.SUBMIT_SUCCESS, "app.workspace.OnSaved");

Watch options

.watch() and .watchOn() take the same WatchOptions object:

  • params holds the static arguments passed to the function. WatchFunctionParamMap is a declaration-merged map, so a built-in id such as FormFunctions.SET_FIELD_DISABLED gets its exact params type checked at compile time. Other ids fall back to Record<string, unknown> | undefined.
  • onParam is one condition or an array of conditions that must all match. Each pairs a key and a value. The key is a dotted path into the event's payload, and the value must match strictly. One FIELD_CHANGE stream can drive a different reaction per field this way.
  • requirePermission is a permission id. The DMS strips the watch from the serialized page of a user who does not hold it.

.watchFilter() filters per user

For logic beyond one 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 must change too.

Built-in function ids

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

BlockFunctions.REFRESH_PAGE (@antelopejs/interface-dms/base/display) works on any component. It asks every block of the page that reads its data from a route to read it again without remounting: the stat groups, key-value lists, cards, charts, a tab set's badgesUrl, and the table views. A form that changes the record a page shows declares it on its own success:

ts
static notes = Form({ fields, fetchUrl: "/api/customers/{{params.id}}", submitUrl: "/api/customers/{{params.id}}" })
  .watch(FormEvents.SUBMIT_SUCCESS, BlockFunctions.REFRESH_PAGE);

You define every other functionId. Register a handler under the same string from a plugin in your frontend layer. Component events gives the full pattern, with the payload shapes and the engine that runs the handler.

watchActions inside .options({...}) has no effect. The builder strips the key from the options you pass, without a warning. Declare watches with .watch() and .watchOn().

Advanced hooks

Four builder methods cover a component that takes part in page registration or in per-request serialization:

  • .onCreated(callback) runs when the owning page registers. It receives the page as a CreatedPage (target and pageInfo, which holds fullId and fullSlug). The built-in builders use it for side effects scoped to the page. Charts register their realtimeTopics as page topics, and TableView registers its hidden new, edit and view sub-pages under the key it is mounted under.
  • .transformOptions(callback) finalizes the options once, during serialize(), when the page that carries the component registers. Use it for work that does not depend on the request, such as signing an upload token. Transforms run in registration order. serializeSync() skips them, because the host's own transform finalizes every component it embeds.
  • .onFilter(callback) is (permissions, options, permissionId, context) => options. It runs per request when the DMS serves the page layout, and rewrites the component's options from what the caller may see. context is a ComponentFilterContext with tenantId, user, and, while a page layout is served, the page URL's query and params and the page's record. Return the options, changed or not.
  • .navBadge({ page?, count }) publishes a count as the navigation badge of the component's page, or of page. count(ctx, user) returns a number or { count, tone }. A count that throws or is zero shows no badge, and a page's static MenuOptions.badge wins over it.

Page-level reactivity

Reactivity can also live on the page instead of a component. The page option setupId names a registered frontend function that runs when the page mounts, with an event-bus context that holds on and emit. Use it to coordinate several components at once. See Pages and components for the option and Page context for the function contract.