Skip to content

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:

examples/erp/protobase.ui.tsx
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.

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 a RecordCard or CardRow ({ model, record, key, etag, permissions }), and undefined elsewhere.

A name with no registered component renders a warning in its place, so a missing registration shows on the page instead of failing silently.

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.

<App ui={...} /> from protobase/ui takes the same object, for an app that mounts the admin itself, and so do stories.