diff --git a/CLAUDE.md b/CLAUDE.md index 19814ad8..a65d18d5 100644 --- a/CLAUDE.md +++ b/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 + +``` + +#### 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 + +<.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"> + + + +``` + +#### 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 +
+ <.input name="filter" value={@filter} options={[...]} /> +
+ + +<.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"}>...` + - 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 diff --git a/docs/plans/2026-02-12-browser-navigation-fix-design.md b/docs/plans/2026-02-12-browser-navigation-fix-design.md new file mode 100644 index 00000000..7e496131 --- /dev/null +++ b/docs/plans/2026-02-12-browser-navigation-fix-design.md @@ -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 + +``` + +### 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 + +<.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"> + + + +``` + +### 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 +
+ <.input name="filter" value={@filter} options={[...]} /> +
+ +<.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