Skip to content
Trilha
Chapters

Cookbook

A form in steps

Where step one lives while somebody is on step two, and what happens when the draft expires.

ctx --pack · est. 1234 tokens est. · the recipe's llms.txt

Three screens, one hard question, and it is not the HTML: where does step one live while somebody is on step two? What gets written instead is a page full of <input type="hidden"> (which the first upload breaks), or a half-filled row in the database (which every report then has to learn to ignore), or one enormous screen with everything on it.

c.Draft is the framework's answer: a named draft, kept between one request and the next, with a deadline.

One struct per step#

// WizardStep1 is what a multi-step form actually needs: one struct per step,
// each with only its own rules. A single struct with every field cannot be
// validated halfway — step one would fail on an address nobody has typed —
// and what people do instead is drop the rules until the last screen, where a
// message about a field three screens back is useless.
type WizardStep1 struct {
	Name  string `form:"name"  validate:"required,max=80"`
	Email string `form:"email" validate:"required,email"`
}

// WizardStep2 is the second screen.
type WizardStep2 struct {
	Postcode string `form:"postcode" validate:"required"`
	City     string `form:"city"     validate:"required"`
}

// Wizard is what travels between the screens.
type Wizard struct {
	Who   WizardStep1 `json:"who"`
	Where WizardStep2 `json:"where"`
}

This is the part worth copying. A single struct with every field and every validate tag cannot be checked halfway: step one would fail on an address nobody has typed yet, and the way out people find is to drop the rules until the last screen — where a message about a field three screens back is useless.

The end of a step#

// WizardPost is the end of a step: load what is there, bind only this step,
// save, move on. A 422 here costs nothing that was typed on an earlier screen,
// because the earlier screens are in the draft and not in this form.
func WizardPost(c *trilha.Ctx) error {
	var w Wizard
	_ = c.Draft("signup").Load(&w) // nothing yet on the first screen
	if err := c.Bind(&w.Who); err != nil {
		return err // FieldErrors: 422 with the messages next to the fields
	}
	if err := c.Draft("signup").Save(w, 30*time.Minute); err != nil {
		return err
	}
	return c.Redirect("/signup/where")
}

Load, bind only this step, save, move on. A 422 here costs nothing that was typed earlier, because the earlier screens are in the draft and not in this form.

Every screen after the first#

// WizardResume is every screen after the first. No draft is not a failure: it
// is somebody whose draft expired, or who typed the address of step two
// directly, and the answer is step one — not an empty form that would lose
// whatever they filled in here.
func WizardResume(c *trilha.Ctx) (h.Node, error) {
	var w Wizard
	if err := c.Draft("signup").Load(&w); err != nil {
		return nil, c.Redirect("/signup/who")
	}
	return h.Div(ui.Steps(wizardSteps, 2), wizardForm(c, w)), nil
}

No draft is not a failure. It is somebody whose draft expired, or who typed the address of step two directly, or who finished this wizard yesterday — and the answer is step one, not an empty form that would lose whatever they fill in here.

The end#

// WizardFinish is where the draft becomes a record and stops existing.
func WizardFinish(c *trilha.Ctx) error {
	var w Wizard
	if err := c.Draft("signup").Load(&w); err != nil {
		return c.Redirect("/signup/who")
	}
	if err := saveSignup(c, w); err != nil {
		return err
	}
	c.Draft("signup").Clear()
	return c.Redirect("/signup/done")
}

Clear before the redirect, not after: a draft that became a record must stop existing, or the next visit to step two resumes something that already happened.

Where the draft lives#

Under 2 KB of JSON it is a signed cookie — nothing to configure, nothing to clean up, and it expires by itself. Above that it needs Config.Drafts, an interface of three methods over whatever the app already runs; without one, Save returns an error naming that field instead of setting a cookie the browser would silently drop.

The cookie is the person's own, so a draft is invisible to everybody else without a line of code about ownership, and TRILHA_SECRET is what signs it: without a secret, Save says so.

The indicator#

ui.Steps(steps, current) draws where somebody is. Steps already done are links; the current one carries aria-current="step"; the ones ahead are plain text — a wizard where step three is one click away is a wizard whose steps did not have to happen in order.

The whole flow runs in examples/cadastro.