entityui

package
v0.94.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 24, 2026 License: MIT Imports: 45 Imported by: 0

Documentation

Overview

Package entityui draws an entity's list and record screens from its schema, its Display config and its States, for generated apps and the admin alike.

One *UI serves one app. The root package builds it (App.EntityUI), so this package imports only the layers below the root and nothing in it is global: two apps in one test process stay apart.

entityui mounts no routes and no islands. A builder is a component rendered inside the page that uses it:

  • Reads run through the entity's in-process CRUD handler with the caller's own context, after the read gates the JSON API applies (CanReadScoped for the list, CanReadRecordScoped for a record). The page's own route policy has already run, so a list on a page is never wider than the page or the API.
  • In-page state (sort, page, search, filter, view) rides the page's own query string. Links and GET forms navigate; the runtime fetches the screen partial and keeps the shell (Hard rule 1).
  • Writes go to the entity's REST routes as form RPCs (data-cui-rpc), whose answer re-fetches the page (data-cui-rpc-navigate). A builder never allows a write the API refuses. An entity with no CRUD routes renders read-only.
  • Moves post the transition route, POST <api>/<entity>/<id>/transitions/<key>.

Every name a builder or an Extensions value uses is checked: at New for Extensions, and when the builder renders for builder options, where a bad name fails that slot with a generic message and a log line.

Index

Constants

View Source
const (
	BulkQueued  = "queued"
	BulkDone    = "done"
	BulkStopped = "stopped"
)

Bulk job statuses.

View Source
const (
	BulkRowDone    = "done"
	BulkRowSkipped = "skipped"
	BulkRowFailed  = "failed"
)

Outcomes a snapshot row settles to.

View Source
const (
	// SavedViewCap is the most views one owner keeps per entity.
	SavedViewCap = 50
	// SavedViewNameMax is the longest name, in bytes.
	SavedViewNameMax = 80
	// SavedViewFilterMax is the longest filter text, in bytes.
	SavedViewFilterMax = 2000
	// SavedViewColumnsMax is the most columns a view names.
	SavedViewColumnsMax = 50
)

Limits a SavedViewStore enforces on every Create.

View Source
const BulkRetention = 30 * 24 * time.Hour

BulkRetention is how long a finished bulk job's row (its creator, action, filter hash and tally) is kept before PruneBulkJobs deletes it. The audit log keeps the run's summary row on its own terms.

View Source
const EveryMatchCap = 10000

EveryMatchCap is the most records an "every match" selection resolves.

View Source
const InRequestCap = 100

InRequestCap is the most records a bulk action runs inside one request, the CRUD batch endpoint's cap. A larger selection needs Extensions.Jobs.

Variables

View Source
var (
	ErrSavedViewNotFound = errors.New("entityui: saved view not found")
	ErrSavedViewBlank    = errors.New("entityui: a saved view needs a name")
	ErrSavedViewExists   = errors.New("entityui: a saved view with that name exists")
	ErrSavedViewCap      = errors.New("entityui: too many saved views")
	ErrSavedViewTooLong  = errors.New("entityui: saved view name, filter or columns too long")
)

SavedViewStore errors.

View Source
var AccessDeniedTitle = i18n.Define("entityui.access_denied_title", "Not available")

AccessDeniedTitle is the heading drawn in place of rows the caller may not read. Exported so a test in another package detects the notice by symbol (AccessDeniedTitle.English()), not by copying prose.

View Source
var ErrBulkJobBusy = errors.New("entityui: bulk job held by another runner")

ErrBulkJobBusy is RunBulkJob's answer while another runner holds the job's live lease. The JobRunner retries later; once the job has finished, RunBulkJob answers nil.

View Source
var ErrBulkLeaseLost = errors.New("entityui: bulk job lease lost")

ErrBulkLeaseLost answers a Settle or Finish from a runner that no longer holds the job's lease. RunBulkJob returns it and the JobRunner retries; the lease's holder finishes the run.

Functions

This section is empty.

Types

type Action

type Action struct {
	Key        string
	Label      i18n.Phrase
	Variant    ui.ButtonVariant
	Permission string
	// Bulk offers the action on the list's selection as well as on the
	// record.
	Bulk bool
	Run  func(ctx context.Context, ac ActionContext) error
}

Action is a record or bulk action: a button in the record's header, posting to the entity's bulk route with the record scope. Permission, when set, is checked about each record on top of the entity's update access, with access.CanResourceExact: a Wildcard grant does not satisfy it. Variant is the button's (ui.ButtonSecondary when empty); New refuses one no Button knows. Label is drawn in the request's language; the zero Phrase shows Key.

type ActionContext

type ActionContext struct {
	Entity string
	IDs    []string
	Crud   *crud.CrudHandler
	// Run names the bulk run: the queued job's id, the same on every
	// retry, or a fresh id for a run inside the request. A queued run
	// hands each record to Run at least once: a worker that dies after
	// Run returns but before the outcomes are saved runs those records
	// again under the same Run. An action with effects outside the
	// database (mail, a payment, a webhook) keys them on Run and the
	// record id so the second delivery does nothing.
	Run string
}

ActionContext is what an Action's Run receives: the resolved selection and a CRUD handle scoped to the caller. Code that reaches past the handle does so outside the framework's guarantees.

type AuditEntry

type AuditEntry struct {
	At        time.Time
	Actor     string
	Operation string
	Reason    string
	Before    map[string]any
	After     map[string]any
}

AuditEntry is one audit row as the Activity tab draws it. Before and After hold the redacted values the audit log stored; entityui strips Hidden and masked fields again before drawing a diff.

type AuditReader

type AuditReader interface {
	Trail(ctx context.Context, entity, recordID string, limit int) ([]AuditEntry, error)
}

AuditReader reads one record's audit trail, newest first, at most limit rows. The record's own read gate has passed before it is called.

type BulkHost

type BulkHost interface {
	// BulkStore returns the snapshot store, nil when the host keeps none.
	BulkStore() BulkStore
	// AuditEvent writes one audit row; a host with no audit log answers
	// nil and writes nothing.
	AuditEvent(ctx context.Context, entity, op, recordID string, detail map[string]any) error
}

BulkHost is what a Host implements to back bulk actions: the snapshot store queued runs walk, and the audit rows every run writes. The host App.EntityUI builds implements it; without it, bulk runs still work inside the request and write no summary row, and Extensions.Jobs is refused.

type BulkJob

type BulkJob struct {
	ID     string
	Entity string
	// Action is the bulk bar's action key: "delete", "set:<field>:<value>",
	// "move:<key>" or "run:<key>".
	Action string
	Count  int
	// Creator is the confirming user's id (handler.GetUser's GetID) and
	// Tenant their tenant, "" for none.
	Creator string
	Tenant  string
	// FilterHash is a SHA-256 of the scope and list query the selection
	// came from, for the audit trail.
	FilterHash string
	// Key identifies the confirmed run: a SHA-256 of the creator, tenant,
	// entity, action and the ids. While a job with a Key is queued, a
	// second confirm of the same run answers that job instead of queuing
	// another.
	Key string
	// Status is "queued", "done" or "stopped"; Store.Job fills it.
	Status string
	// Done, Skipped and Failed are a finished job's tally; Store.Job
	// fills them once Finish has run.
	Done, Skipped, Failed int
}

BulkJob is one queued bulk run. Its selection lives in the snapshot store under ID; no ids ride in the job itself.

type BulkStore

type BulkStore interface {
	// Create writes the job and its ids in one transaction and returns
	// it. When a queued job with the same Key is held it writes nothing
	// and returns that job instead.
	Create(ctx context.Context, job BulkJob, ids []string) (BulkJob, error)
	// Enqueued records that the JobRunner accepted the job.
	Enqueued(ctx context.Context, id string) error
	// Job reads one job; an unknown id is an error.
	Job(ctx context.Context, id string) (BulkJob, error)
	// Claim gives runner the job's lease until until, or renews it when
	// runner holds it. It answers false when the job is not queued or
	// another runner's lease is still live at now.
	// A lease whose expiry equals now has expired.
	Claim(ctx context.Context, id, runner string, now, until time.Time) (bool, error)
	// Pending returns up to limit ids not yet settled, in a stable order.
	Pending(ctx context.Context, id string, limit int) ([]string, error)
	// Settle records each id's outcome (BulkRowDone, BulkRowSkipped,
	// BulkRowFailed) while runner holds the lease, and answers
	// ErrBulkLeaseLost when it does not. A settled id never comes back
	// from Pending.
	Settle(ctx context.Context, id, runner string, outcomes map[string]string) error
	// Tally counts the job's settled ids by outcome, across every call
	// that settled any, so a resumed run's summary covers the whole job.
	Tally(ctx context.Context, id string) (map[string]int, error)
	// Finish, while runner holds the lease, sets the job's final status
	// (BulkDone or BulkStopped) at at, keeps its tally on the job and
	// deletes its ids. It answers ErrBulkLeaseLost when runner does not
	// hold the lease.
	Finish(ctx context.Context, id, runner, status string, at time.Time) error
	// Unenqueued lists the queued jobs created before before that no
	// Enqueued call has marked.
	Unenqueued(ctx context.Context, before time.Time) ([]BulkJob, error)
	// Prune deletes the jobs that finished before before, and reports
	// how many.
	Prune(ctx context.Context, before time.Time) (int, error)
}

BulkStore keeps queued bulk runs: the job and the ids its selection resolved to at confirm, each settled once it has run. One runner at a time holds a job's lease (Claim); Settle and Finish write only for the runner holding it, so two workers handed the same job never both record it.

type CellContext

type CellContext struct {
	Ctx    context.Context
	Entity string
	Field  schema.Field
	Value  any
	Row    map[string]any
}

CellContext is what a Kind's Cell and Detail receive. Row and Value come from the read after its hooks, so a masked column stays masked; a relation whose target the caller may not read draws muted and never reaches the callback.

type Extension

type Extension struct {
	// Views binds a view func to a Display.Views key.
	Views map[string]ViewFunc
	// Tabs are record tabs drawn after the built-in ones.
	Tabs []Tab
	// Side are panels in the record's side column on the Edit tab,
	// after the record's details. A create form draws none.
	Side []SidePanel
	// Actions are record header buttons and, with Bulk, list bulk actions.
	Actions []Action
	// List and Record replace the body of the entity's list or record
	// screen. The route, gates and the drawer stack stay the framework's.
	List   func(ListContext) (component.Component, error)
	Record func(RecordContext) (component.Component, error)
}

Extension is one entity's extensions.

type Extensions

type Extensions struct {
	// Kinds are named field renderers; Display.Fields[f].Input picks one.
	Kinds map[string]Kind
	// Entities holds per-entity extensions keyed by entity name.
	Entities map[string]Extension
	// Jobs runs bulk actions over more records than one request may
	// touch (InRequestCap). nil refuses such a selection, naming the cap.
	// A queued run needs the Host to keep snapshots (BulkHost); New
	// refuses Jobs on a Host that does not.
	Jobs JobRunner
	// FilesURL is the same-origin path the app serves stored files
	// under ("/uploads/" for upload.ServeHandler on /uploads/{key...}).
	// A relative Image or File value is a storage key: the screens draw
	// it at FilesURL + key, and draw nothing for one when FilesURL is
	// empty. With file storage set on the app (framework.WithFileStorage),
	// forms draw Image and File fields as uploads.
	FilesURL string
}

Extensions is the code an app registers next to its screens: field kinds, view funcs, record tabs and actions. The entity says what to show; Extensions draws or acts. The admin and app pages share one value.

type Host

type Host interface {
	// Registry holds the app's entities.
	Registry() entity.Registry
	// Crud returns the entity's in-process CRUD handler, wired exactly as
	// the HTTP routes wire theirs (hooks, audit, events, JSON casing).
	Crud(e *entity.Entity) (*crud.CrudHandler, error)
	// APIPath is the base of the entity's REST routes ("/api/invoices"),
	// and false when the entity mounts none.
	APIPath(e *entity.Entity) (string, bool)
	// Translator is the app's catalog; nil when the app has none.
	Translator() *i18n.Translator
	// Audit reads audit rows; nil when the app keeps no audit log, and
	// the Activity tab is then not drawn.
	Audit() AuditReader
}

Host is one app's view of its entities. App.EntityUI implements it; a test may implement it over a bare registry and database.

type InputContext

type InputContext struct {
	Ctx         context.Context
	Entity      string
	Field       schema.Field
	Control     headless.FieldControl
	Name        string
	Value       string
	Placeholder string
	Label       string
	Help        string
}

InputContext is what a Kind's Input receives: the field, the control wiring the form field built (label association, description chain, invalid state), the input name, the current value as text, and the label and help the form resolved for the field (the catalog entry, else the Display hint, else the humanized name), so a kind draws the same words a built-in field would.

type JobRunner

type JobRunner interface {
	// Enqueue schedules job. It must not run the job inline. Enqueuing a
	// job twice is safe: RunBulkJob runs a job under a lease and answers
	// nil once it has finished, so UI.ResumeBulkJobs can hand over again
	// a job whose first Enqueue is not known to have happened.
	Enqueue(ctx context.Context, job BulkJob) error
	// Principal rebuilds the creator's request context from job.Creator
	// and job.Tenant: the user and their current roles, read fresh, and
	// the run's tenant, fixed at confirm because the snapshot's records
	// belong to it. RunBulkJob calls it before every chunk; an error
	// stops the run, so a runner that tracks tenant membership refuses a
	// creator who has left job.Tenant.
	Principal(ctx context.Context, job BulkJob) (context.Context, error)
}

JobRunner runs a bulk action over more than InRequestCap records, outside the request. entityui writes the confirmed selection to the Host's snapshot store and hands Enqueue the job; the runner's worker then calls UI.RunBulkJob with job.ID, as often as it retries, until it returns nil. The admin backs it with battery/queue.

type Kind

type Kind struct {
	Input  func(InputContext) render.HTML
	Cell   func(CellContext) render.HTML
	Detail func(CellContext) render.HTML
}

Kind draws one field kind: Input on forms, Cell in list cells and cards, Detail read-only (Cell when nil). A kind changes how a value is drawn and entered, never what the API stores.

type ListBuilder

type ListBuilder struct {
	component.ContextOnly
	// contains filtered or unexported fields
}

ListBuilder draws one entity's list. Anything it does not set comes from the entity's Display. It is a component: return it from a screen, or call RenderCtx to place it inside another component.

func (*ListBuilder) Actions

func (b *ListBuilder) Actions(a ...render.HTML) *ListBuilder

Actions appends header actions beside New.

func (*ListBuilder) As

func (b *ListBuilder) As(presentation string) *ListBuilder

As draws rows as "table" or "cards".

func (*ListBuilder) Base

func (b *ListBuilder) Base(path string) *ListBuilder

Base is the path record links hang off (<base>/<id>, <base>/create). The default is the current request's path. Mount Create at <base>/create, or use UI.WithCreatePath to choose another create screen path.

func (*ListBuilder) Bulk

func (b *ListBuilder) Bulk() *ListBuilder

Bulk turns on row selection and bulk actions, off by default on app pages and off for an entity with Display.NoBulk.

func (*ListBuilder) Columns

func (b *ListBuilder) Columns(fields ...string) *ListBuilder

Columns replaces Display.Columns on this list.

func (*ListBuilder) ColumnsMenu

func (b *ListBuilder) ColumnsMenu() *ListBuilder

ColumnsMenu turns on the columns control: a disclosure holding a GET form that shows, hides, reorders and resets the list's columns through the cols param. A cols value naming anything but a visible, non-omitted field is ignored, never an error page; the title column stays — it carries the record link. Off by default on app pages.

func (*ListBuilder) Delete

func (b *ListBuilder) Delete() *ListBuilder

Delete and Duplicate turn on the row menu's delete and duplicate items, off by default on app pages. Each still needs the entity's access.

func (*ListBuilder) Deleted

func (b *ListBuilder) Deleted() *ListBuilder

Deleted turns on the trash view beside the list's views, for an entity with Scope.SoftDelete (a no-op otherwise): ?view=deleted lists only soft-deleted rows under the same owner, tenant and read scope as the live list, and each row offers Restore and Delete permanently through the host's restore and purge handlers. Off by default on app pages.

func (*ListBuilder) Duplicate

func (b *ListBuilder) Duplicate() *ListBuilder

func (*ListBuilder) Embedded

func (b *ListBuilder) Embedded() *ListBuilder

Embedded draws the list as one section of another screen, the way a record's Related tab shows 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 or filters, and a one-line empty state when there are none. Sort and pager stay, keyed as ever.

func (*ListBuilder) Empty

func (b *ListBuilder) Empty(text string) *ListBuilder

Empty replaces the empty state's description.

func (*ListBuilder) Flush

func (b *ListBuilder) Flush() *ListBuilder

Flush draws the table without its frame: rows inside a card, whose frame is the card's (a dashboard's Needs attention).

func (*ListBuilder) Heading

func (b *ListBuilder) Heading(text string, level int) *ListBuilder

Heading replaces the list's heading text; level is 1 to 5 (0 means 1). The empty state takes the level below.

func (*ListBuilder) InlineEdit

func (b *ListBuilder) InlineEdit() *ListBuilder

InlineEdit edits a table's plain cells where they are shown (ui.InlineEdit): an enum, a bool, a number, a date or a short string the caller may update and no read hook masks. Saving PUTs the one field to the record's write route and returns to the list. The title link, long text, relations, files, JSON and NoQuery fields are left as they are.

func (*ListBuilder) Key

func (b *ListBuilder) Key(key string) *ListBuilder

Key namespaces the list's query params (<key>_sort, <key>_page, <key>_q, <key>_filter, <key>_view), so two lists on one page keep their own state. A page with two lists needs a key on each; the key follows the key grammar.

func (*ListBuilder) LayoutSwitch

func (b *ListBuilder) LayoutSwitch() *ListBuilder

LayoutSwitch draws a Table / Cards switch in the toolbar. Its links set the list's as param (<key>_as), which then wins over As and the view's layout; an unknown value is ignored.

func (*ListBuilder) NoCreate

func (b *ListBuilder) NoCreate() *ListBuilder

NoCreate hides the New button even where the caller may create.

func (b *ListBuilder) NoLinks() *ListBuilder

NoLinks draws the rows with no record links: the title is text, there is no row menu and no New. It is for an entity with no screen of its own on this app, such as a Related list of payments on an invoice.

func (*ListBuilder) PageSize

func (b *ListBuilder) PageSize(n int) *ListBuilder

PageSize sets rows per page, capped by Pagination.MaxListLimit.

func (*ListBuilder) QueryBox

func (b *ListBuilder) QueryBox() *ListBuilder

QueryBox turns on the filter's text box: a control named the list's filter param, prefilled with the active filter text and labelled with the entity's queryable field names, riding the list's GET form so a typed filter narrows the same way the chips do. Off by default on app pages; the admin turns it on.

func (*ListBuilder) RenderCtx

func (b *ListBuilder) RenderCtx(ctx context.Context) render.HTML

RenderCtx draws the list for the request in ctx.

func (*ListBuilder) SavedViews

func (b *ListBuilder) SavedViews() *ListBuilder

SavedViews turns on named saved views for this list, when the UI carries a SavedViewStore (UI.WithSavedViews; without one it is a no-op): the caller's views open through ?saved=, and a small form saves the active filter and columns under a name. Off by default on app pages.

func (*ListBuilder) TabCounts

func (b *ListBuilder) TabCounts() *ListBuilder

TabCounts shows each view tab's row count: the rows that tab's link lists, under the page's search, facets, filter, Where pins and read scope. The header then carries the entity's description, when it has one, in place of the count. One COUNT per tab per render; a refused count leaves its tab bare. Off by default on app pages; the admin turns it on.

func (*ListBuilder) Top

func (b *ListBuilder) Top(n int) *ListBuilder

Top previews the list: its first n rows in the view's own order, with no pager, no sort controls and no row menu (each row still links to its record), and the URL's page and sort params ignored. It is for a list whose full form lives on another screen, such as a dashboard panel linking to it.

func (*ListBuilder) Undo

func (b *ListBuilder) Undo() *ListBuilder

Undo puts Undo on the toast a row's Delete leaves, for an entity with Scope.SoftDelete (a no-op otherwise) and a caller who may update the row: it restores the row through the host's RestoreHandler and re-renders the list as it was. Off by default.

func (*ListBuilder) View

func (b *ListBuilder) View(key string) *ListBuilder

View opens the list on a declared view when the URL names none.

func (*ListBuilder) Where

func (b *ListBuilder) Where(field, value string) *ListBuilder

Where pins field = value on every read of this list, ANDed inside the caller's scope (a tab listing one invoice's payments). The field must be queryable; value is matched as the field's type. A pinned field leaves the default columns and the facets, and New carries it as ?prefill_<field>=<value>.

type ListContext

type ListContext struct {
	Ctx    context.Context
	UI     *UI
	Entity string
}

ListContext is what an Extension.List receives.

type Record

type Record struct {
	ID     string
	Values map[string]any
}

Record is the record a tab, action or replaced screen is drawn for, as the read hooks left it.

type RecordBuilder

type RecordBuilder struct {
	component.ContextOnly
	// contains filtered or unexported fields
}

RecordBuilder draws one record: header, move buttons, tabs (Edit, Related, extension tabs, and Activity and API where turned on) and the form from Display.Form. Create draws the same form empty.

func (*RecordBuilder) API

func (b *RecordBuilder) API() *RecordBuilder

API turns on the API tab: the record as the JSON API's GET returns it, the entity's own REST path with the methods its exposure allows, its MCP tool names, and a link to the API's entity index. Off by default. The tab reads nothing the record screen could not.

func (*RecordBuilder) Activity

func (b *RecordBuilder) Activity() *RecordBuilder

Activity turns on the Activity tab: this record's audit rows. Off by default on app pages, and never drawn when the app keeps no audit log.

func (*RecordBuilder) Base

func (b *RecordBuilder) Base(path string) *RecordBuilder

Base is the entity's list path on this app; Back, Cancel and the after-save navigation land there or on <base>/<id>. The default is the current request path with its last segment removed.

func (*RecordBuilder) Delete

func (b *RecordBuilder) Delete() *RecordBuilder

Delete and Duplicate turn on those record actions.

func (*RecordBuilder) Duplicate

func (b *RecordBuilder) Duplicate() *RecordBuilder

func (*RecordBuilder) Form

Form replaces Display.Form on this page.

func (*RecordBuilder) Omit

func (b *RecordBuilder) Omit(fields ...string) *RecordBuilder

Omit leaves fields out of this page's form and detail.

func (*RecordBuilder) Override

func (b *RecordBuilder) Override() *RecordBuilder

Override turns on the status override: a form, inside a disclosure, that writes the state field directly through crud.WithStateOverride for a caller holding <entity>:override_state exactly (a Wildcard grant and a back office's elevation never satisfy it). Off by default, and drawn only for an entity with enforced states on an app that keeps an audit log; UI.OverrideHandler serves its posts.

func (*RecordBuilder) Panel

func (b *RecordBuilder) Panel() *RecordBuilder

Panel turns on the full page's way back to the drawer. Drawn as its own page (a direct load, or the drawer's Open as page), the record's header offers Copy link and Open in panel: the second returns to the list at the builder's Base with this record open as a drawer over it (data-cui-intercept-panel). Turn it on only when the record's route opens as a drawer over Base (app.InterceptFrom); over any other page the runtime treats the link as a plain move to the list. The drawer, which has its own bar, draws neither. Off by default.

func (*RecordBuilder) Prefill

func (b *RecordBuilder) Prefill(values map[string]string) *RecordBuilder

Prefill sets starting values on a create form (a foreign key when New is opened from a related list). Fields a create may not set are ignored.

func (*RecordBuilder) Related

func (b *RecordBuilder) Related(entities ...string) *RecordBuilder

Related names the entities whose lists the Related tab shows on this page. App pages show none unless named; the admin passes every entity it exposes. Each list still passes that entity's own read gate.

func (*RecordBuilder) RelatedAt

func (b *RecordBuilder) RelatedAt(entity, base string) *RecordBuilder

RelatedAt names one related entity whose list hangs off base instead of the path Related derives. An empty base draws that list with no links and no New (ListBuilder.NoLinks): the entity has no screen of its own on this app.

func (*RecordBuilder) RenderCtx

func (b *RecordBuilder) RenderCtx(ctx context.Context) render.HTML

RenderCtx draws the record or create screen for the request in ctx.

func (*RecordBuilder) Steps

func (b *RecordBuilder) Steps() *RecordBuilder

Steps turns on the drawer's previous and next record. Drawn as an intercepted drawer over this entity's list (the builder's Base), the drawer's bar steps through the list's rows in the order the list shows them: its view, saved view, search, filter, facets and sort, read as the caller with the list's own read hooks. Each step renders in the same drawer. The full page, and a drawer over any other page, draw none. Off by default.

func (*RecordBuilder) Tab

func (b *RecordBuilder) Tab(key string, build func(TabContext) (component.Component, error)) *RecordBuilder

Tab adds a tab on this page only, after the entity's extension tabs.

func (*RecordBuilder) Undo

func (b *RecordBuilder) Undo() *RecordBuilder

Undo puts Undo on the toast a Delete leaves, for an entity with Scope.SoftDelete (a no-op otherwise) and a caller who may update the record: it restores the record through the host's RestoreHandler and returns to the list. Off by default.

type RecordContext

type RecordContext struct {
	Ctx    context.Context
	UI     *UI
	Entity string
	Record Record
}

RecordContext is what an Extension.Record receives.

type RecordMatch

type RecordMatch struct {
	ID    string
	Title string
}

RecordMatch is one record SearchRecords found: its id and its title.

type SavedView

type SavedView struct {
	ID     string
	Entity string
	Name   string
	// Filter is the list's DSL filter text. It is re-parsed and re-checked
	// against the entity's fields every time the view opens.
	Filter string
	// Columns are the shown columns in order; empty means the list's own.
	Columns []string
}

SavedView is one user's named list state for one entity: the filter text and the columns, in order. The name is a label only: it is never looked up in a catalog and never collides with a declared view's key.

type SavedViewStore

type SavedViewStore interface {
	// List returns the caller's views of entity, by name.
	List(ctx context.Context, entity string) ([]SavedView, error)
	// Get returns one of the caller's views of entity.
	Get(ctx context.Context, entity, id string) (SavedView, error)
	// Create stores v for the caller and returns it with its ID. It
	// refuses a blank name (ErrSavedViewBlank), a name the caller already
	// uses for entity (ErrSavedViewExists), a view past SavedViewCap
	// (ErrSavedViewCap) and a name, filter or column list past its limit
	// (ErrSavedViewTooLong).
	Create(ctx context.Context, v SavedView) (SavedView, error)
	// Delete removes one of the caller's views of entity.
	Delete(ctx context.Context, entity, id string) error
}

SavedViewStore keeps saved views. Every method acts for the caller in ctx only: the store reads the owner (handler.GetUser's GetID) and the tenant from ctx, never from an argument or the view, and a view another owner or tenant made is ErrSavedViewNotFound, the same answer as an id that does not exist. A ctx with no user is refused. battery/admin provides the table; UI.WithSavedViews hands it to the list.

type ScreenHost added in v0.88.0

type ScreenHost interface {
	HasScreen(path string) (has, known bool)
}

ScreenHost is the optional Host half that answers whether the app's UI routes serve a path. known is false when the host cannot tell (no UI host mounted), and the list then stays quiet. framework.App's host asks the mounted uihost's screen router.

type SidePanel

type SidePanel struct {
	Key   string
	Title i18n.Phrase
	Build func(RecordContext) (component.Component, error)
}

SidePanel is a panel in a record's side column: Title heads it and Build draws its body, as the caller. 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. A panicking or erroring panel fails that panel alone. Title is drawn in the request's language; the zero Phrase shows Key.

type Tab

type Tab struct {
	Key   string
	Label i18n.Phrase
	Build func(TabContext) (component.Component, error)
}

Tab is an extra record tab. Label is drawn in the request's language; the zero Phrase shows Key.

type TabContext

type TabContext struct {
	Ctx    context.Context
	UI     *UI
	Entity string
	Record Record
}

TabContext is what a Tab's Build receives. UI is the same *UI the screen was drawn with; there is no package-level one.

type UI

type UI struct {
	// contains filtered or unexported fields
}

UI draws one app's entity screens. Build it once at boot with New.

func New

func New(h Host, ext Extensions) (*UI, error)

New checks ext against the app's entities and returns the app's UI. It refuses, naming the offender:

  • an Extensions.Entities key that names no registered entity;
  • a view func for a key the entity's Display declares no view for, and a declared view with neither a Where nor a registered Filter;
  • a FieldDisplay.Input naming a kind Extensions.Kinds does not hold;
  • a tab or action key outside the key grammar (entity.ValidKey), a duplicate one, or a tab key that shadows a built-in tab (edit, related, activity, api);
  • an action with no Run, or a Kind with no Input and no Cell.

func (*UI) BulkHandler

func (u *UI) BulkHandler(entityName string) http.Handler

BulkHandler serves POST <api>/_bulk for the entity: the bulk bar's form RPC, and the record header's action buttons (scope "record"). App.EntityUI mounts it for every entity with write routes. It answers 404 for an entity with no write routes, or a list scope on one with Display.NoBulk; 403 when the caller may not read the entity or run the action; 422 naming what to fix; and on success a toast header with the counts (200) or the queued count (202). A record action answers 200 when it ran, 403 when the record's gates skipped it and 500 when Run failed.

func (*UI) Changes

func (u *UI) Changes(ctx context.Context, entityName string, before, after map[string]any) render.HTML

Changes draws what one audit entry changed on a record of entityName: the fields whose value differs between before and after, in the entity's field order, labelled and drawn the way the list draws them, related records by title. It is "" for an entity this UI does not draw, an entry missing either side (a create or a delete), and an edit that changed nothing the caller may see. Relation titles are read through the related entity's own gate as the caller.

func (*UI) CheckEntity

func (u *UI) CheckEntity(e *entity.Entity) error

CheckEntity runs New's checks on an entity registered after it: every FieldDisplay.Input names a kind, and every view has a Where or a registered Filter. Extensions.Entities can only name entities that existed at New, so a later entity's views all need a Where. The host calls it before registering the entity and refuses the entity on an error.

func (*UI) CheckStat

func (u *UI) CheckStat(entityName, agg, field, where, format string) error

CheckStat reports why StatValue could never compute this spec: an unknown entity or agg, a sum with no visible, queryable Int, Float or Decimal field (or a count given one), a filter that does not parse against the entity's fields, or a format other than "" or "money" ("money" only on a sum). A host checks a configured stat with it at boot, so a typo fails the start rather than drawing "—" forever.

func (*UI) Count

func (u *UI) Count(ctx context.Context, entityName, where string) (string, bool)

Count is how many records of the entity the caller can read that match where (filter text in the query DSL; "" for all), formatted with digit grouping, under the same read gate, scope and read hooks as the entity's list. It reports false when the read is refused, the filter is bad or the query fails, so a nav count or a badge draws nothing rather than a figure the caller may not see.

func (*UI) CountInt added in v0.90.0

func (u *UI) CountInt(ctx context.Context, entityName, where string) (int, bool)

CountInt is Count as a number, for a figure the caller computes with (a rate drawn on a ui.Gauge): the same read gate, scope, read hooks and false on a refused read, a bad filter or a failed query.

func (*UI) CountUpTo

func (u *UI) CountUpTo(ctx context.Context, entityName, where string, limit int) (n int, more, ok bool)

CountUpTo counts the records the caller can read that match where, up to limit: a read of at most limit+1 ids, under the same gates and scope as Count, for when an exact count is too slow. more reports that there are more than limit; ok is false when the read is refused or fails.

func (*UI) Create

func (u *UI) Create(entity string) *RecordBuilder

Create starts the create screen: the record form, empty, posting a create. Mount it at <base>/create, or configure UI.WithCreatePath with its route.

func (*UI) ExportHandler

func (u *UI) ExportHandler(entityName string) http.Handler

ExportHandler serves GET <api>/_export.csv for the entity: every row the list's query matches, as CSV, at most EveryMatchCap. The bulk bar links to it with the list's own query, so the file holds what the list narrowed to. Rows are read through the scoped CRUD handler with read hooks, the same values the list shows; NoQuery fields (masked columns among them), omitted and JSON fields are left out. A cell that a spreadsheet would run as a formula is prefixed with a quote. It answers 404 where BulkHandler does and 403 when the caller may not read the entity.

func (*UI) GroupBars

func (u *UI) GroupBars(ctx context.Context, entityName, groupBy string) []ui.BarChartBar

GroupBars counts an entity's rows per value of groupBy, as bar chart bars.

func (*UI) GroupSlices

func (u *UI) GroupSlices(ctx context.Context, entityName, groupBy string) []ui.PieSlice

GroupSlices is GroupBars as pie chart slices.

func (*UI) LastUpdated

func (u *UI) LastUpdated(ctx context.Context, entityName string) (time.Time, bool)

LastUpdated is when the newest record the caller can read was last written: the greatest updated_at in the caller's scope, read through the entity's read hooks. It reports false when the entity has no readable, sortable updated_at, the read is refused, no row has one, or the newest value is not a time (a masked column): a stat card then prints no date rather than a wrong one. Rows without a value are left out, so a database that sorts NULL first does not hide the newest.

func (*UI) LineChart

func (u *UI) LineChart(ctx context.Context, entityName, groupBy string) render.HTML

LineChart draws the grouped counts as a single-series line chart. Fewer than two groups draws ui.LineChart's empty state.

func (*UI) List

func (u *UI) List(entity string) *ListBuilder

List starts a list of the named entity. The name is checked when the list renders.

func (*UI) OverrideHandler

func (u *UI) OverrideHandler(entityName string) http.Handler

OverrideHandler serves POST <write base>/{id}/_override, the route the override form posts to; the host mounts it beside the entity's other write routes (the way it mounts BulkHandler). In order it:

  • takes POST only, refuses a cross-site post (the one shared guard, handler.IsCrossSiteRequest) and caps the body;
  • answers 404 for an unknown entity, an entity with no write routes or one without enforced states;
  • re-checks <entity>:override_state with access.CanResourceExact on the record id — a Wildcard grant and crud.WithElevation never satisfy it — BEFORE reading anything;
  • reads the record under the caller's context: a record the caller's read scope does not reach answers 404, the same as a missing id, and nothing changes;
  • validates the target state against the field's declared values and the reason (non-blank, at most OverrideReasonMax characters), and writes with crud.UpdateOne under crud.WithStateOverride in the caller's context, so the CRUD gates and the audit row ("state_override" with the reason) come from crud itself. An entity no audit log records is refused by crud (409 here); the write never falls back to an unaudited one.

A JSON caller (the form's RPC arm) gets a status, a toast header and a JSON body. A plain form post (a scriptless browser) gets 303 back to the record screen along the form-carried return path, which must be a same-origin relative path (scrubBackPath; anything else is a 400 and nothing is written), and an error answer that says what to fix.

func (*UI) PickerHandler

func (u *UI) PickerHandler(entityName string) http.Handler

PickerHandler serves POST <api>/_pick?field=<field>: a relation picker's search, answered with option rows for that field's input. It answers 405 to another method, 403 to a cross-site post or a caller who may not read the entity or the related one, 404 for a field that is not an editable relation, 415 to a body that is not JSON, 413 past pickerBodyLimit and 400 to a malformed body. The answer is never stored: it is per caller.

func (*UI) PruneBulkJobs

func (u *UI) PruneBulkJobs(ctx context.Context, keep time.Duration) (int, error)

PruneBulkJobs deletes the jobs that finished more than keep ago, and reports how many. A finished job's ids are already gone (Finish deletes them); this drops the job row with its tally. App.EntityUI runs it at start with BulkRetention; a host that runs for long calls it on a schedule.

func (*UI) PurgeHandler

func (u *UI) PurgeHandler(entityName string) http.Handler

PurgeHandler serves POST <write base>/<id>/_purge, the Deleted view's Delete permanently action. It hard-deletes a record that is ALREADY soft-deleted (a live row answers 409 and is not touched) under the caller's own context; the answer shapes are RestoreHandler's.

func (*UI) Record

func (u *UI) Record(entity, id string) *RecordBuilder

Record starts the record screen for one id.

func (*UI) RecordTitle

func (u *UI) RecordTitle(ctx context.Context, entityName, id string) (string, bool)

RecordTitle names one record the way its record screen's heading does, for breadcrumbs and links drawn outside the screen. It reads behind the record's own gate (scope, sign-in, RBAC and a Decider's per-row answer), through the read hooks, and answers false for a record the caller may not see, a missing id and an unknown entity alike.

func (*UI) RestoreHandler

func (u *UI) RestoreHandler(entityName string) http.Handler

RestoreHandler serves POST <write base>/<id>/_restore, the Deleted view's Restore action. It restores the record through the entity's CRUD handler under the CALLER's context, so permission, the Decider, owner and tenant scope are the write route's own gates. A JSON/RPC caller (the form RPC) gets a toast and a status; a plain form post gets a 303 back to the list's Deleted view — a return path the form carried, validated as a same-origin relative path, never an absolute URL. Forbidden answers 403, another owner's or tenant's id 404, a live row 409, and an entity without soft delete 404.

func (*UI) ResumeBulkJobs

func (u *UI) ResumeBulkJobs(ctx context.Context, grace time.Duration) (int, error)

ResumeBulkJobs hands the JobRunner every queued job whose Enqueue is not known to have happened (the process died between writing the snapshot and enqueuing it), created before grace ago, and reports how many. App.EntityUI runs it at start; a host that runs for long calls it on a schedule. Enqueuing a job twice is safe.

func (*UI) RunBulkJob

func (u *UI) RunBulkJob(ctx context.Context, id string) (err error)

RunBulkJob runs the queued bulk job id, the JobRunner worker's call. It takes the job's lease, then walks the snapshot's unsettled ids in chunks of InRequestCap, renewing the lease before each. Before each chunk it rebuilds the creator's context (JobRunner.Principal) and finds the action again under it: a creator who is gone, or who lost the role the action needs, stops the run. Each chunk is re-read under that context, so a row the creator can no longer see is skipped, and each record is asked its write gate before its write.

A retried call skips the rows already settled, and a finished or stopped job answers nil. While another runner holds a live lease it answers ErrBulkJobBusy, and after losing the lease mid-run ErrBulkLeaseLost; the JobRunner retries both. A runner that dies after a chunk ran but before its outcomes were saved leaves those records to run again (ActionContext.Run names the run for an app action that must not repeat an effect). The run's one audit row is written before the job is finished, so a failed write leaves the job queued for the retry to write again rather than finished without one.

func (*UI) SavedViewsHandler

func (u *UI) SavedViewsHandler(entityName string) http.Handler

SavedViewsHandler serves the saved-view forms' two posts, mounted by the host beside the entity's write routes:

POST <write base>/_views                  save the current view
POST <write base>/_views/_delete?id=<id>  delete one of the caller's

The save form carries a name, the active filter text, the shown columns, the list's key and the list's URL to return to. The filter is re-parsed and re-checked against the entity's fields BEFORE anything is stored, and the columns with the same rule the cols param applies: a refused value answers 400 naming the field. The owner and tenant come only from the caller's context — the store enforces that, and nothing about a caller travels from the request into it. A JSON/RPC caller gets a status and a toast; a plain form post gets a 303 back to the list, with the new view open on save and the saved param dropped on delete. The body is capped, cross-site posts are refused, and nothing is stored cacheable.

func (*UI) SearchRecords

func (u *UI) SearchRecords(ctx context.Context, entityName, q string, limit int) []RecordMatch

SearchRecords finds up to limit records (at most 20) whose SearchFields match q, the way the list's search box does, named as RecordTitle names them, in primary-key order. It reads behind the list's gate and each row's own, through the read hooks; the limit counts records the caller may open, and the read pages past refused rows for at most searchPages pages of limit rows. It answers nothing for an entity without SearchFields, an empty q, a caller who may not list the entity, an unknown entity and a failed read alike.

func (*UI) SnapshotTitle

func (u *UI) SnapshotTitle(ctx context.Context, entityName string, row map[string]any) (string, bool)

SnapshotTitle 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: its title fields, else the entity's singular name. A masked title field is left out. It answers false for an entity this UI does not draw. It reads only the related records a relation title part names, so a deleted record still has a name.

func (*UI) StatValue

func (u *UI) StatValue(ctx context.Context, entityName, agg, field, where, format string) string

StatValue computes one metric over an entity for a stat card: agg "count" (or empty), or "sum" of an Int, Float or Decimal field, both computed by the database over every match. where is filter text in the query DSL (`status = "active"`), checked against the entity's fields; format "money" prints a sum as $1,234.00 (the currency symbol is the i18nui.EntityCurrency entry), anything else as a number. A refused read, a bad filter, an unknown agg or a failed query prints "—": a stat sits beside content the caller can see, and must not announce what it cannot.

func (*UI) WithAPIPath

func (u *UI) WithAPIPath(path func(e *entity.Entity) (string, bool)) *UI

WithAPIPath returns a UI that draws the same screens with the same Extensions but points every write (save, delete, moves, bulk, export) at path(e) instead of the entity's REST routes. A back office uses it to send writes through routes it gates itself, the way battery/admin mounts the CRUD handler's write routes behind its own gate. path answers false for an entity whose screens should draw read-only. Reads are unchanged: they run in process under the caller's context.

func (*UI) WithActorName

func (u *UI) WithActorName(name func(ctx context.Context, id string) string) *UI

WithActorName returns a UI whose Activity tab names each entry's actor by name(ctx, id), an account's email say, in place of the bare id the audit row stores. An empty answer, or a panic, falls back to the id. A nil name returns the UI unchanged.

func (*UI) WithCreatePath added in v0.87.2

func (u *UI) WithCreatePath(path func(e *entity.Entity) (string, bool)) *UI

WithCreatePath returns a UI whose New, Duplicate, Create another and relation-picker New links point to path(e), the complete create screen path (for example "/deals/new"). Register the create screen at that same path. The path must have no query or fragment; entityui adds duplicate and prefill query parameters. Answer false or an empty path for an entity with no create screen to hide its create links. Access gates still apply. With no callback, links use the list/record Base + "/create". A nil callback returns the UI unchanged.

func (*UI) WithRecordPath

func (u *UI) WithRecordPath(path func(e *entity.Entity) (string, bool)) *UI

WithRecordPath returns a UI whose list cells link a relation's title to the related record, at path(e) + "/" + id, as a chip. path answers false for an entity whose records have no screen here, and the cell stays text. A link is drawn only for a title the caller's own read of the related entity returned, and the record screen behind it runs its own read gate. A nil path returns the UI unchanged.

func (*UI) WithSavedViews

func (u *UI) WithSavedViews(store SavedViewStore) *UI

WithSavedViews returns a UI whose lists can keep named saved views in store (ListBuilder.SavedViews turns a list on). The same host, Extensions and clock ride along — a UI that WithAPIPath rebuilt keeps its write paths, so the save and delete forms still post through them. The store reads the owner and tenant from the caller's context only; nothing about a caller travels from a request into it. A nil store returns the UI unchanged.

type ViewFunc

type ViewFunc struct {
	Filter func(ctx context.Context) (*filter.Predicate, error)
	Show   func(ctx context.Context) bool
}

ViewFunc is a view whose filter depends on who is looking, the tenant or the clock. Filter's predicate passes filter.ValidatePredicate before it reaches SQL, every time. Show, when set, hides the view from callers it does not apply to; a hidden default view falls back to All.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL