[data-reveal]{opacity:1!important;transform:none!important}
AntelopeJS DMS
DocumentationPage layouts

Page layouts

The layouts a page renders in, the buttons of its header, and pages that fill the panel.

A page renders its components inside a layout. This page covers the layouts interface-dms exports, the header actions a page declares, and pages that fill the panel. The containers that arrange components inside a page are in Layout and containers.

Page layouts

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.

typescript
import {
  PageController,
  pagesCategory,
  RegisterPage,
} from "@antelopejs/interface-dms/page";
import { DefaultLayout } from "@antelopejs/interface-dms/base/layouts";

@RegisterPage()
export class OverviewPage extends PageController(
  "overview",
  { displayName: "Overview", icon: "i-ph-gauge", category: pagesCategory },
  DefaultLayout({ hideHeader: true }),
) {
  static content = overviewGrid;
}

DefaultLayout(options?) returns the ComponentInfo for dms-default-layout.

OptionTypeDescription
fullWidthbooleanLet the content span the whole panel instead of a constrained column. Defaults to true.
hideHeaderbooleanOmit the page header.
fillHeightbooleanLet 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. Defaults to false.
headerActionsCustomButton[]Buttons right of the page title, in order. See Header actions.
recordPageRecordLoaderReads the record the page shows, with the layout, for the header buttons acting on it and every availability of the page. See Buttons acting on the page's record.

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:

typescript
import { FormPageLayout } from "@antelopejs/interface-dms/base/layouts";

const layout = FormPageLayout({ hideHeader: true });

Its optional argument accepts DefaultLayoutOptions without fullWidth; the return value is a ComponentInfo.

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.

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).

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, such as sign-in and onboarding screens.

Because they return a plain ComponentInfo rather than a ComponentBuilder, the page layouts have no .child(), .meta() or .watch().

Header actions

A page puts its main actions next to its title, such as a primary "New invoice", an "Export" or a link to the docs, with headerActions: the same CustomButtons as a table's toolbar (see Tables), plus an order (PageHeaderButton), so a header button opens a link, runs a quick action, opens a drawer or a modal, calls an API or runs an export, with a confirmation and an availability of its own:

typescript
import { DefaultLayout } from "@antelopejs/interface-dms/base/layouts";

DefaultLayout({
  headerActions: [
    {
      id: "docs",
      label: "Documentation",
      icon: "i-ph-book-open",
      target: { type: "external", url: "https://antelopejs.com/docs", newTab: true },
    },
    // An empty label lends the quick action's name (and its icon).
    { id: "new", label: "", color: "primary", target: { type: "quickAction", id: "billing:new-invoice" } },
    {
      id: "audit",
      label: "Audit log",
      icon: "i-ph-scroll",
      target: { type: "page", url: "/audit" },
      permission: "audit", // pages.billing.audit on the page pages.billing
    },
  ],
});
FieldTypeDescription
idstringKey of the button, unique in the header. Defaults to its place (header-0, …).
labelstringButton text (i18n key with $ or literal). A quick action lends its own name to an empty label.
iconstringIcon name. A quick action lends its own icon.
targetActionTargetWhat 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).
confirmConfirmDialog | { from }Asked before the target runs.
color"primary" | "neutral" | …Defaults to neutral, an outline button; any other color is solid.
variantButtonVariantOverrides the fill derived from the color.
permissionstring | ActionAction 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.
permissionIdstringAbsolute permission id the user must hold to see the button; wins over permission.
availabilityCustomButtonAvailabilityDisables 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.
ordernumberPlace among the header's buttons, smallest first; defaults to 0. See Ordering the header.

Buttons that follow the page URL

A page scoped by its URL, such as ?workspace=web or 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:

ts
{
  id: "download-language",
  label: "$lang.queue.download", // "Download {locale}.json"
  icon: "i-ph-download-simple",
  target: {
    type: "download",
    url: "/api/lang/translations/export?workspace={{query.workspace}}&locale={{query.locale}}",
    filename: "{{query.locale}}.json",
  },
}

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.

An availability receives the page URL's query as context.query (one value per name; caller-supplied, so check it like any input), its route parameters as context.params (/projects/project/p1 on a page registered at /projects/project/:id is { id: "p1" }), and the record the layout read as context.record (Buttons acting on the page's record). The header asks again whenever the query or the route parameters change, and once a write refreshed the page (only when one of its buttons declares an availability or the layout reads a record), 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.

typescript
DefaultLayout({
  headerActions: [
    {
      id: "matrix",
      label: "$lang.queue.open_matrix",
      icon: "i-ph-table",
      target: { type: "page", url: "/modules/lang/translations?workspace={{query.workspace}}&missingIn={{params.locale}}" },
    },
    {
      id: "download",
      label: "$lang.queue.download",
      target: { type: "api", url: "/api/lang/export?workspace={{query.workspace}}", successMessage: "$lang.exported" },
      availability: async ({ query }) =>
        (await workspaceExists(query?.workspace)) ? undefined : { reason: "$lang.workspace.unknown" },
    },
  ],
});

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.

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, which are 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).

Buttons acting on the page's record

A page about one record (a site, an invoice) reads it with its layout: record is a function the server calls each time it serves the layout, with the request's context (the page URL's params and query, the user and the tenantId), and answers the record, or undefined when there is none to show. A button's target names its fields as {{record.X}}, filled in by the server and percent-encoded: a maps link to the site's address, an API call on its own id. A button acting on the record, one whose target names {{record.X}} or declaring requiresRecord: true (an "Edit" link that only needs {{params.id}}), is left out of a page whose record was not found, or whose record threw (logged). Every availability of the page reads the record as context.record, so a button can be disabled on the record's state without reading it again:

typescript
import { BasicDataModel, Field, GetModel, RegisterTable, Table } from "@antelopejs/interface-database-decorators";
import { DefaultLayout } from "@antelopejs/interface-dms/base/layouts";

@RegisterTable("sites", "example_sites")
class Site extends Table {
  @Field("string") declare address: string;
  @Field("boolean") declare closed: boolean;
}

class SiteModel extends BasicDataModel(Site, "sites") {}

DefaultLayout({
  record: ({ params }) => GetModel(SiteModel).get(params?.id ?? ""),
  headerActions: [
    {
      label: "$sites.directions",
      icon: "i-ph-map-pin",
      target: { type: "external", url: "https://www.google.com/maps/search/?api=1&query={{record.address}}", newTab: true },
    },
    { label: "$sites.edit", requiresRecord: true, target: { type: "page", url: "/sites/{{params.id}}/edit" } },
    {
      label: "$sites.close",
      target: { type: "api", url: "/api/sites/close?id={{params.id}}", successMessage: "$sites.closed" },
      availability: ({ record }) => (record?.closed ? { reason: "$sites.already_closed" } : undefined),
    },
  ],
});

The record travels with the layout, so the header is drawn with it, on the server too, and never waits for it. Like an availability, it follows the page URL: the header asks for its buttons again when the route parameters or the query change, and once a write refreshed the page (refreshPageBlocks(), which a header button calls after a change, and a form once it saved). Its fields only shape the buttons; the route behind each one still checks the record on its own.

A record deleted under the page

A page reading a record leaves it once it is deleted, in every tab and for every member on it, with no code of the module. On each event of one of the page's realtime topics (its table views' rows, a realtimeTopic, a topic bound with RegisterPageTopic), the page asks its layout again: a record the server found before and now finds none of (its record answers undefined) was deleted. A toast says so, and the page opens the list it is a detail of (MenuOptions.parent); without one, it stays, on the "not found" state of its blocks. A record that throws counts as unknown, never as deleted, and a page opened on a record that never existed shows its blocks' "not found" state without a toast. Publish on one of the page's topics when the record is deleted: a table view's delete does it on its own.

Hidden or disabled

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"). { 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.

typescript
DefaultLayout({
  headerActions: [
    {
      id: "add-key",
      label: "$lang.keys.add",
      color: "primary",
      target: { type: "drawer", component: AddKeyForm() },
      availability: async ({ query }) =>
        (await workspaceOf(query?.workspace))?.readOnly ? { hidden: true } : undefined,
    },
  ],
});

A detail page reads its record from context.params, and hides the buttons that act on it when the record does not exist. The page then shows its "not found" state with no "Edit" or "Delete" beside the title:

typescript
DefaultLayout({
  headerActions: [
    {
      id: "edit",
      label: "$projects.edit",
      target: { type: "page", url: "/projects/project/{{params.id}}/edit" },
      availability: async ({ params }) =>
        (await projectExists(params?.id)) ? undefined : { hidden: true },
    },
  ],
});

Hiding only shapes the UI: the route behind the button must still refuse the operation on its own.

Ordering the header

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). 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:

typescript
// The served "Add key" stays at the default order, 0.
DefaultLayout({ headerActions: [{ id: "add-key", label: "$lang.keys.add", color: "primary", target }] });
app/components/TranslationMatrix.vue vue
<script setup lang="ts">
import { usePageHeaderActions } from "#dms-layout/app/composables/layout/usePageHeaderActions";

// Export, built in the browser, goes before the served "Add key".
usePageHeaderActions(() => h(UButton, { label: t("lang.export"), icon: "i-ph-download-simple", onClick: exportCsv }), { order: -1 });
</script>

Pages that fill the panel

A page built around one tool, such as a file explorer, a data grid or 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:

typescript
import { DefaultLayout } from "@antelopejs/interface-dms/base/layouts";

DefaultLayout({ fillHeight: true })

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:

app/components/MyExplorer.vue vue
<template>
  <div class="flex min-h-0 flex-1 flex-col gap-4">
    <MyToolbar />
    <div class="min-h-0 flex-1 overflow-y-auto">
      <!-- the rows -->
    </div>
  </div>
</template>

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.

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, 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.

  • 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, such as one that shares its component with a card of figures, can still set its own min-h-*, at that cost.
  • 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).
  • 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.