UI: shell, grids, forms
locosdk/ui gives the app its shell, sign in, server paged grids, record cards, the schema form, the guarded delete, the audit journal and the what's new modal.
locosdk/ui is the top tier: React 19, Mantine 8, MobX. An app's screens declare their own columns and fields. The login, the shell, the grids, the pager, the record card, the form, the delete guard and the export come from here.
Setup and the root#
import 'locosdk/ui/setup' // always the first line of the entry
import { createRoot } from 'react-dom/client'
import { AppRoot } from 'locosdk/ui'
import config from '../loco.config.ts'
import { client } from './data.ts'
import { App } from './App.tsx'
createRoot(document.body.appendChild(document.createElement('div'))).render(
<AppRoot app={config.app} client={client} colorScheme="auto">
<App />
</AppRoot>,
)
setupinstalls three globals:X(Mantine components),H(Mantine hooks),I(Material Symbols icons, one component per name:<I.receipt_long />). The page head needs the Material Symbols font link; the template carries it.AppRootholds the Mantine provider, modals and notifications. Its theme derives from the identity's color. The tab takes the app's name and logo. Withclient, it shows the what's new modal after sign in (Changelog and releases).useAppIdentity()reads the identity in any screen. Showapp.name, never a copy of it.makeHouseTheme({ brand: '#0b7285' })gives the default theme from one color, withoverridesfor the rest.
Sign in and routes#
<AppRouter
session={session}
languages={['en', 'fr']}
adminRoles={['admin']}
pages={[
{ path: 'clients', label: 'Clients', icon: 'group', element: <LiveGrid ui={clients} deletable /> },
{ path: 'clients/:id', element: <ClientPage /> },
{ path: 'orders', label: 'Orders', icon: 'receipt_long', group: { caption: 'Sales' }, element: <LiveGrid ui={orders} /> },
]}
/>
AppRouter builds /login, the guard, the layout (header with the profile menu, navbar, the page), a not found page and an error page. A page with a label shows in the navbar; group files it under a caption; show hides its entry. Home goes to the first navbar page. /_/ answers the links in the mails (confirm an email change, reset a password).
Session(client, { collection = 'users', roleField = 'role' }) holds the signed in user: user, display, email, role, isSuperuser, isSignedIn, signIn(), signOut(), refresh(). A right password that still needs a code opens the code step and mails the code. The profile menu offers the account (name, email change), the password and the language.
A collection, described once#
export const orders = defineCollectionUI(db.orders, {
title: 'Orders',
label: (o) => o.label,
href: (o) => `/orders/${o.id}`,
sort: '-created',
form: { fields: ['client_ref', 'label', 'status', 'amount'] },
columns: [
{ id: 'label', label: 'Order', value: (o) => o.label },
{ id: 'client_ref.name', label: 'Client', sort: false, render: (o) => <RefCell row={o} field="client_ref" ui={clients} /> },
{ id: 'status', label: 'Status', value: (o) => o.status, facet: ['open', 'done'], search: false },
{ id: 'amount', label: 'Amount', value: (o) => o.amount, align: 'right', search: false },
],
})
In a grid, a column's id is its SERVER field (a path like client_ref.name works). The sort, the search and the facets compile from it into the server query. A column with no server field says sort: false, search: false. href is the record's page; without it a row opens the form. Two columns with one id throw.
Grids#
| component | shows |
|---|---|
LiveGrid({ ui, filter?, deletable?, selection?, exportMenu?, importable? }) | a collection, server paged: search, sort, facets with counts, pager up to 500 rows, the query kept in the URL |
RelatedGrid({ ui, via, parent }) | every child of one record (1 to many), paged |
JunctionGrid({ through, via, to, ui, parent }) | many to many through a junction collection |
RefCell({ row, field, ui }) | a relation cell: the target's label, linked. One query for the whole page |
RecordCard({ ui, id, onDeleted? }) | one record: its columns as label and value, Edit, Delete |
filterscopes a grid. The user's query only narrows it.exportMenu={{ filename: 'orders' }}adds Export: the columns, the selected rows or the WHOLE filtered list (up to 20 000 rows by default), csv, xlsx or pdf. A cut export says so.formats: ['csv']keeps it to csv.selection={useSelection()}adds checkboxes, shift click for a range, and aSelectionthe page reads for bulk actions.DataTableis the card under every grid, for rows the page already holds.
The schema form#
form: { fields: ['name', 'city'] } builds one input per field from the schema the client holds: text, number, switch, date (local time, sent in UTC), select, relation (a select the server searches as the user types, by the target's presentable fields), json, file. required is checked before sending. Save waits for the server and shows its field errors under their inputs. An edit sends only the changed fields. A file field sends field+ and field-.
SchemaForm is the modal alone, FormState its state, for a page that builds its own form.
The guarded delete#
deletable on a grid, or onDeleted on a record card, deletes through guardedDelete. It counts every record that points at this one, from the schema. A proven reference refuses with a red notice that says where. A count it cannot get refuses too. Otherwise it asks to confirm.
The audit journal#
One row per persisted write of the collections you name, and one per sign in if you ask.
// schema.ts
audit_log: auditLog({ readRule: '@request.auth.role = "admin"' }),
// server.ts
journal(app, { collections: ['clients', 'orders'], logins: ['users'] })
- Every write rule of
audit_logis null: no request can add, edit or delete a row. - A row holds the actor, the action, the record and its label, the changed fields (from and to), the IP, the user agent, and the context (
default,batch, ormcpfor a write an AI made). - A refused write leaves no row. A write made by a hook or a cron leaves none either.
<AuditJournal journal={db.audit_log} uis={[clients, orders]} />is the whole journal as a grid.<RecordHistory journal={db.audit_log} ui={clients} id={id} />is one record's history.
Page state and preferences#
useSearchState(spec)keeps filters in the URL as typed search params.useRouteTabkeeps a tab in the route.readPrefandwritePrefkeep a viewer's own choices in localStorage.uiI18nholds every string the UI shows, in English and French.uiI18n.set('fr')moves the app, the sdk and the UI together.
Tests#
The UI suites run in happy-dom. domSuite() sets it up, typeInto(el, value) types a value React sees. A worked example (sign in, a list, a client and its orders, a new order through the schema form, a delete refused by a reference) ships with the sdk and fails when the examples on this page drift from it.