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:
Graham McIntire 2026-02-12 09:20:14 -06:00
parent c1bdc7d26f
commit 97b396a763
No known key found for this signature in database
2 changed files with 475 additions and 0 deletions

161
CLAUDE.md
View file

@ -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

View 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