Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions cmd/site/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (

"github.com/livetemplate/docs/examples/counter"
counterbasic "github.com/livetemplate/docs/examples/counter-basic"
draftform "github.com/livetemplate/docs/examples/draft-form"
"github.com/livetemplate/docs/examples/greet"
greetloading "github.com/livetemplate/docs/examples/greet-loading"
greetloadingserver "github.com/livetemplate/docs/examples/greet-loading-server"
Expand Down Expand Up @@ -123,6 +124,21 @@ func main() {
livetemplate.WithWebSocketDisabled(),
)))

// draft-form demonstrates server-side formnovalidate: "Publish" validates
// the required title; "Save draft" carries formnovalidate so the SAME
// ctx.ValidateForm() call is skipped. The default mount keeps WebSocket on;
// the /no-js/ mount is WS-disabled (mountStripped rewrites the PRG Location
// back under the prefix) so the reader can watch the skip hold over a plain
// HTTP form POST — the tier where the kebab-case save-draft button name also
// routes to SaveDraft verbatim.
mux.Handle("/apps/draft-form/", http.StripPrefix("/apps/draft-form", draftform.Handler(
livetemplate.WithAllowedOrigins(allowedOrigins),
)))
mux.Handle("/apps/draft-form/no-js/", mountStripped("/apps/draft-form/no-js", draftform.Handler(
livetemplate.WithAllowedOrigins(allowedOrigins),
livetemplate.WithWebSocketDisabled(),
)))

// Step 3 — greet-loading: the HTML-declared loading spinner (a class the
// client toggles on pending/done). WS-disabled like the other middle
// steps; the loading lifecycle resolves over either transport.
Expand Down
83 changes: 83 additions & 0 deletions content/recipes/formnovalidate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
title: "Skip validation with formnovalidate"
description: "Let one button on a form bypass server-side validation — a 'Save draft' next to 'Publish' — using the standard HTML formnovalidate attribute, honored on every tier with no client code."
source_repo: https://github.com/livetemplate/docs
source_path: content/recipes/formnovalidate.md
---

# Skip validation with `formnovalidate`

A 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.

```embed-lvt path="/apps/draft-form/" upstream="http://localhost:9091" height="320px"
```

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.

## One template, two buttons

The only markup that matters is the `formnovalidate` attribute on the second button:

```html include="/examples/draft-form/draft.tmpl" lines="14-19"
```

`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.

## One validation call, honored by submitter

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:

```go include="/examples/draft-form/draft.go" lines="21-48"
```

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.

## It works without JavaScript

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.

```embed-lvt path="/apps/draft-form/no-js/" upstream="http://localhost:9091" height="320px"
```

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.

## A note on trust

`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](/recipes/progressive-enhancement/).
1 change: 1 addition & 0 deletions content/recipes/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,6 @@ Narrative recipes for internals, tradeoffs, and operational shape:
- [Counter, deeper](/recipes/counter)
- [Pubsub](/recipes/pubsub) — `Subscribe`/`Publish` peer fan-out
- [Server push](/recipes/server-push) — server-initiated `TriggerAction`
- [Skip validation with `formnovalidate`](/recipes/formnovalidate) — a "Save draft" button that bypasses validation on every tier
- [How a LiveTemplate update flows](/recipes/architecture-flow)
- [How this docs site works](/recipes/how-this-site-works)
Loading
Loading