Skip to content

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 calendar

Each 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 pivot

Both 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")

See icons and colour.

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 default

The 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 / Imagesfiles the panel shows and hands over, and never asks questions about
HasManyfiles 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 only

The 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()       // detail

Values 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 no value attribute, 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 columnDefault 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.