DocsDevelopers

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>,
)
  • setup installs 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.
  • AppRoot holds the Mantine provider, modals and notifications. Its theme derives from the identity's color. The tab takes the app's name and logo. With client, it shows the what's new modal after sign in (Changelog and releases).
  • useAppIdentity() reads the identity in any screen. Show app.name, never a copy of it.
  • makeHouseTheme({ brand: '#0b7285' }) gives the default theme from one color, with overrides for 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#

componentshows
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
  • filter scopes 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 a Selection the page reads for bulk actions.
  • DataTable is 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_log is 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, or mcp for 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. useRouteTab keeps a tab in the route.
  • readPref and writePref keep a viewer's own choices in localStorage.
  • uiI18n holds 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.

This page as markdown