From 5bec4c9a3d4dda9d72ec9fe9242be549389aa047 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Fri, 26 Dec 2025 22:03:41 +0100 Subject: [PATCH 1/2] chore: upgrade livetemplate to v0.7.7 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 955cb1b..27543b4 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/livetemplate/components v0.0.0-20251224004709-1f8c1de230b4 - github.com/livetemplate/livetemplate v0.7.5 + github.com/livetemplate/livetemplate v0.7.7 github.com/livetemplate/lvt v0.0.0-20251130141940-9b94cde94e9d modernc.org/sqlite v1.39.1 ) diff --git a/go.sum b/go.sum index a5f4f07..09beb13 100644 --- a/go.sum +++ b/go.sum @@ -122,8 +122,8 @@ 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/components v0.0.0-20251224004709-1f8c1de230b4 h1:wLfVleSSlcv4NPg5KN8pul0Rz9ub1CtI8OAcPlyBYlw= github.com/livetemplate/components v0.0.0-20251224004709-1f8c1de230b4/go.mod h1:+C2iGZfdgjc6y6MsaDHBWzWGIbBHna4l+ygFYJfuyUo= -github.com/livetemplate/livetemplate v0.7.5 h1:9esBqvhAG4DK/9lxuSEfFg8uyzc8C3bHlu8Aex6YSBk= -github.com/livetemplate/livetemplate v0.7.5/go.mod h1:mTI76skBGEx4jD9pO52L9xBY4/ZDW4muAKWwXnupvtc= +github.com/livetemplate/livetemplate v0.7.7 h1:vHwV5qjlwelgJCiGwQe88u7J3XheADdW86BEndezQtQ= +github.com/livetemplate/livetemplate v0.7.7/go.mod h1:mTI76skBGEx4jD9pO52L9xBY4/ZDW4muAKWwXnupvtc= github.com/livetemplate/lvt v0.0.0-20251130141940-9b94cde94e9d h1:KKaaDPTlSCL/BEz5B+xowvXgOpl0kLpAdWZLIXL/2a0= github.com/livetemplate/lvt v0.0.0-20251130141940-9b94cde94e9d/go.mod h1:iwP3NZgs3EGXd6mTUAK3j4UENr7qhuUmAME44LoTExE= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= From 4bf0412294c4e3907b7b683cb2b81df5694b326e Mon Sep 17 00:00:00 2001 From: Adnaan Date: Fri, 26 Dec 2025 22:09:35 +0100 Subject: [PATCH 2/2] feat: add flash-messages example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrates per-connection flash messages: - Setting flash via ctx.SetFlash("success", "message") - Displaying flash in templates via .lvt.Flash, .lvt.HasFlash - Different flash types: success, error, warning, info - Show-once pattern (cleared after each action) - HTTP tests validating flash behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- flash-messages/README.md | 87 +++++++++++ flash-messages/flash.tmpl | 220 +++++++++++++++++++++++++++ flash-messages/flash_test.go | 278 +++++++++++++++++++++++++++++++++++ flash-messages/main.go | 166 +++++++++++++++++++++ 4 files changed, 751 insertions(+) create mode 100644 flash-messages/README.md create mode 100644 flash-messages/flash.tmpl create mode 100644 flash-messages/flash_test.go create mode 100644 flash-messages/main.go diff --git a/flash-messages/README.md b/flash-messages/README.md new file mode 100644 index 0000000..14c0966 --- /dev/null +++ b/flash-messages/README.md @@ -0,0 +1,87 @@ +# Flash Messages Example + +This example demonstrates flash messages in LiveTemplate - page-level notifications that show once and clear after each action. + +## Running + +```bash +cd examples/flash-messages +go run . +``` + +Then open http://localhost:8080 + +## Flash Message Types + +| Type | Use Case | Style | +|------|----------|-------| +| `success` | Operation completed | Green | +| `error` | Something went wrong | Red | +| `warning` | Caution/duplicate | Yellow | +| `info` | Informational | Blue | + +## Setting Flash Messages (Controller) + +```go +func (c *Controller) MyAction(state State, ctx *livetemplate.Context) (State, error) { + // Success notification + ctx.SetFlash("success", "Item added successfully!") + + // Error notification + ctx.SetFlash("error", "Failed to save changes") + + // Warning notification + ctx.SetFlash("warning", "Item already exists") + + // Info notification + ctx.SetFlash("info", "Processing complete") + + return state, nil +} +``` + +## Reading Flash Messages (Template) + +```html + +{{if .lvt.HasAnyFlash}} +
+ + + {{if .lvt.HasFlash "success"}} +
{{.lvt.Flash "success"}}
+ {{end}} + + {{if .lvt.HasFlash "error"}} +
{{.lvt.Flash "error"}}
+ {{end}} + +
+{{end}} +``` + +## Flash vs Field Errors + +| Aspect | Flash Messages | Field Errors | +|--------|----------------|--------------| +| **Purpose** | Page-level notifications | Form field validation | +| **Affects Success** | No | Yes | +| **Template Access** | `.lvt.Flash "key"` | `.lvt.Error "field"` | +| **Lifecycle** | Cleared after render | Cleared on next action | +| **Example** | "Changes saved!" | "Email is required" | + +## Key Behaviors + +1. **Show Once**: Flash messages are cleared after each action response +2. **Per-Connection**: Not shared across browser tabs +3. **No Persistence**: Don't survive page refresh or WebSocket reconnects +4. **Don't Block Success**: Unlike field errors, flash messages don't set `Success: false` + +## Available Template Helpers + +| Helper | Description | +|--------|-------------| +| `.lvt.Flash "key"` | Get flash message for key | +| `.lvt.HasFlash "key"` | Check if flash exists for key | +| `.lvt.HasAnyFlash` | Check if any flash messages exist | +| `.lvt.AllFlash` | Get all flash messages as map | diff --git a/flash-messages/flash.tmpl b/flash-messages/flash.tmpl new file mode 100644 index 0000000..3051fb6 --- /dev/null +++ b/flash-messages/flash.tmpl @@ -0,0 +1,220 @@ + + + + {{.Title}} + + + + + +

{{.Title}}

+ + + {{if .lvt.HasAnyFlash}} +
+ {{if .lvt.HasFlash "success"}} +
{{.lvt.Flash "success"}}
+ {{end}} + {{if .lvt.HasFlash "error"}} +
{{.lvt.Flash "error"}}
+ {{end}} + {{if .lvt.HasFlash "warning"}} +
{{.lvt.Flash "warning"}}
+ {{end}} + {{if .lvt.HasFlash "info"}} +
{{.lvt.Flash "info"}}
+ {{end}} +
+ {{end}} + + +
+
+ + + {{if .lvt.HasError "item"}} +
{{.lvt.Error "item"}}
+ {{end}} +
+
+ + +
+ + +
+ + +
+

Items ({{.ItemCount}})

+ {{if .Items}} + {{range .Items}} +
+ {{.}} + +
+ {{end}} + {{else}} +

No items. Add some above!

+ {{end}} +
+ + +
+ About Flash Messages: +
    +
  • Flash messages show once and clear after each action
  • +
  • They don't affect ResponseMetadata.Success
  • +
  • Types: success, error, warning, info
  • +
  • Set via: ctx.SetFlash("success", "message")
  • +
  • Read via: .lvt.Flash "success", .lvt.HasFlash "success"
  • +
+
+ + {{if .lvt.DevMode}} + + {{else}} + + {{end}} + + diff --git a/flash-messages/flash_test.go b/flash-messages/flash_test.go new file mode 100644 index 0000000..57baadc --- /dev/null +++ b/flash-messages/flash_test.go @@ -0,0 +1,278 @@ +//go:build http + +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/livetemplate/livetemplate" +) + +// TestFlash_ShowsInTemplate tests that flash messages appear in rendered HTML. +func TestFlash_ShowsInTemplate(t *testing.T) { + // Create controller and state + controller := &FlashController{} + initialState := &FlashState{} + + // Create template + tmpl := livetemplate.Must(livetemplate.New("flash", + livetemplate.WithDevMode(true), + )) + + // Create test server + handler := tmpl.Handle(controller, livetemplate.AsState(initialState)) + server := httptest.NewServer(handler) + defer server.Close() + + // Create client with cookie jar for session persistence + jar, _ := newCookieJar() + client := &http.Client{Jar: jar} + + // 1. First GET to establish session and mount state + resp, err := client.Get(server.URL + "/") + if err != nil { + t.Fatalf("Initial GET failed: %v", err) + } + resp.Body.Close() + + // 2. POST to add item (should set success flash) + form := url.Values{} + form.Set("action", "add_item") + form.Set("item", "Test Item") + + resp, err = client.PostForm(server.URL+"/", form) + if err != nil { + t.Fatalf("POST add_item failed: %v", err) + } + defer resp.Body.Close() + + // Read response body + body := readBody(t, resp) + + // Flash message should appear in response + if !strings.Contains(body, "Added item: Test Item") { + t.Errorf("Expected success flash 'Added item: Test Item' in response, got:\n%s", body) + } + + // Should have flash-success class + if !strings.Contains(body, "flash-success") { + t.Errorf("Expected flash-success class in response") + } +} + +// TestFlash_ClearsAfterAction tests that flash is cleared on subsequent action. +func TestFlash_ClearsAfterAction(t *testing.T) { + controller := &FlashController{} + initialState := &FlashState{} + + tmpl := livetemplate.Must(livetemplate.New("flash", + livetemplate.WithDevMode(true), + )) + + handler := tmpl.Handle(controller, livetemplate.AsState(initialState)) + server := httptest.NewServer(handler) + defer server.Close() + + jar, _ := newCookieJar() + client := &http.Client{Jar: jar} + + // 1. GET to establish session + resp, err := client.Get(server.URL + "/") + if err != nil { + t.Fatalf("Initial GET failed: %v", err) + } + resp.Body.Close() + + // 2. POST to add first item (sets flash) + form := url.Values{} + form.Set("action", "add_item") + form.Set("item", "First Item") + resp, err = client.PostForm(server.URL+"/", form) + if err != nil { + t.Fatalf("First POST failed: %v", err) + } + body1 := readBody(t, resp) + resp.Body.Close() + + if !strings.Contains(body1, "Added item: First Item") { + t.Error("First action should show flash") + } + + // 3. POST to add second item (new flash replaces old) + form = url.Values{} + form.Set("action", "add_item") + form.Set("item", "Second Item") + resp, err = client.PostForm(server.URL+"/", form) + if err != nil { + t.Fatalf("Second POST failed: %v", err) + } + body2 := readBody(t, resp) + resp.Body.Close() + + // Old flash should be gone + if strings.Contains(body2, "Added item: First Item") { + t.Error("Old flash should be cleared after new action") + } + + // New flash should be present + if !strings.Contains(body2, "Added item: Second Item") { + t.Error("New flash should appear") + } +} + +// TestFlash_DifferentTypes tests success, error, warning, and info flash types. +func TestFlash_DifferentTypes(t *testing.T) { + controller := &FlashController{} + initialState := &FlashState{} + + tmpl := livetemplate.Must(livetemplate.New("flash", + livetemplate.WithDevMode(true), + )) + + handler := tmpl.Handle(controller, livetemplate.AsState(initialState)) + server := httptest.NewServer(handler) + defer server.Close() + + jar, _ := newCookieJar() + client := &http.Client{Jar: jar} + + // Initial GET + resp, _ := client.Get(server.URL + "/") + resp.Body.Close() + + tests := []struct { + name string + action string + item string + wantClass string + wantText string + }{ + { + name: "success flash", + action: "add_item", + item: "New Item", + wantClass: "flash-success", + wantText: "Added item: New Item", + }, + { + name: "warning flash (duplicate)", + action: "add_item", + item: "New Item", // Same item = duplicate + wantClass: "flash-warning", + wantText: "Item already exists", + }, + { + name: "info flash", + action: "remove_item", + item: "New Item", + wantClass: "flash-info", + wantText: "Removed item: New Item", + }, + { + name: "error flash", + action: "simulate_error", + item: "", + wantClass: "flash-error", + wantText: "Something went wrong", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + form := url.Values{} + form.Set("action", tt.action) + if tt.item != "" { + form.Set("item", tt.item) + } + + resp, err := client.PostForm(server.URL+"/", form) + if err != nil { + t.Fatalf("POST failed: %v", err) + } + body := readBody(t, resp) + resp.Body.Close() + + if !strings.Contains(body, tt.wantClass) { + t.Errorf("Expected %s in response", tt.wantClass) + } + if !strings.Contains(body, tt.wantText) { + t.Errorf("Expected '%s' in response, got:\n%s", tt.wantText, body) + } + }) + } +} + +// TestFlash_FieldErrorsStillWork tests that field errors work alongside flash. +func TestFlash_FieldErrorsStillWork(t *testing.T) { + controller := &FlashController{} + initialState := &FlashState{} + + tmpl := livetemplate.Must(livetemplate.New("flash", + livetemplate.WithDevMode(true), + )) + + handler := tmpl.Handle(controller, livetemplate.AsState(initialState)) + server := httptest.NewServer(handler) + defer server.Close() + + jar, _ := newCookieJar() + client := &http.Client{Jar: jar} + + // Initial GET + resp, _ := client.Get(server.URL + "/") + resp.Body.Close() + + // POST with empty item (triggers field error, not flash) + form := url.Values{} + form.Set("action", "add_item") + form.Set("item", "") // Empty = validation error + + resp, err := client.PostForm(server.URL+"/", form) + if err != nil { + t.Fatalf("POST failed: %v", err) + } + body := readBody(t, resp) + resp.Body.Close() + + // Should show field error, not flash + if !strings.Contains(body, "Item name is required") { + t.Error("Expected field error 'Item name is required'") + } + if !strings.Contains(body, "field-error") { + t.Error("Expected field-error class") + } +} + +// Helper functions + +func newCookieJar() (http.CookieJar, error) { + // Simple cookie jar implementation + return &simpleCookieJar{cookies: make(map[string][]*http.Cookie)}, nil +} + +type simpleCookieJar struct { + cookies map[string][]*http.Cookie +} + +func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) { + j.cookies[u.Host] = cookies +} + +func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie { + return j.cookies[u.Host] +} + +func readBody(t *testing.T, resp *http.Response) string { + t.Helper() + buf := new(strings.Builder) + _, err := io.Copy(buf, resp.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + return buf.String() +} diff --git a/flash-messages/main.go b/flash-messages/main.go new file mode 100644 index 0000000..e472657 --- /dev/null +++ b/flash-messages/main.go @@ -0,0 +1,166 @@ +package main + +import ( + "log" + "net/http" + "os" + "time" + + "github.com/livetemplate/livetemplate" + e2etest "github.com/livetemplate/lvt/testing" +) + +// FlashController demonstrates flash messages for page-level notifications. +// +// Flash messages are per-connection and show once (cleared after render). +// They don't affect ResponseMetadata.Success (unlike field validation errors). +// +// Common flash keys: "success", "error", "info", "warning" +type FlashController struct{} + +// FlashState holds the demo data. +type FlashState struct { + Title string `json:"title"` + Items []string `json:"items"` + ItemCount int `json:"item_count"` +} + +// AddItem handles the "add_item" action - demonstrates success flash. +func (c *FlashController) AddItem(state FlashState, ctx *livetemplate.Context) (FlashState, error) { + item := ctx.GetString("item") + + if item == "" { + // Field validation error (affects Success: false) + return state, livetemplate.FieldError{Field: "item", Message: "Item name is required"} + } + + // Check for duplicates + for _, existing := range state.Items { + if existing == item { + // Use flash for page-level warning (doesn't affect Success) + ctx.SetFlash("warning", "Item already exists: "+item) + return state, nil + } + } + + state.Items = append(state.Items, item) + state.ItemCount = len(state.Items) + + // Success flash message + ctx.SetFlash("success", "Added item: "+item) + + return state, nil +} + +// RemoveItem handles the "remove_item" action - demonstrates flash on removal. +func (c *FlashController) RemoveItem(state FlashState, ctx *livetemplate.Context) (FlashState, error) { + item := ctx.GetString("item") + + // Find and remove + found := false + newItems := make([]string, 0, len(state.Items)) + for _, existing := range state.Items { + if existing == item { + found = true + } else { + newItems = append(newItems, existing) + } + } + + if !found { + ctx.SetFlash("error", "Item not found: "+item) + return state, nil + } + + state.Items = newItems + state.ItemCount = len(state.Items) + + ctx.SetFlash("info", "Removed item: "+item) + return state, nil +} + +// ClearItems handles the "clear_items" action - demonstrates warning flash. +func (c *FlashController) ClearItems(state FlashState, ctx *livetemplate.Context) (FlashState, error) { + if len(state.Items) == 0 { + ctx.SetFlash("warning", "No items to clear") + return state, nil + } + + count := len(state.Items) + state.Items = []string{} + state.ItemCount = 0 + + ctx.SetFlash("success", "Cleared all items ("+string(rune('0'+count))+" removed)") + return state, nil +} + +// SimulateError handles the "simulate_error" action - demonstrates error flash. +func (c *FlashController) SimulateError(state FlashState, ctx *livetemplate.Context) (FlashState, error) { + // Simulate a server error that should be shown as flash + ctx.SetFlash("error", "Something went wrong! Please try again.") + return state, nil +} + +// Mount initializes state with sample data. +func (c *FlashController) Mount(state FlashState, ctx *livetemplate.Context) (FlashState, error) { + state.Title = "Flash Messages Demo" + state.Items = []string{"Apple", "Banana", "Cherry"} + state.ItemCount = len(state.Items) + return state, nil +} + +func main() { + log.Println("LiveTemplate Flash Messages Example starting...") + + // Load configuration from environment variables + envConfig, err := livetemplate.LoadEnvConfig() + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + // Validate configuration + if err := envConfig.Validate(); err != nil { + log.Fatalf("Invalid configuration: %v", err) + } + + // Create controller (singleton) + controller := &FlashController{} + + // Create initial state (pure data, cloned per session) + initialState := &FlashState{} + + // Create template with environment-based configuration + tmpl := livetemplate.Must(livetemplate.New("flash", envConfig.ToOptions()...)) + + // Mount handler + http.Handle("/", tmpl.Handle(controller, livetemplate.AsState(initialState))) + + // Health check endpoint for testing + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok","timestamp":"` + time.Now().Format(time.RFC3339) + `"}`)) + }) + + // Serve client library (development only - use CDN in production) + http.HandleFunc("/livetemplate-client.js", e2etest.ServeClientLibrary) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + log.Printf("Server starting on http://localhost:%s", port) + log.Println("") + log.Println("Flash Message Types:") + log.Println(" - success: Green notification (e.g., 'Item added')") + log.Println(" - error: Red notification (e.g., 'Something went wrong')") + log.Println(" - warning: Yellow notification (e.g., 'Item exists')") + log.Println(" - info: Blue notification (e.g., 'Item removed')") + log.Println("") + log.Println("Note: Flash messages show once and are cleared after each action.") + log.Println("") + + if err := http.ListenAndServe(":"+port, nil); err != nil { + log.Fatalf("Server failed to start: %v", err) + } +}