-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add progressive enhancement example #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| # Progressive Enhancement Example | ||
|
|
||
| This example demonstrates how LiveTemplate supports progressive enhancement - allowing apps to work both with and without JavaScript enabled. | ||
|
|
||
| ## How It Works | ||
|
|
||
| ### With JavaScript (WebSocket Mode) | ||
| - Actions are sent via WebSocket for instant updates | ||
| - No page reloads - UI updates in real-time | ||
| - Best user experience for modern browsers | ||
|
|
||
| ### Without JavaScript (HTTP Form Mode) | ||
| - Actions are submitted via standard HTML forms | ||
| - Server returns full HTML pages using POST-Redirect-GET pattern | ||
| - Page reloads after each action | ||
| - Works on any browser, including text-based browsers | ||
|
|
||
| ## Key Concepts | ||
|
|
||
| ### Dual-Mode Forms | ||
|
|
||
| Forms work in both modes by including both `lvt-submit` (for JS) and `method="POST"` + `lvt-action` (for no-JS): | ||
|
|
||
| ```html | ||
| <form method="POST" lvt-submit="add"> | ||
| <input type="hidden" name="lvt-action" value="add"> | ||
| <input type="text" name="title"> | ||
| <button type="submit">Add</button> | ||
| </form> | ||
| ``` | ||
|
|
||
| - **With JS**: `lvt-submit` triggers a WebSocket action, preventing form submission | ||
| - **Without JS**: Standard form submission sends POST request to server | ||
|
|
||
| ### POST-Redirect-GET (PRG) Pattern | ||
|
|
||
| For non-JS clients, successful actions redirect using HTTP 303: | ||
|
|
||
| 1. User submits form via POST | ||
| 2. Server processes action, updates state | ||
| 3. Server responds with 303 redirect to same URL | ||
| 4. Browser follows redirect with GET | ||
| 5. User sees updated page | ||
|
|
||
| This prevents duplicate submissions when users refresh the page. | ||
|
|
||
| ### Validation Errors | ||
|
|
||
| When validation fails: | ||
| - **With JS**: Errors appear instantly via WebSocket update | ||
| - **Without JS**: Server re-renders the page with errors inline (no redirect) | ||
|
|
||
| ### Flash Messages | ||
|
|
||
| Success/error messages are shown once after actions: | ||
| - **With JS**: Messages appear in real-time | ||
| - **Without JS**: Messages passed via query params after redirect | ||
|
|
||
| ## Running the Example | ||
|
|
||
| ```bash | ||
| # Development mode (uses local client library) | ||
| LVT_DEV_MODE=true go run . | ||
|
|
||
| # Production mode (uses CDN client library) | ||
| go run . | ||
| ``` | ||
|
|
||
| Visit http://localhost:8080 and try: | ||
| 1. With JavaScript enabled - notice instant updates | ||
| 2. Disable JavaScript and refresh - notice page reloads after each action | ||
| 3. Both modes provide the same functionality | ||
|
|
||
| ## Configuration | ||
|
|
||
| Progressive enhancement is enabled by default. To disable it: | ||
|
|
||
| ```go | ||
| // Via environment variable | ||
| LVT_PROGRESSIVE_ENHANCEMENT=false go run . | ||
|
|
||
| // Or via code | ||
| tmpl := livetemplate.New("app", livetemplate.WithProgressiveEnhancement(false)) | ||
| ``` | ||
|
|
||
| ## Files | ||
|
|
||
| - `main.go` - Controller, state, and action handlers | ||
| - `progressive-enhancement.tmpl` - Template with dual-mode forms | ||
| - `progressive_enhancement_test.go` - End-to-end tests for progressive enhancement behavior | ||
| - `README.md` - This documentation | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "log" | ||
| "net/http" | ||
| "os" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/go-playground/validator/v10" | ||
| "github.com/livetemplate/livetemplate" | ||
| e2etest "github.com/livetemplate/lvt/testing" | ||
| ) | ||
|
|
||
| // TodoController is a singleton that holds dependencies. | ||
| // In this example, we don't have external dependencies like a database. | ||
| type TodoController struct { | ||
| validate *validator.Validate | ||
| } | ||
|
|
||
| // TodoState is pure data, cloned per session. | ||
| // It contains all the state needed to render the template. | ||
| type TodoState struct { | ||
| Title string `json:"title"` | ||
| Items []Todo `json:"items"` | ||
| // Form input values (preserved on validation errors) | ||
| InputTitle string `json:"input_title"` | ||
| } | ||
|
|
||
| // Todo represents a single todo item. | ||
| type Todo struct { | ||
| ID string `json:"id"` | ||
| Title string `json:"title"` | ||
| Completed bool `json:"completed"` | ||
| CreatedAt string `json:"created_at"` | ||
| } | ||
|
|
||
| // AddInput is the input struct for the Add action. | ||
| type AddInput struct { | ||
| Title string `json:"title" validate:"required,min=3,max=100"` | ||
| } | ||
|
|
||
| // Mount is called once when a session is created. | ||
| func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) { | ||
| state.Title = "Progressive Enhancement Todo List" | ||
|
|
||
| // Pre-populate with sample items if empty | ||
| if len(state.Items) == 0 { | ||
| state.Items = []Todo{ | ||
| {ID: "1", Title: "Learn about progressive enhancement", Completed: true, CreatedAt: formatTime()}, | ||
| {ID: "2", Title: "Try the app without JavaScript", Completed: false, CreatedAt: formatTime()}, | ||
| {ID: "3", Title: "Enable JavaScript and see the difference", Completed: false, CreatedAt: formatTime()}, | ||
| } | ||
| } | ||
|
|
||
| // Check for flash messages from URL (after redirect) | ||
| if success := ctx.GetString("success"); success != "" { | ||
| ctx.SetFlash("success", success) | ||
| } | ||
| if errorMsg := ctx.GetString("error"); errorMsg != "" { | ||
| ctx.SetFlash("error", errorMsg) | ||
| } | ||
|
|
||
| return state, nil | ||
| } | ||
|
|
||
| // Add handles adding a new todo item. | ||
| func (c *TodoController) Add(state TodoState, ctx *livetemplate.Context) (TodoState, error) { | ||
| var input AddInput | ||
| if err := ctx.BindAndValidate(&input, c.validate); err != nil { | ||
| // Preserve the input value so user doesn't have to retype | ||
| state.InputTitle = ctx.GetString("title") | ||
| return state, err | ||
| } | ||
|
|
||
| // Create new todo | ||
| newID := fmt.Sprintf("%d", time.Now().UnixNano()) | ||
| state.Items = append(state.Items, Todo{ | ||
| ID: newID, | ||
| Title: strings.TrimSpace(input.Title), | ||
| Completed: false, | ||
| CreatedAt: formatTime(), | ||
| }) | ||
|
|
||
| // Clear the input field | ||
| state.InputTitle = "" | ||
|
|
||
| // Set flash message for success | ||
| ctx.SetFlash("success", fmt.Sprintf("Added: %s", input.Title)) | ||
|
|
||
| return state, nil | ||
| } | ||
|
|
||
| // Toggle handles toggling a todo's completed status. | ||
| func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (TodoState, error) { | ||
| id := ctx.GetString("id") | ||
| found := false | ||
| for i := range state.Items { | ||
| if state.Items[i].ID == id { | ||
| state.Items[i].Completed = !state.Items[i].Completed | ||
| found = true | ||
| ctx.SetFlash("success", "Item updated") | ||
| break | ||
| } | ||
| } | ||
| if !found { | ||
| ctx.SetFlash("error", "Item not found") | ||
| } | ||
| return state, nil | ||
| } | ||
|
|
||
| // Delete handles deleting a todo item. | ||
| func (c *TodoController) Delete(state TodoState, ctx *livetemplate.Context) (TodoState, error) { | ||
| id := ctx.GetString("id") | ||
|
|
||
| // Find index of the item to delete without modifying the slice during iteration. | ||
| deleteIndex := -1 | ||
| for i, item := range state.Items { | ||
| if item.ID == id { | ||
| deleteIndex = i | ||
| break | ||
| } | ||
| } | ||
|
|
||
| if deleteIndex >= 0 { | ||
| state.Items = append(state.Items[:deleteIndex], state.Items[deleteIndex+1:]...) | ||
| ctx.SetFlash("success", "Item deleted") | ||
| } else { | ||
| ctx.SetFlash("error", "Item not found") | ||
| } | ||
| return state, nil | ||
| } | ||
|
|
||
| func formatTime() string { | ||
| return time.Now().Format("2006-01-02 15:04:05") | ||
| } | ||
|
|
||
| func main() { | ||
| log.Println("Progressive Enhancement 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 with validator | ||
| controller := &TodoController{ | ||
| validate: validator.New(), | ||
| } | ||
|
|
||
| // Create initial state | ||
| initialState := &TodoState{} | ||
|
|
||
| // Create template with configuration | ||
| // Progressive enhancement is enabled by default | ||
| tmpl := livetemplate.Must(livetemplate.New("progressive-enhancement", envConfig.ToOptions()...)) | ||
|
|
||
| // Mount handler | ||
| http.Handle("/", tmpl.Handle(controller, livetemplate.AsState(initialState))) | ||
|
|
||
| // Serve client library (development only) | ||
| 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("Try it:") | ||
| log.Println(" 1. Open in browser with JavaScript ENABLED - uses WebSocket for instant updates") | ||
| log.Println(" 2. Disable JavaScript and refresh - uses HTTP form submissions with page reloads") | ||
| log.Println(" 3. Both modes work identically, just with different performance characteristics") | ||
| log.Println("") | ||
|
|
||
| if err := http.ListenAndServe(":"+port, nil); err != nil { | ||
| log.Fatalf("Server failed to start: %v", err) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Files section lists only three files but omits the test file. Consider adding progressive_enhancement_test.go to the list since it's part of the example and demonstrates E2E testing patterns.