Custom components
Layouts are data; a project’s React code lives in one module the admin bundles with itself: protobase.ui.tsx at the project root, beside protobase.config.ts. It default-exports defineUi({ components, actions }) from protobase/ui. This is the ERP example’s:
import { defineUi, useSeries, type ActionContext } from 'protobase/ui'
/** Bars for orders per day; the custom component behind `<OrdersPerDay>` in config/overview/page.tsx. */const OrdersPerDay = ({ days }: { days: 7 | 30 | 90 }) => { const series = useSeries('orders', { field: 'createdAt', range: `${days}d`, granularity: 'day' }) if (series.isPending) return <p className="text-[13px] text-muted-foreground">Loading</p> if (series.error) return <p className="text-[13px] text-danger-text">Could not load the orders</p> const points = series.data.points const highest = Math.max(1, ...points.map((point) => point.count)) const total = points.reduce((sum, point) => sum + point.count, 0) return ( <figure> <div className="flex h-24 items-end gap-0.5" role="img" aria-label={`${total} orders in ${days} days`}> {points.map((point) => ( <div key={point.bucket} title={`${point.bucket.slice(0, 10)}: ${point.count}`} className="flex-1 rounded-t-sm bg-primary/70" style={{ height: `${(point.count / highest) * 100}%` }} /> ))} </div> <figcaption className="mt-2 text-xs text-muted-foreground">{total.toLocaleString('en-IE')} orders</figcaption> </figure> )}
/** "Send reminder" on an invoice: opens a mail to the customer, and marks a draft invoice as sent. */const send = async ({ record, recordKey, etag, client }: ActionContext) => { const company = await client.get('companies', String(record!.companyId)) const subject = encodeURIComponent(`Invoice ${String(record!.number)}`) window.location.assign(`mailto:${String(company.record.email ?? '')}?subject=${subject}`) if (record!.status === 'draft') await client.update('invoices', recordKey!, { status: 'sent' }, etag ?? '*')}
export default defineUi({ components: { OrdersPerDay }, actions: { send } })protobase dev serves it with hot reload, and protobase build builds it into the bundle’s public/ with the rest of the admin. It is the only project code in the browser, so keep secrets out of it. A project without the file gets an empty one.
Components
Section titled “Components”Declare a component in the layout with component(name) from protobase/layout, typing its props:
/** @jsxImportSource protobase */import { Card, component, page, Page } from 'protobase/layout'
export const OrdersPerDay = component<{ days: 7 | 30 | 90 }>('OrdersPerDay')
export const overview = page('overview', <Page title="Overview"><Card title="Orders per day"><OrdersPerDay days={30} /></Card></Page>)The element stays data, { type: 'OrdersPerDay', props: { days: 30 }, children: [], custom: true }, so its props follow the same rules as any block’s. The name must be PascalCase and not a built-in block’s.
In protobase.ui.tsx, components maps the name to a React component. It gets the props, and the rendered children when the element has any. Inside it:
- the hooks of
protobase/ui(useList,useRecord,useSeries,useFacets,useHistogram,useClient, …) read the API with the user’s token, under the same access rules as everything else; useLayoutRecord()gives the record around it in aRecordCardorCardRow({ model, record, key, etag, permissions }), andundefinedelsewhere.
A name with no registered component renders a warning in its place, so a missing registration shows on the page instead of failing silently.
Action handlers
Section titled “Action handlers”A named action declared with a.action(name, ...) in a view has no built-in behaviour: its button runs actions[name] from protobase.ui.tsx. The handler gets:
resource |
The action’s resource. |
record, recordKey, etag |
The record around the button, when there is one. |
client |
The API client (protobase/client), with the user’s token. |
navigate(to) |
Moves the app to a path such as /orders/42. |
When the handler resolves, everything shown of the resource reloads and a toast says it is done; when it throws, the toast shows the error. Without a handler the button is disabled. Actions with built-in behaviour (a.update, a.remove, a.link) do not need one.
Without protobase dev
Section titled “Without protobase dev”<App ui={...} /> from protobase/ui takes the same object, for an app that mounts the admin itself, and so do stories.