diff --git a/cmd/site/main.go b/cmd/site/main.go index 7d988e2..010dd11 100644 --- a/cmd/site/main.go +++ b/cmd/site/main.go @@ -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" @@ -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. diff --git a/content/recipes/formnovalidate.md b/content/recipes/formnovalidate.md new file mode 100644 index 0000000..bf41a5d --- /dev/null +++ b/content/recipes/formnovalidate.md @@ -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 ` + + + + + diff --git a/examples/draft-form/handler.go b/examples/draft-form/handler.go new file mode 100644 index 0000000..33512bf --- /dev/null +++ b/examples/draft-form/handler.go @@ -0,0 +1,57 @@ +// Package draftform demonstrates server-side formnovalidate honoring: a post +// editor where "Publish" validates the required title and "Save draft" (a +// formnovalidate button) skips validation — the same ctx.ValidateForm() call, +// different outcome by submitter, on every tier. cmd/site mounts it at +// /apps/draft-form/ and (WS-disabled) at /apps/draft-form/no-js/. +package draftform + +import ( + "embed" + "log" + "net/http" + "os" + "path/filepath" + "sync" + + "github.com/livetemplate/livetemplate" +) + +//go:embed draft.tmpl +var templateFS embed.FS + +var ( + tmplPath string + tmplOnce sync.Once +) + +// extractTemplate writes the embedded template to a temp file so livetemplate's +// file-based loader can parse it at runtime (mirrors examples/greet-validate). +func extractTemplate() string { + tmplOnce.Do(func() { + dir, err := os.MkdirTemp("", "draft-form-tmpl-*") + if err != nil { + log.Fatalf("draft-form: mkdtemp: %v", err) + } + data, err := templateFS.ReadFile("draft.tmpl") + if err != nil { + log.Fatalf("draft-form: read embedded tmpl: %v", err) + } + tmplPath = filepath.Join(dir, "draft.tmpl") + if err := os.WriteFile(tmplPath, data, 0o644); err != nil { + log.Fatalf("draft-form: write tmpl: %v", err) + } + }) + return tmplPath +} + +// Handler returns the draft-form app as an http.Handler ready to mount. The +// initial state is an empty editor; callers supply environment-specific options +// (origin allowlists, WithWebSocketDisabled) via opts. +func Handler(opts ...livetemplate.Option) http.Handler { + baseOpts := []livetemplate.Option{ + livetemplate.WithParseFiles(extractTemplate()), + livetemplate.WithAuthenticator(&livetemplate.AnonymousAuthenticator{}), + } + tmpl := livetemplate.Must(livetemplate.New("draft-form", append(baseOpts, opts...)...)) + return tmpl.Handle(&Controller{}, livetemplate.AsState(&State{})) +} diff --git a/go.mod b/go.mod index 49fcd57..225fd1d 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/chromedp/chromedp v0.14.2 github.com/go-playground/validator/v10 v10.30.2 github.com/gorilla/websocket v1.5.3 - github.com/livetemplate/livetemplate v0.14.0 + github.com/livetemplate/livetemplate v0.15.0 github.com/livetemplate/lvt v0.1.6 github.com/livetemplate/lvt/components v0.1.2 gopkg.in/yaml.v3 v3.0.1 @@ -36,8 +36,6 @@ require ( github.com/ncruces/go-strftime v1.0.0 // indirect github.com/redis/go-redis/v9 v9.17.2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/tdewolff/minify/v2 v2.24.8 // indirect - github.com/tdewolff/parse/v2 v2.8.5 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect golang.org/x/net v0.51.0 // indirect diff --git a/go.sum b/go.sum index a8cd8f6..424c688 100644 --- a/go.sum +++ b/go.sum @@ -97,8 +97,8 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/livetemplate/livetemplate v0.14.0 h1:9RoA4c3gLpqjzj0DFnUrBLj+zmlrhQtX8C7V7v8LD3k= -github.com/livetemplate/livetemplate v0.14.0/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= +github.com/livetemplate/livetemplate v0.15.0 h1:QC+3E4rhUjpvwbPZk5WvwVtUpwwadpyO9dw/pV145Xs= +github.com/livetemplate/livetemplate v0.15.0/go.mod h1:xkmgckEpdoYTqretGpaWfh8uqvjsBBLBJYd84n2O5QQ= github.com/livetemplate/lvt v0.1.6 h1:1rDU5hDo+EtZ0mT+868wYD9czF2EHEgdacS4kpIUPQ4= github.com/livetemplate/lvt v0.1.6/go.mod h1:OrTdx3zvh0WeuugVueQoRG3ILRNJe/dThErxKsos6Rw= github.com/livetemplate/lvt/components v0.1.2 h1:MM2M5IZnsUAu0py9ZbtcQCo0bvUrL4Z3Ly/yDkYNyag= @@ -159,12 +159,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tdewolff/minify/v2 v2.24.8 h1:58/VjsbevI4d5FGV0ZSuBrHMSSkH4MCH0sIz/eKIauE= -github.com/tdewolff/minify/v2 v2.24.8/go.mod h1:0Ukj0CRpo/sW/nd8uZ4ccXaV1rEVIWA3dj8U7+Shhfw= -github.com/tdewolff/parse/v2 v2.8.5 h1:ZmBiA/8Do5Rpk7bDye0jbbDUpXXbCdc3iah4VeUvwYU= -github.com/tdewolff/parse/v2 v2.8.5/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo= -github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE= -github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/testcontainers/testcontainers-go v0.39.0 h1:uCUJ5tA+fcxbFAB0uP3pIK3EJ2IjjDUHFSZ1H1UxAts= github.com/testcontainers/testcontainers-go v0.39.0/go.mod h1:qmHpkG7H5uPf/EvOORKvS6EuDkBUPE3zpVGaH9NL7f8= github.com/testcontainers/testcontainers-go/modules/redis v0.39.0 h1:p54qELdCx4Gftkxzf44k9RJRRhaO/S5ehP9zo8SUTLM=