Form
One Form[T] serves both create and edit. Fields decode into your model,
rules run server-side, hooks receive *T, and edits write only the columns that
actually changed — including any a hook touched.
posts.Form(func(f *steward.Form[Post]) {
f.Text("Title").Rules("required|max:255")
f.Select("Status").Options(steward.Options{"draft": "Draft", "published": "Published"})
f.Markdown("Body")
f.Image("Cover").Dir("covers").MaxSize(2 << 20)
f.BelongsTo("AuthorID", "Author", "Name")
f.Datetime("PublishedAt")
})Field kinds
Every kind takes a field path and an optional label — f.Text("Title") or
f.Text("Title", "Headline") — and returns the field for chaining. What
follows is one example of each; the modifiers they share are
below.
Text
f.Text("Title").Rules("required|max:255").Placeholder("Headline")
f.Textarea("Summary").Help("Shown on the listing page.")
f.Email("Email").Rules("required|email")
f.Password("Password").Rules("min:12").OnlyOnCreate()
f.URL("Website").Placeholder("https://example.com")Password never renders the stored value, and leaving it blank on an update
keeps the existing one. Email and URL set the input type, so a phone
keyboard offers the right keys — validation is still Rules.
Numbers
f.Number("Order").Min(0).Max(999).Default(0)
f.Decimal("Rating").Rules("numeric|gte:0|lte:5")
f.Currency("Price").Symbol("Rp").Rules("required|numeric")Number steps in whole numbers, Decimal accepts a fractional part, and
Currency renders the symbol inside the field.
Choice
f.Select("Status").Options(steward.Options{"0": "Draft", "1": "Published"})
f.Radio("Visibility").Options(steward.Options{"public": "Public", "private": "Private"})
f.Switch("Featured").Help("Pinned to the top of the list.")Select and Radio decode against the column they target, so
Options{"0": "Draft", "1": "Published"} writes 0 to an int column and
"0" to a string one — the option keys stay strings either way. Options
render in label order, whatever order the map or OptionsFunc returns them
in. See choice fields for options loaded from the database.
Switch stores a boolean. For a column that holds 0/1 as an integer, it
decodes the same way Select does.
Dates and times
f.Date("PublishedOn")
f.Datetime("PostDate").Rules("required")
f.Time("OpensAt")
f.DateRange("StartsOn", "EndsOn", "Runs") // two columns, one calendarEach stores what its name says — a date, a date and a time, or a time of day. See dates and times for the picker and for bounds.
DateRange pairs two date columns into the control a
grid filter’s range uses: one calendar for both ends on a
pointer device, and the platform’s own two inputs on a touch device. Each end
keeps its own column, so validation, defaults and the change log treat them as
the two fields they are, and the field it returns is the start — chain
Required, Rules and the rest onto it as usual.
Add Datetime() where the times are part of what the pair means — an event
running from 16:30 to 17:30 — and both columns store a moment instead of a day:
f.DateRange("DateStart", "DateEnd", "Runs").Datetime()The calendar then carries a time input per end, and a touch device gets two
native datetime-local inputs. Pair it with a Saving hook if an end before
its start should be refused; the control does not enforce order.
Uploads
f.Image("Cover").Dir("covers").MaxSize(2 << 20).Accept("image/*")
f.File("Attachment").Dir("docs").Accept("application/pdf")
f.Images("Photos").Dir("galleries").MaxFiles(20)
f.Files("Attachments").Dir("docs")The singular kinds store one storage-relative path; the plural ones store a JSON array of them. See uploads.
Long text
f.Markdown("Body")
f.Richtext("Content")Both are sanitized on save and again on render. See markdown and rich text.
Free values
f.Tags("Keywords").Help("Type a keyword and press Enter.")A list the reader writes rather than picks from. See tags.
Relations
f.BelongsTo("CategoryID", "Category", "Name") // fk, relation, title field
f.MultiSelect("Tags") // many-to-many pivotBoth render as comboboxes that fetch their options as you type, so a table of any size is usable. See choice fields.
Pickers and colour
f.Icon("Icon") // Lucide picker
f.Color("Brand").Default("#2563eb")Not an input
f.Hidden("Slug") // submitted, never shown
f.Display("CreatedAt", "Created") // shown, never submitted
f.Divider() // a rule between sections
f.Fieldset("Publishing", func(f *steward.Form[Post]) {
f.Switch("Featured").Span(6)
f.Datetime("PostDate").Span(6)
})Hidden carries a value the reader should not edit but the form must post;
Display is the reverse, and is what read-only values covers.
Modifiers
Every field takes these, in any order:
Rules(s) / CreationRules(s) / UpdateRules(s) | validation, all fields or one operation |
Required() | marks the control and adds the rule |
Default(v) | value for a new record |
Help(s) / Placeholder(s) | guidance beside and inside the field |
Span(n) | width in the twelve-column grid |
Show(fn) | conditional on the record or the reader |
OnlyOnCreate() / OnlyOnUpdate() | which form the field appears on |
ReadOnly() / Disable() | shown but not editable; Disable submits nothing |
Min(v) / Max(v) | bounds for numbers and dates |
Dir(s) / Disk(s) / Accept(s) / MaxSize(n) / MaxFiles(n) | uploads |
Options(o) / OptionsFunc(fn) | choices |
SavingValue(fn) | rewrite the raw value on the way in |
Virtual() | renders and submits, writes no column — read it in a Saved hook |
Every example on this page and on the detail page is called by a test in the framework’s own suite, so a snippet that names something that no longer exists fails the build rather than misleading a reader.
Layout
A form is a twelve-column grid. A field takes the whole row unless Span says
otherwise, so putting two fields side by side is one call each:
f.Text("Title").Span(8)
f.Text("Slug").Span(4)Twelve divides by 2, 3, 4 and 6, which covers the rows people actually build. A span outside 1–12 is ignored rather than emitted.
The span applies from the sm breakpoint up. Below it every field takes the
full width — two controls side by side on a phone are two controls too narrow to
use.
Width caps how wide the form grows: FormNarrow, FormNormal (the default),
FormWide, FormFull. A form putting several fields on a row usually wants
more than the default.
f.Width(steward.FormWide)Fieldset groups fields under a legend, and the fields inside it lay out on
their own grid:
f.Fieldset("Images", func(f *steward.Form[Post]) {
f.Image("Cover").Span(6)
f.Image("Thumbnail").Span(6)
})Divider() puts a rule between fields.
Choice fields
Select, BelongsTo and MultiSelect render the same control: a combobox. Type
to narrow the list, arrow to move, Enter to take. MultiSelect keeps several,
each as a chip; the other two keep one and show its label.
They were <select> elements, which cannot be searched — a 77-option category
list meant scrolling, and MultiSelect was a <select multiple> asking the
reader to hold a modifier key. Radio and Switch are unchanged; a handful of
mutually exclusive choices is what a radio group is for.
An optional field carries a clear button, which is what the empty — option used
to be. A Required() one does not, since there is nothing valid to clear to.
Where the options come from
A list that fits one page — fifty — ships with the page and is filtered in the
browser: no request, no latency. A longer one carries its first page and fetches
the rest as the reader types, from
GET {slug}/_options?field={name}&q={query}. BelongsTo always fetches, because
the target is a table and its size today says nothing about tomorrow.
The match is a case-insensitive substring of the label, capped at fifty, and the reply says whether anything was left out so a too-broad query tells the reader to keep typing rather than quietly truncating.
That is what makes a long list usable. A tag field over 7,776 rows rendered a 923 KB page when every option shipped with it; it is 41 KB now, and a keystroke costs one request of about 50 ms.
Note
The filtering runs in Go, over whatever OptionsFunc returns. Options arrive
as a map, so the framework has no query to push a WHERE into: a function that
loads a whole table still loads it per keystroke. At a few thousand rows that
is cheap. Beyond that, filter inside OptionsFunc — it can read the query with
c.R.URL.Query().Get("q"). BelongsTo is already filtered in SQL.
Multi-select
f.MultiSelect("Tags", "Tag").
OptionsFunc(tagOptions).
ValuesFunc(func(_ *steward.Context, m any) []string {
return tagIDsOf(m.(*Post).Tags)
})OptionsFunc supplies the choices, ValuesFunc the ones already held. The field
is virtual — nothing on the model backs it — so writing the
pivot is yours to do in a Saved hook:
f.Saved(func(c *steward.Context, p *Post, _ bool) error {
return syncTags(c, p.ID, c.R.Form["Tags"])
})c.R.Form[name] is a plain []string, as it always was. The combobox submits
one hidden input holding a JSON array, and that is expanded back into repeated
values before any hook runs — so the widget’s wire shape stays the widget’s
business. A client posting the field the ordinary way still works.
A payload that looks like the widget’s but cannot be read as one is refused
with a 422 rather than passed along. The field is virtual, so nothing downstream
validates it: left in place, a mangled selection reaches a Saved hook as if it
were a chosen value, and a hook that creates missing options by name will store
it.
What gets submitted
A Select or BelongsTo submits its value, exactly as the <select> it
replaced did. A MultiSelect submits one hidden input holding a JSON array,
which is expanded back into repeated form values before any hook runs — so
c.R.Form[name] is the plain []string it always was, and a client posting the
field the ordinary way still works.
A multi-select stores {value, label} objects rather than bare values, because
its list is replaced as the reader types and a chip whose option has gone has
nowhere else to read its text from. That shape stops at the framework’s edge.
Dates and times
Date, Datetime and Time are the browser’s own controls, so they carry its
conventions — the same value reads 31/07/2026 or 07/31/2026 depending on the
reader’s locale. What is submitted is always ISO, so only the presentation
follows the browser.
Date and Datetime gain a calendar where there is a pointer. On a coarse
pointer — a phone or a tablet — nothing is added: the platform’s own picker is
better than anything shipped here, and it is the one its owner already knows.
The native input remains the field either way; the calendar only writes ISO into
it, and picking a day keeps the time already held.
Month and weekday names come from Intl against the document’s lang, so they
follow whatever the panel declares.
The calendar’s heading zooms out — days, then months, then years, twelve to a page — so a date years back is two clicks away rather than a month at a time.
Bounds
Min and Max take a time.Time here, or a number on the numeric kinds:
f.Datetime("PublishedAt").
Min(time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local)).
Max(time.Now().AddDate(1, 0, 0))They reach the control as its own min and max, grey out everything outside
the range in the calendar, and are checked again on save — an attribute is a
hint to whoever is using the page and no obstacle to anyone who is not.
Datetime and Time keep seconds. They are shown, because a field that
holds a second it will not show is a field that loses it: the control returns
what it was given, so rendering to the minute is how a save comes to rewrite a
timestamp nobody edited.
Values are parsed in the server’s local zone, matching a column that stores wall-clock time without one.
Time writes to a string column or a time.Time one.
Uploads
File and Image store through the configured
Storage and refine with Dir, MaxSize and
Accept:
f.Image("Cover").Dir("covers").MaxSize(2 << 20)
f.File("Attachment").Dir("docs").Accept("application/pdf").MaxSize(8 << 20)The field shows what it holds: a prompt when empty — naming what it accepts and how large — and when filled, the file’s name, a thumbnail for an image, a link to it, and a button to remove it. It takes a drop anywhere on itself, reports progress while uploading, and says so when a stored file has gone missing.
The value stored on the model is the path, not a URL; see Storage for turning one back into a link. The submitted filename is sanitised and kept after a unique prefix, so a download arrives with a name someone can read rather than a token.
Accept is enforced, not just passed to the file picker. It takes what the HTML
attribute takes:
f.File("Doc").Accept(".pdf,.docx") // extensions
f.File("Doc").Accept("application/pdf") // types
f.Image("Cover") // image/* by defaultThe type is derived from the extension, never from the Content-Type the
client sent — that header is chosen by whoever is uploading. Prefer naming
extensions: a type has to be looked up, and that lookup reads the host’s MIME
database, which a scratch container has none of. Common office, archive and
media types are carried in the binary so they resolve the same everywhere;
anything more unusual should be named as an extension.
Important
On top of Accept, an Image field takes only image extensions, and a
File field refuses the ones a browser executes — .html, .svg, .js
and their relatives — whatever Accept says.
Uploads are served with Content-Disposition: attachment and nosniff, which
makes visiting one a download rather than a page. That matters because uploads
come back from the panel’s own origin: without it, a stored .html ran its
script as the panel, and an account with nothing but view permission could hand
an administrator a link that acts as them.
Uploading is a write, so it needs one: creating a record requires the create permission, and editing one requires update on that record. The file lands before the form is submitted, so this is the check that counts.
Saving releases what the record has stopped pointing at — the file you replaced, or the one you removed.
Note
Two kinds of file are still left behind. A record’s files are not deleted
with the record: a soft-deleted row can come back, and would find them gone.
Delete them from a Deleted hook with Storage.Delete if your records are
gone for good.
And a file uploaded into a form that was then abandoned stays: nothing knows the form was abandoned.
Several files
Files and Images hold a set in one column, as a JSON array of storage paths
in the order they were added:
f.Files("Attachments").Dir("docs").Accept(".pdf").MaxSize(2 << 20).MaxFiles(4)
f.Images("Gallery").Dir("gallery").MaxFiles(20)The column is text — gorm:"type:text" — and holds
["docs/20260101-ab12cd34ef56-Report.pdf", …]. A value that is not an array is
read as a single path, so a column promoted from File keeps what it held.
Warning
A JSON column cannot be filtered, sorted or counted. Filters and sorts go through SQL against a scalar column, and nothing here reaches into JSON — deliberately, since the syntax for that differs between databases.
So the two are for different jobs:
Files / Images | files the panel shows and hands over, and never asks questions about |
HasMany | files the panel should list, caption, order, or find by |
For the second, model each file as a row:
steward.HasMany(f, "Images", "GalleryID", func(cf *steward.Form[Image]) {
cf.Image("File").Dir("gallery")
cf.Text("Caption")
})That is why the array holds bare paths rather than objects: the moment a file needs a caption or an order beside it, a row is the shape that carries it.
Colour
Color stores #rrggbb.
f.Color("Accent")The control is a text input with a swatch in it. The text input is the field — the swatch only fills it in — and that split is the whole point of the design rather than a stylistic choice.
Important
A native <input type="color"> has no empty state. Hand it "" and it
reports #000000, so a form built on one alone submits black for a colour
nobody chose: opening a record and pressing save wrote black over an unset
column. Here an untouched field submits nothing, and the swatch shows a
chequer rather than a colour it does not hold.
The value is checked server-side against #rrggbb and stored lowercase, because
what submits is free text. A field that is not Required() gets a clear button.
Money
Currency renders a number with a symbol in front of it.
f.Currency("Price") // uses Config.CurrencySymbol
f.Currency("Price").Symbol("Rp") // this field onlyThe symbol defaults to $. Set Config.CurrencySymbol for the panel, or
Field.Symbol for one field. It is a prefix on the control and nothing more —
the stored value is the number, and no rounding, grouping, or conversion is
applied.
Tags
Tags collects free-form values as chips. Type a value, press Enter or comma,
and it joins the list; Backspace on an empty input takes the last one back, and
each chip carries a button that removes it.
f.Tags("Keywords")It needs one text column and nothing else:
type Post struct {
ID uint
Keywords string `gorm:"type:text"`
}The column holds a JSON array — ["launch","steward"] — the same shape
Files uses for its paths, and an empty list is stored as ""
rather than "[]".
Tags is not MultiSelect. A multi-select picks from a list
that exists; Tags has no list to pick from, and the reader writes the values.
Reach for Tags when the set is open: keywords, labels, aliases, anything a
reader would otherwise ask you to add to a dropdown first.
What is stored
Whatever posts the field, the value is normalised before it is written: entries are trimmed, runs of whitespace collapse to one space, blanks and repeats are dropped, and the list is capped at 200 values. So a client posting the field by hand cannot leave the column in a shape the read side has to defend against.
A column that already held a plain string — one promoted from Text — reads as
a single value rather than as nothing, and saving it writes the array form.
Values in another table
A normalised schema keeps tags in a table of their own and joins them through a
pivot, which is a column the record does not have. Virtual() detaches the field
from the model: it renders and submits like any other, nothing is written to a
column, and what it posted reaches a Saved hook as c.R.Form[name].
ValuesFunc supplies the chips the form opens on.
f.Tags("Tags").Virtual().
ValuesFunc(func(_ *steward.Context, m any) []string {
return tagNames(m.(*Post).Tags)
})
f.Saved(func(c *steward.Context, p *Post, _ bool) error {
return syncTags(c, p.ID, c.R.Form["Tags"])
})This is the arrangement MultiSelect has always had, and the
hook is the same hook. What differs is what the values are: a multi-select
posts the ids of rows that exist, so a hook resolves them. A virtual Tags field
posts names, because the reader typed them — including names that are all digits.
"2024" is a tag, not an id, and a hook that reads it as one will attach the
wrong row.
The list reaches the hook normalised the way a stored column is: trimmed, whitespace collapsed, blanks and repeats dropped, capped at 200 values. No column decodes a virtual field, so without this a client posting the list by hand would reach a hook that writes a row per value.
Showing them back
Stored as JSON, a tags column renders as ["launch","steward"] in a grid cell
or a detail row unless you say otherwise. Tags() draws the chips instead:
g.Column("Keywords").Tags() // grid
d.Field("Keywords").Tags() // detailValues are escaped, so a keyword containing markup is text on the page.
Those two read a stored array. Where the values are not a column — the rows a
virtual field stands for — steward.TagList draws the same chips from a list you
already have, so the grid, the detail page and the form all show a tag the same
way:
g.ColumnFunc("tags", "Tag", func(p *Post) template.HTML {
return steward.TagList(tagNames(p.Tags))
})Read-only values
Display shows a value and never persists it: it renders with no name, so a
submission naming it writes nothing, and the field is skipped on decode.
f.Display("Slug")It renders readonly rather than disabled. Both refuse edits, but a disabled
input cannot be focused or selected, and a value put on a form to be read is
usually one somebody wants to copy.
Markdown
Markdown stores markdown source in a monospace textarea, with a Write/Preview
pair above it.
f.Markdown("Body")Read it back with Detail.Markdown(), which renders it as
GitHub-flavoured markdown: headings, lists, tables, strikethrough, autolinks,
fenced code.
The preview is rendered by the server, on the same endpoint pattern the uploads use, and it is gated the same way — whoever cannot write the record cannot use it. That matters more than it looks: rendering in the browser would mean two parsers, and two parsers agree right up until the day they do not. What the Preview tab shows is the string the detail view will produce, not an approximation of it.
Important
Markdown permits raw HTML, so the rendered output passes through the same
allowlist as rich text — including on the
preview endpoint, which renders whatever it is handed. A <script> written
into a markdown document is dropped rather than escaped-and-displayed.
Two consequences worth knowing: a task list’s checkboxes disappear (an <input>
is not on the allowlist, so the items render as an ordinary list), and inline
class attributes are dropped for the reason described below.
With JavaScript off the tabs are absent and the textarea is the whole control.
Rich text
Richtext stores HTML, edited through a small WYSIWYG surface: bold, italic,
underline, strikethrough, headings, lists, links, blockquote, images, and
clear-formatting.
f.Richtext("Body").Required()Read it back with Detail.HTML(), which renders it as markup
rather than escaped text.
Each toolbar button reports the formatting at the caret, not what pressing it would do — put the cursor inside a bold run and the bold button reads as pressed. Block buttons (headings, paragraph, quote) report the block the caret sits in, so exactly one of them is lit at a time.
Images
The image button uploads through the field’s own endpoint and drops an <img> at
the caret. Uploads land in storage under the
resource’s slug, exactly as an Image field’s do, and are held to the
same rules: image extensions only, and the field’s MaxSize if it sets one.
f.Richtext("Body").MaxSize(4 << 20)Nothing is written to the record until the form is submitted, so an abandoned draft leaves the uploaded file behind — the same trade-off the upload fields make.
What survives sanitizing
Important
Submitted markup is sanitized server-side against an allowlist, on save
and again on render. A contenteditable field is an arbitrary-HTML input, so
the value cannot be trusted for having come from the editor. Parsing uses
golang.org/x/net/html rather than pattern matching, because HTML’s error
recovery is where hand-rolled sanitizers leak.
Cleaning on render matters as much as on save: rows written before the field existed, or by a migration or a direct SQL fix, never passed through the save path.
Kept: paragraphs, headings, lists, links, blockquote, code, img, figure,
figcaption, and tables (table, thead, tbody, tfoot, tr, th, td,
caption). Dropped tags keep their children, which is why the table tags matter:
an unlisted <table> does not merely lose its borders — its cells run together
into a single line of text, and an unlisted <img> disappears with nothing left
behind.
Dropped: scripts, event handlers, javascript: and data: URLs, iframes,
objects, inline SVG, and comments. An <a> keeps href, title, target, and
rel; an <img> keeps src, alt, title, width, height, and loading;
a cell keeps colspan and rowspan. An image’s src is held to the same
schemes as a link’s href, which is what stops an SVG data URI carrying script.
A style attribute is filtered rather than dropped: text-align survives with a
known value, and every other declaration goes. Alignment is the one thing a
pasted document carries that means something editorially; type, size, and colour
belong to your stylesheet.
Warning
class is always dropped, and this is deliberate rather than an oversight.
The panel is styled with utility classes, so a class on stored content is
ambient — class="fixed inset-0 z-50" alone is a full-viewport overlay, with
no script involved.
The editor is progressive enhancement over a textarea — with JavaScript off, the raw HTML stays editable — and pasting inserts plain text, so pasted markup does not smuggle in attributes the server would strip anyway.
It is still deliberately modest: no table editing or source view. A field that needs those wants a dedicated editor, which belongs in your project rather than vendored into the framework.
Icons
Icon stores an icon name, chosen from every Lucide
icon — around 1,600 of them:
f.Icon("Icon").Help("Shown beside the entry in the sidebar.")The field is collapsed: it shows the current glyph and its name, and opens a
searchable grid on click. Icons from Config.AssetsFS appear alongside Lucide’s.
The built-in Admin → Menu resource uses this field.
It replaces a text input holding an icon name, which failed the wrong way: a typo renders blank, and nobody notices until a sidebar looks wrong. The picker can only produce a name that resolves.
How it is put together
The three costs worth knowing about, since 1,600 icons is enough that the naive version of each would hurt:
- The glyphs are not in the page. The grid references the vendored sprite
through
<use>, so the browser fetches one cached file (~70 KB compressed) rather than the page carrying 1,600 inline SVGs. The selected icon is inlined, so the closed field shows it before the sprite arrives. - The grid is built on first open, not on render — a field nobody touches costs nothing.
- The
<select>is the field. It submits, and with scripting off it stays a usable control, because browsers give a long select type-ahead. The popover is a skin over it. Its options carry novalueattribute, since an option’s value defaults to its text — naming each icon once instead of twice takes about 40% off the list.
Validation rules
Rules uses a compact pipe syntax; failures render inline (HTML) or as a
422 {"errors": {...}} (JSON):
f.Text("Slug").Rules("required|alpha_dash|max:64|unique:posts,slug,{id}")Available: required, max:n, min:n, email, url, integer,
numeric, alpha_dash, in:a,b,c, gte:n, lte:n, and
unique:table,column[,{id}] ({id} excludes the record being edited).
CreationRules / UpdateRules append rules for one mode only, and
Required() is shorthand for Rules("required").
Common field refinements: Default(v), Placeholder(s), Help(s),
ReadOnly(), Disable(), OnlyOnCreate() / OnlyOnUpdate(),
OptionsFunc(func(c) Options) for dynamic choices,
SavingValue(func(c, raw) (any, error)) to transform input before decode, and
Virtual() for a field no column backs.
Conditional fields
Show(func(*Context) bool) gates a field per request — the seam for a form whose
shape depends on who is filling it in:
f.Select("Status").Options(steward.Options{"draft": "Draft", "published": "Published"}).
Show(func(c *steward.Context) bool { return c.User.HasRole("editor") })A hidden field is skipped when the form renders, when a submission is decoded,
and in the resource’s _schema response. So hiding it is not decoration: a
hand-built POST carrying the field writes nothing, and a headless client is not
told about a field its submissions cannot set.
Because the field never decodes, nothing writes the column — Default is a
render-time value for the input and does not apply. Supply the value in a
Saving hook, or leave it to the column’s database default:
f.Saving(func(c *steward.Context, p *Post) error {
if !c.User.HasRole("editor") {
p.Status = "draft"
}
return nil
})Use Show for “which fields does this person get”, and a
policy for “may this person touch this row at all”. The two
compose: the policy decides whether the form opens, Show decides what is on it.
Hooks
Typed, and able to veto:
f.Submitted(func(c *steward.Context) error { ... }) // before anything
f.Saving(func(c *steward.Context, m *Post) error { ... }) // validated, not yet persisted
f.Saved(func(c *steward.Context, m *Post, created bool) error { ... })
f.Deleting(func(c *steward.Context, ids []string) error { ... }) // veto deletes
f.Deleted(func(c *steward.Context, ids []string) error { ... })Returning an error from Submitted, Saving, or Deleting aborts the
operation and surfaces the message to the user.
Saved and Deleted run after the write, so there is nothing left to abort.
An error from either is reported as a warning: the toast carries it, the
save still counts, and the reader is still taken where a save takes them. That
is the shape a check on nested rows needs — Saving runs before those rows are
written, so anything that depends on them can only be checked here:
f.Saved(func(c *steward.Context, s *Schema, _ bool) error {
if len(s.Fields) == 0 {
return errors.New("this form has no questions yet, so it stays unpublished")
}
return nil
})Nested rows (hasMany)
Edit child records inside the parent form — added, changed, and removed rows are validated and persisted with the parent:
steward.HasMany(f, "Comments", "PostID", func(cf *steward.Form[Comment]) {
cf.Text("Author").Rules("required").Span(4)
cf.Textarea("Body").Span(8)
}).Label("Reader comments")"Comments" must be a has-many relation on the parent model, "PostID"
the child’s foreign key (set automatically).
A child field is a field: Span, Min/Max, Symbol, Disabled, ReadOnly,
Help, Default, Rules and Fieldset all mean inside a row what they mean
outside one.
steward.HasMany(f, "Questions", "FormID", func(cf *steward.Form[Question]) {
cf.Text("Label").Span(6)
cf.Number("Position").Min(1).Span(2)
cf.Fieldset("Shown when", func(g *steward.Form[Question]) {
g.Text("DependsOn").Span(2)
g.Text("EqualTo").Span(2)
})
})What a row cannot honour, Verify() refuses at boot rather than dropping at
render — File, Image and BelongsTo fields, and on a child field
CreationRules/UpdateRules, OnlyOnCreate/OnlyOnUpdate, Show,
SavingValue and ValuesFunc. A row is created or updated by the state of the
row rather than of the parent, and it is cloned in the browser from a template
the server rendered once, so a per-request predicate has nowhere to run.
A row is the same twelve columns as the form around it, so
Span means the same thing inside it: a row of many narrow fields
lays out as one, rather than as a stack of half-width boxes.
Label names the group and its add button. Without it the relation’s field name
is split into words, which is English whatever language the panel is written in.
Referring to a row that did not exist yet
A row the browser adds has no identity until it is written, so the form gives it
a made-up key — new_3_a1b2. A row that was already there carries its own id.
When one record has to point at a sibling row, that is the problem: the target’s
real id does not exist while the form is being filled in.
Context.NestedIDs is the pair. It maps each row from the key the form used to
the primary key it was saved as, per relation:
f.Saved(func(c *steward.Context, form *Form, _ bool) error {
pages := c.NestedIDs("Pages") // {"new_1_a1b2": "17", "42": "42"}
for i := range form.Questions {
form.Questions[i].PageID = pages[form.Questions[i].PageKey]
}
return saveQuestions(c, form.Questions)
})Rows that already existed are in the map too, mapped to themselves, so a caller reads one map rather than deciding per row which kind it is looking at.
Call it from Saved, not Saving: the rows do not exist yet when Saving
runs. Every relation on the form is written before Saved, so one repeater’s
rows resolve against another’s whichever order they were declared in. A relation
that was not part of the request returns nil.
Note
This is the seam, not the feature. Steward records what each row became; deciding which field of yours holds a reference, and what it means, stays yours — a panel that stores page numbers and one that stores page ids want different things from the same map.