CARLOS rastrillo docs

πŸ€– 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 " is required", humanized from the field name.

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.