Your first composed page
It is Monday morning at Veldhuis Supply. Sam, who runs sales, opens the admin and starts the usual round: Orders filtered to drafts, Orders again for deliveries nobody paid, Invoices filtered to overdue, Companies filtered to leads. Four lists, four filters, every week.
What Sam wants is one page that says what needs attention. In Protobase that is a composed page: a layout in the config, made of blocks that read the same API as the lists, under the same access rules. No endpoints to write and no React to maintain. By the end of this guide the page looks like the last step below, and every step is a live story you can click through.
The page lives in one file next to the rest of the config, config/overview/page.tsx, exported from config/index.ts like a resource or a view:
export { salesOverview } from './overview/page'1. Four numbers
Section titled “1. Four numbers”Start with what Sam counts every Monday.
/** @jsxImportSource protobase */import { Grid, Page, Stat, page } from 'protobase/layout'
export const overview = page( 'overview', <Page title="Sales overview" description="What needs attention today."> <Grid columns={4}> <Stat label="Draft orders" resource="orders" filter="status = 'draft'" /> <Stat label="Delivered, not paid" resource="orders" filter="status = 'delivered' AND paid = false" /> <Stat label="Overdue invoices" resource="invoices" filter="status = 'overdue'" /> <Stat label="Open leads" resource="companies" filter="status = 'lead'" /> </Grid> </Page>, { nav: { group: 'Sales', order: 0 }, icon: 'layout-dashboard' },)That first comment matters. @jsxImportSource protobase makes the JSX build plain data instead of React elements: <Stat label="Draft orders" ... /> becomes { type: 'Stat', props: { label: 'Draft orders', ... }, children: [] }. The server sends that to the browser in /api/meta, and the admin renders it. So a layout holds no functions and no state, and the build refuses one that tries (see Layouts).
Each Stat counts the records of resource that match filter, written in the same AIP-160 syntax as the list’s ?filter=. The count is a link to exactly that filtered list. page('overview', ...) puts it at /overview, and nav puts it first in the Sales group of the sidebar.
2. The lists behind the numbers
Section titled “2. The lists behind the numbers”A number says how many; Sam also wants to see which. Two tables under the numbers:
/** @jsxImportSource protobase */import { Grid, Page, Stat, Table, page } from 'protobase/layout'
export const overview = page( 'overview', <Page title="Sales overview" description="What needs attention today."> <Grid columns={4}> <Stat label="Draft orders" resource="orders" filter="status = 'draft'" /> <Stat label="Delivered, not paid" resource="orders" filter="status = 'delivered' AND paid = false" /> <Stat label="Overdue invoices" resource="invoices" filter="status = 'overdue'" /> <Stat label="Open leads" resource="companies" filter="status = 'lead'" /> </Grid> <Grid columns={2}> <Table title="Overdue invoices" resource="invoices" filter="status = 'overdue'" sort="issuedAt" columns={['number', 'companyId', 'dueAt', 'total']} pageSize={2} empty="Nothing is overdue." /> <Table title="Latest orders" resource="orders" sort="createdAt desc" columns={['number', 'companyId', 'status', 'total']} pageSize={5} /> </Grid> </Page>, { nav: { group: 'Sales', order: 0 }, icon: 'layout-dashboard' },)A Table takes the same resource, filter and sort (AIP-132, like ?order_by=), plus the columns to show and a pageSize. Relations such as companyId show the company’s name, the first column opens the record, and the arrows page through the rest. Try them on the overdue invoices: there are three, two to a page.
3. One order to chase
Section titled “3. One order to chase”Hollis & Rowe took a large delivery and has not paid. Sam wants that order on top, with a button that records the payment when it comes in.
/** @jsxImportSource protobase */import { Action, Field, Grid, Page, RecordCard, Show, Stat, Table, page } from 'protobase/layout'
export const overview = page( 'overview', <Page title="Sales overview" description="What needs attention today."> <Grid columns={4}> <Stat label="Draft orders" resource="orders" filter="status = 'draft'" /> <Stat label="Delivered, not paid" resource="orders" filter="status = 'delivered' AND paid = false" /> <Stat label="Overdue invoices" resource="invoices" filter="status = 'overdue'" /> <Stat label="Open leads" resource="companies" filter="status = 'lead'" /> </Grid> <RecordCard title="Biggest unpaid delivery" resource="orders" filter="status = 'delivered' AND paid = false" sort="total desc" empty="Every delivered order is paid." actions={<Action name="markPaid" variant="primary" />} > <Grid columns={4}> <Field name="number" /> <Field name="total" /> <Field name="companyId" label="Customer" /> <Field name="companyId.city" label="City" /> </Grid> <Show when="companyId.status = 'dormant'">The customer has gone quiet: call before sending a reminder.</Show> </RecordCard> <Grid columns={2}> <Table title="Overdue invoices" resource="invoices" filter="status = 'overdue'" sort="issuedAt" columns={['number', 'companyId', 'dueAt', 'total']} pageSize={2} empty="Nothing is overdue." /> <Table title="Latest orders" resource="orders" sort="createdAt desc" columns={['number', 'companyId', 'status', 'total']} pageSize={5} /> </Grid> </Page>, { nav: { group: 'Sales', order: 0 }, icon: 'layout-dashboard' },)A RecordCard shows one record: the first that matches its filter in sort order, here the biggest unpaid delivery. Inside it, Field shows a field of that record. A dotted name follows a relation: companyId.city is the city of the order’s company, fetched the way the record page fetches it.
The button is <Action name="markPaid" />, a named action of the orders view. The layout only names it; what it does is declared once, in the view:
import { view } from 'protobase/schema'import type { orders } from './data'
export const ordersView = view<typeof orders>('orders') .title((r) => r.number) .names({ singular: 'Order', plural: 'Orders' }) .nav({ recent: { status: 'status', tones: { draft: 'neutral', confirmed: 'info', picking: 'warning', shipped: 'success' }, pulse: ['picking'], filter: 'status != "delivered" AND status != "cancelled"', orderBy: 'createdAt desc', }, }) .fields((r) => ({ status: r.status.help('Where the order is in its life. Moving it forward can notify the customer.').format('badge'), total: r.total.help('Order total including VAT.').prefix('€').decimals(2), discountPercent: r.discountPercent.label('Discount').help('Percentage taken off the order total.').format('percent'), paid: r.paid.help('Set when the payment has been received.'), createdAt: r.createdAt.label('Created').format('absolute'), })) .list((r) => ({ columns: [r.number, r.companyId, r.status, r.total, r.discountPercent, r.paid, r.ownerId, r.createdAt], sort: [[r.createdAt, 'desc']], search: [r.number], })) .filters((r, w) => [ w.facets(r.status), w.facets(r.ownerId), w.dateRange(r.createdAt, { presets: ['7d', '30d', '90d', 'quarter', 'year'] }), w.range(r.total, { histogram: true }), w.toggle(r.paid), ]) .chart((r) => ({ field: r.createdAt, range: '30d', granularity: 'day' })) .saveFeedback('toast') .actions((a) => [ a.update('markPaid', { label: 'Mark as paid', icon: 'wallet', set: { paid: true }, confirm: 'Record that the customer paid this order?' }), a.action('createInvoice', { label: 'Create invoice', icon: 'file-text' }), ])a.update('markPaid', { set: { paid: true } }) patches the record with its ETag, after the confirm question. Because it is an ordinary update, the access rules decide whether this user may do it, and a user who may not sees the button disabled.
Show keeps its children only while when holds. The condition reads fields of the record around it, through relations too: the warning appears because Hollis & Rowe is dormant. Mark the order as paid in the story: the card moves on to the next unpaid delivery, the warning disappears, and the number above drops to one.
4. Leads, and a way to add them
Section titled “4. Leads, and a way to add them”Last on Sam’s list: new leads, with a quick way to add the one met at a trade fair.
/** @jsxImportSource protobase */import { Action, CardRow, Field, Grid, ModalForm, Page, RecordCard, Show, Stat, Table, page } from 'protobase/layout'
export const overview = page( 'overview', <Page title="Sales overview" description="What needs attention today."> <Grid columns={4}> <Stat label="Draft orders" resource="orders" filter="status = 'draft'" /> <Stat label="Delivered, not paid" resource="orders" filter="status = 'delivered' AND paid = false" /> <Stat label="Overdue invoices" resource="invoices" filter="status = 'overdue'" /> <Stat label="Open leads" resource="companies" filter="status = 'lead'" /> </Grid> <RecordCard title="Biggest unpaid delivery" resource="orders" filter="status = 'delivered' AND paid = false" sort="total desc" empty="Every delivered order is paid." actions={<Action name="markPaid" variant="primary" />} > <Grid columns={4}> <Field name="number" /> <Field name="total" /> <Field name="companyId" label="Customer" /> <Field name="companyId.city" label="City" /> </Grid> <Show when="companyId.status = 'dormant'">The customer has gone quiet: call before sending a reminder.</Show> </RecordCard> <Grid columns={2}> <Table title="Overdue invoices" resource="invoices" filter="status = 'overdue'" sort="issuedAt" columns={['number', 'companyId', 'dueAt', 'total']} pageSize={2} empty="Nothing is overdue." /> <Table title="Latest orders" resource="orders" sort="createdAt desc" columns={['number', 'companyId', 'status', 'total']} pageSize={5} /> </Grid> <CardRow title="New leads" description="Companies that have not ordered yet." resource="companies" filter="status = 'lead'" sort="createdAt desc" actions={<ModalForm mode="create" label="Add lead" resource="companies" fields={['name', 'city', 'email']} values={{ status: 'lead' }} />} > <Field name="name" label={false} /> <Field name="city" /> <Field name="email" /> <ModalForm mode="edit" label="Edit" fields={['name', 'city', 'email', 'status']} /> </CardRow> </Page>, { nav: { group: 'Sales', order: 0 }, icon: 'layout-dashboard' },)A CardRow shows a few records side by side, and its children are the template of each card. Inside a card, Field, Action and ModalForm work on that card’s record. The header’s ModalForm mode="create" opens a form for a new company, and values adds status: 'lead' to what is filled in. The ModalForm mode="edit" in each card edits that lead. Add a lead in the story, then edit another one to active: it leaves the row, and the count of open leads follows.
What the page did without
Section titled “What the page did without”There is no endpoint for “biggest unpaid delivery” and no component for “lead card”. Every block reads the regular API with Sam’s token, so the access rules apply exactly as on the lists. A sales rep with orders.read.own sees only their own orders in every count, table and card, and a field they may not read is not on their page at all (see Access).
The ERP example carries this page, with a few more blocks, in examples/erp/config/overview/page.tsx. Next:
- A billing page adds forms, delete and link actions, and a React component of your own.
- Blocks lists every block and its props.