Components
Overview
A component is a serializable description of a piece of UI, not a live object. ComponentBuilder collects a component name, an options object, children, watches and actions; serialize() turns that into plain JSON-safe data; the page layout route returns the result. Nothing in this interface renders anything.
Everything on this page lives in @antelopejs/interface-dms/component. The Base domain ships the concrete builders (Form, TableView, Grid, the chart family) built on top of these types; use this page when you write a builder of your own or when you need to know what a serialized component looks like.
The Serialize-Then-Render Contract
Two shapes describe the same component on the two sides of serialization:
interface ComponentInfo<T = unknown, C = Component<T>, ChildType = ComponentChild<T, C>> {
componentName: string;
options?: T;
children?: ChildType[];
}
type ComponentInfoSerialized<T = unknown> = Omit<ComponentInfo<T>, "children"> & {
children?: ChildSerialized[];
};
ComponentInfo is what a builder holds: children carry live Component instances. ComponentInfoSerialized is what the page layout carries: every child has been replaced by its own serialized description.
| Type | Shape |
|---|---|
ComponentChild<T, C> | { id: string; component: C; slot?: string; [key: string]: unknown } |
BaseComponentChild | ComponentChild with component fixed to Component |
ChildSerialized | { id: string; component: ComponentInfoSerialized; slot?: string; [key: string]: unknown } |
ComponentInfoPromise<T> | MaybePromise<ComponentInfo<T, Component<T>, BaseComponentChild>> |
ComponentMetadata | { name: string; description?: string; icon?: string } |
The extra index signature on a child is the escape hatch for per-child props a container needs (a grid column span, a tab label): whatever you pass in the child metadata survives serialization next to id, component and slot.
Build a Component
ComponentBuilder<T> is the class you instantiate with the frontend component name. Every method returns this, so calls chain.
import { ComponentBuilder } from "@antelopejs/interface-dms/component";
interface CounterOptions {
title: string;
fetchUrl: string;
}
export function Counter(options: CounterOptions) {
return new ComponentBuilder<CounterOptions>("acme-counter")
.options(options)
.meta({ name: options.title, icon: "i-ph-hash" });
}
Assign the result to a static field of a page class and @RegisterPage() picks it up (Pages and Modules).
| Method | Description |
|---|---|
options(opts?) | Replace the options object. A watchActions key on the input is stripped: watches are declared with watch/watchOn only. |
mergeOptions(opts) | Shallow-merge into the existing options. Logs a warning and does nothing when no options were set. |
child(id, component, metadata?) | Append a child with its own positional permission. metadata may carry slot plus any additional props. A duplicate id logs a warning. |
meta(meta) | Merge into the component metadata (name, description, icon). |
onCreated(callback) | Run callback(pageMetadata) when the component's page registers. |
onFilter(callback) | Transform the options per caller, from their permission set. |
watch(event, functionId, opts?) | Declare a watch on this component. |
watchOn(childId, event, functionId, opts?) | Declare a watch spliced into a child's options at serialization time. |
watchFilter(callback) | Filter the accumulated watches per caller. |
action(id, definition) | Declare an Action on the component. |
button(id, { permission? }) | Register a named button a quick action can press, with the permission (an action id of this component, or any Action) that gates it. A duplicate id logs a warning. The table view registers its customButtons that carry an id. |
navBadge({ count, page? }) | Publish a count as the navigation badge of a page (NavBadgeSource): count(ctx, user) answers it for the caller on each page visit — a number, drawn neutral, or { count, tone } (Navigation badges) — and throws to publish nothing for a caller who may not read it. page names the page controller the badge goes to, the component's own page by default. A table view's tab declared with navBadge publishes through it; a component of your own calls it directly (the DMS's notifications, security and roles pages do). A count of zero shows no badge, and a page's static MenuOptions.badge wins. |
before(anchor) | Place the component above anchor (a component key of the target page) when injecting it into another page. |
after(anchor) | Place it below anchor. |
order(value) | Break ties between components landing at the same spot. Defaults to 0. |
Navigation badges
count answers a number, drawn as a neutral grey pill, or a NavBadgeCount that also gives the tone it is drawn in, with the colours of the dashboard's status pills:
interface NavBadgeCount {
count: number;
tone?: Tone; // from "@antelopejs/interface-dms/base/types/tone"
}
component.navBadge({
count: async (_ctx, user) => {
const [overdue, dueSoon] = await countInvoices(user._id);
return overdue > 0
? { count: overdue + dueSoon, tone: "error" }
: { count: dueSoon, tone: "warning" };
},
});
Pick the tone on the grid the DMS's own notifications follow: error for an immediate risk, warning for something to check, primary for an action available, success for good news, neutral (or no tone) for plain information; secondary and info are accepted too. When several components publish a badge for one page, their counts add up and the badge takes the strongest of their tones, in this order: error, warning, primary, secondary, success, info, neutral. Each page visit serves the counts as navBadges, { badge, tone? } by page full id, next to the page and never in the site layout a browser keeps for minutes.
Placement
The last three methods only mean something on a component declared inside a @RegisterPageExtension class; on a component declared on its own page they are ignored. Together they build a ComponentPlacement:
type PlacementSide = "before" | "after" | "end";
interface ComponentPlacement {
side: PlacementSide;
anchor?: readonly string[];
order: number;
}
const DEFAULT_PLACEMENT: ComponentPlacement = { side: "end", order: 0 };
anchor is a component key of the target page: the name of the static field it declared the component under, passed as .before("table"), or a dotted path to a nested position, .before("content.tasks") — stored as the split path. The target page is never imported, so the key is a string. A component that declares no placement gets DEFAULT_PLACEMENT — appended after every component of the target page, with no ordering hint. Read the current value off component.placement, which is undefined until one of the three methods is called.
Lower order comes first, and equal orders fall back to the extension class name and then to declaration order, so the result never depends on module start order. Pages and Modules covers the extension mechanism these describe placement for.
Children and Slots
import { ComponentBuilder } from "@antelopejs/interface-dms/component";
import { Placeholder } from "@antelopejs/interface-dms/base";
const panel = new ComponentBuilder<{ title: string }>("acme-panel")
.options({ title: "Overview" })
.child("header", Placeholder({ label: "Header" }), { slot: "header" })
.child("body", Placeholder({ label: "Body" }), { slot: "default", grow: true });
The grow: true above is an arbitrary extra prop: it lands on the serialized child alongside id, component and slot.
The Component Class
ComponentBuilder extends Component<T>, which is also usable directly when you already hold a ComponentInfo:
new Component<T>(
componentInfo: ComponentInfoPromise<T>,
metadata: ComponentMetadata,
onPageCreated?: (page: PageMetadata) => void,
)
PageMetadata is the page record described in Pages and Modules.
| Member | Description |
|---|---|
componentInfo | The ComponentInfo, possibly a promise. |
metadata | The ComponentMetadata. |
actions | Record<string, Action> of the declared actions. |
getAction(id) | One action, or undefined. |
getButton(id) | One registered button (ComponentButton), or undefined. |
resolveButtonPermissionId(permission) | The permission id a button's permission stands for on this component, or undefined when it cannot be resolved (callers treat that as refused). |
onPageCreated | The callback run when the owning page registers. |
onFilterCallback | The per-caller options filter, when one is installed. |
placement | The ComponentPlacement, or undefined when none was declared. |
serialize() | Promise<ComponentInfoSerialized<T>> — awaits the component info and serializes every child. |
serializeSync() | The same, for a component whose info is not a promise. |
Both serialize methods normalize children to [] when the component has none.
Because componentInfo may be a promise, a builder can resolve data at registration time. Use serialize() unless you know the info is synchronous.
Actions
An action is a named capability of a component that carries its own permission — "add a row", "export", "restore". Declare one on the builder:
import { ComponentBuilder } from "@antelopejs/interface-dms/component";
const table = new ComponentBuilder<{ fetchUrl: string }>("acme-table")
.options({ fetchUrl: "/api/things" })
.action("export", {
title: "$acme.table.action_export",
icon: "i-ph-download-simple",
description: "$acme.table.action_export_description",
});
const exportAction = table.getAction("export");
ActionDefinition carries title (required), and optional icon, description and defaultGranted.
An Action instance exposes:
| Member | Description |
|---|---|
id | The action id, as passed to action(). |
definition | The ActionDefinition. |
permissionId | The component's permission id with .<id> appended, or undefined while the component has no permission id yet. |
toPermission() | A Permission built from the definition, or undefined when permissionId is. |
The component's permission id only exists once the owning page has registered, so read permissionId from onCreated or later — not while the builder is still being assembled.
An Action is accepted anywhere a permission is: as the permission option of a page (Pages and Modules) and as the target of AuthUserWithPermission (Permissions).
getPermissionIdRef is the seam that lets Action resolve its component's permission id without importing the page module: it is an object with a single get(component) method, replaced by dms/page when that module loads. Consumers read permissionId; there is no reason to call or reassign the ref.
Per-Caller Filtering
onFilter rewrites a component's options for the caller who requested the page layout. It runs during layout resolution, after the component itself passed its permission check.
import { ComponentBuilder } from "@antelopejs/interface-dms/component";
interface ListOptions {
fetchUrl: string;
allowBulkDelete: boolean;
}
const list = new ComponentBuilder<ListOptions>("acme-list")
.options({ fetchUrl: "/api/things", allowBulkDelete: true })
.onFilter((permissions, options) => ({
...options,
allowBulkDelete: permissions.has("things.delete"),
}));
The callback receives (permissions: Set<string>, options: T, permissionId: string, context: ComponentFilterContext) and returns the options — synchronously or as a promise. The third argument is the component position's own permission id, including the full parent path for a child, so a filter can derive the ids of the actions it needs to test. The fourth is the request the layout is built for: its tenantId, its user (undefined when unauthenticated), and query, the page URL's query when the request carries one (what a header button's availability reads; check its values like any input).
Watches
A watch binds an event on a component to a registered function. The backend only describes the binding.
watch<F extends string>(event: string, functionId: F, opts?: WatchOptions<F>): this
watchOn<F extends string>(childId: string, event: string, functionId: F, opts?: WatchOptions<F>): this
WatchOptions field | Type | Description |
|---|---|---|
params | WatchFunctionParams<F> | Arguments handed to the function. Typed per functionId when the function declares its parameter map. |
onParam | WatchActionCondition | WatchActionCondition[] | Conditions on the event payload, each { key, value }. |
requirePermission | string | Permission id the caller must hold; the watch is dropped from the layout otherwise. |
watch targets the component itself; watchOn(childId, ...) targets a child added with child() and is spliced into that child's serialized options. Calling watchOn with an unknown child id logs a warning.
import {
Form,
FormEvents,
FormFunctions,
} from "@antelopejs/interface-dms/base/form";
const form = Form({ title: "Profile", fields: [] }).watch(
FormEvents.FIELD_CHANGE,
FormFunctions.SET_FIELD_DISABLED,
{
params: { targetField: "adminNotes", setDisabled: true },
onParam: [
{ key: "fieldId", value: "isOwner" },
{ key: "value", value: false },
],
requirePermission: "pages.crm.contacts.form",
},
);
watchFilter(callback) narrows the watch list per caller: it receives (permissions, watches, permissionId) and returns the watches to keep. It runs right after onFilter in the same filtering pass, and the requirePermission narrowing is applied to whatever it returns.
Component Events
ComponentEvents holds the lifecycle event names:
| Constant | Value |
|---|---|
ComponentEvents.LOAD | "DmsComponent.Load" |
ComponentEvents.UNLOAD | "DmsComponent.Unload" |
Use them as the event argument of watch or watchOn, or as the event name in a page setup function's on() subscription (Pages and Modules).
RegisterComponentSlot
RegisterComponentSlot(slotId: string, resolver: ComponentSlotResolver): () => void, from @antelopejs/interface-dms/component-slots, claims a component slot. The resolver receives the options of each component declaring that slotId, once per layout request, and returns the replacement options. Returning the input unchanged contributes nothing.
SlotOptions is Record<string, unknown>; ComponentSlotResolver is (options: SlotOptions) => MaybePromise<SlotOptions>. The latest claim for an id wins. The returned release function removes the claim only if that resolver still owns it, so releasing an older claim does not remove a newer one.
Use the returned function when the claiming module stops. TableView form slots show how a controller exposes a slot; the controller still owns validation and persistence of contributed fields.
Next Steps
Permissions covers the ids these components and actions register, and the guards that check them.
Pages and Modules
Pages are controller classes registered into a navigation tree of modules and categories, with their own routes, layouts and extensions.
Permissions
Permission ids are hierarchical strings built from the page tree: a page's `fullId`, then every component id in the position's path, then the action id when present.