Skip to main content

max / makenotwork

Make the projects-mode discover filters native controls Fix a form control name broken by the sidebar extraction in 0ecc3874. A rename pass rewrote name="has_source" to name="sidebar.has_source", which left the projects-mode source filter inert: the control submitted under a name DiscoverQuery does not read. The whole suite passed, because nothing asserted the wire names of any discover form control. Two tests now do. Convert the category facet from .filter-btn to radios. Category is single-valued server-side, and a native control is keyboard-operable without JS mirroring its value into a hidden input. This was the last non-native control in the sidebar, so the hidden #category-input and #ai-tier-input fields and the whole htmx:beforeRequest .filter-btn handler are now dead and removed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 18:57 UTC
Commit: 52384e8a1cfbc0d24be9981de36979e3b246a9b2
Parent: 0ecc387
4 files changed, +101 insertions, -50 deletions
@@ -116,37 +116,6 @@
116 116 });
117 117 })();
118 118
119 - // Sync filter UI state on filter click: update active highlight and write
120 - // the selected filter value into the hidden form inputs so subsequent
121 - // search/sort submissions include it.
122 - document.body.addEventListener('htmx:beforeRequest', function(evt) {
123 - if (evt.detail.elt.classList.contains('filter-btn')) {
124 - // Only deactivate siblings in the same filter section. aria-pressed must
125 - // track is-selected: the class is the visual state, the attribute is what
126 - // assistive tech reads, and letting them drift is worse than either alone.
127 - var section = evt.detail.elt.closest('.filter-list');
128 - if (section) {
129 - section.querySelectorAll('.filter-item').forEach(function(i) {
130 - i.classList.remove('is-selected');
131 - });
132 - section.querySelectorAll('.filter-btn').forEach(function(b) {
133 - b.setAttribute('aria-pressed', 'false');
134 - });
135 - }
136 - evt.detail.elt.closest('.filter-item').classList.add('is-selected');
137 - evt.detail.elt.setAttribute('aria-pressed', 'true');
138 -
139 - // Only the projects-mode category facet still uses .filter-btn.
140 - // Type, tag, and AI tier are real checkboxes and radios now, so
141 - // they carry their own state and need no mirroring.
142 - var hxVals = JSON.parse(evt.detail.elt.getAttribute('hx-vals') || '{}');
143 - if ('category' in hxVals) {
144 - var categoryInput = document.getElementById('category-input');
145 - if (categoryInput) categoryInput.value = hxVals.category || '';
146 - }
147 - }
148 - });
149 -
150 119 // View toggle (list vs grid) with localStorage persistence.
151 120 // Re-applied after HTMX swaps because new content replaces the container.
152 121 (function() {
@@ -93,8 +93,6 @@
93 93 {% for value in sidebar.hidden_tags %}
94 94 <input type="hidden" name="tag" value="{{ value }}" class="discover-filter" data-facet="tag">
95 95 {% endfor %}
96 - <input type="hidden" id="category-input" name="category" value="{{ sidebar.current_category }}" class="discover-filter">
97 - <input type="hidden" id="ai-tier-input" name="ai_tier" value="{{ sidebar.current_ai_tier }}" class="discover-filter">
98 96 </div>
99 97 </form>
100 98
@@ -4,26 +4,37 @@
4 4 <aside class="discover-sidebar" id="discover-sidebar" aria-label="Filters">
5 5 {% if mode == "projects" %}
6 6 <div class="filter-section">
7 - <div class="filter-title" id="category-label">Category</div>
8 - <ul class="filter-list" aria-labelledby="category-label">
9 - {% for cf in sidebar.category_filters %}
10 - <li class="filter-item{% if cf.active %} is-selected{% endif %}">
11 - <button type="button" class="filter-btn"
12 - aria-pressed="{% if cf.active %}true{% else %}false{% endif %}"
13 - hx-get="/discover/results"
14 - hx-target="#results-container"
15 - hx-indicator="#search-spinner"
16 - hx-include=".discover-filter"
17 - hx-vals='{"category": "{{ cf.value }}"}'>
18 - <span>{{ cf.name }}</span> <span class="count" aria-label="{{ cf.count }} projects">{{ cf.count }}</span>
19 - </button>
20 - </li>
21 - {% endfor %}
22 - </ul>
7 + <fieldset class="filter-fieldset">
8 + {# Radios, not buttons: `category` is single-valued server-side
9 + (DiscoverQuery.category is an Option), and a native control is
10 + keyboard-operable without mirroring its value into a hidden
11 + input from JS. The "All" entry carries the empty value, which
12 + parses back to no filter. #}
13 + <legend class="filter-title">Category</legend>
14 + <ul class="filter-list">
15 + {% for cf in sidebar.category_filters %}
16 + <li class="filter-item{% if cf.active %} is-selected{% endif %}">
17 + <label class="filter-check">
18 + <input type="radio" name="category" value="{{ cf.value }}"
19 + id="catsel-{% if cf.value.is_empty() %}all{% else %}{{ cf.value }}{% endif %}"
20 + class="discover-filter"
21 + {% if cf.active %}checked{% endif %}
22 + hx-get="/discover/results"
23 + hx-target="#results-container"
24 + hx-indicator="#search-spinner"
25 + hx-include=".discover-filter"
26 + hx-trigger="change">
27 + <span>{{ cf.name }}</span>
28 + <span class="count" aria-label="{{ cf.count }} projects">{{ cf.count }}</span>
29 + </label>
30 + </li>
31 + {% endfor %}
32 + </ul>
33 + </fieldset>
23 34 </div>
24 35 <div class="filter-section">
25 36 <label class="filter-checkbox discover-filter-checkbox">
26 - <input type="checkbox" id="has-source-input" name="sidebar.has_source"
37 + <input type="checkbox" id="has-source-input" name="has_source"
27 38 class="discover-filter"
28 39 value="1"
29 40 {% if sidebar.has_source %}checked{% endif %}
@@ -442,6 +442,79 @@ async fn price_filter_round_trips_into_its_inputs() {
442 442 }
443 443
444 444 // ---------------------------------------------------------------------------
445 + // Projects mode facets.
446 + // ---------------------------------------------------------------------------
447 +
448 + /// The projects-mode filters submit under the names the handler reads.
449 + ///
450 + /// A rename refactor once rewrote `name="has_source"` to `name="sidebar.has_source"`
451 + /// and nothing failed: no test asserted the wire names, so the source filter was
452 + /// simply inert. Names are the contract between the form and `DiscoverQuery`.
453 + #[tokio::test]
454 + async fn projects_mode_filters_use_the_query_param_names() {
455 + let mut h = TestHarness::new().await;
456 +
457 + let resp = h.client.get("/discover?mode=projects").await;
458 + assert!(resp.status.is_success(), "{}", resp.status);
459 + assert!(
460 + resp.text.contains(r#"name="has_source""#),
461 + "the source filter must submit as has_source, the name DiscoverQuery reads"
462 + );
463 + assert!(
464 + resp.text.contains(r#"name="category""#),
465 + "the category facet must submit as category"
466 + );
467 + assert!(
468 + !resp.text.contains(r#"name="sidebar."#),
469 + "no form control may submit under a template field path"
470 + );
471 + }
472 +
473 + /// Projects mode has no non-native controls left.
474 + #[tokio::test]
475 + async fn projects_mode_category_facet_is_a_native_control() {
476 + let mut h = TestHarness::new().await;
477 +
478 + let resp = h.client.get("/discover?mode=projects").await;
479 + assert!(resp.status.is_success(), "{}", resp.status);
480 + assert!(
481 + resp.text.contains(r#"type="radio" name="category""#),
482 + "category should be radios: it is single-valued server-side"
483 + );
484 + assert!(
485 + !resp.text.contains("filter-btn"),
486 + "no .filter-btn should remain; it needed JS to mirror its value into a hidden input"
487 + );
488 + }
489 +
490 + /// The source filter actually narrows the project list.
491 + #[tokio::test]
492 + async fn has_source_filter_narrows_projects() {
493 + let mut h = TestHarness::new().await;
494 + let setup = h.create_creator_with_item("hassrc", "digital", 1000).await;
495 + sqlx::query("UPDATE projects SET is_public = true, title = 'Sourced Project' WHERE id = $1::uuid")
496 + .bind(&setup.project_id)
497 + .execute(&h.db)
498 + .await
499 + .expect("publish project");
500 +
501 + // No git repo attached, so the filter must exclude it.
502 + let filtered = h.client.get("/discover?mode=projects&has_source=1").await;
503 + assert!(filtered.status.is_success(), "{}", filtered.status);
504 + assert!(
505 + !filtered.text.contains("Sourced Project"),
506 + "a project with no git repo must not survive has_source=1"
507 + );
508 +
509 + // Unfiltered, it shows.
510 + let all = h.client.get("/discover?mode=projects").await;
511 + assert!(
512 + all.text.contains("Sourced Project"),
513 + "the project should be listed without the filter"
514 + );
515 + }
516 +
517 + // ---------------------------------------------------------------------------
445 518 // Out-of-band sidebar refresh.
446 519 // ---------------------------------------------------------------------------
447 520