π€ Forms
One rule outranks everything else on this page: never bind a request body onto a model.
Why not
No reflection binding, no PostForm loop, nothing that walks the
submitted keys and writes whatever it finds. A form is
attacker-supplied, and your model has fields the user must not choose β
UserID, Role, PriceCents, CreatedAt.
Read each permitted field by name:
title := r.PostFormValue("Title")
body := r.PostFormValue("Body")
and name the columns on the way out:
a.owned(r).Model(&n).Select("Title", "Body", "UpdatedAt").Updates(update)
Select's strings are GORM field names, and they are the allowlist that
matters. An unexpected field in the request cannot reach a column,
because no code path exists that would carry it there.
form.Parse
Declare your fields once and validate them in one pass:
p := form.Parse(r,
form.Field{Name: "title", Required: true},
form.Field{Name: "body", Kind: form.Textarea})
if !p.OK() {
w.WriteHeader(http.StatusUnprocessableEntity)
renderContent(w, r, "new", formView{
Errors: p.Errors(),
Note: Note{Title: p.String("title"), Body: p.String("body")},
})
return
}
Write the status before rendering. The render helper writes none.
p.Errors() is a form.Errors β a map from field name to message,
empty rather than nil when everything validated, which is what a
template renders beside each input. p.Echo() gives you the whole
seed-back map at once if your view is map-shaped.
Nothing gets retyped on a validation failure. That is the point of the echo: a rejected form comes back populated.
The kinds
The zero value is Text, so a plain form.Field{Name: "Title"} does
the common thing.
| Kind | Read with | Behaviour |
|---|---|---|
Text (zero value) |
p.String |
Trimmed; value and echo are both the trimmed text |
Textarea |
p.String |
Kept exactly as typed, whitespace included; only the required check trims |
Money |
p.Cents |
Parsed to integer cents; the echo keeps the raw text |
Date |
p.Date |
2006-01-02, parsed in Location |
Time |
p.Time |
15:04 β a clock reading, no location |
DateTime |
p.DateTime |
2006-01-02T15:04, parsed in Location |
Required on a blank Text or Textarea reports "
Required on Money and on the three date kinds is checked against the
raw text rather than the parsed value, so a present but unparseable
value reports the parse error instead of the required message β telling
someone a field is required when they filled it in would be a small lie
the parser is in a position to avoid. For Money that also makes "0"
a present, valid zero where "" is required-blank; a date has no such
pair, since "0" is simply not a date and reports
rastrillo.ui.date_invalid.
Parse reads through r.PostFormValue, which parses the form on first
use. If you want a body-size cap or a 400 on a malformed body, wrap
r.Body and call r.ParseForm yourself first, the way the generated
actions do.
Dates and times
The three date kinds parse one exact wire format each and nothing looser, because the browser has already normalised whatever the person typed:
p := form.Parse(r,
form.Field{Name: "starts", Kind: form.DateTime, Required: true, Location: tz},
form.Field{Name: "ends", Kind: form.DateTime, Required: true, Location: tz},
form.Field{Name: "doors", Kind: form.Time})
form.Range(p, "starts", "ends")
Location is the zone Date and DateTime parse in; leave it nil and
it is UTC. Time is a bare clock reading and ignores it. A timezone is
not a date field's concern β an app that needs one renders a
field-select of zones beside the field and hands the chosen location
to Parse.
Reading back:
starts := p.DateTime("starts") // time.Time
h, m, ok := p.Time("doors") // ok=false when absent or unreadable
An empty optional date is the zero time.Time, and no error at all. An
unparseable one is the zero time.Time too, but it does carry an error:
rastrillo.ui.date_invalid. A name you never declared is the zero
time.Time as well, and silently β no value, no error. All three read
back identically from p.Date, so ask p.OK() or read p.Errors() to
tell them apart rather than testing IsZero. p.Time says the same
through its ok, and returns 0, 0 rather than half a reading.
form.Range
Range is the two-field check Parse cannot do, because Parse takes
one declaration per field:
form.Range(p, "starts", "ends")
It puts an error on ends when its instant precedes starts. Equal
instants are fine. If either side is blank or unparseable, Range says
nothing at all β Parse has already named that field's problem, and
stacking a second message on top of it only crowds the screen. Either
kind works on either side, and the two can be mixed.
Errors are catalog keys
Text, Textarea and Money report finished English sentences. The
three date kinds report rastrillo.ui.* keys instead β
rastrillo.ui.date_invalid, rastrillo.ui.field_required,
rastrillo.ui.date_end_before_start β so a French app's date error is
in French without the app writing one.
Resolving happens at render. The generated actions wrap every field's
error in T unconditionally, and a hand-written date field does the
same:
{{template "field-datetime" dict "Name" "Starts" "Label" (T "event.field.starts")
"Value" .Fields.Starts "Error" (T (index .Errors "Starts"))}}
Wrapping in T is safe for every field, which is why the generated
template does it without asking what kind it has. T hands back a
string it does not recognise as a key exactly as given, so "Title is
required" and your own hand-written sentences pass straight through, and
only the keys get looked up. The wrapping lives in the calling template
rather than in the partial, so a hand-written caller that already passes
a finished sentence keeps rendering it unchanged.
Daylight saving
time.ParseInLocation resolves a wall-clock time that does not exist β
the hour a spring-forward skips β using the offset in force before the
transition, so a time inside the skipped hour and the real time it
collapses onto land on the same instant.
Money is int64 cents
Never a float. form.Money parses through form.ParseCents, which is
strict on purpose: at most two decimal places, no $, no sign character
at all, both halves ASCII digits. An empty string parses to zero,
because a required money field has already rejected blankness on the raw
text before ParseCents runs.
The strictness earns its keep. Handing each half to strconv.ParseInt
accepts its own +/-, so "12.-5" would quietly parse to a different
magnitude than its digits suggest instead of being rejected as the
not-a-dollar-amount it is.
There are two formatters, and using the wrong one is a real bug:
form.FormatCents(cents) // "$12.34" β for display
form.FormatCentsPlain(cents) // "12.34" β for seeding a form field
Seed with the plain one. A browser may resubmit an untouched field
completely unchanged, so the seed has to be exactly what ParseCents
accepts back, and ParseCents rejects a leading $. Seed with
FormatCents and resubmitting an unmodified money field always fails.
Both handle negatives correctly, writing the sign once against the
absolute value. Formatting a negative directly produces "$-1.-50",
since Go's / and % both truncate toward zero.
The main field
Most forms have one main field that is required to be filled and typically labels the record.
Mark it Primary to get a bigger input; the label stays the same, so
the emphasis lands on what someone types. One per form. A primary field
almost always sits on its own row (except in cases like first/last name)
Two fields on one row
rst-field-row is the wrapper for fields that belong side by side β a
city and a postcode, a start and an end. field-daterange emits one;
everywhere else you write it yourself around a run of field partials:
<div rst-field-row>
<div class="rst-grow" rst-field>β¦Cityβ¦</div>
<div rst-field>β¦<input rst-input="short">β¦</div>
</div>
The row aligns by the control line, not the bottom. A field carrying an error is taller than the one beside it, so aligning at the top is what keeps every control on one line; each message then flows under its own column and shifts nothing. A field with no label reserves the label's line anyway, so an unlabelled control still lines up with a labelled sibling's; a field whose label is long enough to wrap is a field that wants its own row.
Every field in a row has an 8rem floor, and rst-input="short" is
8rem wide. A row that runs out of width wraps rather than squeezing its
fields into slivers.
rst-grow is flex: 1 1 12rem, not flex: 1. flex: 1 is
flex: 1 1 0% β a grown field with no basis, which collapses on a
narrow screen instead of taking the next line. 12rem is the width it
wraps at.
A long error message never buys its column extra width β the messages
are contain: inline-size, so the sentence wraps under its own control
rather than stretching the column and squeezing the field beside it.
The form owns the block rhythm
rst-form is a flex column with a var(--rst-sp-5) gap β 24px, the
same rhythm rst-form-flow uses β and it zeroes the block margins of
every child it holds. That is one spacing mechanism rather than two:
fields carry margins of their own for use outside a form, and inside one
those margins used to add to the gap. The gap was 8px then, so two
fields sat 40px apart β 8 between them plus 16 above and 16 below β and
the form gained 16px of dead air at each end where nothing was being
separated from anything.
It is stated as a rule over every child rather than a list of the
kinds the library happens to ship, because a list cannot name your own
<div> and a child the list forgets lands at exactly the spacing the
rule exists to prevent. The two exceptions are written into the
selector: rst-form-foot, the action row form-foot emits, and
rst-form-bar, the sticky save bar you write by hand, keep their
block-start margin β which is not rhythm but the extra air separating a
closing action row from the last question above it.
The two swapped names in the release that made the vocabulary
attributes. rst-form-foot was the save bar and rst-form__foot the
partial's row; flattened, both wanted one attribute, so the partial's
row took the name the partial is called and the save bar became
rst-form-bar. rastrillo markup --fix applies the rename.
If you mean to override it, the shipped selector is
[rst-form] > *:not([rst-form-foot], [rst-form-bar]), and a :not()
takes the specificity of its most specific argument β so that is
(0,2,0), exactly the weight of [rst-form] > .whatever. You win the tie
on source order, and you have it: the shell's head block puts your
stylesheet after tokens.css.
The busy button is not a guarantee
rastrillo.js gives every submit button a loading state while its form
is out β spinner, aria-busy, then disabled β and refuses a second
submit from the same form while the first is in flight. It is on by
default; data-busy="false" on the form or on one button opts out, and
data-busy-label replaces the text. The whole rule, including what it
looks like, is in
Templates.
What it buys you is that the ordinary double click stops posting twice. What it does not buy you is idempotency, and it is worth being blunt about the difference: the guard lives in the browser, and the browser is not where your data is. With JavaScript off there is no guard at all and the form submits exactly as it always did β twice, if someone clicks twice. A refresh, a back button, a retried request, two tabs, someone with a script: none of them go through it.
There is one shape to watch for on the other side of it: a submission
that never navigates β a 204, or a file handed to the downloads shelf
β leaves the button disabled for good, because nothing arrives to clear
it. Put data-busy="false" on those forms. Templates has the detail.
So the server still has to be able to see the same write twice and only do it once. A unique index, an idempotency key on the form, a token you consume β whichever fits the write. The busy button is manners. The constraint is the correctness.
After a successful write
flash.Set(w, "notice", "Note created.")
http.Redirect(w, r, "/notes/"+id, http.StatusSeeOther)
flash.Set writes a one-shot cookie; your render helper calls
flash.Take(w, r) once per page and the layout renders it. A flash is
display state, not a record β losing one to a cleared cookie costs a
notice, not data.
303 rather than 302, so the browser follows with a GET and a refresh does not resubmit.
Read this page as markdown β exact, unstyled, and cheap for an agent to fetch.