[data-reveal]{opacity:1!important;transform:none!important}
Extending The Dashboard

Component events

The window-CustomEvent bus that lets DMS components react to one another, plus the named-function registry that backend watch-actions invoke.

Components on a DMS page are mounted independently, yet they often need to react to one another — a form disables a field when a checkbox flips, a table reloads after a form submits. The DMS wires this without prop-drilling or a store, through three cooperating pieces:

  • An event bususeComponentEvent dispatches CustomEvents on window. Any component can broadcast; any component can listen.
  • A function registryuseDefinedFunctions maps a string id to a handler, registered once frontend-side.
  • A watch engineuseWatch reads the watchActions a component carries (declared on the backend), subscribes to the named events, and on a match calls the registered handler.

The result: the backend declares "when event E fires on component C and its payload matches, run function F" as pure data, and the frontend executes it — no frontend code per page.

Everything here is the frontend half of the mechanism. Declaring watch-actions on the backend — the .watch() / .watchOn() builder API and the events each component emits — is Actions & reactivity.

The event bus

useComponentEvent returns sendComponentEvent(name, componentId, data?), which dispatches a CustomEvent on window. The event name is what listeners subscribe to, the componentId identifies the emitter (listeners filter on it), and data is the payload — the detail is { component, data }.

emitting an event
const { sendComponentEvent } = useComponentEvent();
sendComponentEvent("CmsComponent.Form.FieldChange", componentId, {
  fieldId: "isOwner",
  value: true,
});

Pass a componentId to useComponentEvent(componentId) and it also emits two lifecycle events for you — Events.LOAD ("CmsComponent.Load") on mount and Events.UNLOAD on unmount — so other components can react to yours appearing or disappearing. Call it with no argument when you only want to send.

The function registry

useDefinedFunctions is a single shared registry:

const { registerFunction, getFunction, unregisterFunction } = useDefinedFunctions();

registerFunction("app.form.HighlightOnChange", (action, eventData, componentState) => {
  /* ... */
});

Ids are plain strings: the backend names a function in a watch-action, the frontend registers a handler under the same string — they only have to agree on the literal. Register your own from a plugin in your frontend layer.

Reacting with useWatch

A component that wants to react calls useWatch(actions, componentId?, defaultState?). It returns isLoading and state, subscribes to window for every distinct action.event, and on each event runs the actions that match. A WatchAction is the data the backend produced:

WatchAction
interface WatchAction {
  component: string;
  event: string;
  functionId: string;
  params?: Record<string, unknown>;
  onParam?: WatchActionCondition | WatchActionCondition[];
}

An action matches when all of these hold:

  • action.component equals the emitter's id. ComponentId.SELF ("$component:self") resolves to the watcher's own componentId, so a component can watch its own events.
  • action.event equals the CustomEvent type.
  • the onParam conditions pass (below).

On a match, useWatch resolves getFunction(action.functionId) and calls it as fn(action, event.detail, state), where state is a ref seeded from defaultState. The handler mutates state; the component reads it reactively.

Two execution rules to know: an action that throws is swallowed — isLoading never gets stuck — but it aborts the remaining matched actions for that event.

onParam matching

onParam filters on the event's payload by dotted path: each condition pairs a key (walked through event.detail.data) with a value (strict equality), and an array of conditions is AND-ed:

backend: only when isOwner becomes true
onParam: [
  { key: "fieldId", value: "isOwner" },
  { key: "value", value: true },
];

This is how one FIELD_CHANGE stream drives different reactions for different fields and values.

useEventedAction

The producer half of the loop: useEventedAction wraps an async operation so it toggles a loading flag and broadcasts start / success / error component events around it — other components watch that lifecycle.

const { execute } = useEventedAction<FormSubmitResponse>({
  componentId: props.componentId,
  events: {
    start: FormEvents.SUBMIT,
    success: FormEvents.SUBMIT_SUCCESS,
    error: FormEvents.SUBMIT_ERROR,
  },
});

execute(asyncFn, opts?) sets isLoading, emits start, awaits asyncFn, then emits success — or error and re-throws — and clears isLoading in a finally. Optional startPayload, successPayload(result), and errorPayload(error) map the operation's data onto the emitted events. Events are only emitted when both an event name and a componentId are configured.

End-to-end example

A form field's change drives custom behavior — say, writing a derived flag into the watching component's state. Two pieces, one shared id.

1. Register the handler (frontend, in a frontend-layer plugin):

app/plugins/register-app-functions.ts
export default defineNuxtPlugin(() => {
  const { registerFunction } = useDefinedFunctions();

  registerFunction("app.form.MarkOwner", (action, eventData, componentState) => {
    // eventData is { component, data }
    const payload = eventData.data as { fieldId: string; value: unknown };
    componentState.value.isOwnerSelected = payload.value === true;
  });
});

2. Declare the watch-action (backend, on the component builder). .watch(event, functionId, opts) records an action with component: ComponentId.SELF, so the form listens to the events it emits itself:

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

static form = Form({
  title: "Profile",
  fields: [
    { id: "isOwner", label: "Is Owner", type: new DefaultDataTypes.BooleanType() },
    { id: "displayName", label: "Display Name", type: new DefaultDataTypes.StringType() },
  ],
})
  .watch(FormEvents.FIELD_CHANGE, "app.form.MarkOwner", {
    onParam: { key: "fieldId", value: "isOwner" },
  });

When isOwner changes, the form emits FIELD_CHANGE; useWatch sees the onParam match, resolves app.form.MarkOwner from the registry, and runs it — flipping state.isOwnerSelected, which the component reads reactively. The same shape with a built-in functionId is how conditional fields work out of the box; your custom function only adds a handler under a new id.