docs: add browser navigation best practices and design document
Add comprehensive documentation for idiomatic Phoenix LiveView URL state management to ensure browser back/forward buttons work correctly. Changes: - Add design document: docs/plans/2026-02-12-browser-navigation-fix-design.md - Documents the problem (18 LiveViews missing handle_params/3) - Provides idiomatic Phoenix patterns using push_patch/2 - Includes migration strategy for updating all LiveViews - Contains testing protocols and success criteria - Update CLAUDE.md: Add "Browser Navigation and URL State Management" section - Documents the three core patterns: tabs, modals, filters - Establishes critical rules for all future LiveView development - Provides working code examples for each pattern - Links to official Phoenix documentation Key Principles: - URL is single source of truth for visible state - Use push_patch/2 in event handlers, never assign for URL-backed state - Always implement handle_params/3 to validate and process URL params - Use Phoenix built-ins (no custom helpers needed) This ensures future LiveView development follows the correct pattern and browser navigation works as users expect. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c1bdc7d26f
commit
97b396a763
2 changed files with 475 additions and 0 deletions
161
CLAUDE.md
161
CLAUDE.md
|
|
@ -692,6 +692,167 @@ The `<.pagination>` component (defined in `CoreComponents`) accepts:
|
|||
- The component handles URL building automatically - just provide the base path and params
|
||||
- See `lib/towerops_web/live/admin/dashboard_live.ex` for a complete example
|
||||
|
||||
### Browser Navigation and URL State Management
|
||||
|
||||
**Critical Requirement:** All LiveViews MUST properly handle browser back/forward buttons by syncing UI state to URL parameters.
|
||||
|
||||
**The Idiomatic Phoenix Pattern:**
|
||||
|
||||
Use Phoenix LiveView's built-in `push_patch/2` and `handle_params/3` - no custom helpers needed.
|
||||
|
||||
**Core Principle:** URL is the single source of truth for all visible state (tabs, modals, filters, pagination).
|
||||
|
||||
#### Pattern 1: Tab Navigation
|
||||
|
||||
**LiveView:**
|
||||
```elixir
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
# Validate params - never trust user input
|
||||
tab = case params["tab"] do
|
||||
tab when tab in ~w(overview sensors interfaces) -> tab
|
||||
_ -> "overview" # Safe default
|
||||
end
|
||||
|
||||
{:noreply, socket
|
||||
|> assign(:active_tab, tab)
|
||||
|> load_tab_data(tab)}
|
||||
end
|
||||
|
||||
def handle_event("select_tab", %{"tab" => tab}, socket) do
|
||||
# Update URL with push_patch - handle_params will update assigns
|
||||
{:noreply, push_patch(socket, to: ~p"/devices/#{socket.assigns.device.id}?tab=#{tab}")}
|
||||
end
|
||||
```
|
||||
|
||||
**Template:**
|
||||
```heex
|
||||
<.link patch={~p"/devices/#{@device.id}?tab=overview"}
|
||||
class={if @active_tab == "overview", do: "active", else: ""}>
|
||||
Overview
|
||||
</.link>
|
||||
```
|
||||
|
||||
#### Pattern 2: Modal State
|
||||
|
||||
**LiveView:**
|
||||
```elixir
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
modal = params["modal"]
|
||||
|
||||
{:noreply, socket
|
||||
|> assign(:show_modal, modal != nil)
|
||||
|> assign(:modal_name, modal)
|
||||
|> maybe_initialize_form(modal)}
|
||||
end
|
||||
|
||||
def handle_event("show_add_token", _params, socket) do
|
||||
{:noreply, push_patch(socket, to: ~p"/settings?modal=add_token")}
|
||||
end
|
||||
|
||||
def handle_event("save_token", %{"token" => attrs}, socket) do
|
||||
case Accounts.create_api_token(attrs) do
|
||||
{:ok, _token} ->
|
||||
{:noreply, socket
|
||||
|> put_flash(:info, "Token created")
|
||||
|> push_patch(to: ~p"/settings")} # Closes modal by removing ?modal
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Template:**
|
||||
```heex
|
||||
<.button phx-click="show_add_token">Add API Token</.button>
|
||||
|
||||
<.modal :if={@show_modal && @modal_name == "add_token"}
|
||||
id="add-token-modal"
|
||||
on_cancel={JS.patch(~p"/settings")}>
|
||||
<.simple_form for={@form} phx-submit="save_token">
|
||||
<!-- form fields -->
|
||||
</.simple_form>
|
||||
</.modal>
|
||||
```
|
||||
|
||||
#### Pattern 3: Filters with Pagination
|
||||
|
||||
**LiveView:**
|
||||
```elixir
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
filter = case params["filter"] do
|
||||
f when f in ~w(active resolved all) -> f
|
||||
_ -> "active"
|
||||
end
|
||||
|
||||
page = params["page"] || "1" |> String.to_integer()
|
||||
|
||||
{:noreply, socket
|
||||
|> assign(:filter, filter)
|
||||
|> assign(:page, page)
|
||||
|> load_alerts(filter, page)}
|
||||
end
|
||||
|
||||
def handle_event("apply_filters", %{"filter" => filter}, socket) do
|
||||
# Reset to page 1 when filter changes
|
||||
{:noreply, push_patch(socket, to: ~p"/alerts?filter=#{filter}&page=1")}
|
||||
end
|
||||
```
|
||||
|
||||
**Template:**
|
||||
```heex
|
||||
<form phx-change="apply_filters">
|
||||
<.input name="filter" value={@filter} options={[...]} />
|
||||
</form>
|
||||
|
||||
<!-- Pagination component preserves filter param -->
|
||||
<.pagination
|
||||
meta={@pagination}
|
||||
path={~p"/alerts"}
|
||||
params={%{"filter" => @filter}}
|
||||
/>
|
||||
```
|
||||
|
||||
#### Critical Rules
|
||||
|
||||
1. **Every LiveView must implement `handle_params/3`**
|
||||
- This is the ONLY place that reads URL params and updates assigns
|
||||
- Validate all params - never trust user input
|
||||
- Provide sensible defaults for missing/invalid params
|
||||
|
||||
2. **Never use `assign` for URL-backed state**
|
||||
- ❌ `assign(socket, :active_tab, "sensors")`
|
||||
- ✅ `push_patch(socket, to: ~p"/path?tab=sensors")`
|
||||
- Let `handle_params/3` update assigns after URL changes
|
||||
|
||||
3. **Use Phoenix built-in navigation**
|
||||
- Event handlers: `push_patch(socket, to: ~p"/path?param=value")`
|
||||
- Templates: `<.link patch={~p"/path?param=value"}>...</.link>`
|
||||
- Modal cancel: `on_cancel={JS.patch(~p"/path")}`
|
||||
|
||||
4. **Test browser back/forward**
|
||||
- Click through tabs/filters
|
||||
- Press browser back - should return to previous state
|
||||
- Press browser forward - should restore next state
|
||||
- Refresh page - should preserve state from URL
|
||||
|
||||
#### What This Achieves
|
||||
|
||||
✅ Browser back button returns to previous tab/filter/modal state
|
||||
✅ Browser forward button restores next state
|
||||
✅ Page refresh preserves state from URL
|
||||
✅ Bookmarkable URLs share exact application state
|
||||
✅ Deep linking works correctly
|
||||
|
||||
#### References
|
||||
|
||||
- [Phoenix LiveView Live Navigation Guide](https://hexdocs.pm/phoenix_live_view/live-navigation.html)
|
||||
- [Persistent Forms with URL on LiveView](https://fly.io/phoenix-files/persistent-forms-with-your-url-on-liveview/)
|
||||
- Design document: `docs/plans/2026-02-12-browser-navigation-fix-design.md`
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### SNMP Mocking with Mox
|
||||
|
|
|
|||
314
docs/plans/2026-02-12-browser-navigation-fix-design.md
Normal file
314
docs/plans/2026-02-12-browser-navigation-fix-design.md
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
# Browser Back/Forward Navigation Fix - Design Document
|
||||
|
||||
**Date:** 2026-02-12
|
||||
**Status:** Approved
|
||||
**Goal:** Ensure browser back/forward buttons work correctly across all LiveView pages
|
||||
|
||||
## Problem Statement
|
||||
|
||||
18 LiveViews in the application don't properly sync UI state to URL parameters, causing browser back/forward buttons to not work as expected. Users cannot navigate back through tab changes, modal opens/closes, or filter selections.
|
||||
|
||||
## Root Causes
|
||||
|
||||
1. **Missing `handle_params/3` callbacks** - 18 LiveViews lack this required callback
|
||||
2. **State changes via `assign` only** - Event handlers update assigns without updating URL
|
||||
3. **No URL synchronization** - Tabs, modals, filters change without `push_patch/2`
|
||||
4. **Cookie consent forced reload** - JavaScript breaks navigation flow
|
||||
|
||||
## Solution: The Idiomatic Phoenix Pattern
|
||||
|
||||
Use Phoenix LiveView's built-in `push_patch/2` and `handle_params/3` pattern. No custom helpers needed.
|
||||
|
||||
### Core Architecture
|
||||
|
||||
**Principle:** URL is the single source of truth for all visible state.
|
||||
|
||||
**Flow:**
|
||||
```
|
||||
User interaction (click tab, open modal)
|
||||
→ Event handler calls push_patch(socket, to: ~p"/path?param=value")
|
||||
→ Browser URL updates
|
||||
→ handle_params/3 receives new params
|
||||
→ Validates params, updates assigns
|
||||
→ Minimal diff sent to client
|
||||
→ UI updates
|
||||
```
|
||||
|
||||
**Browser back/forward:**
|
||||
```
|
||||
User clicks browser back
|
||||
→ Browser navigates to previous URL
|
||||
→ handle_params/3 receives previous params
|
||||
→ Assigns update to previous state
|
||||
→ UI restores previous state
|
||||
```
|
||||
|
||||
## Implementation Patterns
|
||||
|
||||
### Pattern 1: Tab Navigation
|
||||
|
||||
**LiveView:**
|
||||
```elixir
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
tab = case params["tab"] do
|
||||
tab when tab in ~w(overview sensors interfaces) -> tab
|
||||
_ -> "overview" # Default
|
||||
end
|
||||
|
||||
{:noreply, socket
|
||||
|> assign(:active_tab, tab)
|
||||
|> load_tab_data(tab)}
|
||||
end
|
||||
|
||||
def handle_event("select_tab", %{"tab" => tab}, socket) do
|
||||
{:noreply, push_patch(socket, to: ~p"/devices/#{socket.assigns.device.id}?tab=#{tab}")}
|
||||
end
|
||||
```
|
||||
|
||||
**Template:**
|
||||
```heex
|
||||
<.link patch={~p"/devices/#{@device.id}?tab=overview"}
|
||||
class={if @active_tab == "overview", do: "active", else: ""}>
|
||||
Overview
|
||||
</.link>
|
||||
```
|
||||
|
||||
### Pattern 2: Modal State
|
||||
|
||||
**LiveView:**
|
||||
```elixir
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
modal = params["modal"]
|
||||
|
||||
{:noreply, socket
|
||||
|> assign(:show_modal, modal != nil)
|
||||
|> assign(:modal_name, modal)
|
||||
|> maybe_initialize_form(modal)}
|
||||
end
|
||||
|
||||
def handle_event("show_add_token", _params, socket) do
|
||||
{:noreply, push_patch(socket, to: ~p"/settings?modal=add_token")}
|
||||
end
|
||||
|
||||
def handle_event("save_token", %{"token" => attrs}, socket) do
|
||||
case Accounts.create_api_token(attrs) do
|
||||
{:ok, _token} ->
|
||||
{:noreply, socket
|
||||
|> put_flash(:info, "Token created")
|
||||
|> push_patch(to: ~p"/settings")} # Closes modal
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Template:**
|
||||
```heex
|
||||
<.button phx-click="show_add_token">Add API Token</.button>
|
||||
|
||||
<.modal :if={@show_modal && @modal_name == "add_token"}
|
||||
id="add-token-modal"
|
||||
on_cancel={JS.patch(~p"/settings")}>
|
||||
<.simple_form for={@form} phx-submit="save_token">
|
||||
<!-- form fields -->
|
||||
</.simple_form>
|
||||
</.modal>
|
||||
```
|
||||
|
||||
### Pattern 3: Filters + Pagination
|
||||
|
||||
**LiveView:**
|
||||
```elixir
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
filter = case params["filter"] do
|
||||
f when f in ~w(active resolved all) -> f
|
||||
_ -> "active"
|
||||
end
|
||||
|
||||
page = params["page"] || "1" |> String.to_integer()
|
||||
|
||||
{:noreply, socket
|
||||
|> assign(:filter, filter)
|
||||
|> assign(:page, page)
|
||||
|> load_alerts(filter, page)}
|
||||
end
|
||||
|
||||
def handle_event("apply_filters", %{"filter" => filter}, socket) do
|
||||
# Reset to page 1 when filter changes
|
||||
{:noreply, push_patch(socket, to: ~p"/alerts?filter=#{filter}&page=1")}
|
||||
end
|
||||
```
|
||||
|
||||
**Template:**
|
||||
```heex
|
||||
<form phx-change="apply_filters">
|
||||
<.input name="filter" value={@filter} options={[...]} />
|
||||
</form>
|
||||
|
||||
<.pagination meta={@pagination} path={~p"/alerts"} params={%{"filter" => @filter}} />
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### The 18 LiveViews to Update
|
||||
|
||||
**Priority 1: High-traffic user-facing (5 LiveViews)**
|
||||
1. `DashboardLive` - Main landing page
|
||||
2. `NetworkMapLive` - Topology view with tabs
|
||||
3. `AlertLive.Index` - Alerts with filters
|
||||
4. `UserSettingsLive` - Settings with modals
|
||||
5. `DeviceLive.Show` - Device details (add modal state)
|
||||
|
||||
**Priority 2: Admin and specialized (8 LiveViews)**
|
||||
6. `Admin.MonitoringLive`
|
||||
7. `Admin.DashboardLive`
|
||||
8. `AgentLive.Index`
|
||||
9. `AgentLive.Show`
|
||||
10. `SiteLive.Index`
|
||||
11. `SiteLive.Show`
|
||||
12. `OrganizationLive.Index`
|
||||
13. `BackupLive.*` (multiple)
|
||||
|
||||
**Priority 3: Auth and low-traffic (5 LiveViews)**
|
||||
14. `MobileQrLive`
|
||||
15. `OrgLive.New`
|
||||
16. `UserRegistrationLive`
|
||||
17. `UserResetPasswordLive`
|
||||
18. `Account.TotpEnrollment`
|
||||
19. `Account.Activity`
|
||||
20. `Account.MyData`
|
||||
|
||||
### Update Checklist Per LiveView
|
||||
|
||||
```markdown
|
||||
- [ ] 1. Read current implementation
|
||||
- [ ] 2. Identify stateful UI elements (tabs, modals, filters, pagination)
|
||||
- [ ] 3. Add/update `handle_params/3`:
|
||||
- [ ] Validate params with whitelist
|
||||
- [ ] Provide sensible defaults
|
||||
- [ ] Update assigns from params only
|
||||
- [ ] 4. Update event handlers:
|
||||
- [ ] Replace `assign` with `push_patch` for state changes
|
||||
- [ ] Preserve existing params when adding new ones
|
||||
- [ ] 5. Update templates:
|
||||
- [ ] Use `<.link patch={...}>` for navigation
|
||||
- [ ] Use `JS.patch(...)` in modal `on_cancel`
|
||||
- [ ] 6. Test manually:
|
||||
- [ ] Navigate through states
|
||||
- [ ] Press back - returns to previous state
|
||||
- [ ] Press forward - restores next state
|
||||
- [ ] Refresh page - state preserved
|
||||
```
|
||||
|
||||
### Batch Timeline
|
||||
|
||||
- **Days 1-2:** Priority 1 (high-traffic user-facing)
|
||||
- **Days 3-4:** Priority 2 (admin/specialized)
|
||||
- **Day 5:** Priority 3 (auth/low-traffic)
|
||||
- **Day 6:** Cookie consent fix, final testing
|
||||
|
||||
Each batch is a separate commit with manual testing before proceeding.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Manual Testing Protocol
|
||||
|
||||
For each LiveView:
|
||||
|
||||
**Tab Navigation Test:**
|
||||
1. Load page → verify default tab in URL (?tab=default)
|
||||
2. Click Tab 2 → verify URL updates (?tab=tab2)
|
||||
3. Browser back → verify returns to Tab 1
|
||||
4. Browser forward → verify returns to Tab 2
|
||||
5. Refresh → verify stays on current tab
|
||||
|
||||
**Modal State Test:**
|
||||
1. Open modal → verify ?modal=name in URL
|
||||
2. Browser back → verify modal closes
|
||||
3. Browser forward → verify modal reopens
|
||||
4. Cancel button → verify modal closes and ?modal= removed
|
||||
5. Copy URL with ?modal → paste in new tab → modal opens
|
||||
|
||||
**Filter + Pagination Test:**
|
||||
1. Change filter → verify ?filter=value&page=1
|
||||
2. Navigate to page 3 → verify ?filter=value&page=3
|
||||
3. Browser back → verify returns to page 1 with filter
|
||||
4. Change filter → verify resets to page 1
|
||||
5. Bookmark URL → reopen → verify state restored
|
||||
|
||||
### Automated Testing
|
||||
|
||||
**LiveView Test Pattern:**
|
||||
```elixir
|
||||
defmodule ToweropsWeb.DeviceLiveTest do
|
||||
use ToweropsWeb.ConnCase
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
describe "browser history with tabs" do
|
||||
test "navigating tabs updates URL", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device}")
|
||||
|
||||
# Click sensors tab
|
||||
view |> element("a", "Sensors") |> render_click()
|
||||
assert_patch(view, ~p"/devices/#{device}?tab=sensors")
|
||||
|
||||
# Simulate back button
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device}?tab=overview")
|
||||
assert has_element?(view, "[data-active-tab='overview']")
|
||||
end
|
||||
|
||||
test "handles invalid tab param", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device}?tab=invalid")
|
||||
assert has_element?(view, "[data-active-tab='overview']")
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Cookie Consent Fix
|
||||
|
||||
**File:** `assets/js/cookie_consent.js`
|
||||
|
||||
**Problem:**
|
||||
```javascript
|
||||
window.location.href = location.href; // Forces reload, breaks history
|
||||
```
|
||||
|
||||
**Fix:**
|
||||
```javascript
|
||||
// Remove the reload line - LiveView handles state updates
|
||||
```
|
||||
|
||||
**Test:**
|
||||
1. Accept cookies → no page reload
|
||||
2. Navigate away and back → cookies still accepted
|
||||
3. Browser back → smooth navigation
|
||||
|
||||
## Key Principles
|
||||
|
||||
1. **URL is source of truth** - All visible state must be in URL params
|
||||
2. **Never trust params** - Always validate in `handle_params/3`
|
||||
3. **Use Phoenix built-ins** - `push_patch/2`, `<.link patch={...}>`, `JS.patch(...)`
|
||||
4. **No custom abstractions** - Phoenix provides everything needed
|
||||
5. **Test back/forward** - Manual testing is critical for UX validation
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ Browser back button returns to previous tab/filter/modal state
|
||||
✅ Browser forward button restores next state
|
||||
✅ Page refresh preserves state from URL
|
||||
✅ Bookmarked URLs restore exact application state
|
||||
✅ All 18 LiveViews implement `handle_params/3`
|
||||
✅ Automated tests verify URL state synchronization
|
||||
✅ Cookie consent doesn't break navigation
|
||||
|
||||
## References
|
||||
|
||||
- [Phoenix LiveView Live Navigation Guide](https://hexdocs.pm/phoenix_live_view/live-navigation.html)
|
||||
- [Persistent Forms with URL on LiveView](https://fly.io/phoenix-files/persistent-forms-with-your-url-on-liveview/)
|
||||
- Phoenix LiveView `push_patch/2` documentation
|
||||
- Phoenix LiveView `handle_params/3` documentation
|
||||
Loading…
Add table
Reference in a new issue