π€ 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 with a Currency |
p.Minor |
An amount in that currency's smallest unit; see Money |
Money without a Currency |
p.Cents |
Integer cents, read strictly; 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 |
URL |
p.String |
A web address, scheme optional; stored as an http(s) URL |
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. For
Money that also makes "0" a present, valid zero where "" is
required-blank. A date has no such pair, since "0" is not a date and
reports rastrillo.ui.date_invalid. A Money field with a Currency
treats text of only spaces or direction marks as blank, and its required
message is the key rastrillo.ui.field_required.
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 and Textarea report finished English sentences, and so does
Money without a Currency. The three date kinds, URL and Money
with a Currency report rastrillo.ui.* keys instead, such as
rastrillo.ui.date_invalid, rastrillo.ui.field_required and
rastrillo.ui.money_ambiguous. A French app's error is in French
without the app writing one.
Some money keys carry values: the currency for money_wrong_currency,
the two amounts for money_ambiguous. p.ErrorArgs() holds them, per
field. Pass the key and its values to Tf when you render the error.
The generated form does this for every field:
{{template "field-text" dict "L" $L "Name" "Name" "Label" (T $L "resource.ticket_types.field.name") "Value" .Fields.Name "Error" (Tf $L (index .Errors "Name") (index .ErrorArgs "Name")) "Required" true}}
Tf gives back a string that is not a key exactly as it was given, so
"Title is required" and your own sentences pass through unchanged. That
is why the generated form wraps every field's error without asking what
kind it is. Do the same in your own templates.
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.
Web addresses
form.URL takes a web address however it was typed and gives you one
you can link to:
p := form.Parse(r, form.Field{Name: "website", Kind: form.URL})
site := p.String("website") // "example.com" arrives as "https://example.com"
It adds https:// when there is no scheme, lowercases the host and
drops a bare trailing slash. It refuses any scheme but http and https,
a username or password in the address, a host with no dot, and
whitespace. A refused address reads back as "", so it cannot reach
your database even if you forget to check p.OK(). The echo keeps what
was typed.
Its errors are catalog keys, like the date kinds':
rastrillo.ui.url_invalid, rastrillo.ui.url_credentials, and
rastrillo.ui.field_required when a required field is blank. Wrap them
in T the same way.
Render the input with field-url, and show a stored address with
displayURL and safeHref; see
Templates. To tell whether two
addresses are the same site, compare form.URLKey of each.
Money
Store an amount as two plain fields: a whole number of the currency's smallest unit, and its ISO 4217 code. Cents for USD, yen for JPY, fils for KWD. Never a float.
Give a money field its Currency:
p := form.Parse(r,
form.Field{Name: "Name", Required: true},
form.Field{Name: "Price", Kind: form.Money, Currency: "USD", LocaleEcho: true, Required: true},
form.Field{Name: "Status"},
form.Field{Name: "MaxPerOrder"},
)
Currency is the currency the amount is stored in, and it always comes
from the server: the record's on an edit, your default on a create. The
field accepts that currency's decimals: none for JPY, two for USD, three
for KWD.
Read the amount with p.Minor, and store the code beside it:
func amount(p *form.Parsed, name, code string) (*int64, *string) {
minor, ok := p.Minor(name)
if !ok {
return nil, nil
}
return &minor, &code
}
ok is false for a blank field and for a field with an error, so a
refused amount never reaches your database. A field with no amount
stores no currency either.
What a money field accepts
A money field reads any text that has one plausible reading, and refuses text that could mean two different amounts. It ignores spaces and direction marks. It accepts the field's own currency written any way CLDR spells it, before or after the amount. It reads group marks where they can go, either decimal mark where only one reading fits, and a sign or parentheses. Nothing is ever rounded.
| Page | Typed | Field | Stored | With Strict |
|---|---|---|---|---|
| en | US$ 5 |
USD | 500 | money_currency |
| en | 1.284,50 |
USD | 128450 | money_decimal_point |
| en | 1,23,456 |
USD | 12345600 | money_decimal_point |
| en | 12.5000 |
USD | 1250 | money_precision |
| en | 1,500 |
USD | 150000 | 150000 |
| en | 1.500 |
USD | money_ambiguous: 1.5 USD or 1500 USD |
money_precision |
| en | (5.00) |
USD | -500, if the field allows negatives | money_invalid |
| en | β¬5 |
USD | money_wrong_currency |
money_currency |
| en | $5 |
CAD | 500 | money_currency |
| en | 1.500 |
KWD | money_ambiguous: 1.5 KWD or 1500 KWD |
1500 |
| en | 1.500 |
JPY | money_ambiguous_precision: 1500 JPY |
money_precision_0 |
| de | 1.500 |
EUR | 150000 | 150000 |
| de | 1,500 |
KWD | money_ambiguous: 1,5 KWD or 1500 KWD |
1500 |
| fr | 1 284,50 β¬ |
EUR | 128450 | 128450 |
| ja | οΏ₯1,285 |
JPY | 1285 | 1285 |
| ja | 1285ε |
JPY | money_invalid |
money_currency |
Zeros past the currency's decimals change nothing, so 12.5000 is 12.50
in a USD field. A nonzero digit past them is money_precision. ε is
not how CLDR writes the yen in Japanese, so 1285ε is refused like any
other word.
When the separator could be either mark
Text with one . or , and three digits after it has two readings.
When the page's language groups digits with that mark, the field takes
the grouping reading: on an English page 1,500 is fifteen hundred, and
on a German page 1.500 is fifteen hundred. Otherwise the field asks
which amount was meant: 1.500 on an English page could be 1.5 or 1500.
This has a cost. A German amount pasted into an English page, 1,500
meaning 1.5 KWD, is stored as 1500 KWD, a thousand times too much. The
page shows the stored amount after the save, and that is where the
person can see it.
Symbols shared by several currencies
A symbol that several currencies use, such as $, is read as the
field's own currency. The field always shows its ISO code beside the
input, so the person can see which currency $ means there. A spelling
that names a different currency is refused with money_wrong_currency.
| Symbol | Shared by | Accepted | Refused |
|---|---|---|---|
$ |
30 dollars and pesos, among them USD, CAD, AUD and MXN | $5 in a USD, CAD, AUD or MXN field |
US$5 in a CAD field; CA$5 or A$5 in a USD field |
Β₯ |
JPY and CNY | Β₯5 in a JPY or CNY field |
CNΒ₯5 in a JPY field; JPΒ₯5 in a CNY field |
Β£ |
GBP, FKP, GIP, SHP, SSP and SYP | Β£5 in a GBP or GIP field |
Β£5 in an EGP field, whose symbol is EΒ£ |
kr |
SEK, NOK, DKK and ISK | 5 kr in a SEK, NOK or DKK field |
SEK 5 in a NOK field; kr 5 in a EUR field |
Rs |
PKR, LKR, NPR and MUR | Rs 5 in a PKR or LKR field |
Rs 5 in an INR field, whose symbol is βΉ |
So a USD price pasted with a bare $ into a CAD field is stored as CAD.
The ISO code beside the input is what lets the person catch it.
Strict fields
Set Strict: true for a field that must take exactly the page
language's own format, such as an import column. A strict field
accepts one sign and one marker for its currency: the symbol in that
language, or the ISO code. It reads only that language's decimal and
group marks, with groups where they belong. More decimals than the
currency has, zeros included, is an error, and so is any other currency
sign (money_currency).
The field's options
| Option | What it does |
|---|---|
Currency |
The ISO 4217 code the amount is stored in. Empty keeps the cents reading, below |
Negative |
Allows an amount below zero. Without it, -5 and (5) are money_negative |
Strict |
Reads only the page language's exact format |
LocaleEcho |
Reads the language the text was typed in from the form, and checks the currency the form showed. The generated forms set it |
Locale |
The language to read the text in when LocaleEcho is off: for a hand-written handler or an API client |
Stored |
The record's current amount, on an edit form. Set it, or an unchanged amount is read again as text |
Errors
Every money error is a rastrillo.ui.* key, translated in all twelve
shipped languages.
| Key | English | When |
|---|---|---|
field_required |
This field is required. | The field is Required and blank |
money_invalid |
Enter a valid amount. | The text is not an amount |
money_precision |
Enter an amount with no more than 2 decimal places. | Too many decimals for a two-decimal currency |
money_precision_0 |
Enter a whole amount, with no decimal places. | Any decimals for a currency without them, such as JPY |
money_precision_3 |
Enter an amount with no more than 3 decimal places. | Too many decimals for a three-decimal currency, such as KWD |
money_decimal_comma |
Use a comma for decimals, like 12,50. | The separators cannot be read, on a page that writes decimals with a comma |
money_decimal_point |
Use a point for decimals, like 12.50. | The same, on a page that writes decimals with a point |
money_wrong_currency |
Enter the amount in {currency}. | The text names another currency |
money_ambiguous |
This could be {a} or {b}. Enter the one you mean. | The text could be two amounts |
money_ambiguous_precision |
This could be {b}. {a} has too many decimal places for {currency}. Enter the amount you mean. | The text could be two amounts, and one has too many decimals |
money_currency |
Enter the amount without a currency sign. | A Strict field was given a currency sign it does not take |
money_negative |
Enter an amount of 0 or more. | Below zero, and the field does not allow it |
money_currency_changed |
The currency changed while you were editing. Check the amount and save again. | The currency changed between showing the form and saving it |
money_reenter |
Enter the amount again. | The form's hidden language or seed was refused |
{currency}, {a} and {b} are in p.ErrorArgs(). Each amount is
written with its ISO code and no group marks, so it cannot be misread.
Edit forms
An edit form fills the field from the record and sends that text back in
a hidden <Name>.seed. When the posted text is exactly the seed, the
person left the field alone, and it keeps the record's amount without
reading the text. That is how 1.500 for 1.5 KWD saves on an English
page, though the text alone could mean two amounts.
The seed is never a value. Load the record in the POST handler and set
Stored to its amount. If the record no longer shows that seed, because
it changed or the seed was forged, the field reports money_reenter and
stores nothing. A create form never has a seed, and one posted to it is
refused the same way.
Fill the input with form.SeedMoney:
func seed(ctx *rastrillo.Ctx, w http.ResponseWriter, r *http.Request, id int64, field string, minor *int64, code string) (form.MoneyInput, bool) {
in, err := form.SeedMoney(rastrillo.FormatLocale(r), minor, code, false)
if err != nil {
view.Fail(ctx, w, r, fmt.Sprintf("%s %d: %s in %s", "ticket_types", id, field, code), err)
return in, false
}
return in, true
}
After a failed POST, show the person's own text again with
p.MoneyInput. When it returns reseed true, the text could not be
trusted, so fill the field from the record instead:
priceInput, reseed := p.MoneyInput("Price", rastrillo.FormatLocale(r))
if reseed {
if priceInput, ok = seed(ctx, w, r, id, "Price", n.PriceMinor, priceCode); !ok {
return
}
}
Drawing the field
Render a money input with field-money. Give it the four values
SeedMoney or p.MoneyInput returned:
{{template "field-money" dict "L" $L "Name" "Price" "Label" (T $L "resource.ticket_types.field.price") "Value" (index .Money "Price").Value "Locale" (index .Money "Price").Locale "Currency" (index .Money "Price").Currency "Seed" (index .Money "Price").Seed "Error" (Tf $L (index .Errors "Price") (index .ErrorArgs "Price")) "Required" true}}
It shows the ISO code in the label, and sends three hidden inputs with the text:
| Input | Holds |
|---|---|
<Name>.locale |
The language the text is written in. Read in another, 1.500 can mean fifteen hundred |
<Name>.currency |
The currency the form showed. If it changed before the save, the field reports money_currency_changed and keeps the text |
<Name>.seed |
The text the server filled in, or empty. See Edit forms |
The server compares the currency and then ignores it. The amount is always stored in the server's currency, so a forged value can only cause an error.
Fields without a currency
A Money field with no Currency works as it always has. It reads
integer cents through form.ParseCents: no more than two decimal
places, no $, no sign. Read it with p.Cents, and seed it with
form.FormatCentsPlain, never form.FormatCents, which adds a $ the
parser refuses. See form.
What a form looks like
Everything above is about reading a form. This is about drawing one,
and it is the half that gets hand-rolled by accident: <label>Email <input></label> compiles, submits and validates perfectly well, so
nothing fails β it just renders as a ragged column of labels sitting
inline beside inputs of a dozen different widths, with an unstyled
button under it.
A labelled control is never hand-written. There is a partial for
every field kind, and each draws the label above the control, wires
aria-describedby to the lines that actually rendered, and sets
aria-invalid when there is an error. field-text and
field-textarea are the two you reach for most:
{{define "content"}}{{$L := localizer $}}
{{template "page-header" dict "L" $L "Title" "New note"}}
{{with .Content}}
<form rst-form method="post" action="{{path $ "/notes"}}">
{{template "field-text" dict "L" $L "Name" "title" "Label" "Title" "Value" .Note.Title "Required" true "Autofocus" true "Error" (index .Errors "Title")}}
{{template "field-textarea" dict "L" $L "Name" "body" "Label" "Body" "Value" .Note.Body "Error" (index .Errors "Body")}}
{{template "form-foot" dict "L" $L "Submit" "Create" "CancelHref" (path $ "/") "CancelLabel" "Cancel"}}
</form>
{{end}}
{{end}}
Every partial call passes "L" $L, the page's localizer, so the
partial's own words come out in the reader's language. See
Templates.
The rest are field-money, field-select, field-check, field-date,
field-datetime, field-time and field-daterange. field is the
older, more configurable text control. Use it when you need Pattern,
Maxlength or Placeholder.
Read the partial's own doc comment before your first call to it;
the keys are not uniform. field-text derives the control's id
from Name, and its Hint is the muted line under the control.
field-select and field take ID and Name separately, put
Hint in parentheses after the label, and use Help for the line
underneath. Copying one call shape onto the other silently renders
for="" and drops the guidance.
rst-form is yours to write, as rst-page and rst-list are. It is
what makes the column a column: a 44rem maximum, so lines stay
readable, and a consistent gap between fields. Without it the fields
are loose in the page and inherit whatever the surrounding layout does.
form-foot closes the form. It emits one primary submit and, given
CancelHref, a cancel that is a real <a> β leaving a form is
navigation, so it must survive middle-click, a new tab and no JS.
Destructive actions do not belong there; they get a confirm route of
their own.
Buttons have a size
rst-btn comes in three steps β sm, the default, and lg β and the
size composes with the variant, so a form's submit is
rst-btn="primary lg". form-foot and confirm-form both write that
for you; the one place a submit stays at the default step is the sticky
save bar (rst-form-bar), which is persistent chrome pinned to the
viewport rather than the end of a form, and where a taller button just
eats the page.
The default step is sized for a control that sits beside other controls:
a page-header action, a button in a row. It is the wrong size for a
form's submit, and the failure is easy to recognise once you have seen
it β a 34px-tall button with a 12.5px label stretched the full width of
a 44rem column reads as a skinny blue bar rather than the thing the
screen is asking you to press. If you are reaching for a full-width
button, that is rst-btn="primary lg block", and block centres its
own label.
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
busy.js gives every submit button a loading state while its form is
out β a spinner in place of the label for at least 650ms, 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, r, "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.