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:

1 lines
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:

ScreenRouteBuilder
List/dealsappUI.List("deals").Base("/deals")
Create/deals/createappUI.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:

2 lines
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:

4 lines
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")): a ui.DataTable (or cards), its columns from Display.Columns, a title cell that links to the record, view tabs, facets, a search box over SearchFields, 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 whose From holds 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; with Duplicate() 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 declared danger is 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 from Display.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 a ui.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) and Hidden fields never appear, nor does a field an AfterGet hook 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's AfterGet chain 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 (see Changes below 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 wears ui.DrawerBar (close, its path, copy link, open as page) and its menu drops Copy link. With Steps(list), a drawer over the record's own list (the builder's Base) adds previous and next record to the bar, in the order list shows its rows under the query it was opened from: the list's key, View and Where pins, and the view, saved view, search, filter, facets and sort its query names, read as the caller. Pass the same ListBuilder the 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 first EveryMatchCap rows, or outside the list, draws no steps. With Panel(), the record's full page heads its actions with Copy link (out of the menu) and Open in panel: a link to the list at Base marked data-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 over Base (app.InterceptFrom); elsewhere the link is a plain move to the list. Delete asks first, and on an entity with Scope.SoftDelete the confirm says the record can be restored. With Undo() (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 a ui.Picker: focus lists the first 20 related records by title, typing searches the related entity (its SearchFields, else its titles) through POST <api>/_pick?field=<field>, and a note says when the list is cut off. A hidden input submits the picked id. With WithRecordPath, 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. PickerHandler serves 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.EntityUI and 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. A JSON field 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. A Timestamp input shows the stored instant in UTC, with seconds or milliseconds when it has them (a fraction finer than a millisecond is cut, since datetime-local cannot hold it). An optional Int is 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. A Decimal field steps by 0.0001, the column's four fraction digits, or finer when the stored value has more, so any stored value saves untouched. The money kind steps by cents, or finer when the stored value has more. A list cell, a card and the read-only view show a Decimal or Float in the reader's locale, rounded to the column's scale (four for that DECIMAL(19,4), or the s of a RawType NUMERIC(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. An Image or File field 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 a ui.FileUpload of 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 at Extensions.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's Default, 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: a unique field 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 a Where-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 (agg count or empty, or sum of an int, float or decimal field; where in the query DSL), GroupBars, GroupSlices and LineChart (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 with AfterList hooks 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 where StatValue would print "—", so a nav row or a badge draws nothing instead. appUI.CountInt is the same count as an int, for a figure the host computes with, like a success rate drawn on a ui.Gauge. appUI.CheckStat(entity, agg, field, where, format) returns why StatValue could never compute a spec, so a host can refuse a configured stat at boot. appUI.LastUpdated is when the newest record the caller can read was written (the greatest updated_at in 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

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

16 lines
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: Input on forms, Cell in list cells and cards, Detail read-only (Cell when nil). Display.Fields[f].Input picks one by name. email, url, color, markdown and code are built in (color draws the picker only for a stored lowercase #rrggbb, and a text input that keeps any other value), and so is money for 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 the ui.entity.currency catalog entry ($ by default), which the format: money stat reads too. A built-in kind on a field type it does not fit (money on 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's Detail. Cell and Detail get 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. Only Input gets the stored value, since a form prefills from it. InputContext.Label and Help carry 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.Views key, 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 passes filter.ValidatePredicate before 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. Show hides 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= (a Default view, or the builder's View), its All tab links ?view=all, the reserved key. A view with neither a Where nor 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's Title and an action's Label are i18n.Phrase values declared at package level (i18n.Define("myapp.customers.billing", "Billing")), drawn in the request's language. The zero Phrase shows the extension's Key.
  • Tabs add a record tab after the built-in ones. Build runs 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. Build gets 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 a form attribute naming a form outside it. New refuses a panel whose key is not a key, a duplicate key, or a nil Build.
  • Actions add record header buttons and, with Bulk, list bulk actions. A record button posts to the entity's _bulk route with scope record and 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 when Run failed. It shows only to a caller who may run it on that record, in its Variant (ui.ButtonSecondary when empty; New refuses a variant no Button knows). A ui.ButtonDanger action 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; a Wildcard grant does not satisfy it. Run receives the resolved selection and a CRUD handle scoped to the caller. Up to InRequestCap (100) records run inside the request; a larger selection needs Extensions.Jobs (the admin backs it with battery/queue), and without one it is refused naming the cap. "Every match" resolves at most EveryMatchCap (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 CanReadScoped before any read; the record also runs CanReadRecordScoped, 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.CRUD not 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, as auth.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.

8 lines
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 first n rows 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's filter param, 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 as ui.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's contains, a boolean's true; 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, NoQuery or 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: an or group, an in list, 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 with and, and the box's text joins them in parentheses, so the and binds the whole box however its or is spelled.
  • The columns menu (.ColumnsMenu()) is a menu of checkbox rows, one per available field, each a link that toggles that field in the cols param and keeps the filter and the sort, plus a Reset row that drops it. The title row is checked and disabled. A cols param typed by hand still sets the order. Every cols name 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 of Columns(...), makes the whole param ignored — the list's resolved columns stand. The title column carries the record link, so it may not be hidden: a cols that 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 with Scope.SoftDelete only) adds a Deleted tab beside the views: ?view=deleted lists 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>/_restore and POST <write base>/<id>/_purge — served by appUI.RestoreHandler(entity) and appUI.PurgeHandler(entity), which a host mounts the way it mounts appUI.BulkHandler(entity). The handlers run the CRUD handler's RestoreOne / PurgeOne under 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 a BeforeList or BeforeGet hook 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, Where pins 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's Display.Description in 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 a SavedViewStore the host backs with a table (battery/admin provides it). ?saved=<id> opens one: its filter and columns apply as if they were the filter and cols params, with an explicit param in the URL winning (an empty filter= 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 to POST <write base>/_views and POST <write base>/_views/_delete?id=<id>, served by appUI.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 a Where-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 app Actions entry with Bulk) 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 its Permission by 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 by Where, 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 View included), search, filter and facets from the posted query, up to EveryMatchCap, 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 posts undo and 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 the deleted scope with the action restore: the ids are re-read from the trash under the caller's scope, at most InRequestCap, 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 has WithAuditLog. The actor is the audit log's actor.
  • Past InRequestCap the run needs Extensions.Jobs. App.EntityUI then requires App.DB and creates two snapshot tables, gofastr_bulk_jobs and gofastr_bulk_items: the job records the resolved ids at the moment of the request, and RunBulkJob walks 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. RunBulkJob claims the job for BulkJobLease (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 (ErrBulkJobBusy asks 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 least BulkJobLease, without consuming execution attempts just for contention (admin.NewBulkJobs does this). Delivery is at least once, so an app Actions callback reads ActionContext.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 Enqueue failed) is handed over again at App.Start once it is a minute old (UI.ResumeBulkJobs); a refused Enqueue marks 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. Custom BulkStore.Unenqueued implementations must honor this recovery rule too. Finished jobs older than entityui.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.csv with the list's narrowing (a builder's View included) and _list=<key>: the file holds what the list narrowed to, up to EveryMatchCap, 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 an Int, Float or Decimal column is written as it is, so -12.5 stays a number a spreadsheet sums; any other value in such a column is quoted like text. A list pinned with Where draws 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 most InRequestCap, 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, as GET <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 Display to change access. Nav.Hide drops an entity from the sidebar, Omit drops a field from screens; neither gates anything. Exposure, scope and RBAC decide who reads what, and the builders enforce them whatever Display says.
  • Registering a view func under a key no view declares (or declaring a Where-less view with no func). App.EntityUI refuses at boot, naming the entity and the key.
  • Trusting a view func's predicate. It passes ValidatePredicate on every render; a func naming a Hidden or unknown field fails its slot with the generic message, and the log line carries the detail.
  • Calling App.EntityUI twice. The second call panics: build the one *entityui.UI at 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 Locked to protect the column. Locked keeps a field out of the screen's submit; the JSON API still writes it. A field the API must also refuse belongs in States, which the CRUD handler enforces.