UI config
A view says how the admin shows one resource: what its records are called, which columns the list has, which filters sit above it, how the record page is laid out, and which buttons it offers. It lives in config/<resource>/ui.ts; by convention every export of a ui.ts is picked up, and config/index.ts may export views too. A view only presents: what a user may see and do is decided by the data config’s access rules, and /api/meta removes from each user’s view every field they cannot read.
import { view } from 'protobase/schema'import type { repairs } from './data'
export const repairsView = view<typeof repairs>('repairs')view<typeof resource>(name) takes the resource’s type, so every field reference below (r.status) is checked by TypeScript. A view is immutable: each method returns a new view, so one view can build on another. The examples are the files of the bike shop guide, where each step builds on the last.
Names and columns
Section titled “Names and columns”import { view } from 'protobase/schema'import type { customers, repairs } from './data'
// Step 2: names, what a record is called, the columns the workshop reads, and how values look.
export const customersView = view<typeof customers>('customers') .names({ singular: 'Customer', plural: 'Customers' }) .title((r) => r.name) .list((r) => ({ columns: [r.name, r.phone, r.email], sort: [[r.name, 'asc']] }))
export const repairsView = view<typeof repairs>('repairs') .names({ singular: 'Repair', plural: 'Repairs' }) .title((r) => r.number) .fields((r) => ({ number: r.number.format('code'), customerId: r.customerId.label('Customer'), status: r.status.format('badge'), estimate: r.estimate.prefix('€').decimals(2), bookedOn: r.bookedOn.label('Booked on'), })) .list((r) => ({ columns: [r.number, r.customerId, r.bike, r.status, r.mechanic, r.estimate, r.bookedOn], sort: [[r.bookedOn, 'desc']], search: [r.number, r.bike], }))| Method | |
|---|---|
.names({ singular, plural }) |
“New repair”, “Repairs” in the sidebar and breadcrumbs. Default: the resource name in words. |
.title((r) => r.number) |
The field that names a record: in breadcrumbs, the record page’s heading, relation links and search results. |
.list((r) => ({ columns, sort?, search? })) |
The list’s columns in order; its starting sort as [[field, 'asc' | 'desc'], ...]; the fields the search box suggests (the resource’s .search decides what search matches on the server). |
.fields((r) => ({ name: r.name.label(...) })) |
Hints per field, below. Calls add up. |
.help(text) |
A line under the list’s title. |
.nav({ group, order, hidden, recent }) |
Where the resource sits in the sidebar, and its recent-record group. |
.icon(name) |
Carried in /meta for apps that render their own shell; the admin picks sidebar icons by resource name. |
Field hints:
| Hint | |
|---|---|
.label(text) |
Instead of the name in words (customerId would be “Customer” anyway). |
.help(text) |
A line under the field on the record page and in forms. |
.format(format) |
badge shows statuses as coloured badges, code sets identifiers in monospace, percent adds a % sign. relative, absolute and compact are accepted and carried in /meta for apps that render their own lists; the admin does not use them yet. |
.prefix(text), .decimals(n) |
Money and measures: € and 2 show €1,234.50. |
Filters and the chart
Section titled “Filters and the chart”import { customersView, repairsView as named } from './step-2'
// Step 3: the filters the front desk reaches for, and a chart of bookings above the list.
export { customersView }
export const repairsView = named .filters((r, w) => [ w.facets(r.status), w.facets(r.mechanic, { search: true }), w.dateRange(r.bookedOn, { presets: ['7d', '30d', 'month'] }), w.range(r.estimate, { histogram: true }), w.toggle(r.paid), ]) .chart((r) => ({ field: r.bookedOn, range: '30d', granularity: 'day' })).filters((r, w) => [...]) puts widgets above the list, in order. Each needs a field that is .filterable(); together they write the list’s ?filter=, so a filtered list can be bookmarked and shared.
| Widget | |
|---|---|
w.facets(r.status, { search? }) |
A menu of the field’s values with counts; search adds a search box for long lists. |
w.dateRange(r.bookedOn, { presets? }) |
Buttons for today, yesterday, 7d, 30d, 90d, month, quarter, year. |
w.range(r.estimate, { histogram? }) |
A minimum and maximum, with a histogram of the values. |
w.toggle(r.paid) |
A switch for a boolean field. |
.chart((r) => ({ field, range, granularity })) draws records per day or week over a date or timestamp field above the list, following its filters: range is 7d, 30d, 90d, 1y or all, granularity hour, day, week or month.
The record page
Section titled “The record page”import { l } from 'protobase/schema'import { customersView, repairsView as filtered } from './step-3'
// Step 4: a record page that reads like the paper repair card: what is wrong, who works on it, what it costs.
export { customersView }
export const repairsView = filtered .fields((r) => ({ problem: r.problem.help('In the customer’s words; the mechanic adds findings to the notes.'), notes: r.notes.label('Workshop notes').help('Only the workshop reads these.'), paid: r.paid.help('Set when the customer pays at the desk.'), })) .layout((r) => [ l.section('The bike', [r.customerId, r.bike, r.problem]), l.section('In the workshop', [r.status, r.mechanic, r.notes], { help: 'Pick up a repair by putting your name on it.' }), l.section('Money', [r.estimate, r.currency, r.paid]), l.sidebar([r.status, r.estimate, r.bookedOn]), ]) .saveFeedback('button').layout((r) => [...]) with l from protobase/schema:
| Item | |
|---|---|
l.section(title, fields, { help? }) |
A titled group of fields in the main column, editable where the user may write. |
l.sidebar(fields) |
A summary card beside it, read-only. |
Without a layout the record page shows one section with every field the user may change. .saveFeedback('button') shows saving on the Save button instead of a toast ('toast', the default).
Actions and views per role
Section titled “Actions and views per role”import { l } from 'protobase/schema'import { customersView, repairsView as laidOut } from './step-4'
// Step 5: buttons for the moves the shop makes all day, and a quieter view for the mechanics.
export { customersView }
export const repairsView = laidOut.actions((a) => [ a.update('ready', { label: 'Ready for pickup', icon: 'send', set: { status: 'ready' } }), a.update('collect', { label: 'Collected and paid', icon: 'wallet', set: { status: 'collected', paid: true }, confirm: 'Has the customer paid and taken the bike?' }), a.link('customerRepairs', { label: 'Other repairs of this customer', icon: 'file-text', href: '/repairs?filter=customerId%20%3D%20{customerId}' }),])
/** What mechanics get instead: no money, and their own move. */export const workshopRepairsView = laidOut .forRoles(['mechanic']) .list((r) => ({ columns: [r.number, r.bike, r.problem, r.status, r.mechanic], sort: [[r.bookedOn, 'asc']], search: [r.number, r.bike] })) .filters((r, w) => [w.facets(r.status), w.facets(r.mechanic, { search: true }), w.dateRange(r.bookedOn, { presets: ['7d', '30d', 'month'] })]) .layout((r) => [l.section('The bike', [r.bike, r.problem]), l.section('In the workshop', [r.status, r.mechanic, r.notes]), l.sidebar([r.status, r.bookedOn])]) .actions((a) => [a.update('ready', { label: 'Ready for pickup', icon: 'send', set: { status: 'ready' } })]).actions((a) => [...]) names the moves a record page offers in its action rail, and composed pages can put the same actions on buttons. a.update, a.remove and a.link work without code; a.action runs a handler the app registers. They are described with named actions.
.forRoles([...]) makes a view for users with one of those roles: each user gets the first such view that matches their roles, and the view without roles otherwise. A per-role view is a whole view, so build it from another (laidOut.forRoles(...) above) and change what differs.
The ERP’s orders view uses most of this page at once, with a recent-record group in the sidebar:
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' }), ])