@antelopejs/interface-dms 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +70 -68
  3. package/dist/auth/db/models/sessions.model.d.ts +1 -0
  4. package/dist/auth/db/models/userExternalIdentities.model.d.ts +1 -0
  5. package/dist/auth/db/models/users.model.d.ts +1 -0
  6. package/dist/base/block-registry/index.d.ts +1 -0
  7. package/dist/base/block-registry/index.js +1 -0
  8. package/dist/base/block-registry/index.js.map +1 -1
  9. package/dist/base/block-registry/presentation.d.ts +21 -0
  10. package/dist/base/block-registry/presentation.js +108 -0
  11. package/dist/base/block-registry/presentation.js.map +1 -0
  12. package/dist/base/block-types.d.ts +1 -0
  13. package/dist/base/block-types.js +1 -0
  14. package/dist/base/block-types.js.map +1 -1
  15. package/dist/base/live-toggle.d.ts +2 -2
  16. package/dist/base/period-selector.d.ts +2 -2
  17. package/dist/base/placeholder.js +2 -2
  18. package/dist/base/placeholder.js.map +1 -1
  19. package/dist/base/table-view/schema.d.ts +4 -4
  20. package/dist/db/internal/exportJobs.model.d.ts +1 -0
  21. package/dist/db/internal/inviteResolutions.model.d.ts +1 -0
  22. package/dist/db/internal/tenantLifecycle.model.d.ts +1 -0
  23. package/dist/db/models/roles.model.d.ts +1 -0
  24. package/dist/db/models/tenantMembers.model.d.ts +1 -0
  25. package/dist/db/models/tenants.model.d.ts +1 -0
  26. package/dist/db/models/user_invites.model.d.ts +1 -0
  27. package/docs/.navigation.yml +2 -0
  28. package/docs/1.core/.navigation.yml +2 -0
  29. package/docs/1.core/1.introduction.md +169 -0
  30. package/docs/1.core/10.layout-banners.md +133 -0
  31. package/docs/1.core/2.pages-and-modules.md +649 -0
  32. package/docs/1.core/3.components.md +287 -0
  33. package/docs/1.core/4.permissions.md +279 -0
  34. package/docs/1.core/5.tenancy.md +358 -0
  35. package/docs/1.core/6.invites.md +239 -0
  36. package/docs/1.core/7.hooks.md +354 -0
  37. package/docs/1.core/8.realtime.md +147 -0
  38. package/docs/1.core/9.quick-actions.md +115 -0
  39. package/docs/2.auth/.navigation.yml +2 -0
  40. package/docs/2.auth/1.introduction.md +75 -0
  41. package/docs/2.auth/2.authentication.md +222 -0
  42. package/docs/2.auth/3.tokens-and-sessions.md +352 -0
  43. package/docs/2.auth/4.users.md +256 -0
  44. package/docs/2.auth/5.external-identities.md +122 -0
  45. package/docs/3.base/.navigation.yml +2 -0
  46. package/docs/3.base/1.introduction.md +202 -0
  47. package/docs/3.base/10.display-blocks.md +136 -0
  48. package/docs/3.base/2.types.md +402 -0
  49. package/docs/3.base/3.forms.md +349 -0
  50. package/docs/3.base/4.tables.md +608 -0
  51. package/docs/3.base/5.charts-and-widgets.md +466 -0
  52. package/docs/3.base/6.layout.md +437 -0
  53. package/docs/3.base/8.data-types.md +361 -0
  54. package/docs/3.base/9.helpers.md +414 -0
  55. package/docs/4.notifications/.navigation.yml +2 -0
  56. package/docs/4.notifications/1.introduction.md +81 -0
  57. package/docs/4.notifications/2.categories-and-subjects.md +214 -0
  58. package/docs/4.notifications/3.sending.md +285 -0
  59. package/docs/5.html-render/.navigation.yml +2 -0
  60. package/docs/5.html-render/1.introduction.md +386 -0
  61. package/docs/5.html-render/2.built-in-emails.md +157 -0
  62. package/docs/images/emails-admin-invite.png +0 -0
  63. package/docs/images/emails-change-notice.png +0 -0
  64. package/docs/images/emails-change-verification.png +0 -0
  65. package/docs/images/emails-digest-logo-dark.png +0 -0
  66. package/docs/images/emails-digest-logo-light.png +0 -0
  67. package/docs/images/emails-digest.png +0 -0
  68. package/docs/images/emails-export-partial.png +0 -0
  69. package/docs/images/emails-export-ready.png +0 -0
  70. package/docs/images/emails-reset-password.png +0 -0
  71. package/docs/images/emails-two-factor.png +0 -0
  72. package/docs/images/emails-user-validation.png +0 -0
  73. package/docs/index.md +109 -0
  74. package/docs/tsconfig.json +11 -0
  75. package/package.json +25 -18
@@ -0,0 +1,437 @@
1
+ ---
2
+ title: "Layout and Containers"
3
+ description: "Grid, stacks, tabs, guard, placeholder and the page layouts that hold every other component."
4
+ ---
5
+
6
+ # Layout and Containers
7
+
8
+ ## Overview
9
+
10
+ Layout components hold other components. They come in two kinds.
11
+
12
+ | What you want to change | Use |
13
+ | --- | --- |
14
+ | The frame around a page: dashboard, settings, or no navigation | A page layout (`DefaultLayout`, `SettingsLayout`, `EmptyLayout`). |
15
+ | The arrangement of blocks inside the page | A container builder (`Grid`, stacks, or tabs). |
16
+
17
+ See the [settings-page screenshot](/docs/building/settings-pages) for the frame and the [container previews](/docs/components/layout-and-containers) for the blocks inside it. The reference below gives their exact contracts.
18
+
19
+ **Containers** are builders: `Grid`, `GridRow`, `HStack`, `VStack`, `Spacer`, `Tab`, `Guard` and `Placeholder` return a `ComponentBuilder`, and you nest components inside them with `.child(id, component)`. They are assigned to a static field of a page class like any other component.
20
+
21
+ **Page layouts** are not builders: `DefaultLayout`, `FormPageLayout`, `SettingsLayout` and `EmptyLayout` return a plain `ComponentInfo` and are passed as the third argument of `PageController` — or as the `layout` option of a page or category, the default of everything declared under it — to say how the page's chrome is drawn.
22
+
23
+ `CustomComponent` sits outside both: it names a Vue component from the frontend registry and gives you a builder for it, which is the escape hatch when the vocabulary on this page is not enough.
24
+
25
+ ```typescript
26
+ import { Grid, GridRow } from "@antelopejs/interface-dms/base/grid";
27
+ import { HStack, Spacer, VStack } from "@antelopejs/interface-dms/base/stack";
28
+ import { Tab } from "@antelopejs/interface-dms/base/tab";
29
+ import { Guard } from "@antelopejs/interface-dms/base/guard";
30
+ import { DefaultLayout, EmptyLayout, FormPageLayout, SettingsLayout } from "@antelopejs/interface-dms/base/layouts";
31
+ import { Placeholder } from "@antelopejs/interface-dms/base/placeholder";
32
+ import { CustomComponent } from "@antelopejs/interface-dms/base/custom";
33
+ ```
34
+
35
+ `Grid`, `GridRow`, `HStack`, `VStack`, `Spacer`, `Tab`, `Guard`, `Placeholder`, `DefaultLayout`, `FormPageLayout`, `SettingsLayout` and `EmptyLayout` are also exported from the `base` barrel. `CustomComponent` is only exported from `@antelopejs/interface-dms/base/custom`.
36
+
37
+ ## Grid
38
+
39
+ `Grid` is a container whose direct children are `GridRow`s. The number of columns follows the widest row.
40
+
41
+ ```typescript
42
+ import { Grid, GridRow } from "@antelopejs/interface-dms/base/grid";
43
+ import { KpiCard } from "@antelopejs/interface-dms/base";
44
+
45
+ static kpis = Grid({ gap: "1rem" }).child(
46
+ "kpiRow",
47
+ GridRow()
48
+ .child("proposed", KpiCard({ title: "Proposed", fetchUrl: "/metrics/proposed" }))
49
+ .child("approved", KpiCard({ title: "Approved", fetchUrl: "/metrics/approved" }))
50
+ .child("denied", KpiCard({ title: "Denied", fetchUrl: "/metrics/denied" })),
51
+ );
52
+ ```
53
+
54
+ `GridOptions`:
55
+
56
+ | Option | Type | Description |
57
+ | --- | --- | --- |
58
+ | `gap` | `string` | Gap between rows and columns, as a CSS length. Defaults to `"1rem"`. |
59
+ | `minColumnWidth` | `string` | Width below which a column is dropped rather than squeezed. Defaults to `"240px"`. |
60
+
61
+ `GridRow()` takes no options. Its children are laid out horizontally within the row.
62
+
63
+ `.child()` accepts a third argument of extra metadata, which is carried verbatim onto the serialized child alongside its `id` — that is how a child declares that it spans more than one column:
64
+
65
+ ```typescript
66
+ GridRow().child("chart", chartCard, { colSpan: 2 }).child("list", topListCard);
67
+ ```
68
+
69
+ ## Stacks
70
+
71
+ `HStack` and `VStack` arrange their children along one axis; `Spacer` fills the leftover space between them.
72
+
73
+ ```typescript
74
+ import { HStack, Spacer } from "@antelopejs/interface-dms/base/stack";
75
+
76
+ static toolbar = HStack({ alignment: "center", distribution: "space-between", spacing: "16px" })
77
+ .child("title", titleComponent)
78
+ .child("gap", Spacer())
79
+ .child("actions", actionsComponent);
80
+ ```
81
+
82
+ `StackAlignment` is `"start" | "center" | "end" | "stretch"` and `StackDistribution` is `"start" | "center" | "end" | "space-between" | "space-around" | "space-evenly"`.
83
+
84
+ ### HStackOptions
85
+
86
+ | Option | Type | Description |
87
+ | --- | --- | --- |
88
+ | `alignment` | `StackAlignment` | Vertical alignment of children. Defaults to `"center"`. |
89
+ | `distribution` | `StackDistribution` | Horizontal distribution of children. Defaults to `"start"`. |
90
+ | `spacing` | `string` | Space between children, as a CSS length. Defaults to `"8px"`. |
91
+ | `wrap` | `boolean` | Wrap children onto the next line when the row runs out of space. Defaults to `false`. |
92
+ | `grow` | `number` | Share of the parent stack's free space this stack takes, against its siblings. Unset, a stack nested in an `HStack` shares the row equally with its sibling stacks, or keeps its content width when the row has a `Spacer`. |
93
+
94
+ ### VStackOptions
95
+
96
+ | Option | Type | Description |
97
+ | --- | --- | --- |
98
+ | `alignment` | `StackAlignment` | Horizontal alignment of children. Defaults to `"center"`. |
99
+ | `distribution` | `StackDistribution` | Vertical distribution of children. Defaults to `"start"`. |
100
+ | `spacing` | `string` | Space between children, as a CSS length. Defaults to `"8px"`. |
101
+ | `grow` | `number` | Share of the parent stack's free space this stack takes, against its siblings. Unset, a stack nested in an `HStack` shares the row equally with its sibling stacks, or keeps its content width when the row has a `Spacer`. |
102
+
103
+ ### SpacerOptions
104
+
105
+ | Option | Type | Description |
106
+ | --- | --- | --- |
107
+ | `minSize` | `string` | Minimum size, as a CSS length. No minimum by default. |
108
+ | `maxSize` | `string` | Maximum size, as a CSS length. No maximum by default. |
109
+ | `grow` | `number` | Flex grow factor. Defaults to `1`. |
110
+
111
+ The three builders apply their defaults into the options they carry, so a `Spacer()` with no arguments still ships `{ grow: 1 }`.
112
+
113
+ ## Tabs
114
+
115
+ `Tab` declares a tab bar. Each `TabItem` names a `slot`, and the component that belongs in a tab is attached as a child with that slot.
116
+
117
+ ```typescript
118
+ import { Tab, TabVariant } from "@antelopejs/interface-dms/base/tab";
119
+ import { Color, Size } from "@antelopejs/interface-dms/base/types";
120
+
121
+ static tabs = Tab({
122
+ items: [
123
+ { label: "Overview", slot: "overview", icon: "i-ph-gauge" },
124
+ { label: "Members", slot: "members", icon: "i-ph-users", badge: 12 },
125
+ ],
126
+ variant: TabVariant.pill,
127
+ color: Color.primary,
128
+ size: Size.medium,
129
+ persistState: true,
130
+ stateKey: "tenant-tabs",
131
+ })
132
+ .child("overview", overviewComponent, { slot: "overview" })
133
+ .child("members", membersTable, { slot: "members" });
134
+ ```
135
+
136
+ ### TabProps
137
+
138
+ `TabProps` extends [`BaseComponentProps`](/docs/interfaces/base/types).
139
+
140
+ | Option | Type | Description |
141
+ | --- | --- | --- |
142
+ | `items` | `TabItem[]` | The tabs. Required. |
143
+ | `color` | `Color` | Colour of the active tab. |
144
+ | `size` | `Size` | Control size. |
145
+ | `variant` | `TabVariant` | `TabVariant.pill` or `TabVariant.link`. |
146
+ | `orientation` | `AxeOrientation` | Direction of the tab bar. |
147
+ | `fit` | `boolean` | A horizontal `pill` list as wide as its tabs instead of the full width; a `link` list already sizes its tabs to their labels. Defaults to `false`. |
148
+ | `unmountOnHide` | `boolean` | Discard a tab's content when it is not active. |
149
+ | `persistState` | `boolean` | Keep the active tab in the URL by its slot (`?<stateKey>=<slot>`); an index (`?tab=2`) is still read. |
150
+ | `stateKey` | `string` | Key the state is stored under. |
151
+
152
+ ### TabItem
153
+
154
+ | Field | Type | Description |
155
+ | --- | --- | --- |
156
+ | `label` | `string` | Tab label. Required. |
157
+ | `slot` | `string` | Slot name the tab's content is attached to. Required. |
158
+ | `icon` | `string` | Leading icon. |
159
+ | `badge` | `BlockText \| number \| BadgeProps` | Badge on the tab; a `$` key, a `ComposedText` and a `BadgeProps` `label` are translated. |
160
+ | `disabled` | `boolean` | Make the tab unselectable. |
161
+ | `shortcut` | `string` | Keyboard shortcut. |
162
+ | `avatar` | `{ src?: string; alt?: string; size?: Size }` | Leading avatar. |
163
+
164
+ `BadgeProps` is `{ label?: string | number; color?: string; variant?: "solid" | "outline" | "soft" | "subtle"; size?: Size }`. Note that its `variant` union is its own, narrower than `ButtonVariant`.
165
+
166
+ `TabEvents.TAB_CHANGE` is `"DmsComponent.Tab.Change"`.
167
+
168
+ ## Page Layouts
169
+
170
+ A page layout is the third argument of `PageController`. It is optional: a page that omits it is registered with `DefaultLayout()` and its default options.
171
+
172
+ ```typescript
173
+ import {
174
+ PageController,
175
+ pagesCategory,
176
+ RegisterPage,
177
+ } from "@antelopejs/interface-dms/page";
178
+ import { DefaultLayout } from "@antelopejs/interface-dms/base/layouts";
179
+
180
+ @RegisterPage()
181
+ export class OverviewPage extends PageController(
182
+ "overview",
183
+ { displayName: "Overview", icon: "i-ph-gauge", category: pagesCategory },
184
+ DefaultLayout({ hideHeader: true }),
185
+ ) {
186
+ static content = overviewGrid;
187
+ }
188
+ ```
189
+
190
+ `DefaultLayout(options?)` returns the `ComponentInfo` for `dms-default-layout`.
191
+
192
+ | Option | Type | Description |
193
+ | --- | --- | --- |
194
+ | `fullWidth` | `boolean` | Let the content span the whole panel instead of a constrained column. Defaults to `true`. |
195
+ | `hideHeader` | `boolean` | Omit the page header. |
196
+ | `fillHeight` | `boolean` | Let the page fill the height of the panel instead of growing with its content, for a tool that scrolls inside itself — see [Pages that fill the panel](#pages-that-fill-the-panel). Defaults to `false`. |
197
+ | `headerActions` | `CustomButton[]` | Buttons right of the page title, in order — see [Header actions](#header-actions). |
198
+
199
+ Dashboard pages are full-width unless they opt out, which is what a table, a grid of cards or a chart wants. Set `fullWidth: false` for a page that only holds a form, where a constrained column stays readable — or use `FormPageLayout`, which is exactly that:
200
+
201
+ ```typescript
202
+ import { FormPageLayout } from "@antelopejs/interface-dms/base/layouts";
203
+
204
+ const layout = FormPageLayout({ hideHeader: true });
205
+ ```
206
+
207
+ Its optional argument accepts `DefaultLayoutOptions` without `fullWidth`; the return value is a `ComponentInfo`.
208
+
209
+ The generated `new` and `edit` pages of a table view use it, unless their page's category chain declares a layout, so a form page you write by hand matches them without your having to remember the flag.
210
+
211
+ `SettingsLayout(options?)` returns the `ComponentInfo` for `dms-settings-layout`: the dashboard frame with the settings navigation next to the page, in the column width every settings page shares. Its options are `DefaultLayoutOptions` without `fullWidth`. `settingsCategory` declares it as its `layout`, so the pages under the settings root get it without naming it ([Settings pages](/docs/building/settings-pages)).
212
+
213
+ `EmptyLayout()` takes no options and returns the `ComponentInfo` for `dms-empty-layout`, with an empty options object. Use it for pages that draw their own chrome — sign-in and onboarding screens, for instance.
214
+
215
+ Because they return a plain `ComponentInfo` rather than a `ComponentBuilder`, the page layouts have no `.child()`, `.meta()` or `.watch()`.
216
+
217
+ ### Header actions
218
+
219
+ A page puts its main actions next to its title — a primary "New invoice", an "Export", a link to the docs — with `headerActions`: the same `CustomButton`s as a table's toolbar (see [Tables](/docs/components/tables)), plus an `order` (`PageHeaderButton`), so a header button opens a link, runs a [quick action](/docs/interfaces/core/quick-actions), opens a drawer or a modal, calls an API or runs an export, with a confirmation and an availability of its own:
220
+
221
+ ```typescript
222
+ import { DefaultLayout } from "@antelopejs/interface-dms/base/layouts";
223
+
224
+ DefaultLayout({
225
+ headerActions: [
226
+ {
227
+ id: "docs",
228
+ label: "Documentation",
229
+ icon: "i-ph-book-open",
230
+ target: { type: "external", url: "https://antelopejs.com/docs", newTab: true },
231
+ },
232
+ // An empty label lends the quick action's name (and its icon).
233
+ { id: "new", label: "", color: "primary", target: { type: "quickAction", id: "billing:new-invoice" } },
234
+ {
235
+ id: "audit",
236
+ label: "Audit log",
237
+ icon: "i-ph-scroll",
238
+ target: { type: "page", url: "/audit" },
239
+ permission: "audit", // pages.billing.audit on the page pages.billing
240
+ },
241
+ ],
242
+ });
243
+ ```
244
+
245
+ | Field | Type | Description |
246
+ | --- | --- | --- |
247
+ | `id` | `string` | Key of the button, unique in the header. Defaults to its place (`header-0`, …). |
248
+ | `label` | `string` | Button text (i18n key with `$` or literal). A quick action lends its own name to an empty label. |
249
+ | `icon` | `string` | Icon name. A quick action lends its own icon. |
250
+ | `target` | `ActionTarget` | What the button does: `page` or `external` (drawn as a link), `quickAction` (`{ id }`: `category:id`, or the bare id), `drawer`, `modal`, `api`, `exportJob`, `download` (`{ url, filename? }`: fetched with the user's session and saved). |
251
+ | `confirm` | `ConfirmDialog \| { from }` | Asked before the target runs. |
252
+ | `color` | `"primary" \| "neutral" \| …` | Defaults to `neutral`, an outline button; any other color is solid. |
253
+ | `variant` | `ButtonVariant` | Overrides the fill derived from the color. |
254
+ | `permission` | `string \| Action` | Action of the page, relative to its permission id (`"export"` on `pages.sales` → `pages.sales.export`), or an `Action` of any component, the user must hold to see the button. |
255
+ | `permissionId` | `string` | Absolute permission id the user must hold to see the button; wins over `permission`. |
256
+ | `availability` | `CustomButtonAvailability` | Disables the button for a request it answers `{ reason }` with, the reason shown in a tooltip; leaves it out of the header for one it answers `{ hidden: true }`. Receives the page URL's query as `context.query`. |
257
+ | `order` | `number` | Place among the header's buttons, smallest first; defaults to `0`. See [Ordering the header](#ordering-the-header). |
258
+
259
+ #### Buttons that follow the page URL
260
+
261
+ A page scoped by its URL — `?workspace=web`, a record id in the path — declares its buttons once and lets them follow the URL. A target's URL (`page`, `external`, `download`, `api`, `exportJob`) takes the tokens of a block's `fetchUrl`: `{{params.X}}` is filled with the route parameter `X` of the page, `{{query.X}}` with its query parameter `X`, percent-encoded, again whenever the URL changes. A query pair whose value is a token the page has no value for is left out (`?workspace={{query.workspace}}` on a page without `?workspace=` leads to the target's default), and any other token without a value is left empty. A `download` target's `filename` takes the same tokens, written as they are; while one has no value, the file is saved under the server's name:
262
+
263
+ ```ts
264
+ {
265
+ id: "download-language",
266
+ label: "$lang.queue.download", // "Download {locale}.json"
267
+ icon: "i-ph-download-simple",
268
+ target: {
269
+ type: "download",
270
+ url: "/api/lang/translations/export?workspace={{query.workspace}}&locale={{query.locale}}",
271
+ filename: "{{query.locale}}.json",
272
+ },
273
+ }
274
+ ```
275
+
276
+ A label's translation takes the page's route and query parameters as its own (a query parameter wins over a route parameter of the same name), so `"Download {locale}.json"` names the language of `?locale=fr` and follows it as the URL changes.
277
+
278
+ An `availability` receives the page URL's query as `context.query` (one value per name; caller-supplied, so check it like any input). The header asks again whenever the query changes — only when one of its buttons declares an `availability` — and keeps the buttons it has until the answer lands. The keys the DMS writes for the view itself never cause a request: `record` (the row open in a side panel), `live` (the paused live mode) and a period selector's `<id>.period`, `<id>.compare`, `<id>.from`, `<id>.to`, `<id>.compareFrom` and `<id>.compareTo`. A query key may carry dots, so `{{query.api.period}}` reads a period selector's choice.
279
+
280
+ ```typescript
281
+ DefaultLayout({
282
+ headerActions: [
283
+ {
284
+ id: "matrix",
285
+ label: "$lang.queue.open_matrix",
286
+ icon: "i-ph-table",
287
+ target: { type: "page", url: "/modules/lang/translations?workspace={{query.workspace}}&missingIn={{params.locale}}" },
288
+ },
289
+ {
290
+ id: "download",
291
+ label: "$lang.queue.download",
292
+ target: { type: "api", url: "/api/lang/export?workspace={{query.workspace}}", successMessage: "$lang.exported" },
293
+ availability: async ({ query }) =>
294
+ (await workspaceExists(query?.workspace)) ? undefined : { reason: "$lang.workspace.unknown" },
295
+ },
296
+ ],
297
+ });
298
+ ```
299
+
300
+ A table view of the page places its own buttons in the header with `placement: "header"` — a custom button, or its built-in add (`rowActions: { add: { placement: "header" } }`): the server adds them after the layout's own buttons, served as the table view was (left out without its permission or when its `availability` hides it, disabled with the reason its `availability` gives), and the table view runs them, in place. They take the order `0`.
301
+
302
+ Who sees a button is settled on the server when the layout is served: a button whose `permission` or `permissionId` the user lacks is left out of the layout, and a quick-action button only shows to the users the quick action is served to — that is, the users its page admits. A page drawn by a Vue page component can add its own buttons next to these with `usePageHeaderActions`; call it before the first `await` of the component's setup, so the server renders the buttons with the page and they never pop in. A page whose changes save as soon as they are made shows the shared **Saved instantly** pill there with `useInstantSaveHeader` ([Instant save or a save bar](/docs/components/lists-and-settings/section#instant-save-or-a-save-bar)).
303
+
304
+ #### Hidden or disabled
305
+
306
+ An `availability` has three answers. `undefined` leaves the button as declared, `{ reason }` disables it with the reason in a tooltip — for something the user can change ("No seat left: upgrade the plan") — and `{ hidden: true }` leaves it out of the header, as a missing permission does — for a button that makes no sense in the scope the page shows. Like a disabled one, a hidden button follows the page URL: the header asks again when the query changes, so a button can disappear on one `?workspace=` and come back on another, and the server-rendered header already carries the answer for the URL it was rendered for.
307
+
308
+ ```typescript
309
+ DefaultLayout({
310
+ headerActions: [
311
+ {
312
+ id: "add-key",
313
+ label: "$lang.keys.add",
314
+ color: "primary",
315
+ target: { type: "drawer", component: AddKeyForm() },
316
+ availability: async ({ query }) =>
317
+ (await workspaceOf(query?.workspace))?.readOnly ? { hidden: true } : undefined,
318
+ },
319
+ ],
320
+ });
321
+ ```
322
+
323
+ Hiding only shapes the UI: the route behind the button must still refuse the operation on its own.
324
+
325
+ #### Ordering the header
326
+
327
+ The header shows, left to right, the buttons the layout declares, the buttons a table view places there (`placement: "header"`), then the actions a Vue component of the page adds with `usePageHeaderActions` ([UI toolkit](/docs/frontend-composables/ui-toolkit#page-header-actions-usepageheaderactions)). To interleave them, give a button an `order` and the component's actions one too (`usePageHeaderActions(render, { order })`): the header sorts everything by `order`, smallest first, `0` when none is given, and at an equal order keeps the served buttons first, in the order above. So `{ order: -1 }` on a component's actions puts them before every served button left at the default:
328
+
329
+ ```typescript
330
+ // The served "Add key" stays at the default order, 0.
331
+ DefaultLayout({ headerActions: [{ id: "add-key", label: "$lang.keys.add", color: "primary", target }] });
332
+ ```
333
+
334
+ ```vue [app/components/TranslationMatrix.vue]
335
+ <script setup lang="ts">
336
+ import { usePageHeaderActions } from "#dms-layout/app/composables/layout/usePageHeaderActions";
337
+
338
+ // Export, built in the browser, goes before the served "Add key".
339
+ usePageHeaderActions(() => h(UButton, { label: t("lang.export"), icon: "i-ph-download-simple", onClick: exportCsv }), { order: -1 });
340
+ </script>
341
+ ```
342
+
343
+ ### Pages that fill the panel
344
+
345
+ A page built around one tool — a file explorer, a data grid, a graph canvas — usually wants that tool to take the height of the panel and scroll inside itself, rather than make the whole page scroll. Set `fillHeight` on its layout:
346
+
347
+ ```typescript
348
+ DefaultLayout({ fillHeight: true })
349
+ ```
350
+
351
+ The layout then lays the page out as a flex column the height of the panel. The page header keeps its own height and the height left under it goes to the **last component** of the page; the components above it keep theirs. Give the root of that component `flex-1 min-h-0`, and let an element inside it scroll:
352
+
353
+ ```vue [app/components/MyExplorer.vue]
354
+ <template>
355
+ <div class="flex min-h-0 flex-1 flex-col gap-4">
356
+ <MyToolbar />
357
+ <div class="min-h-0 flex-1 overflow-y-auto">
358
+ <!-- the rows -->
359
+ </div>
360
+ </div>
361
+ </template>
362
+ ```
363
+
364
+ A custom page — a page component your frontend layer registers under the page's slug — sits straight in the layout, so its own root takes `flex-1 min-h-0` the same way.
365
+
366
+ Do not size the tool from the viewport, with `h-[calc(100vh-…)]` or an offset measured in the browser. The dashboard header, the [layout banners](/docs/interfaces/core/layout-banners), the page header and the panel padding all take height above the page, and that height changes: a banner shows up or is dismissed, a description wraps. The layout accounts for all of it.
367
+
368
+ - **Minimum height.** The page area, header included, never gets shorter than 30rem: on a shorter panel it stops shrinking and the panel scrolls again. The floor sits on the page area rather than on the tool, because a `min-h-*` set inside the page overflows the column, and the panel then scrolls past its own bottom padding. A tool that needs more room than what is left to it at that floor — one that shares its component with a card of figures, say — can still set its own `min-h-*`, at that cost.
369
+ - **Every width.** The page fills the panel on a phone too: the panel is the part of the dashboard that scrolls at every width. A page whose panes stack on a narrow screen (`grid-cols-1 lg:grid-cols-[…]`) decides how they share the height there, or lets its own root scroll below that breakpoint (`max-lg:overflow-y-auto`).
370
+ - **Several components.** Only the last one fills. Put the tool last, under whatever summarizes it, or keep both in one component that lays them out itself.
371
+
372
+ ## Guard
373
+
374
+ `Guard` is a container that renders its children unchanged unless a route says there is nothing for them; then it renders one `EmptyState`, its `fallback`, in their place. Several children stack with the spacing the page puts between its blocks.
375
+
376
+ ```typescript
377
+ import { Guard } from "@antelopejs/interface-dms/base/guard";
378
+
379
+ static statistics = Guard({
380
+ fetchUrl: "/api/routes/activity?route={{query.route}}", // { calls: 0 }
381
+ periodScope: "api",
382
+ field: "calls",
383
+ fallback: {
384
+ title: "$api.routes.statistics.idle_title",
385
+ actions: [{ label: "$api.routes.test", to: "/modules/api/routes?tab=tester" }],
386
+ },
387
+ }).child("charts", statisticsGrid);
388
+ ```
389
+
390
+ `GuardOptions`:
391
+
392
+ | Option | Type | Description |
393
+ | --- | --- | --- |
394
+ | `fetchUrl` | `string` | The route read. It takes the `{{params.X}}` / `{{query.X}}` tokens of a block's `fetchUrl`; nothing is requested while one has no value. |
395
+ | `fetchUrlMethod` | `HttpMethod` | Method of the request. |
396
+ | `periodScope` | `string` | Id of the `PeriodSelector` the route is read with, again on each change. |
397
+ | `realtimeTopic` | `string \| string[]` | Topics whose events make the guard read the route again. |
398
+ | `when` | `GuardCondition` | `"empty"` (the default): the value at `field` is absent, `null`, `false`, `0`, `""` or `[]`. `"notFound"`: the route answers 404. |
399
+ | `field` | `string` | Dot path of the value `"empty"` checks; the whole answer without it. |
400
+ | `fallback` | `GuardFallback` | An `EmptyState`'s options without `watchActions`, all optional. The title defaults to "Nothing to show yet", or "Not found" for `"notFound"`, in the reader's language; for `"notFound"` the description defaults to the 404 body's `message`, as for a block. |
401
+
402
+ An `emptyState` object in the answer, or in the body of the 404, is laid over `fallback`. The children show while the route loads, after any failure other than the condition, and whenever the value is set. The guide is [Layout & containers](/docs/components/layout-and-containers/guard).
403
+
404
+ ## Placeholder
405
+
406
+ `Placeholder` renders a labelled box. It exists so a layout can be assembled and reviewed before the real components are written. It takes `PlaceholderOptions`:
407
+
408
+ ```typescript
409
+ import { Placeholder } from "@antelopejs/interface-dms/base/placeholder";
410
+
411
+ static preview = Placeholder({ label: "Chart goes here", height: "200px" });
412
+ ```
413
+
414
+ | Option | Type | Description |
415
+ | --- | --- | --- |
416
+ | `label` | `string` | Text shown in the box. |
417
+ | `height` | `string` | CSS height. Defaults to `"120px"`. |
418
+ | `width` | `string` | CSS width. Auto by default. |
419
+
420
+ ## CustomComponent
421
+
422
+ `CustomComponent(componentName)` returns a bare `ComponentBuilder` for a frontend component resolved by name. Nothing else in the `base` domain is involved: the options you pass with `.options()` are handed to that component as-is, and the component itself comes from a frontend module registered with `AddFrontendModule`.
423
+
424
+ ```typescript
425
+ import { CustomComponent } from "@antelopejs/interface-dms/base/custom";
426
+
427
+ static activity = CustomComponent("DmsAiActivityView").meta({
428
+ name: "Activity",
429
+ icon: "i-ph-clock-counter-clockwise",
430
+ });
431
+ ```
432
+
433
+ Since the return value is an ordinary builder, `.options()`, `.child()`, `.meta()`, `.watch()`, `.onCreated()` and `.onFilter()` all apply, and the component can hold `base` components as children.
434
+
435
+ `CustomComponent` is also how a table view's `formContainer.pages.*.customPage` option is used: you register your own page carrying a custom component at the slug the table view would otherwise generate — declare that slug as the entry's `urlSlug`, which is taken relative to the page as it stands — see [Tables](/docs/interfaces/base/tables).
436
+
437
+ ## Next Steps