From 66898a5d5bbde2d48a8cf95952b11e88b2bd65db Mon Sep 17 00:00:00 2001 From: Adnaan Date: Wed, 1 Apr 2026 20:56:33 +0530 Subject: [PATCH 1/7] feat: remove redundant examples, merge component patterns into todos (#42) - Delete todos-progressive (Tier 1 CRUD patterns covered by todos) - Delete profile-progressive (form/validation patterns covered by todos-progressive) - Delete todos-components (standalone); merge modal + toast into todos - todos now demonstrates: CRUD, auth, pagination, delete confirmation modal, toast notifications - Add delete-via-modal E2E test to todos_test.go - Update test-all.sh, README.md, CLAUDE.md to reflect 8 remaining examples Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 3 +- README.md | 5 +- profile-progressive/README.md | 43 - profile-progressive/main.go | 87 -- profile-progressive/profile.tmpl | 61 -- .../profile_progressive_test.go | 159 --- test-all.sh | 3 - todos-components/main.go | 292 ------ todos-components/todos-components.tmpl | 95 -- todos-components/todos_test.go | 980 ------------------ todos-progressive/README.md | 75 -- todos-progressive/main.go | 148 --- todos-progressive/todos.tmpl | 67 -- todos-progressive/todos_progressive_test.go | 245 ----- todos/controller.go | 69 +- todos/main.go | 28 +- todos/state.go | 7 + todos/todos.tmpl | 17 +- todos/todos_test.go | 62 ++ 19 files changed, 176 insertions(+), 2270 deletions(-) delete mode 100644 profile-progressive/README.md delete mode 100644 profile-progressive/main.go delete mode 100644 profile-progressive/profile.tmpl delete mode 100644 profile-progressive/profile_progressive_test.go delete mode 100644 todos-components/main.go delete mode 100644 todos-components/todos-components.tmpl delete mode 100644 todos-components/todos_test.go delete mode 100644 todos-progressive/README.md delete mode 100644 todos-progressive/main.go delete mode 100644 todos-progressive/todos.tmpl delete mode 100644 todos-progressive/todos_progressive_test.go diff --git a/CLAUDE.md b/CLAUDE.md index ef9c994..290ae4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,8 +63,7 @@ When a form is submitted, the framework resolves the action in this order: ### Reference Examples -- `todos-progressive/` — Canonical Tier 1 example (zero `lvt-*` attributes) -- `profile-progressive/` — Simple Tier 1 form with validation +- `todos/` — Canonical Tier 1 example: CRUD, auth, pagination, modal + toast components - `live-preview/` — Tier 1 with `Change()` method for live updates - `chat/` — Tier 1+2 (uses `lvt-scroll` for auto-scroll) diff --git a/README.md b/README.md index c7215dd..8072da3 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,10 @@ All examples follow the [progressive complexity](https://github.com/livetemplate |---------|------|-------------|--------------------| | `counter/` | 1 | Counter with logging + graceful shutdown | None | | `chat/` | 1+2 | Real-time multi-user chat | `lvt-scroll` | -| `todos/` | 1 | Full CRUD with SQLite | None | -| `todos-progressive/` | 1 | Zero-attribute CRUD demo | None | -| `todos-components/` | 1+2 | Component library (modal, toast) | Component-internal | +| `todos/` | 1+2 | Full CRUD with SQLite, auth, modal + toast components | Component-internal | | `flash-messages/` | 1 | Flash notification patterns | None | | `avatar-upload/` | 1+2 | File upload with progress | `lvt-upload` | | `progressive-enhancement/` | 1 | Works with/without JS | None | -| `profile-progressive/` | 1 | Form validation | None | | `ws-disabled/` | 1 | HTTP-only mode | None | | `live-preview/` | 1 | Change() live updates | None | | `login/` | 1 | Authentication + sessions | None | diff --git a/profile-progressive/README.md b/profile-progressive/README.md deleted file mode 100644 index a51e7c2..0000000 --- a/profile-progressive/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# Profile Editor — Progressive Complexity (Tier 1) - -A profile editing form built with **zero `lvt-*` attributes**. The form auto-submits to the conventional `Submit()` method. - -This demonstrates LiveTemplate's [progressive complexity model](https://github.com/livetemplate/livetemplate/blob/main/docs/guides/progressive-complexity.md) — the simplest possible form. - -## What It Shows - -- **Auto-submit**: `
` with no `lvt-submit` routes to `Submit()` -- **Server-side validation**: Uses `BindAndValidate()` with struct tags -- **Error display**: `.lvt.HasError` and `.lvt.Error` template helpers -- **Preview section**: Current state rendered alongside the form -- **Progressive enhancement**: Works with and without JavaScript - -## Running - -```bash -cd profile-progressive -go run main.go -``` - -Open http://localhost:8081 - -## Key Pattern - -The entire form uses zero custom attributes: - -```html - - - - - -
-``` - -The framework auto-intercepts the form and routes it to `Submit()` — the conventional default when no button name or form name is specified. - -## Next Steps - -- Add ` - - -
-
Preview
-

Name: {{.DisplayName}}

-

Email: {{.Email}}

-

Bio: {{.Bio}}

-
- - - {{if .lvt.DevMode}} - - {{else}} - - {{end}} - - diff --git a/profile-progressive/profile_progressive_test.go b/profile-progressive/profile_progressive_test.go deleted file mode 100644 index 55884d6..0000000 --- a/profile-progressive/profile_progressive_test.go +++ /dev/null @@ -1,159 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "strings" - "testing" - "time" - - "github.com/chromedp/chromedp" - e2etest "github.com/livetemplate/lvt/testing" -) - -func TestMain(m *testing.M) { - e2etest.CleanupChromeContainers() - code := m.Run() - e2etest.CleanupChromeContainers() - os.Exit(code) -} - -func TestProfileProgressiveE2E(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - serverPort, err := e2etest.GetFreePort() - if err != nil { - t.Fatalf("Failed to get free port for server: %v", err) - } - - debugPort, err := e2etest.GetFreePort() - if err != nil { - t.Fatalf("Failed to get free port for Chrome: %v", err) - } - - e2etest.StartTestServer(t, "main.go", serverPort) - - if err := e2etest.StartDockerChrome(t, debugPort); err != nil { - t.Fatalf("Failed to start Docker Chrome: %v", err) - } - defer e2etest.StopDockerChrome(t, debugPort) - - chromeURL := fmt.Sprintf("http://localhost:%d", debugPort) - allocCtx, allocCancel := chromedp.NewRemoteAllocator(context.Background(), chromeURL) - defer allocCancel() - - ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf)) - defer cancel() - - ctx, cancel = context.WithTimeout(ctx, 60*time.Second) - defer cancel() - - err = chromedp.Run(ctx, - chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`h1`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - ) - if err != nil { - t.Fatalf("Failed to load page: %v", err) - } - - t.Run("InitialLoad", func(t *testing.T) { - var html string - var hasSuccess bool - var previewHTML string - err := chromedp.Run(ctx, - chromedp.OuterHTML("body", &html, chromedp.ByQuery), - chromedp.Evaluate(`document.querySelector('ins') !== null`, &hasSuccess), - chromedp.OuterHTML("article", &previewHTML, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to get initial state: %v", err) - } - - if !strings.Contains(html, "Edit Profile") { - t.Error("Page heading not found") - } - if !strings.Contains(html, "Jane Doe") { - t.Error("Initial display name not found") - } - if !strings.Contains(html, "jane@example.com") { - t.Error("Initial email not found") - } - if hasSuccess { - t.Error("Success message should not be visible initially") - } - if !strings.Contains(previewHTML, "Jane Doe") { - t.Error("Preview should show initial name") - } - }) - - t.Run("ValidSave", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Clear(`input[name="DisplayName"]`, chromedp.ByQuery), - chromedp.SendKeys(`input[name="DisplayName"]`, "John Smith", chromedp.ByQuery), - chromedp.Clear(`input[name="Email"]`, chromedp.ByQuery), - chromedp.SendKeys(`input[name="Email"]`, "john@example.com", chromedp.ByQuery), - chromedp.Clear(`textarea[name="Bio"]`, chromedp.ByQuery), - chromedp.SendKeys(`textarea[name="Bio"]`, "Updated bio text.", chromedp.ByQuery), - chromedp.Evaluate(`document.querySelector('button[type="submit"]').click()`, nil), - e2etest.WaitFor(`document.querySelector('ins') !== null`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to save profile: %v", err) - } - - var html string - if err := chromedp.Run(ctx, chromedp.OuterHTML("body", &html, chromedp.ByQuery)); err != nil { - t.Fatalf("Failed to get HTML: %v", err) - } - if !strings.Contains(html, "Profile saved successfully") { - t.Error("Success message not found") - } - - var previewHTML string - if err := chromedp.Run(ctx, chromedp.OuterHTML("article", &previewHTML, chromedp.ByQuery)); err != nil { - t.Fatalf("Failed to get preview: %v", err) - } - if !strings.Contains(previewHTML, "John Smith") { - t.Error("Preview should show new name") - } - if !strings.Contains(previewHTML, "john@example.com") { - t.Error("Preview should show new email") - } - }) - - t.Run("UpdateProfile", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Evaluate(`window.liveTemplateClient.send({action: 'submit', data: {DisplayName: 'Jane Updated', Email: 'jane.updated@example.com', Bio: 'Fixed and saved.'}})`, nil), - e2etest.WaitFor(`document.querySelector('ins') !== null`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to update profile: %v", err) - } - - var previewHTML string - if err := chromedp.Run(ctx, chromedp.OuterHTML("article", &previewHTML, chromedp.ByQuery)); err != nil { - t.Fatalf("Failed to get preview: %v", err) - } - if !strings.Contains(previewHTML, "Jane Updated") { - t.Error("Preview should show updated name") - } - if !strings.Contains(previewHTML, "jane.updated@example.com") { - t.Error("Preview should show updated email") - } - }) - - t.Run("LiveTemplateWrapper", func(t *testing.T) { - var html string - if err := chromedp.Run(ctx, chromedp.OuterHTML(`[data-lvt-id]`, &html, chromedp.ByQuery)); err != nil { - t.Fatalf("Failed to find LiveTemplate wrapper: %v", err) - } - if !strings.Contains(html, "data-lvt-id") { - t.Error("LiveTemplate wrapper not preserved") - } - }) -} diff --git a/test-all.sh b/test-all.sh index ae85c0d..6650364 100755 --- a/test-all.sh +++ b/test-all.sh @@ -29,11 +29,8 @@ WORKING_EXAMPLES=( "chat" "todos" "live-preview" - "todos-progressive" - "profile-progressive" "ws-disabled" "login" - "todos-components" "shared-notepad" "flash-messages" ) diff --git a/todos-components/main.go b/todos-components/main.go deleted file mode 100644 index 9f7f393..0000000 --- a/todos-components/main.go +++ /dev/null @@ -1,292 +0,0 @@ -package main - -import ( - "log" - "net/http" - "os" - "time" - - "github.com/livetemplate/lvt/components/base" - "github.com/livetemplate/lvt/components/modal" - "github.com/livetemplate/lvt/components/toast" - "github.com/livetemplate/lvt/components/toggle" - "github.com/livetemplate/livetemplate" - e2etest "github.com/livetemplate/lvt/testing" -) - -// Todo represents a single todo item. -type Todo struct { - ID int - Title string - Completed *toggle.Checkbox -} - -// TodoState holds the application state. -type TodoState struct { - Title string `lvt:"persist"` - Todos []Todo `lvt:"persist"` - NewTodoTitle string `lvt:"persist"` - Toasts *toast.Container - DeleteConfirm *modal.ConfirmModal - DeleteID int // ID of todo pending deletion - NextID int `lvt:"persist"` -} - -// TodoController handles todo actions. -type TodoController struct{} - -// Mount re-initializes non-serializable component objects on every request. -func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) { - if state.Toasts == nil { - toasts := toast.New("notifications", - toast.WithPosition(toast.TopRight), - toast.WithMaxVisible(3), - ) - toasts.SetStyled(false) - state.Toasts = toasts - } - if state.DeleteConfirm == nil { - state.DeleteConfirm = modal.NewConfirm("delete_confirm", - modal.WithConfirmTitle("Delete Todo"), - modal.WithConfirmMessage("Are you sure you want to delete this todo?"), - modal.WithConfirmDestructive(true), - modal.WithConfirmText("Delete"), - modal.WithCancelText("Cancel"), - ) - } - return state, nil -} - -// AddTodo handles the "add_todo" action. -func (c *TodoController) AddTodo(state TodoState, ctx *livetemplate.Context) (TodoState, error) { - title := ctx.GetString("title") - if title == "" { - state.Toasts.AddWarning("Warning", "Please enter a todo title") - return state, nil - } - - state.NextID++ - newTodo := Todo{ - ID: state.NextID, - Title: title, - Completed: toggle.NewCheckbox("todo-"+itoa(state.NextID), - toggle.WithCheckboxLabel(title), - ), - } - state.Todos = append(state.Todos, newTodo) - state.NewTodoTitle = "" - state.Toasts.AddSuccess("Added", "Todo added successfully") - return state, nil -} - -// ToggleTodo handles toggle_todo-{id} actions (checkbox change events). -func (c *TodoController) ToggleTodo(state TodoState, ctx *livetemplate.Context) (TodoState, error) { - idStr := ctx.GetString("id") - id := atoi(idStr) - - for i := range state.Todos { - if state.Todos[i].ID == id { - state.Todos[i].Completed.Toggle() - if state.Todos[i].Completed.Checked { - state.Toasts.AddInfo("Completed", "Todo marked as complete") - } else { - state.Toasts.AddInfo("Reopened", "Todo marked as incomplete") - } - break - } - } - return state, nil -} - -// ConfirmDelete handles the "confirm_delete" action - shows confirmation modal. -func (c *TodoController) ConfirmDelete(state TodoState, ctx *livetemplate.Context) (TodoState, error) { - idStr := ctx.GetString("id") - state.DeleteID = atoi(idStr) - state.DeleteConfirm.Show() - return state, nil -} - -// ConfirmDeleteConfirm handles the "confirm_delete_confirm" action from modal. -func (c *TodoController) ConfirmDeleteConfirm(state TodoState, ctx *livetemplate.Context) (TodoState, error) { - // Find and remove the todo - for i := range state.Todos { - if state.Todos[i].ID == state.DeleteID { - state.Todos = append(state.Todos[:i], state.Todos[i+1:]...) - state.Toasts.AddSuccess("Deleted", "Todo deleted successfully") - break - } - } - state.DeleteConfirm.Hide() - state.DeleteID = 0 - return state, nil -} - -// CancelDeleteConfirm handles the "cancel_delete_confirm" action from modal. -func (c *TodoController) CancelDeleteConfirm(state TodoState, ctx *livetemplate.Context) (TodoState, error) { - state.DeleteConfirm.Hide() - state.DeleteID = 0 - return state, nil -} - -// DismissToastNotifications handles "dismiss_toast_notifications" action from toast close button. -// The action name includes the container ID (notifications) as generated by the template. -func (c *TodoController) DismissToastNotifications(state TodoState, ctx *livetemplate.Context) (TodoState, error) { - toastID := ctx.GetString("toast") // matches lvt-data-toast attribute - state.Toasts.Dismiss(toastID) - return state, nil -} - -// ClearCompleted handles the "clear_completed" action. -func (c *TodoController) ClearCompleted(state TodoState, ctx *livetemplate.Context) (TodoState, error) { - var remaining []Todo - cleared := 0 - for _, todo := range state.Todos { - if !todo.Completed.Checked { - remaining = append(remaining, todo) - } else { - cleared++ - } - } - state.Todos = remaining - if cleared > 0 { - state.Toasts.AddSuccess("Cleared", itoa(cleared)+" completed todo(s) removed") - } else { - state.Toasts.AddInfo("Nothing to clear", "No completed todos found") - } - return state, nil -} - -// Helper functions -func itoa(n int) string { - if n == 0 { - return "0" - } - if n < 0 { - return "-" + itoa(-n) - } - var digits []byte - for n > 0 { - digits = append([]byte{byte('0' + n%10)}, digits...) - n /= 10 - } - return string(digits) -} - -func atoi(s string) int { - n := 0 - for _, c := range s { - if c >= '0' && c <= '9' { - n = n*10 + int(c-'0') - } - } - return n -} - -// convertTemplateSet converts a base.TemplateSet to livetemplate.TemplateSet. -// This is needed because the components library uses its own TemplateSet type -// to avoid import cycles with livetemplate. -func convertTemplateSet(set *base.TemplateSet) *livetemplate.TemplateSet { - return &livetemplate.TemplateSet{ - FS: set.FS, - Pattern: set.Pattern, - Namespace: set.Namespace, - Funcs: set.Funcs, - } -} - -func main() { - log.Println("LiveTemplate Todos with Components 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, holds dependencies) - controller := &TodoController{} - - // Create initial state with components - initialState := &TodoState{ - Title: "Todos with Components", - Todos: []Todo{ - { - ID: 1, - Title: "Learn LiveTemplate", - Completed: toggle.NewCheckbox("todo-1", - toggle.WithCheckboxLabel("Learn LiveTemplate"), - ), - }, - { - ID: 2, - Title: "Try the components library", - Completed: toggle.NewCheckbox("todo-2", - toggle.WithCheckboxLabel("Try the components library"), - ), - }, - }, - NextID: 2, - } - - // Create toast container (unstyled — Pico CSS handles base styling, - // template adds position:fixed via inline style below) - toasts := toast.New("notifications", - toast.WithPosition(toast.TopRight), - toast.WithMaxVisible(3), - ) - toasts.SetStyled(false) - initialState.Toasts = toasts - - // Create confirmation modal (unstyled for Pico CSS) - deleteConfirm := modal.NewConfirm("delete_confirm", - modal.WithConfirmTitle("Delete Todo"), - modal.WithConfirmMessage("Are you sure you want to delete this todo?"), - modal.WithConfirmDestructive(true), - modal.WithConfirmText("Delete"), - modal.WithCancelText("Cancel"), - ) - initialState.DeleteConfirm = deleteConfirm - - // Get options from env config - opts := envConfig.ToOptions() - - // Add only the component templates we need (modal, toast, toggle) - // Using specific components instead of components.All() to avoid loading - // unused components that may have additional function dependencies - componentSets := []*base.TemplateSet{ - modal.Templates(), - toast.Templates(), - toggle.Templates(), - } - ltSets := make([]*livetemplate.TemplateSet, len(componentSets)) - for i, set := range componentSets { - ltSets[i] = convertTemplateSet(set) - } - opts = append(opts, livetemplate.WithComponentTemplates(ltSets...)) - - // Create template with component templates registered - tmpl := livetemplate.Must(livetemplate.New("todos-components", opts...)) - - // Mount handler with Controller+State pattern - http.Handle("/", tmpl.Handle(controller, livetemplate.AsState(initialState))) - - // 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.Printf("Started at %s", time.Now().Format(time.RFC3339)) - - if err := http.ListenAndServe(":"+port, nil); err != nil { - log.Fatalf("Server failed to start: %v", err) - } -} diff --git a/todos-components/todos-components.tmpl b/todos-components/todos-components.tmpl deleted file mode 100644 index 8fd48fb..0000000 --- a/todos-components/todos-components.tmpl +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - {{.Title}} - - {{if .lvt.DevMode}} - - {{else}} - - {{end}} - - - -
-

{{.Title}}

- - {{/* Add Todo Form */}} -
- - -
- - {{/* Todo List */}} -
- {{if .Todos}} -
- {{range .Todos}} -
-
- - - -
- -
- - -
-
- {{end}} -
- {{else}} -

No todos yet. Add one above!

- {{end}} -
- - {{/* Actions */}} - {{if .Todos}} -
- {{len .Todos}} todo{{if gt (len .Todos) 1}}s{{end}} - -
- {{end}} -
- - {{/* Delete Confirmation Modal - uses component template */}} - {{template "lvt:modal:confirm:v1" .DeleteConfirm}} - - {{/* Toast Container - uses component template */}} - {{template "lvt:toast:container:v1" .Toasts}} - - diff --git a/todos-components/todos_test.go b/todos-components/todos_test.go deleted file mode 100644 index 1f7367d..0000000 --- a/todos-components/todos_test.go +++ /dev/null @@ -1,980 +0,0 @@ -package main - -import ( - "context" - "fmt" - "net/http" - "os" - "os/exec" - "strings" - "testing" - "time" - - "github.com/chromedp/chromedp" - "github.com/gorilla/websocket" - e2etest "github.com/livetemplate/lvt/testing" -) - -func TestMain(m *testing.M) { - e2etest.CleanupChromeContainers() - - code := m.Run() - - e2etest.CleanupChromeContainers() - os.Exit(code) -} - -// TestTodosComponentsE2E tests the todos-components app end-to-end with a real browser -func TestTodosComponentsE2E(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - // Get free ports for server and Chrome debugging - serverPort, err := e2etest.GetFreePort() - if err != nil { - t.Fatalf("Failed to get free port for server: %v", err) - } - - debugPort, err := e2etest.GetFreePort() - if err != nil { - t.Fatalf("Failed to get free port for Chrome: %v", err) - } - - // Start todos server - serverCmd := e2etest.StartTestServer(t, "main.go", serverPort) - defer func() { - if serverCmd != nil && serverCmd.Process != nil { - serverCmd.Process.Kill() - } - }() - - // Start Docker Chrome container - chromeCmd := e2etest.StartDockerChrome(t, debugPort) - defer e2etest.StopDockerChrome(t, debugPort) - _ = chromeCmd - - // Connect to Docker Chrome via remote debugging - chromeURL := fmt.Sprintf("http://localhost:%d", debugPort) - allocCtx, allocCancel := chromedp.NewRemoteAllocator(context.Background(), chromeURL) - defer allocCancel() - - ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf)) - defer cancel() - - // Set timeout for the entire test - ctx, cancel = context.WithTimeout(ctx, 180*time.Second) - defer cancel() - - // Navigate and wait for page to be ready (done outside subtests so each subtest starts with a loaded page) - err = chromedp.Run(ctx, - chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`h1`, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to navigate to page: %v", err) - } - t.Logf("✅ Page loaded and WebSocket ready") - - t.Run("Initial Load", func(t *testing.T) { - var initialHTML string - - err := chromedp.Run(ctx, - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - chromedp.OuterHTML(`body`, &initialHTML, chromedp.ByQuery), - ) - - if err != nil { - t.Fatalf("Failed to load page: %v", err) - } - - // Verify initial state - if !strings.Contains(initialHTML, "Todos with Components") { - t.Error("Page title not found") - } - if !strings.Contains(initialHTML, "Learn LiveTemplate") { - t.Error("Initial todo not found") - } - if !strings.Contains(initialHTML, "Try the components library") { - t.Error("Second initial todo not found") - } - - t.Log("✅ Initial page load verified with 2 todos") - }) - - t.Run("Add Todo", func(t *testing.T) { - var html string - var todoCountBefore, todoCountAfter int - - // First get the current todo count - err := chromedp.Run(ctx, - chromedp.Evaluate(`document.querySelectorAll('.todo-item').length`, &todoCountBefore), - ) - if err != nil { - t.Fatalf("Failed to get initial todo count: %v", err) - } - t.Logf("Initial todo count: %d", todoCountBefore) - - // Now add the todo - var inputValue string - err = chromedp.Run(ctx, - // Type in the input field - chromedp.WaitVisible(`input[name="title"]`, chromedp.ByQuery), - chromedp.Clear(`input[name="title"]`, chromedp.ByQuery), - chromedp.SendKeys(`input[name="title"]`, "Test component integration", chromedp.ByQuery), - // Small wait to ensure text is fully entered - chromedp.Sleep(100*time.Millisecond), - // Check what value is in the input - chromedp.Evaluate(`document.querySelector('input[name="title"]').value`, &inputValue), - ) - if err != nil { - t.Fatalf("Failed to type in input: %v", err) - } - t.Logf("Input value before submit: %q", inputValue) - - err = chromedp.Run(ctx, - // Submit the form - chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), - // Wait for todo count to increase - e2etest.WaitFor(fmt.Sprintf(`document.querySelectorAll('.todo-item').length > %d`, todoCountBefore), 5*time.Second), - // Wait a bit for DOM to fully update - chromedp.Sleep(200*time.Millisecond), - // Count todos after - chromedp.Evaluate(`document.querySelectorAll('.todo-item').length`, &todoCountAfter), - chromedp.OuterHTML(`body`, &html, chromedp.ByQuery), - ) - - if err != nil { - t.Fatalf("Failed to add todo: %v", err) - } - - t.Logf("Todo count before: %d, after: %d", todoCountBefore, todoCountAfter) - - // Debug: Get all todo titles and their HTML - var todoTitles string - var lastTodoHTML string - chromedp.Run(ctx, chromedp.Evaluate(`Array.from(document.querySelectorAll('.todo-item label')).map(el => el.textContent.trim()).join(' | ')`, &todoTitles)) - chromedp.Run(ctx, chromedp.Evaluate(`document.querySelector('.todo-item:last-child').outerHTML`, &lastTodoHTML)) - t.Logf("Todo titles: %s", todoTitles) - t.Logf("Last todo HTML: %s", lastTodoHTML) - - // Verify the new todo is visible - if !strings.Contains(html, "Test component integration") { - // Try to find what text is actually there - if strings.Contains(html, "todo-item") { - t.Logf("Todo items exist in HTML, but 'Test component integration' not found") - } - t.Error("New todo not visible in HTML") - } - - // Verify existing todos are still visible (this was the bug!) - if !strings.Contains(html, "Learn LiveTemplate") { - t.Error("REGRESSION: First initial todo disappeared after adding new todo") - } - if !strings.Contains(html, "Try the components library") { - t.Error("REGRESSION: Second initial todo disappeared after adding new todo") - } - - // Verify count increased - if todoCountAfter <= todoCountBefore { - t.Errorf("Todo count did not increase: before=%d, after=%d", todoCountBefore, todoCountAfter) - } - - t.Log("✅ Add todo works - all todos remain visible") - }) - - t.Run("Add Empty Todo Shows Warning Toast", func(t *testing.T) { - var todoCountBefore, todoCountAfter int - var hasWarningToast bool - - err := chromedp.Run(ctx, - // Count todos before - chromedp.Evaluate(`document.querySelectorAll('.todo-item').length`, &todoCountBefore), - // Clear input and submit empty - chromedp.Clear(`input[name="title"]`, chromedp.ByQuery), - chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), - chromedp.Sleep(500*time.Millisecond), - // Count todos after - should be same - chromedp.Evaluate(`document.querySelectorAll('.todo-item').length`, &todoCountAfter), - // Check for warning toast - chromedp.Evaluate(`document.body.innerHTML.includes('Please enter a todo title') || document.body.innerHTML.includes('Warning')`, &hasWarningToast), - ) - - if err != nil { - t.Fatalf("Failed to test empty todo: %v", err) - } - - if todoCountAfter != todoCountBefore { - t.Logf("Note: Todo count changed (before=%d, after=%d) - may be timing issue with input clearing", todoCountBefore, todoCountAfter) - } - - if !hasWarningToast { - t.Log("Note: Warning toast may have auto-dismissed or rendered differently") - } else { - t.Log("Warning toast shown for empty todo") - } - - t.Log("✅ Empty todo validation works") - }) - - t.Run("Toggle Todo Completion", func(t *testing.T) { - var checkedBefore, checkedAfter bool - var hasCompletedClass bool - - err := chromedp.Run(ctx, - // Get initial checkbox state for any available checkbox - chromedp.Evaluate(`document.querySelector('input[type="checkbox"][id^="todo-checkbox-"]').checked`, &checkedBefore), - // Click checkbox directly - chromedp.Click(`input[type="checkbox"][id^="todo-checkbox-"]`, chromedp.ByQuery), - chromedp.Sleep(500*time.Millisecond), - // Get new checkbox state - chromedp.Evaluate(`document.querySelector('input[type="checkbox"][id^="todo-checkbox-"]').checked`, &checkedAfter), - // Check if any completed class exists after toggle - chromedp.Evaluate(`document.querySelector('.todo-item.completed') !== null`, &hasCompletedClass), - ) - - if err != nil { - t.Fatalf("Failed to toggle todo: %v", err) - } - - t.Logf("Checkbox state: before=%v, after=%v, hasCompletedClass=%v", checkedBefore, checkedAfter, hasCompletedClass) - - // State should have changed - if checkedBefore == checkedAfter { - t.Log("Warning: Checkbox state did not change after click (may be timing issue)") - } else { - t.Log("Checkbox state changed successfully") - } - - // Note: completed class may or may not be present depending on which checkbox was toggled - if checkedAfter { - t.Logf("Checkbox is now checked, completed class present: %v", hasCompletedClass) - } - - t.Log("✅ Toggle todo completion works") - }) - - t.Run("Delete Confirmation Modal Appears", func(t *testing.T) { - var modalVisible bool - var modalPosition string - - modalCtx, modalCancel := context.WithTimeout(ctx, 15*time.Second) - defer modalCancel() - - // Use JS .click() instead of chromedp.Click — chromedp.Click doesn't - // reliably trigger event delegation handlers in headless Chrome. - err := chromedp.Run(modalCtx, - chromedp.Evaluate(`(() => { const f = document.querySelector('.todo-item[data-key="2"] form[name="confirmDelete"]'); f.querySelector('button[name="confirmDelete"]').click(); })()`, nil), - e2etest.WaitFor(`document.querySelector('[data-modal="delete_confirm"]') !== null`, 5*time.Second), - chromedp.Evaluate(`document.querySelector('[data-modal="delete_confirm"]') !== null`, &modalVisible), - chromedp.Evaluate(` - (function() { - var modal = document.querySelector('[data-modal="delete_confirm"]'); - if (!modal) return 'not found'; - return window.getComputedStyle(modal).position; - })() - `, &modalPosition), - ) - - if err != nil { - t.Fatalf("Failed to open delete modal: %v", err) - } - - if !modalVisible { - t.Fatal("Delete confirmation modal not visible") - } - - if modalPosition != "fixed" { - t.Logf("Note: Modal position is %q (Pico CSS may override component styling)", modalPosition) - } - - t.Log("✅ Delete confirmation modal appears correctly") - }) - - t.Run("Cancel Delete Modal", func(t *testing.T) { - modalCtx, modalCancel := context.WithTimeout(ctx, 10*time.Second) - defer modalCancel() - - // Modal should still be open from previous subtest - var modalVisibleBefore bool - err := chromedp.Run(modalCtx, - chromedp.Evaluate(`document.querySelector('[data-modal="delete_confirm"]') !== null`, &modalVisibleBefore), - ) - if err != nil || !modalVisibleBefore { - // Reopen modal if needed - chromedp.Run(modalCtx, - chromedp.Evaluate(`document.querySelector('form[name="confirmDelete"] button[name="confirmDelete"]').click()`, nil), - e2etest.WaitFor(`document.querySelector('[data-modal="delete_confirm"]') !== null`, 5*time.Second), - ) - } - - var modalVisibleAfter bool - err = chromedp.Run(modalCtx, - chromedp.Evaluate(`document.querySelector('button[lvt-click="cancel_delete_confirm"]').click()`, nil), - e2etest.WaitFor(`document.querySelector('[data-modal="delete_confirm"]') === null`, 5*time.Second), - chromedp.Evaluate(`document.querySelector('[data-modal="delete_confirm"]') !== null`, &modalVisibleAfter), - ) - - if err != nil { - t.Fatalf("Cancel modal failed: %v", err) - } - - if modalVisibleAfter { - t.Error("Modal should be hidden after cancel") - } - - t.Log("✅ Cancel delete modal works") - }) - - t.Run("Confirm Delete Modal Deletes Todo", func(t *testing.T) { - var todoCountBefore, todoCountAfter int - - modalCtx, modalCancel := context.WithTimeout(ctx, 15*time.Second) - defer modalCancel() - - err := chromedp.Run(modalCtx, - chromedp.Evaluate(`document.querySelectorAll('.todo-item').length`, &todoCountBefore), - // Open delete modal for first todo - chromedp.Evaluate(`(() => { const f = document.querySelector('.todo-item[data-key="1"] form[name="confirmDelete"]'); f.querySelector('button[name="confirmDelete"]').click(); })()`, nil), - e2etest.WaitFor(`document.querySelector('[data-modal="delete_confirm"]') !== null`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to open delete modal: %v", err) - } - - err = chromedp.Run(modalCtx, - // Click confirm button in modal - chromedp.Evaluate(`document.querySelector('button[lvt-click="confirm_delete_confirm"]').click()`, nil), - // Wait for modal to close AND todo count to decrease - e2etest.WaitFor(`document.querySelector('[data-modal="delete_confirm"]') === null`, 5*time.Second), - e2etest.WaitFor(fmt.Sprintf(`document.querySelectorAll('.todo-item').length < %d`, todoCountBefore), 5*time.Second), - chromedp.Evaluate(`document.querySelectorAll('.todo-item').length`, &todoCountAfter), - ) - if err != nil { - t.Fatalf("Confirm delete failed: %v", err) - } - - t.Logf("Todo count before: %d, after: %d", todoCountBefore, todoCountAfter) - - if todoCountAfter >= todoCountBefore { - t.Errorf("Todo count should decrease after deletion (before: %d, after: %d)", todoCountBefore, todoCountAfter) - } - - t.Log("✅ Confirm delete modal works") - }) - - t.Run("Toast Notifications Appear", func(t *testing.T) { - var toastVisible bool - var toastContainerPosition string - - err := chromedp.Run(ctx, - // Add a new todo to trigger toast - chromedp.Clear(`input[name="title"]`, chromedp.ByQuery), - chromedp.SendKeys(`input[name="title"]`, "Toast trigger todo", chromedp.ByQuery), - chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), - chromedp.Sleep(500*time.Millisecond), - // Check toast container exists - chromedp.Evaluate(`document.querySelector('[data-toast-container]') !== null`, &toastVisible), - // Check if toast container has fixed positioning - chromedp.Evaluate(` - (function() { - var container = document.querySelector('[data-toast-container]'); - if (!container) return 'not found'; - var style = window.getComputedStyle(container); - return style.position; - })() - `, &toastContainerPosition), - ) - - if err != nil { - t.Fatalf("Failed to trigger toast: %v", err) - } - - if !toastVisible { - t.Log("Warning: Toast container not found") - } else { - // Verify toast container has fixed positioning (this was the bug!) - if toastContainerPosition != "fixed" { - t.Logf("Note: Toast position is %q (Pico CSS may override component styling)", toastContainerPosition) - } else { - t.Log("Toast container has correct fixed positioning") - } - } - - t.Log("✅ Toast notifications appear correctly") - }) - - t.Run("Dismiss Toast", func(t *testing.T) { - var hasToast bool - var toastCountBefore, toastCountAfter int - - toastCtx, toastCancel := context.WithTimeout(ctx, 15*time.Second) - defer toastCancel() - - // Check if there are any toasts to dismiss - err := chromedp.Run(toastCtx, - chromedp.Evaluate(`document.querySelector('[data-toast]') !== null`, &hasToast), - chromedp.Evaluate(`document.querySelectorAll('[data-toast]').length`, &toastCountBefore), - ) - - if err != nil || !hasToast { - t.Log("No toast to dismiss - triggering one") - // Add a todo to trigger a toast - var countBefore int - chromedp.Run(toastCtx, chromedp.Evaluate(`document.querySelectorAll('.todo-item').length`, &countBefore)) - chromedp.Run(toastCtx, - chromedp.Clear(`input[name="title"]`, chromedp.ByQuery), - chromedp.SendKeys(`input[name="title"]`, "Another toast trigger", chromedp.ByQuery), - chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), - e2etest.WaitFor(fmt.Sprintf(`document.querySelectorAll('.todo-item').length > %d`, countBefore), 5*time.Second), - chromedp.Evaluate(`document.querySelectorAll('[data-toast]').length`, &toastCountBefore), - ) - } - - if toastCountBefore == 0 { - t.Log("Note: No toasts available to dismiss") - t.Log("✅ Dismiss toast test skipped (no toasts)") - return - } - - // Try to click dismiss button with short timeout - dismissCtx, dismissCancel := context.WithTimeout(toastCtx, 5*time.Second) - defer dismissCancel() - - err = chromedp.Run(dismissCtx, - // Click dismiss button on a toast - chromedp.Click(`[data-toast-container] button[lvt-click^="dismiss_toast"]`, chromedp.ByQuery), - chromedp.Sleep(300*time.Millisecond), - // Count toasts after - chromedp.Evaluate(`document.querySelectorAll('[data-toast]').length`, &toastCountAfter), - ) - - if err != nil { - t.Logf("Toast dismiss click failed (may have auto-dismissed): %v", err) - } - - t.Logf("Toast count before: %d, after: %d", toastCountBefore, toastCountAfter) - - if toastCountAfter >= toastCountBefore { - t.Log("Warning: Toast count did not decrease (may have auto-dismissed before click)") - } else { - t.Log("Toast dismissed successfully") - } - - t.Log("✅ Dismiss toast test completed") - }) - - t.Run("Clear Completed Removes Completed Todos", func(t *testing.T) { - var countBefore, countAfter int - var hasCompletedTodo bool - - clearCtx, clearCancel := context.WithTimeout(ctx, 15*time.Second) - defer clearCancel() - - // First ensure we have at least one completed todo - err := chromedp.Run(clearCtx, - // Check if any todo is completed - chromedp.Evaluate(`document.querySelector('.todo-item.completed') !== null`, &hasCompletedTodo), - ) - - if !hasCompletedTodo { - // Toggle a todo to complete it - err = chromedp.Run(clearCtx, - chromedp.Click(`input[type="checkbox"][id^="todo-checkbox-"]`, chromedp.ByQuery), - chromedp.Sleep(500*time.Millisecond), - ) - if err != nil { - t.Logf("Could not complete a todo: %v", err) - } - } - - err = chromedp.Run(clearCtx, - // Count todos before - chromedp.Evaluate(`document.querySelectorAll('.todo-item').length`, &countBefore), - // Click clear completed - chromedp.Evaluate(`document.querySelector('button[name="clearCompleted"]').click()`, nil), - chromedp.Sleep(500*time.Millisecond), - // Count todos after - chromedp.Evaluate(`document.querySelectorAll('.todo-item').length`, &countAfter), - ) - - if err != nil { - t.Logf("Clear completed test had issues: %v", err) - } - - t.Logf("Todos before: %d, after: %d", countBefore, countAfter) - - if countAfter >= countBefore { - t.Log("Warning: Todo count did not decrease (may have had no completed todos)") - } else { - t.Log("Completed todos cleared successfully") - } - - t.Log("✅ Clear completed test completed") - }) - - t.Run("Clear Completed When None Completed Shows Info Toast", func(t *testing.T) { - var hasCompletedTodo bool - var html string - - clearCtx, clearCancel := context.WithTimeout(ctx, 10*time.Second) - defer clearCancel() - - err := chromedp.Run(clearCtx, - // Check if any todo is completed - chromedp.Evaluate(`document.querySelector('.todo-item.completed') !== null`, &hasCompletedTodo), - ) - - if err != nil { - t.Logf("Could not check completed status: %v", err) - } - - if hasCompletedTodo { - // Clear completed first so none remain - chromedp.Run(clearCtx, - chromedp.Evaluate(`document.querySelector('button[name="clearCompleted"]').click()`, nil), - chromedp.Sleep(500*time.Millisecond), - ) - } - - // Now click clear completed again - should show info toast - err = chromedp.Run(clearCtx, - chromedp.Evaluate(`document.querySelector('button[name="clearCompleted"]').click()`, nil), - chromedp.Sleep(500*time.Millisecond), - chromedp.OuterHTML(`body`, &html, chromedp.ByQuery), - ) - - if err != nil { - t.Logf("Clear completed (none) test had issues: %v", err) - } - - // Check for info toast message - if strings.Contains(html, "Nothing to clear") || strings.Contains(html, "No completed") { - t.Log("Info toast shown for no completed todos") - } else { - t.Log("Note: Info toast may have auto-dismissed or rendered differently") - } - - t.Log("✅ Clear completed (none) shows info toast") - }) - - t.Run("Multiple Todos Persist After Multiple Adds", func(t *testing.T) { - var todoCount int - var html string - var countBefore, countAfter int - - multiCtx, multiCancel := context.WithTimeout(ctx, 30*time.Second) - defer multiCancel() - - // Get current count - err := chromedp.Run(multiCtx, - chromedp.Evaluate(`document.querySelectorAll('.todo-item').length`, &countBefore), - ) - if err != nil { - t.Logf("Failed to get initial count: %v", err) - t.Log("✅ Multiple todos test skipped (context issue)") - return - } - t.Logf("Starting count: %d", countBefore) - - // Add three todos with simple sleep-based waiting - todos := []string{"Persist-First", "Persist-Second", "Persist-Third"} - for _, title := range todos { - err = chromedp.Run(multiCtx, - chromedp.Clear(`input[name="title"]`, chromedp.ByQuery), - chromedp.SendKeys(`input[name="title"]`, title, chromedp.ByQuery), - chromedp.Sleep(100*time.Millisecond), - chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), - chromedp.Sleep(500*time.Millisecond), - ) - if err != nil { - t.Logf("Failed to add todo '%s': %v", title, err) - } - } - - // Get final state - err = chromedp.Run(multiCtx, - chromedp.Evaluate(`document.querySelectorAll('.todo-item').length`, &countAfter), - chromedp.OuterHTML(`body`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Logf("Failed to get final state: %v", err) - } - - todoCount = countAfter - t.Logf("Final todo count: %d (started with %d)", todoCount, countBefore) - - // Check if any of the new todos are visible - foundCount := 0 - for _, title := range todos { - if strings.Contains(html, title) { - foundCount++ - } - } - t.Logf("Found %d/%d new todos in HTML", foundCount, len(todos)) - - // The test passes if at least some of the adds worked - if foundCount > 0 || todoCount > countBefore { - t.Log("Multiple adds working - some todos were added") - } else { - t.Log("Note: No new todos visible (may be server state issue)") - } - - t.Log("✅ Multiple todos persist test completed") - }) - - fmt.Println("\n" + strings.Repeat("=", 60)) - fmt.Println("🎉 All Todos Components E2E tests passed!") - fmt.Println(strings.Repeat("=", 60)) -} - -// TestWebSocketBasic tests basic WebSocket connectivity -func TestWebSocketBasic(t *testing.T) { - // Get a free port - port, err := e2etest.GetFreePort() - if err != nil { - t.Fatalf("Failed to get free port: %v", err) - } - - portStr := fmt.Sprintf("%d", port) - serverURL := fmt.Sprintf("http://localhost:%s", portStr) - wsURL := fmt.Sprintf("ws://localhost:%s/", portStr) - - // Start server on dynamic port - cmd := exec.Command("go", "run", "main.go") - cmd.Env = append([]string{"PORT=" + portStr}, cmd.Environ()...) - - serverLogs := e2etest.NewSafeBuffer() - cmd.Stdout = serverLogs - cmd.Stderr = serverLogs - - if err := cmd.Start(); err != nil { - t.Fatalf("Failed to start server: %v", err) - } - defer func() { - cmd.Process.Kill() - t.Logf("=== SERVER LOGS ===\n%s", serverLogs.String()) - }() - - // Wait for server - time.Sleep(2 * time.Second) - for i := 0; i < 30; i++ { - if resp, err := http.Get(serverURL); err == nil { - resp.Body.Close() - break - } - time.Sleep(100 * time.Millisecond) - } - - t.Log("Server is up, trying to connect WebSocket...") - - // Try to connect - dialer := websocket.Dialer{} - conn, resp, err := dialer.Dial(wsURL, nil) - if err != nil { - t.Fatalf("Failed to connect: %v, response: %v", err, resp) - } - defer conn.Close() - - t.Log("WebSocket connected successfully!") - - // Read first message (initial tree) - _, msg, err := conn.ReadMessage() - if err != nil { - t.Fatalf("Failed to read message: %v", err) - } - - t.Logf("Received initial tree, length: %d bytes", len(msg)) - - // Verify initial tree contains expected data - if !strings.Contains(string(msg), "Learn LiveTemplate") { - t.Error("Initial tree should contain first todo") - } - if !strings.Contains(string(msg), "Try the components library") { - t.Error("Initial tree should contain second todo") - } - - // Send add_todo action - t.Log("Sending add_todo action...") - action := []byte(`{"action":"add_todo","data":{"title":"WebSocket test todo"}}`) - if err := conn.WriteMessage(websocket.TextMessage, action); err != nil { - t.Fatalf("Failed to send message: %v", err) - } - - // Read response - _, msg, err = conn.ReadMessage() - if err != nil { - t.Fatalf("Failed to read response: %v", err) - } - - t.Logf("Received add_todo response, length: %d bytes", len(msg)) - t.Logf("add_todo response content: %s", string(msg)) - - // Verify response contains append operation or the new todo - msgStr := string(msg) - if strings.Contains(msgStr, `"a"`) || strings.Contains(msgStr, "WebSocket test todo") { - t.Log("Response contains append operation or new todo text") - } else { - t.Log("Response may use different operation format") - } - - // Test toggle action - t.Log("Sending toggle_todo action...") - toggleAction := []byte(`{"action":"toggle_todo","data":{"id":"1"}}`) - if err := conn.WriteMessage(websocket.TextMessage, toggleAction); err != nil { - t.Fatalf("Failed to send toggle message: %v", err) - } - - _, msg, err = conn.ReadMessage() - if err != nil { - t.Fatalf("Failed to read toggle response: %v", err) - } - - t.Logf("Received toggle response, length: %d bytes", len(msg)) - - // Test delete confirm action - t.Log("Sending confirm_delete action...") - deleteAction := []byte(`{"action":"confirm_delete","data":{"id":"1"}}`) - if err := conn.WriteMessage(websocket.TextMessage, deleteAction); err != nil { - t.Fatalf("Failed to send delete message: %v", err) - } - - _, msg, err = conn.ReadMessage() - if err != nil { - t.Fatalf("Failed to read delete response: %v", err) - } - - t.Logf("Received confirm_delete response, length: %d bytes", len(msg)) - - // Verify modal should now be open (response should contain modal data) - if strings.Contains(string(msg), "delete_confirm") || strings.Contains(string(msg), "Delete Todo") { - t.Log("Modal appears to have opened") - } - - // Test confirm delete action (actually delete) - t.Log("Sending confirm_delete_confirm action...") - confirmAction := []byte(`{"action":"confirm_delete_confirm","data":{}}`) - if err := conn.WriteMessage(websocket.TextMessage, confirmAction); err != nil { - t.Fatalf("Failed to send confirm message: %v", err) - } - - _, msg, err = conn.ReadMessage() - if err != nil { - t.Fatalf("Failed to read confirm response: %v", err) - } - - t.Logf("Received confirm_delete_confirm response, length: %d bytes", len(msg)) - - // Test clear_completed action - t.Log("Sending clear_completed action...") - clearAction := []byte(`{"action":"clear_completed","data":{}}`) - if err := conn.WriteMessage(websocket.TextMessage, clearAction); err != nil { - t.Fatalf("Failed to send clear message: %v", err) - } - - _, msg, err = conn.ReadMessage() - if err != nil { - t.Fatalf("Failed to read clear response: %v", err) - } - - t.Logf("Received clear_completed response, length: %d bytes", len(msg)) - - t.Log("✅ WebSocket test passed!") -} - -// TestWebSocketDeleteFlow tests the complete delete flow via WebSocket -func TestWebSocketDeleteFlow(t *testing.T) { - // Get a free port - port, err := e2etest.GetFreePort() - if err != nil { - t.Fatalf("Failed to get free port: %v", err) - } - - portStr := fmt.Sprintf("%d", port) - serverURL := fmt.Sprintf("http://localhost:%s", portStr) - wsURL := fmt.Sprintf("ws://localhost:%s/", portStr) - - // Start server on dynamic port - cmd := exec.Command("go", "run", "main.go") - cmd.Env = append([]string{"PORT=" + portStr}, cmd.Environ()...) - - serverLogs := e2etest.NewSafeBuffer() - cmd.Stdout = serverLogs - cmd.Stderr = serverLogs - - if err := cmd.Start(); err != nil { - t.Fatalf("Failed to start server: %v", err) - } - defer func() { - cmd.Process.Kill() - t.Logf("=== SERVER LOGS ===\n%s", serverLogs.String()) - }() - - // Wait for server - time.Sleep(2 * time.Second) - for i := 0; i < 30; i++ { - if resp, err := http.Get(serverURL); err == nil { - resp.Body.Close() - break - } - time.Sleep(100 * time.Millisecond) - } - - t.Log("Server is up, connecting WebSocket...") - - // Connect to WebSocket - dialer := websocket.Dialer{} - conn, _, err := dialer.Dial(wsURL, nil) - if err != nil { - t.Fatalf("Failed to connect: %v", err) - } - defer conn.Close() - - // Read initial tree - _, msg, err := conn.ReadMessage() - if err != nil { - t.Fatalf("Failed to read initial tree: %v", err) - } - t.Logf("Initial tree length: %d bytes", len(msg)) - - // Step 1: Open delete modal for todo 1 - t.Log("Step 1: Opening delete modal...") - confirmDelete := []byte(`{"action":"confirm_delete","data":{"id":"1"}}`) - if err := conn.WriteMessage(websocket.TextMessage, confirmDelete); err != nil { - t.Fatalf("Failed to send confirm_delete: %v", err) - } - - _, msg, err = conn.ReadMessage() - if err != nil { - t.Fatalf("Failed to read confirm_delete response: %v", err) - } - t.Logf("confirm_delete response: %s", string(msg)) - - // Step 2: Confirm the deletion - t.Log("Step 2: Confirming deletion...") - confirmConfirm := []byte(`{"action":"confirm_delete_confirm","data":{}}`) - if err := conn.WriteMessage(websocket.TextMessage, confirmConfirm); err != nil { - t.Fatalf("Failed to send confirm_delete_confirm: %v", err) - } - - _, msg, err = conn.ReadMessage() - if err != nil { - t.Fatalf("Failed to read confirm_delete_confirm response: %v", err) - } - t.Logf("confirm_delete_confirm response: %s", string(msg)) - - // Verify the action was successful - if !strings.Contains(string(msg), `"success":true`) { - t.Error("confirm_delete_confirm did not return success") - } - - // Verify todo was removed (response should show tree changes) - if strings.Contains(string(msg), "Learn LiveTemplate") { - t.Log("Note: Deleted todo text still in response (may be removal operation)") - } - - t.Log("✅ WebSocket delete flow test passed!") -} - -// TestBrowserDeleteFlow tests the complete delete flow in actual browser -func TestBrowserDeleteFlow(t *testing.T) { - // Get ports - serverPort, err := e2etest.GetFreePort() - if err != nil { - t.Fatalf("Failed to get server port: %v", err) - } - debugPort, err := e2etest.GetFreePort() - if err != nil { - t.Fatalf("Failed to get debug port: %v", err) - } - - // Start server - serverCmd := e2etest.StartTestServer(t, "main.go", serverPort) - defer serverCmd.Process.Kill() - - // Start Chrome - chromeCmd := e2etest.StartDockerChrome(t, debugPort) - defer e2etest.StopDockerChrome(t, debugPort) - _ = chromeCmd - - // Connect to Chrome - chromeURL := fmt.Sprintf("http://localhost:%d", debugPort) - allocCtx, allocCancel := chromedp.NewRemoteAllocator(context.Background(), chromeURL) - defer allocCancel() - - ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf)) - defer cancel() - - ctx, cancel = context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - // Navigate and wait for page to load - var todoCountBefore, todoCountAfter int - var hasFirstTodo bool - - t.Log("Step 1: Loading page and counting todos...") - err = chromedp.Run(ctx, - chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`h1`, chromedp.ByQuery), - chromedp.Sleep(500*time.Millisecond), - chromedp.Evaluate(`document.querySelectorAll('.todo-item').length`, &todoCountBefore), - chromedp.Evaluate(`document.body.innerHTML.includes('Learn LiveTemplate')`, &hasFirstTodo), - ) - if err != nil { - t.Fatalf("Page load failed: %v", err) - } - t.Logf("Initial todo count: %d, has first todo: %v", todoCountBefore, hasFirstTodo) - - if todoCountBefore < 1 { - t.Fatal("Expected at least 1 todo initially") - } - - // Use JS .click() instead of chromedp.Click — chromedp.Click doesn't - // reliably trigger event delegation handlers in headless Chrome. - t.Log("Step 2: Clicking delete button...") - err = chromedp.Run(ctx, - chromedp.Evaluate(`document.querySelector('form[name="confirmDelete"] button[name="confirmDelete"]').click()`, nil), - e2etest.WaitFor(`document.querySelector('[data-modal="delete_confirm"]') !== null`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to open delete modal: %v", err) - } - t.Log("Step 3: Modal appeared") - - // Click confirm button in modal - t.Log("Step 4: Clicking confirm button in modal...") - err = chromedp.Run(ctx, - chromedp.Evaluate(`document.querySelector('button[lvt-click="confirm_delete_confirm"]').click()`, nil), - e2etest.WaitFor(`document.querySelector('[data-modal="delete_confirm"]') === null`, 5*time.Second), - e2etest.WaitFor(fmt.Sprintf(`document.querySelectorAll('.todo-item').length < %d`, todoCountBefore), 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to confirm delete: %v", err) - } - - // Verify deletion - t.Log("Step 5: Verifying deletion...") - var hasFirstTodoAfter bool - err = chromedp.Run(ctx, - chromedp.Evaluate(`document.querySelectorAll('.todo-item').length`, &todoCountAfter), - chromedp.Evaluate(`document.body.innerHTML.includes('Learn LiveTemplate')`, &hasFirstTodoAfter), - ) - if err != nil { - t.Fatalf("Verification failed: %v", err) - } - - t.Logf("Todo count: before=%d, after=%d", todoCountBefore, todoCountAfter) - - if todoCountAfter >= todoCountBefore { - t.Errorf("Todo count should decrease (before: %d, after: %d)", todoCountBefore, todoCountAfter) - } - - if hasFirstTodoAfter { - t.Error("First todo should be deleted") - } - - t.Log("✅ Browser delete flow test passed!") -} - diff --git a/todos-progressive/README.md b/todos-progressive/README.md deleted file mode 100644 index ed7a9be..0000000 --- a/todos-progressive/README.md +++ /dev/null @@ -1,75 +0,0 @@ -# Todo App — Progressive Complexity (Tier 1) - -A todo application built with **zero `lvt-*` attributes**. All action routing uses standard HTML. - -This demonstrates LiveTemplate's [progressive complexity model](https://github.com/livetemplate/livetemplate/blob/main/docs/guides/progressive-complexity.md) — Tier 1: Standard HTML. - -## What Makes This Different - -Compare with the [standard todos example](../todos/) which uses `lvt-submit` and `lvt-click`. This version achieves the same functionality with pure HTML: - -| Feature | Standard Example | This Example | -|---------|-----------------|--------------| -| Add todo | `
` | `` (auto-routes to `Submit()`) | -| Toggle done | ` -
-``` -Routes to `Submit()` — the conventional default. - -### Button name routing -```html - - -``` -Routes to `Toggle()` and `Delete()`. - -### Form name routing -```html -
- -
-``` -Routes to `Filter()`. - -### Data passing via hidden inputs -```html - -``` -Accessed in Go via `ctx.GetString("id")`. - -## When to Add `lvt-*` (Tier 2) - -This example stays in Tier 1. You'd reach for Tier 2 when you need: -- `lvt-debounce` — wait for typing pause (e.g., live search) -- `lvt-key="Enter"` — keyboard shortcut filtering -- `lvt-addClass-on:pending` — reactive DOM during submission -- `lvt-hook` — integrating JavaScript libraries (charts, maps) diff --git a/todos-progressive/main.go b/todos-progressive/main.go deleted file mode 100644 index 567e43c..0000000 --- a/todos-progressive/main.go +++ /dev/null @@ -1,148 +0,0 @@ -// Progressive Complexity Demo: Todo App -// -// Demonstrates LiveTemplate's Tier 1 (Standard HTML) — ZERO lvt-* attributes. -// All action routing uses standard HTML: -// - Form auto-submit → Submit() method -// - button name="X" → X() method -// - form name="X" → X() method -// -// Works at all three transport levels: -// - No JS: POST + PRG pattern (full page reload) -// - JS + HTTP: fetch POST + DOM patch -// - JS + WebSocket: WS message + DOM patch -package main - -import ( - "fmt" - "log" - "log/slog" - "net/http" - "os" - "slices" - - "github.com/go-playground/validator/v10" - "github.com/google/uuid" - "github.com/livetemplate/livetemplate" - e2etest "github.com/livetemplate/lvt/testing" -) - -var validate = validator.New() - -type Todo struct { - ID string - Title string - Done bool -} - -type TodoState struct { - Items []Todo `lvt:"persist"` - ActiveFilter string `lvt:"persist"` -} - -func (s TodoState) ActiveCount() int { - count := 0 - for _, item := range s.Items { - if !item.Done { - count++ - } - } - return count -} - -func (s TodoState) FilteredItems() []Todo { - if s.ActiveFilter == "" || s.ActiveFilter == "all" { - return s.Items - } - var filtered []Todo - for _, item := range s.Items { - if s.ActiveFilter == "active" && !item.Done { - filtered = append(filtered, item) - } else if s.ActiveFilter == "done" && item.Done { - filtered = append(filtered, item) - } - } - return filtered -} - -type TodoController struct { - Logger *slog.Logger -} - -// Submit handles the default form (no button name, no form name). -func (c *TodoController) Submit(state TodoState, ctx *livetemplate.Context) (TodoState, error) { - var input struct { - Title string `json:"Title" validate:"required,min=1,max=200"` - } - if err := ctx.BindAndValidate(&input, validate); err != nil { - return state, err - } - - state.Items = append(state.Items, Todo{ - ID: uuid.New().String(), - Title: input.Title, - }) - c.Logger.Info("Todo added", slog.String("title", input.Title)) - return state, nil -} - -// Toggle handles - - - {{if .lvt.HasError "title"}} - {{.lvt.Error "title"}} - {{end}} - - -
-
-
-
-
- - - - - {{range .FilteredItems}} - - - - - - {{end}} - -
{{if .Done}}{{.Title}}{{else}}{{.Title}}{{end}} -
- - -
-
-
- - -
-
- - - - {{if .lvt.DevMode}} - - {{else}} - - {{end}} - - diff --git a/todos-progressive/todos_progressive_test.go b/todos-progressive/todos_progressive_test.go deleted file mode 100644 index e3dc344..0000000 --- a/todos-progressive/todos_progressive_test.go +++ /dev/null @@ -1,245 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "strings" - "testing" - "time" - - "github.com/chromedp/chromedp" - e2etest "github.com/livetemplate/lvt/testing" -) - -func TestMain(m *testing.M) { - e2etest.CleanupChromeContainers() - code := m.Run() - e2etest.CleanupChromeContainers() - os.Exit(code) -} - -func TestTodosProgressiveE2E(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - serverPort, err := e2etest.GetFreePort() - if err != nil { - t.Fatalf("Failed to get free port for server: %v", err) - } - - debugPort, err := e2etest.GetFreePort() - if err != nil { - t.Fatalf("Failed to get free port for Chrome: %v", err) - } - - e2etest.StartTestServer(t, "main.go", serverPort) - - if err := e2etest.StartDockerChrome(t, debugPort); err != nil { - t.Fatalf("Failed to start Docker Chrome: %v", err) - } - defer e2etest.StopDockerChrome(t, debugPort) - - chromeURL := fmt.Sprintf("http://localhost:%d", debugPort) - allocCtx, allocCancel := chromedp.NewRemoteAllocator(context.Background(), chromeURL) - defer allocCancel() - - ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf)) - defer cancel() - - ctx, cancel = context.WithTimeout(ctx, 60*time.Second) - defer cancel() - - err = chromedp.Run(ctx, - chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`h1`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - ) - if err != nil { - t.Fatalf("Failed to load page: %v", err) - } - - t.Run("InitialLoad", func(t *testing.T) { - var html string - var count int - var hasDone bool - err := chromedp.Run(ctx, - chromedp.OuterHTML("body", &html, chromedp.ByQuery), - chromedp.Evaluate(`document.querySelectorAll('tbody tr').length`, &count), - chromedp.Evaluate(`document.querySelector('tbody tr:nth-child(3) s') !== null`, &hasDone), - ) - if err != nil { - t.Fatalf("Failed to get initial state: %v", err) - } - - if !strings.Contains(html, "Todos (2 remaining)") { - t.Errorf("Expected 'Todos (2 remaining)' heading") - } - if !strings.Contains(html, "Read the progressive complexity guide") { - t.Error("First todo not found") - } - if !strings.Contains(html, "Try zero-attribute forms") { - t.Error("Second todo not found") - } - if !strings.Contains(html, "Add lvt-* only when needed") { - t.Error("Third todo not found") - } - if count != 3 { - t.Errorf("Expected 3 items, got %d", count) - } - if !hasDone { - t.Error("Third item should have 'done' class") - } - }) - - t.Run("AddTodo", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Evaluate(`window.liveTemplateClient.send({action: 'submit', data: {Title: 'Buy groceries'}})`, nil), - e2etest.WaitFor(`document.querySelectorAll('tbody tr').length === 4`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to add todo: %v", err) - } - - var html string - if err := chromedp.Run(ctx, chromedp.OuterHTML("body", &html, chromedp.ByQuery)); err != nil { - t.Fatalf("Failed to get HTML: %v", err) - } - if !strings.Contains(html, "Buy groceries") { - t.Error("New todo 'Buy groceries' not found") - } - if !strings.Contains(html, "3 remaining") { - t.Errorf("Active count should be 3") - } - }) - - t.Run("ToggleDone", func(t *testing.T) { - var itemID string - if err := chromedp.Run(ctx, chromedp.Evaluate( - `document.querySelector('tbody tr:nth-child(1) input[name="id"]').value`, &itemID)); err != nil { - t.Fatalf("Failed to get item ID: %v", err) - } - - err := chromedp.Run(ctx, - chromedp.Evaluate(fmt.Sprintf(`window.liveTemplateClient.send({action: 'toggle', data: {id: '%s'}})`, itemID), nil), - e2etest.WaitFor(`document.querySelector('tbody tr:nth-child(1) s') !== null`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to toggle done: %v", err) - } - - var heading string - if err := chromedp.Run(ctx, chromedp.Evaluate(`document.querySelector('h1').textContent`, &heading)); err != nil { - t.Fatalf("Failed to get heading: %v", err) - } - if !strings.Contains(heading, "2 remaining") { - t.Errorf("Expected '2 remaining', got %q", heading) - } - }) - - t.Run("ToggleUndo", func(t *testing.T) { - var itemID string - if err := chromedp.Run(ctx, chromedp.Evaluate( - `document.querySelector('tbody tr:nth-child(1) input[name="id"]').value`, &itemID)); err != nil { - t.Fatalf("Failed to get item ID: %v", err) - } - - err := chromedp.Run(ctx, - chromedp.Evaluate(fmt.Sprintf(`window.liveTemplateClient.send({action: 'toggle', data: {id: '%s'}})`, itemID), nil), - e2etest.WaitFor(`document.querySelector('tbody tr:nth-child(1) s') === null`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to undo toggle: %v", err) - } - - var heading string - if err := chromedp.Run(ctx, chromedp.Evaluate(`document.querySelector('h1').textContent`, &heading)); err != nil { - t.Fatalf("Failed to get heading: %v", err) - } - if !strings.Contains(heading, "3 remaining") { - t.Errorf("Expected '3 remaining', got %q", heading) - } - }) - - t.Run("DeleteTodo", func(t *testing.T) { - var itemID string - if err := chromedp.Run(ctx, chromedp.Evaluate( - `document.querySelector('tbody tr:last-child input[name="id"]').value`, &itemID)); err != nil { - t.Fatalf("Failed to get item ID: %v", err) - } - - err := chromedp.Run(ctx, - chromedp.Evaluate(fmt.Sprintf(`window.liveTemplateClient.send({action: 'delete', data: {id: '%s'}})`, itemID), nil), - e2etest.WaitFor(`document.querySelectorAll('tbody tr').length === 3`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to delete todo: %v", err) - } - - var html string - if err := chromedp.Run(ctx, chromedp.OuterHTML("body", &html, chromedp.ByQuery)); err != nil { - t.Fatalf("Failed to get HTML: %v", err) - } - if strings.Contains(html, "Buy groceries") { - t.Error("Deleted todo should not be present") - } - }) - - t.Run("FilterActive", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Evaluate(`window.liveTemplateClient.send({action: 'filter', data: {filter: 'active'}})`, nil), - e2etest.WaitFor(`document.querySelectorAll('tbody tr').length === 2`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to filter active: %v", err) - } - - var html string - if err := chromedp.Run(ctx, chromedp.OuterHTML("tbody", &html, chromedp.ByQuery)); err != nil { - t.Fatalf("Failed to get HTML: %v", err) - } - if strings.Contains(html, "Add lvt-* only when needed") { - t.Error("Done item should not be visible in active filter") - } - }) - - t.Run("FilterDone", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Evaluate(`window.liveTemplateClient.send({action: 'filter', data: {filter: 'done'}})`, nil), - e2etest.WaitFor(`document.querySelectorAll('tbody tr').length === 1`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to filter done: %v", err) - } - - var html string - if err := chromedp.Run(ctx, chromedp.OuterHTML("tbody", &html, chromedp.ByQuery)); err != nil { - t.Fatalf("Failed to get HTML: %v", err) - } - if !strings.Contains(html, "Add lvt-* only when needed") { - t.Error("Done item should be visible in done filter") - } - }) - - t.Run("FilterAll", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Evaluate(`window.liveTemplateClient.send({action: 'filter', data: {filter: 'all'}})`, nil), - e2etest.WaitFor(`document.querySelectorAll('tbody tr').length === 3`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to filter all: %v", err) - } - }) - - t.Run("LiveTemplateWrapper", func(t *testing.T) { - var html string - if err := chromedp.Run(ctx, chromedp.OuterHTML(`[data-lvt-id]`, &html, chromedp.ByQuery)); err != nil { - t.Fatalf("Failed to find LiveTemplate wrapper: %v", err) - } - if !strings.Contains(html, "data-lvt-id") { - t.Error("LiveTemplate wrapper not preserved") - } - }) -} diff --git a/todos/controller.go b/todos/controller.go index 41a9bec..ebbce9d 100644 --- a/todos/controller.go +++ b/todos/controller.go @@ -8,6 +8,8 @@ import ( "github.com/livetemplate/examples/todos/db" "github.com/livetemplate/livetemplate" + "github.com/livetemplate/lvt/components/modal" + "github.com/livetemplate/lvt/components/toast" ) type TodoController struct { @@ -16,15 +18,18 @@ type TodoController struct { func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) { state.Username = ctx.UserID() + state = initComponents(state) return c.loadTodos(context.Background(), state, ctx.UserID()) } func (c *TodoController) OnConnect(state TodoState, ctx *livetemplate.Context) (TodoState, error) { state.Username = ctx.UserID() + state = initComponents(state) return c.loadTodos(context.Background(), state, ctx.UserID()) } func (c *TodoController) Sync(state TodoState, ctx *livetemplate.Context) (TodoState, error) { + state = initComponents(state) return c.loadTodos(context.Background(), state, ctx.UserID()) } @@ -49,6 +54,7 @@ func (c *TodoController) Add(state TodoState, ctx *livetemplate.Context) (TodoSt return state, fmt.Errorf("failed to create todo: %w", err) } + state.Toasts.AddSuccess("Added", fmt.Sprintf("%q added", input.Text)) state.LastUpdated = formatTime() return c.loadTodos(dbCtx, state, ctx.UserID()) } @@ -78,30 +84,57 @@ func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (Tod return state, fmt.Errorf("failed to update todo: %w", err) } + if !todo.Completed { + state.Toasts.AddInfo("Done", "Todo marked as complete") + } else { + state.Toasts.AddInfo("Reopened", "Todo marked as incomplete") + } state.LastUpdated = formatTime() return c.loadTodos(dbCtx, state, ctx.UserID()) } -func (c *TodoController) Delete(state TodoState, ctx *livetemplate.Context) (TodoState, error) { - var input DeleteInput - if err := ctx.BindAndValidate(&input, validate); err != nil { - return state, err +// ConfirmDelete shows the delete confirmation modal for the given todo ID. +func (c *TodoController) ConfirmDelete(state TodoState, ctx *livetemplate.Context) (TodoState, error) { + state.DeleteID = ctx.GetString("id") + state.DeleteConfirm.Show() + return state, nil +} + +// ConfirmDeleteConfirm executes the deletion after the user confirms the modal. +func (c *TodoController) ConfirmDeleteConfirm(state TodoState, ctx *livetemplate.Context) (TodoState, error) { + if state.DeleteID == "" { + return state, nil } dbCtx := context.Background() - err := c.Queries.DeleteTodo(dbCtx, db.DeleteTodoParams{ - ID: input.ID, + ID: state.DeleteID, UserID: ctx.UserID(), }) if err != nil { return state, fmt.Errorf("failed to delete todo: %w", err) } + state.Toasts.AddSuccess("Deleted", "Todo removed") + state.DeleteConfirm.Hide() + state.DeleteID = "" state.LastUpdated = formatTime() return c.loadTodos(dbCtx, state, ctx.UserID()) } +// CancelDeleteConfirm dismisses the delete confirmation modal. +func (c *TodoController) CancelDeleteConfirm(state TodoState, ctx *livetemplate.Context) (TodoState, error) { + state.DeleteConfirm.Hide() + state.DeleteID = "" + return state, nil +} + +// DismissToastNotifications handles the dismiss action emitted by the toast component. +func (c *TodoController) DismissToastNotifications(state TodoState, ctx *livetemplate.Context) (TodoState, error) { + state.Toasts.Dismiss(ctx.GetString("toast")) + return state, nil +} + func (c *TodoController) Change(state TodoState, ctx *livetemplate.Context) (TodoState, error) { if ctx.Has("query") { state.SearchQuery = ctx.GetString("query") @@ -176,6 +209,7 @@ func (c *TodoController) ClearCompleted(state TodoState, ctx *livetemplate.Conte return state, fmt.Errorf("failed to delete completed todos: %w", err) } + state.Toasts.AddSuccess("Cleared", fmt.Sprintf("%d completed todo(s) removed", state.CompletedCount)) state.LastUpdated = formatTime() return c.loadTodos(dbCtx, state, ctx.UserID()) } @@ -212,3 +246,26 @@ func (c *TodoController) loadTodos(ctx context.Context, state TodoState, userID return state, nil } + +// initComponents initializes non-serializable component objects. +// Called from Mount/OnConnect/Sync since components can't survive serialization. +func initComponents(state TodoState) TodoState { + if state.Toasts == nil { + toasts := toast.New("notifications", + toast.WithPosition(toast.TopRight), + toast.WithMaxVisible(3), + ) + toasts.SetStyled(false) + state.Toasts = toasts + } + if state.DeleteConfirm == nil { + state.DeleteConfirm = modal.NewConfirm("delete_confirm", + modal.WithConfirmTitle("Delete Todo"), + modal.WithConfirmMessage("Are you sure you want to delete this todo?"), + modal.WithConfirmDestructive(true), + modal.WithConfirmText("Delete"), + modal.WithCancelText("Cancel"), + ) + } + return state +} diff --git a/todos/main.go b/todos/main.go index 58ed56d..dc1834c 100644 --- a/todos/main.go +++ b/todos/main.go @@ -7,6 +7,9 @@ import ( "github.com/go-playground/validator/v10" "github.com/livetemplate/livetemplate" + "github.com/livetemplate/lvt/components/base" + "github.com/livetemplate/lvt/components/modal" + "github.com/livetemplate/lvt/components/toast" e2etest "github.com/livetemplate/lvt/testing" ) @@ -51,7 +54,19 @@ func main() { LastUpdated: formatTime(), } - opts := append(envConfig.ToOptions(), livetemplate.WithAuthenticator(auth)) + componentSets := []*base.TemplateSet{ + modal.Templates(), + toast.Templates(), + } + ltSets := make([]*livetemplate.TemplateSet, len(componentSets)) + for i, set := range componentSets { + ltSets[i] = convertTemplateSet(set) + } + + opts := append(envConfig.ToOptions(), + livetemplate.WithAuthenticator(auth), + livetemplate.WithComponentTemplates(ltSets...), + ) tmpl := livetemplate.Must(livetemplate.New("todos", opts...)) http.Handle("/", tmpl.Handle(controller, livetemplate.AsState(initialState))) @@ -68,3 +83,14 @@ func main() { log.Fatalf("Server failed to start: %v", err) } } + +// convertTemplateSet converts a base.TemplateSet to livetemplate.TemplateSet. +// Required because the components library uses its own TemplateSet type to avoid import cycles. +func convertTemplateSet(set *base.TemplateSet) *livetemplate.TemplateSet { + return &livetemplate.TemplateSet{ + FS: set.FS, + Pattern: set.Pattern, + Namespace: set.Namespace, + Funcs: set.Funcs, + } +} diff --git a/todos/state.go b/todos/state.go index 5a0c7cb..4c471dd 100644 --- a/todos/state.go +++ b/todos/state.go @@ -2,6 +2,8 @@ package main import ( "github.com/livetemplate/examples/todos/db" + "github.com/livetemplate/lvt/components/modal" + "github.com/livetemplate/lvt/components/toast" ) // Default configuration constants for the todo application. @@ -84,4 +86,9 @@ type TodoState struct { ShowPagination bool `json:"show_pagination"` PrevDisabled bool `json:"prev_disabled"` NextDisabled bool `json:"next_disabled"` + + // Component state (non-persistent, re-initialized in Mount) + Toasts *toast.Container + DeleteConfirm *modal.ConfirmModal + DeleteID string `json:"delete_id" lvt:"persist"` } diff --git a/todos/todos.tmpl b/todos/todos.tmpl index 55a9f11..a2b1520 100644 --- a/todos/todos.tmpl +++ b/todos/todos.tmpl @@ -92,11 +92,11 @@ {{ if .Completed }}{{ .Text }}{{ else }}{{ .Text }}{{ end }} -
+