Entity screens (entityui)
EntityUI reserves POST _bulk, GET _export.csv and POST _pick beneath
each entity it owns. Custom endpoints at those method/path pairs are
refused before publishing registry entries or routes, in either registration
order. A corrected entity declaration can be retried after refusal.
framework/entityui draws an entity's list, record and create screens from
its schema, its Display config and its States. Generated apps draw
through it, the admin battery composes it, and a hand-written app page can
place the same builders anywhere a component goes. It replaced
framework/ui/resource (deleted): the old engine mounted island routes
beside each screen. entityui draws no islands, and the only routes it
adds are two JSON endpoints per entity, mounted by App.EntityUI beside
the CRUD routes (see "Bulk actions and export").
One *entityui.UI serves one app. Build it once, after the entities its
Extensions name are registered (an entity registered later still gets
its bulk and export routes); a second App.EntityUI call panics, so the
admin battery and the app's screens share the one the app built:
appUI := fwApp.EntityUI(entityui.Extensions{})App.EntityUI checks every name the extensions use against the registered
entities and panics at boot on a bad one, naming it, the way App.Entity
refuses a bad declaration. An entity registered after App.EntityUI gets
the same checks at App.Entity (UI.CheckEntity): an input naming no kind
panics there, and so does a view with no Where, since
Extensions.Entities could not name the entity to register its filter.
Builders are components: return them from a
screen, or call RenderCtx(ctx) to place one inside another component.
Generated form controls use the mounted CRUD handler's response wire key as
their submitted name: an explicit WireName, or the column in the handler's
configured JSON casing (camelCase by default). For example, address_line_1
is named addressLine1 under the default casing, not address_line_1 or
address_line1. This keeps CRUD's wire-keyed validation and constraint
refusals attached to the right control without trying to reverse the casing.
Conditional fields follow the same input names; schema names, IDs, display
configuration, and relation-picker field parameters remain column-based.
Multipart forms accept the same aliases, including file and checkbox inputs,
and refuse distinct aliases for one column before saving any uploaded file.
Mounting app-owned screens
The builders draw components; your app registers their screen routes.
For List("deals").Base("/deals"), the default contract is:
| Screen | Route | Builder |
|---|---|---|
| List | /deals | appUI.List("deals").Base("/deals") |
| Create | /deals/create | appUI.Create("deals").Base("/deals") |
| Record | /deals/{id} | appUI.Record("deals", id).Base("/deals") |
An entity with a composite PrimaryKey links its records as one segment
per key column, /enrollments/{student_id}/{course_id}; register that
screen and pass entity.JoinKey of the params as the id.
A key the caller assigns draws as an input on the create form, read-only
on the record's form after that, and blank on a Duplicate: each column
of a composite key, and a one-column key that is Required with no
AutoGenerate (a plan keyed on its code). Any other one-column key
belongs to the server and stays off the forms; an optional key may be
filled by a hook, which a blank input would defeat.
Mount the exact create route: the default New, Duplicate, Create another,
related-list Add and relation-picker New links use <base>/create.
Mounting only /deals/new leaves those links pointing at a missing screen;
with /deals/{id} registered, it can look like a missing record named
"create". entityui cannot validate your app's screen registry at boot,
because lists are built inside a screen's render. Instead, the first time
a list renders rows whose <base>/<id> link matches no registered screen,
it logs one warning per base naming the route to register
(entityui: list rows link to a path no screen serves). The check runs
through App.EntityUI with a mounted uihost; a custom Host opts in by
implementing entityui.ScreenHost. A NoLinks() list draws no record
links and is not checked. Each base is looked up once, and the check
stops after 256 bases: a list with no Base takes the request path, which
a dynamic route lets callers vary.
The builders draw no page frame: no container, no max width, no gutter.
Under a site header whose content sits in ui.Container, wrap the
builder's output in the same container so the screen lines up with it:
page := ui.Container(ui.ContainerConfig{}, appUI.List("deals").Base("/deals").RenderCtx(ctx)) _ = page
To choose another route, use one create-path function for the screen registration and for the UI's links:
createPath := func(e *entity.Entity) (string, bool) { return "/" + e.GetName() + "/new", true } appUI = appUI.WithCreatePath(createPath)
Register each Create screen at the path that function returns, and draw
all your lists and records through the returned UI. WithCreatePath takes
the complete path, with no query or fragment; entityui adds duplicate
and prefill_<field> query parameters. It covers every create link,
including empty states, row menus, Related lists and relation pickers
whose related screens are configured with WithRecordPath.
Answer false or an empty path for an entity with no create screen to hide
its create links. The caller still needs create access. A nil callback
keeps the UI unchanged; without a callback the default stays
<base>/create. WithAPIPath changes write endpoints independently.
What it draws
- A list (
appUI.List("invoices")): aui.DataTable(or cards), its columns fromDisplay.Columns, a title cell that links to the record, view tabs, facets, a search box overSearchFields, filter chips, sort headers and a pager. The row menu offers Open, Duplicate and Delete where the builder turns them on. Five more controls are off by default on app pages and on in the admin: the query box (.QueryBox()), the columns menu (.ColumnsMenu()), the trash view (.Deleted()), saved views (.SavedViews()) and tab counts (.TabCounts()) — "Query box, columns menu, trash view, saved views, tab counts" covers them. On a phone (below 720px) the table becomes two-line rows (ui.ResponsiveRows): the title over the card subtitle (or the first other text column), the card badge (or the first enum) over the first number at the end, the checkbox before and the row menu after. The other columns show on the record. - A record (
appUI.Record("invoices", id)): a page header with the record's title, its state as the badge, and "Created … · Updated …" from its timestamps; a button per state move whoseFromholds the stored value; one icon-only menu named for the record (Copy link; Copy API URL, the record's REST address on this origin, when its REST routes mounted; withDuplicate()and a caller who may create, Duplicate and Create another, the blank create screen; and Delete where turned on); and on the Edit tab, Save. A move or app action declareddangeris not a header button: it sits in the menu above Delete and runs only after a confirm dialog names it ("Void this invoice?" / "It moves from Draft to Paid."). Save submits the form from the header, answers Mod+S (⌘S, Ctrl+S), and reads as idle until the form has edits; beside it a move or app action declared primary draws as secondary, so Save is the header's one primary action. Tabs: Edit (the form fromDisplay.Form), Related (the related entities the page names), any extension tabs, and Activity (the audit trail) and API (the record as the API returns it) where turned on. Related draws each related entity as an embedded list (see below) whose Add opens the create form with this record prefilled, and shows how many rows its lists hold, each entity counted through its own read gate and scope, and Activity how many trail entries it draws (50 at most, "50+" past that). The trail starts after the key's last purge, or last delete on an entity with no soft delete, that a later row follows, so a record that reuses a gone record's hand-assigned key does not show the old one's history. Activity is a timeline, newest first: each entry is a sentence with the actor in bold ("ada@example.com made changes", "… created this invoice", "… ran Send", "… overrode the status") and how long ago, and an edit lists the fields it changed as aui.ChangeList, each value drawn the way the list's cell draws it (a badge, a money figure, a related record's title). An update that changed no field the caller may see reads "… saved this invoice". Masked (NoQuery) andHiddenfields never appear, nor does a field anAfterGethook rewrites (the audit row stored what was written, which the hook hides): one it rewrites on the record's stored value, or on a non-empty probe value, so a hook that masks only a value that is there is caught while the record's own value is empty. The probe runs the entity'sAfterGetchain once over a copy of the stored row with each empty field filled, reads and writes nothing itself, and a chain that fails on it keeps every probed field out. The hooks run with the caller's context, so they must be side-effect free (seeChangesbelow for what they see). A hook that masks only some non-empty values (one prefix, one owner) is caught only on the values the record and the probe hold. An override's reason shows under the entry. The form's side column holds Details: the id with a copy button, the created and updated times, and each move's stamp; the state field and its stamps are not form fields. Opened as a drawer, the record wearsui.DrawerBar(close, its path, copy link, open as page) and its menu drops Copy link. WithSteps(list), a drawer over the record's own list (the builder'sBase) adds previous and next record to the bar, in the orderlistshows its rows under the query it was opened from: the list's key,ViewandWherepins, and the view, saved view, search, filter, facets and sort its query names, read as the caller. Pass the sameListBuilderthe list screen renders, or the steps walk rows the list does not show; a list of another entity draws none. The query arrives from the client, so it is parsed the way the list parses its own URL, and a sort the list refuses (a masked field's) is refused here too. A record past the firstEveryMatchCaprows, or outside the list, draws no steps. WithPanel(), the record's full page heads its actions with Copy link (out of the menu) and Open in panel: a link to the list atBasemarkeddata-cui-intercept-panel, which loads the list in the page's place and opens the record over it as a drawer, so Back from the drawer lands on the list. A failed drawer fetch opens the record as its own page instead. A failed list navigation cancels the pending drawer; a later ordinary list visit never reopens that old request. Turn it on only where the record's route opens as a drawer overBase(app.InterceptFrom); elsewhere the link is a plain move to the list. Delete asks first, and on an entity withScope.SoftDeletethe confirm says the record can be restored. WithUndo()(on the list builder too), the toast a soft delete leaves carries Undo for ten seconds, for a caller who may update the record: it posts the restore (<write base>/<id>/_restore, the trash view's route) and returns to the list. The bulk bar's Delete offers it too (see "Bulk actions and export"). A relation field is aui.Picker: focus lists the first 20 related records by title, typing searches the related entity (itsSearchFields, else its titles) throughPOST <api>/_pick?field=<field>, and a note says when the list is cut off. A hidden input submits the picked id. WithWithRecordPath, the picker draws an open button when it holds a value (when the caller's own read of the related entity returns the row) and a New button to the related create screen when the caller may create one.PickerHandlerserves the search: POST only, JSON only, 4 KB body, no-store, refused cross-site, and gated by the host entity's read and the related entity's read and per-row Decider.App.EntityUIand the admin mount it beside_bulk. A failed related-record read answers a structured 500 without exposing store or hook details, not a successful empty list. The initial form displays the field's unavailable state when its picker read fails. AJSONfield is a mono text area checked as JSON in the browser as it is typed (ui.TextAreaConfig.JSON): an invalid value stops the save before it is sent; the server still validates. The area, the record view and a list cell show the stored document as JSON. ATimestampinput shows the stored instant in UTC, with seconds or milliseconds when it has them (a fraction finer than a millisecond is cut, sincedatetime-localcannot hold it). An optionalIntis a plain number field that may be blank, an optional bool stored NULL draws a Not set / Yes / No select so an untouched save keeps it NULL (a set bool keeps its switch), and an optional enum has an empty choice that clears it; an enum holding a value no longer declared keeps a disabled placeholder, so an untouched save does not clear it; on a required enum the placeholder stops the save until a declared value is picked. A relation a read hook masks changes through its own Replace button, as a masked text field does. ADecimalfield steps by0.0001, the column's four fraction digits, or finer when the stored value has more, so any stored value saves untouched. Themoneykind steps by cents, or finer when the stored value has more. A list cell, a card and the read-only view show aDecimalorFloatin the reader's locale, rounded to the column's scale (four for that DECIMAL(19,4), or thesof aRawTypeNUMERIC(p,s)/DECIMAL(p,s)) and padded to two places when the scale has them, so 0.0049 does not read 0.00 and 10.5 reads 10.50. AnImageorFilefield is an upload when the app has file storage (framework.WithFileStorage): the stored file (a thumbnail, or a link named by its file name) above aui.FileUploadof the types the field takes; the form then posts multipart (the runtime does so for any form with a file input), and an empty input keeps the stored file. Without storage the stored URL stays an editable text box. A stored key draws atExtensions.FilesURL+ key (each segment escaped; a key with..or a scheme other than http(s) draws nothing). - A create screen (
appUI.Create("invoices")): the same form, starting at each field'sDefault, posting a create to the entity's REST base.?duplicate=<id>prefills from that record minus what a create may not set and what the copy would collide on: auniquefield and the fields of a unique index start blank (a relation in a mixed index keeps its value, so an invoice copies under its customer with a blank number);?prefill_<field>=<value>prefills one field — the convention aWhere-pinned list's New link uses, the Related tab's among them. A bare?<field>=is some other param and prefills nothing. - Stats and charts:
appUI.StatValue(aggcountor empty, orsumof an int, float or decimal field;wherein the query DSL),GroupBars,GroupSlicesandLineChart(rows per value of a field, in value order, an enum's in its declared order). Aggregates cover every match (crud.SumAll,GroupCountAll), so a sum is whole and rounded once. Decimal sums use exact database arithmetic on Postgres and exact addition of stored decimal values on SQLite. Any other agg prints "—". On an entity withAfterListhooks the stat totals the masked rows instead, and past 100,000 rows prints "—" rather than part of them; a field with more than 100 values draws no chart. Each logs why. A dashboard block reads an entity without a screen of its own.appUI.Count(ctx, entity, where)is the count alone, formatted, and reports false whereStatValuewould print "—", so a nav row or a badge draws nothing instead.appUI.CountIntis the same count as anint, for a figure the host computes with, like a success rate drawn on aui.Gauge.appUI.CheckStat(entity, agg, field, where, format)returns whyStatValuecould never compute a spec, so a host can refuse a configured stat at boot.appUI.LastUpdatedis when the newest record the caller can read was written (the greatestupdated_atin scope); it reports false on an entity without timestamps, a refused read or no rows.
An Image field draws a ui.Thumbnail: a small one in a list cell, a
large one above the URL input on the record. A URL that
urlsafe.ImageSource refuses (a javascript: or SVG data URI) draws
no image.
How it reads the entity
Display (entity.DisplayConfig, spelled display: in an app spec) is
plain data on the entity; nil means every default falls back to the schema
itself. It names the record (Singular, Plural, TitleFields,
Description), the list (Columns, Views, Facets, PageSizes,
Card, NoDuplicate, NoBulk), the sidebar (Nav), the form layout
(Form, with rows, sections and a side rail) and per-field hints
(Fields: Label, Help, Placeholder, Locked, Omit, ShowWhen,
Input). A hint changes how a screen draws a field, never what the API
accepts; the field's own Hidden, ReadOnly and NoQuery keep their
meaning and every screen honours them first.
A form input carries the field's own validators, so the browser refuses a
bad value before the round trip: Required sets required; a string's
Min and Max remain server-side rune-count checks, not native
minlength / maxlength attributes (which count UTF-16 units and can
truncate valid emoji text). A number's bounds set min and max; Pattern sets
pattern, wrapped as [\s\S]*(?:<pattern>)[\s\S]* because the
server's check is an unanchored match and the browser anchors the
attribute. A pattern using Go-only syntax (inline flags such as (?i))
stays off the input and the server alone checks it. The server still
validates every write; the attributes only move the first refusal
earlier.
States (entity.StatesConfig) gives the record its moves. The state
field and every stamp render read-only on every screen; a button per
transition posts the entity's transition route
(POST <api>/<entity>/<id>/transitions/<key>), whose success re-fetches
the page. A move with Variant: "danger" is a menu item behind a
confirm in place of a button. A System: true move draws no button —
only Go code calls it.
A move's Permission gates its button with the same exact resource check
the route runs: the caller's roles must grant it by name, and a
Wildcard grant does not, so no button is drawn that the route would
refuse.
Every chrome string (headings, buttons, tab names, notices) resolves
through framework/i18nui keys, so a catalog entry translates the screens
without touching the entity. The list's count reads as a sentence ("11
customers"): i18nui.EntityNoun lowercases the English name, keeps an
acronym ("API keys"), and uses a catalog's entity.<entity>.plural as
written.
Building a screen
appUI := fwApp.EntityUI(entityui.Extensions{}) list := appUI.List("invoices"). Columns("number", "amount"). // Display.Columns when unset PageSize(20). // capped by Pagination.MaxListLimit RenderCtx(ctx) // a component; place it anywhere record := appUI.Record("invoices", "inv-42").Delete().RenderCtx(ctx) _, _ = list, record
Under the rows a list says which rows show out of how many ("1–25 of
40") and, when more rows exist than the smallest size, offers rows per
page: the entity's Display.PageSizes, else 25, 50 and 100, each within
Pagination.MaxListLimit. The menu's links set <key>_per and return to
the first page; the param rides every other link like as, and a size
not on offer is ignored. A builder's PageSize, a preview (Top) and
an embedded list offer none.
An unset optional boolean draws the muted empty mark in table cells and bulk previews, not a No badge. Stored false and true draw No and Yes. Boolean link labels and card titles, subtitles and metadata use the same localized Yes/No words; unset values contribute no text.
The list builder also takes Key (namespaces its query params when two
lists share a page), View (the view that opens when the URL names
none), As("cards"), InlineEdit() (a table's plain cells, an enum, a bool, a
number, a date or a short string, are edited in place through
ui.InlineEdit: the form PUTs the one field to the record's write route
and returns to the list; only for a caller who may update the row, never
on a value a read hook masks, the title link, a state field or a locked
one. The editor is the field with its label hidden from view (an enum as
a select, a whole number with no stepper) beside Save, and a line under
the rows says "Select a value to edit it in place."), LayoutSwitch() (a labelled Table / Cards switch at
the end of the toolbar, after Columns, ui.SegmentedLinks, whose links set <key>_as; it then wins
over As and the view's layout), Where(field, value) (pins a term inside the
caller's scope — a tab listing one invoice's payments; the pinned field leaves the default columns and the facets, and New carries it as ?prefill_<field>=), Base (where
record links hang off), Heading(text, level) (it also names the table,
as a hidden caption, so two lists on one page are two named regions) and
Empty(text),
NoCreate, NoLinks (rows with no record links, no row menu and no New,
for an entity with no screen of its own), Delete, Undo, Duplicate, Bulk,
QueryBox, ColumnsMenu, Deleted and SavedViews (the four list
controls below), and Actions for header buttons beside New.
Empty(text) replaces the description of an empty list's state, which
offers New. A list narrowed to nothing draws its own state instead,
without New: a search, typed filter, facet or saved view that matches
no row reads "No {entity} match" with a "Clear search and filters"
link (the view and columns stay, the sort resets), and a view with no
rows reads "No {entity} in this view".
The record builder takes Base, Form (replaces Display.Form on this
page), Omit(fields...), Tab(key, build) for a page-local tab,
Related(entities...) for the Related tab's lists, RelatedAt(entity,
base) for one whose screens live elsewhere (an empty base draws it with
NoLinks), Activity(), API(), Override(), Delete(), Undo(),
Duplicate(), Steps(list), Panel() and Prefill(values).
A related list's heading sits one level below the record's title, so the
page keeps one <h1>. The Related tab's count is the rows those lists
draw: each list's default view, and the filter, search and facets the URL
sets on it, narrow the count the way they narrow the list.
A builder name that is wrong — an unknown entity, a bad As, an unknown
column — fails that slot with a generic message and a log line, never a
failed page.
Naming records and pointing writes elsewhere
appUI.RecordTitle(ctx, entity, id) answers the name a record's heading
shows, for a breadcrumb or a link drawn outside its screen: its
TitleFields joined with " · ", a relation part read as the related
record's own title.
appUI.SearchRecords(ctx, entity, q, limit) answers up to limit
records (at most 20) whose SearchFields match q, the way the list's
search box matches, as entityui.RecordMatch{ID, Title}, in primary-key
order. The limit counts records the caller may open: a row a Decider
refuses does not use up a place, and the read pages past refused rows for
at most five pages of limit rows. Both read behind the same gates as
the screens (scope, sign-in, RBAC, a Decider's per-row answer, the read
hooks) and answer nothing for a record or entity the caller may not see.
appUI.SnapshotTitle(ctx, entity, row) names a record from a stored copy
of its values (an audit row's old or new side) the way RecordTitle
names a live one, reading only the related records a relation part
names, so a deleted record keeps its name; a masked title field is left
out. A list names its page's rows in one batch: each relation part is
one read for the page, not one per row.
appUI.Changes(ctx, entity, before, after) draws what one edit changed
as the Activity tab's change list, "" when no field the caller may see
differs. It leaves out the fields the entity's AfterGet hooks rewrite
for the caller on the record the entry names (its key values in
before or after), found the way the record screen finds them: the
stored row and the row through the caller's read hooks are read and
every field they differ on is left out, then the chain runs once more
over the stored row with only its empty fields given a probe value,
with the record's real id. So a hook that masks a field unless the
caller owns the row masks it here too and leaves the others drawn. A
caller whose hooked read of the record fails or is refused sees no
field of it. A record that no longer exists (deleted, purged) has no
row to ask about, so the chain runs over a probe row whose every field
holds a value, with no id, as the fallback: a hook that refuses that
probe keeps every field out. before and after are keyed by the API's
wire names, the way the audit log stores them.
entityui.WithChangeCache(ctx) returns a context under which Changes
asks the hooks about each record once and reuses the answer for every
later entry of the same record; a page drawing many entries installs it
once per render (the admin's audit page and dashboard do), so the hooks
run once per record shown, not once per entry. Do not keep it past the
render.
Masking calls the app's AfterGet hooks as the caller (elevation
removed) with a request carrying the caller's context, outside any
read the app made itself, so those hooks must be free of side effects.
On the Activity tab, and once per record on each page that draws
changes, a hook sees: a GetPayload for the record's real id holding
its stored row (the hooked read), and a second one for the same id
whose empty fields hold "entityui-probe", 1 or true by type; for
a deleted record, one with an empty ID and every field holding a
probe value. A hook that counts reads, stamps "last viewed", sends a
notification or writes an access log fires on these too, and a probe
value it writes anywhere is a made-up one. appUI.WithActorName(name) returns a UI whose
Activity tab names each actor by name(ctx, id) (the admin passes the
account's email), the id where it answers "" or panics.
appUI.WithAPIPath(path) returns a UI with the same screens and
Extensions whose writes (save, delete, moves, bulk, export) post to
path(e) instead of the entity's REST routes; path answering false
draws that entity read-only. A back office uses it to send writes through
routes it gates itself: battery/admin mounts the CRUD handler's write
routes under /admin/api/<entity> this way. Reads are unchanged.
appUI.WritePath(e) reports where a UI's screens send e's writes, and
false when they draw it read-only, so a host that wraps a UI again can
fall back on the base it had.
appUI.WithRecordPath(path) returns a UI whose list cells draw a
relation's title as a chip linking to path(e) + "/" + id, the related
record's screen. A link is drawn only for a title the caller's own read
of the related entity returned; a refused relation stays muted, an id
the read did not return stays text, and path answering false leaves
that entity's titles as text. The admin points it at
/admin/entities/<entity> for the entities it exposes. An enum cell
draws its badge with a dot.
Extensions
Extensions is the code an app registers next to its screens: the entity
says what to show, extensions draw or act.
Extension code runs as the caller. A back office that elevates its own
reads and writes (crud.WithElevation, as battery/admin does) does not
vouch for an action's Run, a tab's Build, a view func, a field kind
or a replaced list or record body: each receives the caller's context
with the elevation removed (crud.WithoutElevation), and the component
it returns draws with that context too, so it passes only
the read and write gates the caller's own roles pass.
appUI := fwApp.EntityUI(entityui.Extensions{ Kinds: map[string]entityui.Kind{ "slug": {Input: func(ic entityui.InputContext) render.HTML { return render.HTML("") // draw the input for ic.Field }}, }, Entities: map[string]entityui.Extension{ "invoices": { Views: map[string]entityui.ViewFunc{ "overdue": {Filter: func(context.Context) (*filter.Predicate, error) { return &filter.Predicate{Field: "status", Op: filter.OpEq, Value: "past_due"}, nil }}, }, }, }, })
- Kinds draw one field kind:
Inputon forms,Cellin list cells and cards,Detailread-only (Cellwhen nil).Display.Fields[f].Inputpicks one by name.email,url,color,markdownandcodeare built in (colordraws the picker only for a stored lowercase#rrggbb, and a text input that keeps any other value), and so ismoneyfor an Int, Float or Decimal: a number input behind the currency symbol, and the value printed as an amount ($1,234.50,-$5.00) in cells and read-only. The symbol is theui.entity.currencycatalog entry ($by default), which theformat: moneystat reads too. A built-in kind on a field type it does not fit (moneyon a String) fails registration. An app kind of the same name replaces a built-in one, and its fit is the app's to judge. A locked field drawn by a kind keeps its label above the kind'sDetail.CellandDetailget the row after the read hooks, so a column a hook masks stays masked in them, and a relation whose target the caller may not read draws muted without calling them. OnlyInputgets the stored value, since a form prefills from it.InputContext.LabelandHelpcarry the words the form resolved (catalog entry, else the Display hint, else the humanized name), so a kind labels its field the way a built-in field would. - Views bind a func to a
Display.Viewskey, for a filter that depends on who is looking, the tenant or the clock. The URL carries the key (?view=overdue), never the predicate. The func's predicate passesfilter.ValidatePredicatebefore it reaches SQL, every time — field names are spliced into WHERE clauses, so a func's own tree gets the same validation URL input gets.Showhides the view from callers it does not apply to; a hidden default view falls back to All. When a list falls to a view with no?view=(aDefaultview, or the builder'sView), its All tab links?view=all, the reserved key. A view with neither aWherenor a registered func fails at boot, and so does a func registered for a key no view declares. - A tab's
Label, a side panel'sTitleand an action'sLabelarei18n.Phrasevalues declared at package level (i18n.Define("myapp.customers.billing", "Billing")), drawn in the request's language. The zero Phrase shows the extension'sKey. - Tabs add a record tab after the built-in ones.
Buildruns inside a recover: a panicking tab fails that tab alone. - Side adds panels to the record's side column on the Edit tab,
after the record's details, each a compact section headed by its
Title.Buildgets the record as the read hooks left it and runs as the caller, inside a recover: a panicking or erroring panel fails that panel alone. A create form draws none. The column sits inside the record's form, so a panel is read-only or gives its own controls aformattribute naming a form outside it.Newrefuses a panel whose key is not a key, a duplicate key, or a nilBuild. - Actions add record header buttons and, with
Bulk, list bulk actions. A record button posts to the entity's_bulkroute with scoperecordand the one id, so it runs through the same re-read, gates and audit row as a bulk run; it answers 200 when the action ran, 403 when the record's gates skipped it and 500 whenRunfailed. It shows only to a caller who may run it on that record, in itsVariant(ui.ButtonSecondarywhen empty;Newrefuses a variant no Button knows). Aui.ButtonDangeraction is a record menu item above Delete, behind a confirm naming the action and the record.Permission, when set, is checked against the caller's own roles on top of the entity's update access; aWildcardgrant does not satisfy it.Runreceives the resolved selection and a CRUD handle scoped to the caller. Up toInRequestCap(100) records run inside the request; a larger selection needsExtensions.Jobs(the admin backs it withbattery/queue), and without one it is refused naming the cap. "Every match" resolves at mostEveryMatchCap(10,000) records. - List and Record replace an entity's list or record body with the app's own component, under the same read gates and route.
The read gates
Route middleware never runs for a component render, so the checks the JSON API applies live in the builders themselves:
- The list runs
CanReadScopedbefore any read; the record also runsCanReadRecordScoped, so a resource-aware Decider that allows the listing and denies one row answers not-found, byte-identical to a missing id. The page answers 404 with that body (app.SetStatus), the status the JSON API gives the same caller; a Create duplicating such a row answers the same. - Every read of a DIFFERENT entity — relation cell labels (one IN read
per relation column), the create form's relation picker, a relation
facet's options, the Related tab's lists, the stats and charts — passes
that entity's own gate first. A label, facet or picker also needs what
?include=needs (CanReadRelatedScoped), as do Related lists and their count badges: the related entity's CRUD routes on (Exposure.CRUDnot false), and a scope-restricted request (an API token or embed grant) holding<table>:read. So a posts screen shows no author names when the users entity has CRUD off, asauth.UserEntityConfig()declares. A related row a resource-aware Decider refuses by id is left out of labels, facets and the picker. A refused Related list is omitted without an error callout; when every related section is refused, the Related tab is omitted too. Unresolvable entities, relations, or metadata still render the failed-slot callout. - Locked and key-column relation fields use the same related-read gate as editable pickers, including CRUD exposure and token scopes.
- A relation the caller may not read renders muted (an em dash), never the related record's name and never the raw foreign key. A refused relation facet draws no options, so the facet is absent. A refused related list is omitted without an access notice. A refused stat prints the em dash: an aggregate never announces an entity it cannot read.
Bulk set, move, delete and restore also honor BeforeGet hooks on each
target, including queued runs; a row hidden only from single-record reads
cannot be changed through a bulk selection.
App-action callbacks receive the same read-hook-enabled context, including
when they write through their action context's CRUD handler.
A builder never draws a write the API would refuse the caller. New,
Duplicate and the create screen need the entity's create access
(CanCreateScoped); the edit form and the state moves need update
access to the record (CanUpdateRecordScoped), and a move also its own
Permission by name; Delete needs delete access to the record
(CanDeleteRecordScoped). A caller who lacks one sees the record's
values without that control, and the create screen draws the
not-available notice. An entity with no REST write routes renders
read-only for everyone: values, moves and deletes gone, nothing
submittable.
State in the URL, writes as form RPCs
A list keeps its state in the page's own query string: sort, dir,
page, q, filter (DSL text), view, cols (the shown columns, in
order), saved (an open saved view's id) and f_<field> facets, each
prefixed by the list's key when it has one (due_sort for
.Key("due")). Sort headers, pager links and view tabs are plain
anchors the client router intercepts; the toolbar is one GET form whose
hidden inputs round-trip the state it does not own. There is no island
to mount and no second route onto the rows: every request passes the
page's own route gate. A page out of range lands on the last page; a
failed count leaves the reader on the page asked for with no pager.
The record's tabs are the same shape (?tab=related). Writes — save,
delete, a state move — are form RPCs (data-cui-rpc) to the entity's
REST routes whose answer re-fetches the page
(data-cui-rpc-navigate), carrying a toast and, on a refusal, the
runtime's error toast. The runtime drops the page the write ran on from
its screen cache before it navigates, so Back after a delete fetches
the record again instead of replaying it. The edit form's inputs prefill from the unhooked
read so they round-trip; read-only values show what an AfterGet
redaction shows.
Query box, columns menu, trash view, saved views
Four list controls are off by default on app pages and on in the admin.
Each is one builder method, and each keeps its state in the page's own
query string like the rest of the list. The list draws one toolbar
form: the search, a Filters dropdown (the facets, the query box and
the one Apply/Reset pair, with a badge counting the filters set) and
the Columns menu at the row's end. Saved views are tabs beside the
declared views, and the "Save view" dropdown sits at the tab row's
end. Active filters show as chips under the toolbar, each linking to
the list without it, plus a "Clear all". A ?filter= makes one chip
per top-level and term; an or group is one chip, written in
parentheses beside the others, so removing a chip leaves the remaining
terms meaning what they meant.
list := fwApp.EntityUI(entityui.Extensions{}). List("invoices"). QueryBox(). // the filter typed by hand ColumnsMenu(). // show, hide, reset Deleted(). // the trash view, ?view=deleted SavedViews(). // the caller's named views, ?saved=<id> TabCounts() // a row count on each view tab _ = list.RenderCtx(ctx)
- An embedded list (
.Embedded()) is one section of another screen, the way a record's Related tab draws its lists: a compact header holding the heading, the row count and a small "Add <singular>" button, then the rows with no view tabs, search, filters or bulk selection, and a one-line empty state when there are none. Sort and pager stay, keyed as ever..Flush()drops the table's frame, for rows inside a card whose frame is the card's.
- A preview (
.Top(n)) shows the firstnrows in the view's own order, with no pager, sort controls or row menu (each row still links to its record), ignoring the URL's page and sort params. It is for a list whose full form lives on another screen: the admin's Needs attention panel draws.Embedded().Top(5)lists linking to theirs.
- The query box (
.QueryBox()) is where the reader types the filter: a labelled text field named the list'sfilterparam, prefilled with the active filter text, inside the Filters dropdown of the toolbar form, which round-trips the state it does not own. Under it a reference shows, each asui.InlineCode, an example built from the entity's own fields, also the placeholder (status = "open" and amount > 100: an enum against its first value, a number against 100, a text field'scontains, a boolean'strue; never the id, a relation or a date), the operators (=,!=,<,>,<=,>=,contains,in [a, b]), the joining words (and,or, parentheses) and the queryable fields. The server parses the text with the same parser the chips use, so both stay in sync; text that fails to parse, or names a Hidden,NoQueryor unknown field, keeps the filter-did-not-apply warning and lists without it — never an error page, never SQL. Above the box, filter rows (field, operator, value) hold each plain top-level term of the filter; the box keeps the rest: anorgroup, aninlist, or a value a row would change (empty, padded with spaces, or over 200 characters). A row trims its value and is dropped when the value is empty. On Apply the rows join withand, and the box's text joins them in parentheses, so theandbinds the whole box however itsoris spelled. - The columns menu (
.ColumnsMenu()) is a menu of checkbox rows, one per available field, each a link that toggles that field in thecolsparam and keeps the filter and the sort, plus a Reset row that drops it. The title row is checked and disabled. Acolsparam typed by hand still sets the order. Everycolsname must be one of the list's resolved columns (the menu's rows); an unknown, Hidden, omitted or duplicate name, or a field the list left out ofColumns(...), makes the whole param ignored — the list's resolved columns stand. The title column carries the record link, so it may not be hidden: acolsthat leaves it out gets it back, first. Columns change what a row shows, not which rows match, so the page stays; the read asks only for the shown columns. - The trash view (
.Deleted(), an entity withScope.SoftDeleteonly) adds a Deleted tab beside the views:?view=deletedlists only soft-deleted rows, under the same owner, tenant and read scope as the live list, with no bulk bar, no New, no row menu and no record link (the record screens read live rows only). Each row offers Restore and Delete permanently, behind a confirm. The two post to the host's write base for the entity —POST <write base>/<id>/_restoreandPOST <write base>/<id>/_purge— served byappUI.RestoreHandler(entity)andappUI.PurgeHandler(entity), which a host mounts the way it mountsappUI.BulkHandler(entity). The handlers run the CRUD handler'sRestoreOne/PurgeOneunder the caller's own context, so permission, the Decider and owner and tenant scope are the write route's own gates; a purge of a live row answers 409 and touches nothing. Before the write each handler reads the row through the caller's read hooks (crud.WithReadHooks), as the trash view and the bulk restore do, so a row aBeforeListorBeforeGethook hides answers 404 and is not restored or purged. A form-RPC caller gets a status and a toast; a plain form post gets a 303 back to the Deleted view, along a same-origin relative return path the form carried — never an absolute URL. Cross-site posts are refused, the body is capped and nothing is stored cacheable. - Tab counts (
.TabCounts()) put a row count on each view tab: the rows that tab's link would list, under the page's search, facets, filter,Wherepins and the caller's read scope (owner, tenant, read hooks). A saved view's tab counts its own filter, a Deleted tab the trashed rows. The open tab reuses the page's own count; every other tab is one COUNT. A refused count leaves its tab bare. With the counts on the tabs, the page header shows the entity'sDisplay.Descriptionin place of the "N invoices" line, when it has one. - Saved views (
.SavedViews(), when the UI carries a store —appUI.WithSavedViews(store)) keep a named filter-and-columns state per caller, in aSavedViewStorethe host backs with a table (battery/adminprovides it).?saved=<id>opens one: its filter and columns apply as if they were thefilterandcolsparams, with an explicit param in the URL winning (an emptyfilter=is the reader clearing the view's filter, and sort, page, column and chip links keep it empty), and both are re-parsed and re-checked on every open — a view that no longer applies (or an unknown or foreign id) draws a callout and lists the All view, revealing nothing. The save form ("Save view", a name; shown once the filter or the columns differ from the open view) and the open view's delete form post toPOST <write base>/_viewsandPOST <write base>/_views/_delete?id=<id>, served byappUI.SavedViewsHandler(entity). The handler re-checks the filter and columns against the entity's fields before storing anything, and answers 400 to a name or filter holding a NUL byte or invalid UTF-8 (404 to such a delete id), text no store can bind; owner and tenant come only from the caller's context, which the store enforces. A list with a saved view open draws no Export link and offers no "every match" bulk scope: the saved narrowing is not in the query those routes read, the same rule aWhere-pinned list follows.
A saved view's own columns apply even when the columns menu is off: they are the saved-view feature's state, not the menu's. Views belong to the entity, so one saved on a wider list can open on a narrower one: it shows the columns that list offers, in the view's order, and the list's own columns when it offers none of them.
API tab and status override
Record("invoices", id).API() adds the API tab: how to reach this
record from code. It shows the record as JSON, as the REST read under
the caller's context returns it (Hidden columns never appear, an
AfterGet mask shows the mask); the entity's own REST path and the
methods its Exposure allows, even on a back office whose
UI.WithAPIPath moved the writes; the MCP tool names while
Exposure.MCP is on (crud.MCPToolNames); and a link to /api/llm.md.
The methods come from the declaration, so a read-only App.View mount
still lists the write methods. The tab reads nothing the record screen
could not.
Record("invoices", id).Override() adds a status override: a form in a
disclosure below the tabs, for setting the state field outside the
declared moves (a bad import, a support case). It is drawn only for an
entity with enforced States on an app that keeps an audit log, and
only for a caller who may update the record and also holds
<entity>:override_state, checked with access.CanResourceExact: a
Wildcard grant does not satisfy it, and neither does
crud.WithElevation. The capability adds to the update permission; it
does not replace it. The form takes a state and a
required reason (at most 500 characters) and asks before it posts.
It posts to <write base>/{id}/_override, served by
UI.OverrideHandler(entity), which the host mounts beside its other
write routes. The handler takes POST only, refuses cross-site posts,
caps the body, checks the capability before reading anything, reads the
record under the caller's context and the entity's read permission
(another owner's id answers 404), refuses a caller the update
permission refuses (403), validates the state and the reason, and
writes with crud.UpdateOne under crud.WithStateOverride. The audit
row (state_override, with the reason) comes from crud. An entity no
audit log records answers 409; the write never falls back to an
unaudited one. A write crud refuses answers what the JSON API would: 403,
404, 422 for invalid values, 400 for a hook's refusal. A form RPC gets a
status and a toast; a plain form post gets a 303 to the form's back
path, which must be a same-origin relative path.
Bulk actions and export
.Bulk() on a list (on in the admin) adds a select column, a bulk bar and
an Export CSV link. Display.NoBulk turns all three off for the entity,
and so does an entity with no REST write routes.
- The bar is one form RPC to
POST <api>/<entity>/_bulk. It names an action (Delete, set an enum or bool field, a state move, or an appActionsentry withBulk) and a scope: the checked rows, the page's rows, or every match of the list's query. The bar offers only the actions the caller's collection gates allow (a move or an app action also needs itsPermissionby name), and asks before it posts. The route takes JSON only and answers 415 to anything else, so a cross-site form cannot post it. Cards have no checkboxes, so they offer no "selected" scope; "every match" appears only when the count is known, the list is not pinned byWhere, and more rows match than the page shows. - The server resolves the selection itself. Posted ids are re-read
through the scoped CRUD handler under the caller's context, so an id
from another owner or tenant drops out. "Every match" rebuilds the
list's view (a builder's
Viewincluded), search, filter and facets from the posted query, up toEveryMatchCap, and the bar posts a digest of the ids it offered (match): any other set is refused with 409, even one of the same size, so a row that entered the list after it was drawn is never touched. A list past the cap is not offered every match. Each record then passes its own update or delete gate before the write; a refused record counts as skipped. - Undo. Under
.Undo()on a soft-deleting entity the bar also postsundoand the list's path, and a delete run in the request answers a toast whose Undo button restores the rows it deleted that the caller could update, then returns to the list. The button posts thedeletedscope with the actionrestore: the ids are re-read from the trash under the caller's scope, at mostInRequestCap, and each restore passes the caller's own update gate, so a forged id restores nothing the trash view's Restore would refuse. The ids ride the toast header, so a run whose button would pass 2 KiB offers no Undo (a proxy refuses a response with oversized headers, and the delete has already happened); the Deleted view restores those. A queued run offers none either. - Every run writes one audit row (
op: "bulk") with the action, the count and the done, skipped and failed tallies, when the app hasWithAuditLog. The actor is the audit log's actor. - Past
InRequestCapthe run needsExtensions.Jobs.App.EntityUIthen requiresApp.DBand creates two snapshot tables,gofastr_bulk_jobsandgofastr_bulk_items: the job records the resolved ids at the moment of the request, andRunBulkJobwalks them in order. A retried job resumes at the first record with no outcome, and the first outcome recorded for a record is the one kept. The summary row counts every outcome the store holds (BulkStore.Tally), so a resumed run reports the whole job, and a queued run writes that row once, when it finishes. - Queued runs are leased.
RunBulkJobclaims the job forBulkJobLease(five minutes) before each chunk, and every outcome and the finish write are fenced on that claim: a second worker handed the same job runs nothing while the lease is live (ErrBulkJobBusyasks its queue to retry), and a worker whose lease lapsed cannot write (ErrBulkLeaseLost). A worker that dies holding the lease blocks the job until it expires. A run can resume at the exact expiry timestamp; a released lease does not require another wall-clock tick before a retry. Queue runners delay busy and lost-lease deliveries by at leastBulkJobLease, without consuming execution attempts just for contention (admin.NewBulkJobsdoes this). Delivery is at least once, so an appActionscallback readsActionContext.Run, the job id, to make its own side effects idempotent. - A double submit answers the job already queued. The job is keyed on the action, its input and the resolved ids; while one with that key is queued, the second confirm gets its id and count and enqueues nothing.
- App start resumes and prunes. A job written but never handed to
the runner (the process died in between, or
Enqueuefailed) is handed over again atApp.Startonce it is a minute old (UI.ResumeBulkJobs); a refusedEnqueuemarks the job stopped instead, even if the confirming client disconnects. That settlement preserves request values without its cancellation and has a bounded timeout. Resume also adopts already-enqueued jobs whose non-null bulk lease expired before the grace cutoff, preserving settled outcomes. An enqueued job with no lease, a live lease, or a recently expired lease is not adopted. CustomBulkStore.Unenqueuedimplementations must honor this recovery rule too. Finished jobs older thanentityui.BulkRetention(30 days) are deleted then too (UI.PruneBulkJobs). An app that runs for weeks schedules both, with cron or its queue. - Export is
GET <api>/<entity>/_export.csvwith the list's narrowing (a builder'sViewincluded) and_list=<key>: the file holds what the list narrowed to, up toEveryMatchCap, read through the same scoped handler and read hooks the list uses.NoQuery, omitted and JSON fields are left out, and a cell a spreadsheet would run as a formula is prefixed with a quote. A number in anInt,FloatorDecimalcolumn is written as it is, so-12.5stays a number a spreadsheet sums; any other value in such a column is quoted like text. A list pinned withWheredraws no Export link: the route reads the query, and a pin is not in it. Repeated_id=<id>parameters replace the query with those rows (at mostInRequestCap, repeats counted), still through the scoped handler, so a row the caller may not read drops out, and so does a row the Decider refuses by id, asGET <api>/<entity>/<id>would. The floating bulk bar's Copy CSV uses it: it fetches the checked rows' ids and writes the CSV to the clipboard. It shows where Export does.
Both routes mount on the router the entity's CRUD routes went on, so an
entity registered with App.GroupEntity keeps its group's prefix and
middleware, and its screens post to the group's path. Both answer 404 for
an entity with bulk off, except a record action, which _bulk still
runs. The router serves the static _bulk and
_export.csv segments ahead of /{id}, so no record id can shadow them.
The screens look an entity up by name (Registry.Get), so they draw the
version a name resolves to: the unversioned entity, else the sole
version. Only that entity gets the two routes. A group version that
shares its name with an unversioned entity gets none, and when several
versions share a name and none is unversioned, the name is ambiguous and
no version gets them. A version that owned its name stops answering
(404) once a later App.Entity takes the name over. The app's OpenAPI document lists both routes for each entity EntityUI
mounted them on; openapi.EntityOpenAPIWithBulk builds that document
outside the app.
Common mistakes
- Expecting
Displayto change access.Nav.Hidedrops an entity from the sidebar,Omitdrops a field from screens; neither gates anything. Exposure, scope and RBAC decide who reads what, and the builders enforce them whateverDisplaysays. - Registering a view func under a key no view declares (or declaring
a
Where-less view with no func).App.EntityUIrefuses at boot, naming the entity and the key. - Trusting a view func's predicate. It passes
ValidatePredicateon every render; a func naming aHiddenor unknown field fails its slot with the generic message, and the log line carries the detail. - Calling
App.EntityUItwice. The second call panics: build the one*entityui.UIat boot and pass it to whatever else draws entity screens, the admin included. - Two lists on one page with no
Key. The second list with a taken key fails its slot. Give each list its own key. - Expecting
Lockedto protect the column.Lockedkeeps a field out of the screen's submit; the JSON API still writes it. A field the API must also refuse belongs inStates, which the CRUD handler enforces.