Skip to content

Commit 42385dc

Browse files
feat(ops): URL param parsing + chip-state-from-URL (A3c, final A3)
A3c of docs/specs/ops-workflows-page-refinement/. Final piece of the A3 implementation. Mirror of Specs page A3c (PR #539). What changes in workflows_refined.js: - New readURLState() — parses ?bucket=…&q=… on init. Bucket values are filtered against VALID_BUCKETS so invalid params fall through to defaults (no zero-chip lockout). Search value is taken verbatim. - New bucketsAreDefault() — returns true when all 7 buckets are on. Used by syncURL() to keep "clean" URLs short. - New syncURL() — called after every state change (chip toggle, search input). Uses history.replaceState so toggles don't pollute the back button. Defaults are normalized OUT of the URL — share a link from default state and you get bare /workflows. - syncChipsToState() — new helper called in init() AFTER readURLState(). The server-rendered HTML has all chips chip-active; URL params may have flipped some off. This brings visual state in line with the logical state on first paint. - init() reorders: readURLState → syncChipsToState → wireChips → wireSearch → set input.value → applyFilters. Chip handlers now also call syncURL() after applyFilters(). URL schema: ?bucket=review,audit → only review + audit chips active ?q=security → search prefilled with "security" ?bucket=review&q=auth → combine both (no params) → defaults (all 7 buckets, empty search) Bucket values are sorted in the URL for stability (toggle order doesn't affect the generated link). Older browsers silently fall through to default state if URL or history APIs are unavailable — no UX regression. Tests (9 new): tests/unit/ops/test_workflows_refined_js.py gains a TestURLStateSurface class covering: - readURLState / syncURL / bucketsAreDefault on the public namespace - ?bucket= parsing (searchParams.get("bucket")) - ?q= parsing (searchParams.get("q")) - replaceState used (not pushState) — no history pollution - Defaults removed from URL (delete("bucket") + delete("q")) - syncURL called from chip + search handlers (>= 2 occurrences) - init() calls readURLState() All 60 Workflows-page tests pass (A2 + A3a + A3b + A3c combined). Spec progression now complete: - A1 — module (#552) - A2 — route (#553) - A3a — chips + pill + JS (#554) - A3b — kebab menu (#555) - A3c — URL params (this PR — final A3) After this stack merges, the /workflows route fully mirrors the just-shipped /specs page UX. Worth a v7.3.1 patch when combined with Phase 6 of sdk-error-message-fidelity (#551, merged). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 86eef96 commit 42385dc

2 files changed

Lines changed: 148 additions & 9 deletions

File tree

src/attune/ops/static/js/workflows_refined.js

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
// Workflows page — concern-chip filter + search behavior (A3a).
1+
// Workflows page — concern-chip filter + search + URL state (A3a + A3c).
22
//
33
// Reads the server-rendered table at /workflows and applies client-side
4-
// filtering by concern bucket + workflow name substring.
4+
// filtering by concern bucket + workflow name substring. URL state
5+
// (?bucket=…&q=…) is the source of truth — JS reads on init, writes
6+
// on chip toggle / search input.
57
//
68
// Default state per docs/specs/ops-workflows-page-refinement/decisions.md D1/D2:
79
// - All 7 concern chips active (no chip is structurally "noise"
@@ -11,8 +13,6 @@
1113
// No sort dropdown — workflows render alphabetical by name from the
1214
// server (decisions.md D7: alphabetical-by-name; v2 may add by-last-run).
1315
//
14-
// URL params ship in A3c. A3b adds the kebab action menu.
15-
//
1616
// Exports a `window.__attuneWorkflows` namespace so future PRs and
1717
// source-grep tests can verify the surface (matches the
1818
// `__attuneSpecs` + `__attuneRunner` convention).
@@ -38,6 +38,73 @@
3838
search: "",
3939
};
4040

41+
// ---------- URL state (A3c) ----------
42+
//
43+
// ?bucket=review,audit&q=security → state.buckets={review,audit}, search="security"
44+
//
45+
// Defaults are normalized OUT of the URL — a "clean" URL (no params)
46+
// means "default state: all buckets, no search". This keeps shared
47+
// links short and survives back/forward navigation cleanly.
48+
49+
function readURLState() {
50+
try {
51+
var u = new URL(window.location.href);
52+
var bucketParam = u.searchParams.get("bucket");
53+
if (bucketParam !== null && bucketParam.length > 0) {
54+
var candidates = bucketParam
55+
.split(",")
56+
.map(function (b) {
57+
return b.trim();
58+
})
59+
.filter(function (b) {
60+
return VALID_BUCKETS.has(b);
61+
});
62+
// Empty after filtering → invalid param, fall back to defaults
63+
// (don't strand the user with zero chips active on first paint).
64+
if (candidates.length > 0) {
65+
state.buckets = new Set(candidates);
66+
}
67+
}
68+
var q = u.searchParams.get("q");
69+
if (q !== null) {
70+
state.search = q;
71+
}
72+
} catch (e) {
73+
// URL parse failure in older browsers / restrictive contexts —
74+
// silent fall-through to defaults is fine, no UX regression.
75+
}
76+
}
77+
78+
function bucketsAreDefault(bucketSet) {
79+
// "Default" = all 7 buckets on. URL stays clean in that case.
80+
return bucketSet.size === VALID_BUCKETS.size;
81+
}
82+
83+
function syncURL() {
84+
try {
85+
var u = new URL(window.location.href);
86+
if (bucketsAreDefault(state.buckets)) {
87+
u.searchParams.delete("bucket");
88+
} else {
89+
// Sort for stable URLs — same bucket set always serializes the
90+
// same way regardless of toggle order.
91+
var sorted = Array.from(state.buckets).sort();
92+
u.searchParams.set("bucket", sorted.join(","));
93+
}
94+
var q = (state.search || "").trim();
95+
if (q.length === 0) {
96+
u.searchParams.delete("q");
97+
} else {
98+
u.searchParams.set("q", q);
99+
}
100+
// Use replaceState — toggling a chip shouldn't push a history
101+
// entry per toggle. Back button still leaves /workflows entirely.
102+
window.history.replaceState({}, "", u.toString());
103+
} catch (e) {
104+
// Older browser — silent no-op (state still works, just no URL sync).
105+
}
106+
}
107+
41108
// ---------- DOM helpers ----------
42109

43110
function $(sel) {
@@ -139,6 +206,7 @@
139206
setChipState(chip, true);
140207
}
141208
applyFilters();
209+
syncURL();
142210
});
143211
});
144212
}
@@ -149,20 +217,35 @@
149217
input.addEventListener("input", function () {
150218
state.search = input.value;
151219
applyFilters();
220+
syncURL();
152221
});
153222
}
154223

155224
// ---------- Init ----------
156225

226+
function syncChipsToState() {
227+
// After readURLState() runs, the visual chip state may not match
228+
// what state.buckets says. Server-rendered HTML has all chips
229+
// chip-active; URL params may have flipped some off. This brings
230+
// visual state in line with the logical state on first paint.
231+
$$(".workflows-toolbar .chip[data-bucket]").forEach(function (chip) {
232+
var bucket = chip.getAttribute("data-bucket");
233+
setChipState(chip, state.buckets.has(bucket));
234+
});
235+
}
236+
157237
function init() {
238+
// A3c — URL state is the source of truth on init. Read it before
239+
// wiring handlers so the first handler invocation already sees
240+
// the URL-derived state.
241+
readURLState();
242+
syncChipsToState();
158243
wireChips();
159244
wireSearch();
160-
// First-paint render — server already filtered nothing, but the
161-
// empty-state logic needs to run in case there's a stored search
162-
// (browser back/forward restored the input value).
245+
// Sync the search <input>'s value to the URL-derived state.
163246
var input = $("#workflows-search");
164-
if (input && input.value) {
165-
state.search = input.value;
247+
if (input) {
248+
input.value = state.search;
166249
}
167250
applyFilters();
168251
}
@@ -180,6 +263,9 @@
180263
state: state,
181264
applyFilters: applyFilters,
182265
setChipState: setChipState,
266+
readURLState: readURLState,
267+
syncURL: syncURL,
268+
bucketsAreDefault: bucketsAreDefault,
183269
init: init,
184270
};
185271
})();

tests/unit/ops/test_workflows_refined_js.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,56 @@ def test_template_loads_workflows_refined_js(self) -> None:
143143
)
144144
template_text = template_path.read_text(encoding="utf-8")
145145
assert "workflows_refined.js" in template_text
146+
147+
148+
class TestURLStateSurface:
149+
"""A3c — URL param parsing + state sync.
150+
151+
The JS module exposes readURLState/syncURL on the namespace and
152+
wires them into chip + search handlers so ?bucket=…&q=… is the
153+
source of truth on init and updates as the user toggles.
154+
"""
155+
156+
def test_exposes_read_url_state(self, js_text: str) -> None:
157+
"""readURLState is on the public surface."""
158+
assert "readURLState" in js_text
159+
160+
def test_exposes_sync_url(self, js_text: str) -> None:
161+
"""syncURL is on the public surface."""
162+
assert "syncURL" in js_text
163+
164+
def test_exposes_buckets_are_default(self, js_text: str) -> None:
165+
"""bucketsAreDefault helper for clean-URL detection."""
166+
assert "bucketsAreDefault" in js_text
167+
168+
def test_parses_bucket_query_param(self, js_text: str) -> None:
169+
"""URL ?bucket= is read into state.buckets."""
170+
assert 'searchParams.get("bucket")' in js_text
171+
172+
def test_parses_q_query_param(self, js_text: str) -> None:
173+
"""URL ?q= is read into state.search."""
174+
assert 'searchParams.get("q")' in js_text
175+
176+
def test_uses_replace_state_not_push(self, js_text: str) -> None:
177+
"""Toggling a chip uses replaceState — toggles don't pollute
178+
browser history.
179+
"""
180+
assert "history.replaceState" in js_text
181+
182+
def test_clean_url_when_defaults(self, js_text: str) -> None:
183+
"""When all buckets are on and search is empty, both params
184+
are removed from the URL.
185+
"""
186+
assert 'searchParams.delete("bucket")' in js_text
187+
assert 'searchParams.delete("q")' in js_text
188+
189+
def test_sync_url_called_on_chip_toggle(self, js_text: str) -> None:
190+
"""The chip click handler calls syncURL() after applyFilters()."""
191+
# Pattern: applyFilters() is called, then syncURL() within the
192+
# same handler. Just check syncURL() appears more than once
193+
# (definition + at least one call site).
194+
assert js_text.count("syncURL()") >= 2
195+
196+
def test_init_calls_read_url_state(self, js_text: str) -> None:
197+
"""init() reads URL state before wiring handlers."""
198+
assert "readURLState()" in js_text

0 commit comments

Comments
 (0)