From bfb50e1b193c2b183ac10602059449c51af78e04 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Thu, 14 May 2026 21:25:51 +0200 Subject: [PATCH] fix(goals/index): persist filter + search in URL across reloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX audit finding. The filter chip state and search input lived only in Stimulus values — a Behind-filter selection survived turbo morphs but vanished on F5, browser back from a goal's show page, and any deeplink share. For a family with 10+ goals filtering by "behind", every navigation reset to "all". Hydrate on `connect()`: - read `?filter=behind` → statusValue - read `?q=…` → input target Sync on every `filter()` call via `history.replaceState`: - filter=all → drop key - q empty → drop key - else preserve both Uses `replaceState` (not `pushState`) so each keystroke / chip click doesn't bloat the back-history. The page URL becomes shareable for the filtered view. --- .../controllers/goals_filter_controller.js | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/app/javascript/controllers/goals_filter_controller.js b/app/javascript/controllers/goals_filter_controller.js index cb534cf51..b2479a440 100644 --- a/app/javascript/controllers/goals_filter_controller.js +++ b/app/javascript/controllers/goals_filter_controller.js @@ -25,7 +25,11 @@ export default class extends Controller { }; connect() { + this.#hydrateFromUrl(); this.syncChipState(); + if (this.statusValue !== "all" || (this.hasInputTarget && this.inputTarget.value)) { + this.filter(); + } } filter() { @@ -56,6 +60,37 @@ export default class extends Controller { } this.updateEmptyState(visible, query, active); + this.#syncUrl(); + } + + #hydrateFromUrl() { + const params = new URLSearchParams(window.location.search); + const status = params.get("filter"); + if (status && this.chipTargets.some((c) => c.dataset.status === status)) { + this.statusValue = status; + } + const q = params.get("q"); + if (q && this.hasInputTarget) { + this.inputTarget.value = q; + } + } + + #syncUrl() { + const params = new URLSearchParams(window.location.search); + if (this.statusValue && this.statusValue !== "all") { + params.set("filter", this.statusValue); + } else { + params.delete("filter"); + } + const q = this.hasInputTarget ? this.inputTarget.value.trim() : ""; + if (q) { + params.set("q", q); + } else { + params.delete("q"); + } + const qs = params.toString(); + const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname; + window.history.replaceState(window.history.state, "", url); } updateEmptyState(visible, query, active) {