formnovalidateA form often has two submit buttons that mean different things. Publish should
enforce every rule — the title is required, the URL must parse. Save draft
should save whatever you have so far, half-finished and all, so you can come back
later. HTML already has an attribute for exactly this: formnovalidate on a submit
control tells the browser to skip its native constraint check for that button.
LiveTemplate honors formnovalidate on the server too. ctx.ValidateForm()
checks which control submitted the form, and when that control carried
formnovalidate, validation is skipped — so the same call enforces the rules for
Publish and waves them through for Save draft. No client code, no second
code path, and it works whether the submit arrived over the WebSocket, an HTTP
fetch(), or a plain no-JS form POST.
Leave the title empty and press Publish — you get a "required" error. Leave it empty and press Save draft — it saves as a draft. Same field, same validation call, different button.
The only markup that matters is the formnovalidate attribute on the second button:
<form method="POST">
<input name="title" placeholder="Post title" required {{.lvt.AriaInvalid "title"}}>
{{.lvt.ErrorTag "title"}}
<button name="publish">Publish</button>
<button name="save-draft" formnovalidate class="secondary">Save draft</button>
</form>
required on the input drives the browser's native check (and the server's — the
framework infers the rule from the same attribute). formnovalidate on
save-draft opts that button out of both.
Both actions call ctx.ValidateForm(). It enforces for Publish and skips for
SaveDraft — not because the handlers differ, but because the framework knows
which button was clicked:
// Publish enforces validation: the title is required, so an empty submit
// returns a field error and nothing is stored. ctx.ValidateForm() infers the
// rule from the `required` attribute in draft.tmpl — the authoritative check a
// malicious or no-JS client can't skip.
func (c *Controller) Publish(s State, ctx *livetemplate.Context) (State, error) {
if err := ctx.ValidateForm(); err != nil {
return s, err
}
s.Title = strings.TrimSpace(ctx.GetString("title"))
s.Status = "published"
return s, nil
}
// SaveDraft saves whatever is typed, even an empty title. The "Save draft"
// button carries formnovalidate, so the IDENTICAL ctx.ValidateForm() call here
// is skipped server-side — on every tier (WebSocket, HTTP-fetch, and no-JS
// native POST), with no client code. The framework records the button's name
// from the template (FormSchema.NoValidateSubmitters) and matches it against
// the submission's submitter; the kebab-case name save-draft also routes to
// this SaveDraft method verbatim on the no-JS tier.
func (c *Controller) SaveDraft(s State, ctx *livetemplate.Context) (State, error) {
if err := ctx.ValidateForm(); err != nil {
return s, err
}
s.Title = strings.TrimSpace(ctx.GetString("title"))
s.Status = "draft"
return s, nil
}
At parse time the framework records every submit control that carries
formnovalidate into the form schema (FormSchema.NoValidateSubmitters, keyed by
the control's name). At request time it compares the form's submitter — the
clicked button — against that set, and ctx.ValidateForm() returns nil early
when it matches. Because the decision is keyed on the submitter rather than the
action, it holds even under lvt-on:submit routing, where the action is the
handler and the submitter is a separate button.
The skip is enforced on the server, so it survives all the way down to a plain
form POST. The mount below is the same app with WithWebSocketDisabled(); with JS
off, the browser submits natively and the server still skips validation for the
save-draft button.
On the no-JS tier the button's name is the action verbatim, so the kebab-case
name="save-draft" routes to the Go method SaveDraft (LiveTemplate matches
kebab-case, camelCase, snake_case, and PascalCase action names). That is why the
documented <button name="save-draft"> pattern works end to end without a hidden
lvt-action field.
formnovalidate is a convenience, not a security boundary. It lets a cooperating
client say "this submit is a draft, don't nag me about required fields" — and the
server obliges only when the clicked button is one your own template marked
formnovalidate. It does not authorize skipping authorization or integrity checks:
a draft still belongs to a session, and any rule that must always hold (ownership,
quotas, sanitization) belongs in the handler, unconditionally, not in
ValidateForm(). For the CSRF posture of these no-JS form posts, see
Progressive enhancement.