Skip to content

Custom screens

A resource registered with defineResource gets its chrome for free: the form and list runtimes mount the header, the breadcrumb, the tabs, the toolbar, the action bar and every confirmation. This page is the rest of the app — a dashboard, a settings screen, a stock overview — where you register the route and mount those pieces yourself.

<OnerpApp> takes two route arrays alongside resources, both plain RouteObject[] from React Router. routes nests inside the authenticated shell, where the session, the workspace, meta and the application’s layout are already there — everything a signed-in user reaches lives here, including the index route and a path: "*" catch-all over the framework’s not-found. publicRoutes mounts top-level, before the auth gate: no session, no workspace, no meta, no layout — login and invitation-acceptance pages, little else.

apps/web/src/main.tsx
const routes: RouteObject[] = [
{ index: true, element: <DashboardPage /> },
{ path: "inventory/stock", element: <StockOverviewPage /> },
];
const publicRoutes: RouteObject[] = [{ path: "/login", element: <LoginPage /> }];
<OnerpApp
layout={Layout}
publicRoutes={publicRoutes}
resources={resources}
routes={routes}
// … plus baseUrl and title
/>;

Both arrays must be stable references — module-level const, never built inside the component — because the router is rebuilt whenever they change identity. Resource routes are generated after routes and the framework’s not-found route comes last, so a custom path never collides with /{ref}/…. Nothing registers navigation: the sidebar is the application’s own chrome, hand-written.

The single source of truth for a page’s top edge, from @onerp/react/chrome as is everything else here. It owns the title row — title, an extra slot for badges beside it, and action buttons on the right as children, rendered in reading order so the primary one goes last — plus a below slot.

Use it on every routed page inside the application layout — and nowhere else. The header is sticky and bleeds full-width against the page’s scroll container, so in a dialog (which has <DialogHeader>), a card or a side panel the stickiness is wrong and the negative margin leaks past the edges. Body chrome — filters, search, toolbars — is page content, not identity.

The breadcrumb does not sit inside <PageHeader> — it portals into the layout’s #breadcrumb slot from wherever it is mounted.

<Breadcrumb>
<BreadcrumbItem>
<Link to="/inventory/stock">{t("onerp.stock.title")}</Link>
</BreadcrumbItem>
<BreadcrumbPage>{t("onerp.stockLedger.title")}</BreadcrumbPage>
</Breadcrumb>;

<Breadcrumb> inserts the chevron separators by walking its children, so the crumbs must be direct children — a Fragment wrapper collapses several into one for that walk. The resource runtimes render their own resource › record trail, which has no public entry point, so a custom page composes these instead.

<PageTabs> is the provider, <PageHeaderTabs /> renders the trigger row inside the header’s below slot, and each <PageTab> declares one panel. Tabs register on mount, so the trigger row tracks whatever is rendered.

<PageTabs defaultValue="general">
<PageHeader below={<PageHeaderTabs />} title={t("onerp.settings.title")} />
<PageTab label={t("onerp.settings.members.title")} value="general">…</PageTab>
<PageTab label={t("onerp.settings.roles.title")} value="roles">…</PageTab>
</PageTabs>;

Inside a resource form you write only the <PageTab>s — <FormPage> provides the provider and the trigger row.

The active tab is reflected in the URL as ?tab=<value>, written with history.replaceState, so switching tabs adds no history entries. Resolution is URL → defaultValue → first registered tab, so a deep link always wins and an unknown defaultValue falls back without throwing.

Hidden tabs stay mounted — <PageTab> toggles the hidden attribute, so form registrations, scroll position and in-flight queries all survive a switch. That is what lets one save bind inputs from every tab, but it also means every panel renders on first mount.

Use tabs for one resource seen from several angles, where the panels share the header’s title and actions without lying. Not for moving between resources — that is the sidebar — and not for sequenced steps: tabs are non-linear.

Four pieces, from @onerp/react/feedback — except useNotify, a platform hook on @onerp/react.

Hook Shape What it does
useConfirm() (options) => Promise<boolean> Shows the alert dialog, resolves true or false. Just asks.
useNotify() .success/.error/.info/.warning(message) A toast. Non-blocking.
useErrorDialog() .open(error) Blocking “something failed” modal.
useRunWithFeedback() (spec) => Promise<void> The policy: confirm, run, toast, route the error, then onDone.

useRunWithFeedback is composed from the other three, in that order:

await runWithFeedback({
confirm: { title: t("…deleteTitle"), body: t("…deleteConfirm"), tone: "danger" },
run: () => deleteRole.mutateAsync(id),
success: t("onerp.settings.roles.deleted"),
onDone: () => setEditing(null),
});

The translate hook is key-based: t looks its argument up in the active locale’s message bundle, so passing an English sentence is a missing key that renders unlocalised. Every call site passes a dotted key.

It answers nothing, because there is nothing left to ask: whatever you would have done on success — close a dialog, clear the inputs, navigate — is onDone, which runs after the success toast and never on a declined confirm or a failure. A throw inside onDone is a bug in the follow-up, not in the write, so it goes to the error boundary rather than the modal.

A failure lands on the blocking error dialog; surface: "toast" routes it to a toast instead, for a page the user stays on to try again. Errors render from their code through the error contract, so you never format a message yourself. Reach for it when the flow is maybe confirm → do async work → report how it went, which is almost every mutation, and for useConfirm only when you want the yes/no gate and nothing else.

There is no exception for a form. A refused save or release reports like every other, and core.validation_failed lists what it refused, by record and field label; the form also lands the issues on its own fields, and editing a field clears its issues.

You will rarely need either. On framework surfaces — save and delete, the action bar, Release, Archive and Restore, has-many and related-list deletes, every action in the record menu — useRunWithFeedback is already called internally. These hooks are for hand-rolled UI.