# 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

```tsx
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](https://golocomotive.fr/docs/developers/releases.md)).
- `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

```tsx
<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

```tsx
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 |

- `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.

```ts
// 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.
