max / makenotwork
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 |