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.
Registering the page
Section titled “Registering the page”<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.
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.
<PageHeader>
Section titled “<PageHeader>”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
Section titled “The breadcrumb”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.
Tabbed pages
Section titled “Tabbed pages”<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.
Confirmations and feedback
Section titled “Confirmations and feedback”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.