Skip to main content

max / makenotwork

Support faceted multi-select in discover filters Repeated params now OR within a facet and AND across facets: ?tag=a&tag=b&item_type=audio&item_type=sample selects (a OR b) AND (audio OR sample). Tag filtering becomes one EXISTS over array binds, t2.slug = ANY($5) OR t2.path LIKE ANY($6), with the descendant patterns built by tagtree::like_descendant_pattern so the dot-path convention and the LIKE metacharacter escaping stay in one place. A single value parses as a one-element vec, so existing links, bookmarks, and the current single-select UI keep working unchanged. Notes: - axum::extract::Query cannot collect repeated keys into a Vec, since it uses serde_urlencoded. Switch to axum_extra::extract::Query, which uses serde_html_form (already in the lockfile as a transitive dep). - ai_tier stays single-valued. The three choices are nested ranges (everything covers human-led covers handmade), so OR-ing any two only widens back to the looser one. - Multi-valued facets carry one hidden input per selected value rather than one delimited input. The form is the state carrier for hx-include, and joining selections would both break on a value containing the delimiter and read back as a single tag literally named "a,b". The visible controls are still single-select; this is what lets a multi-select round-trip, and what a checkbox UI will write into. - Blank values are dropped and duplicates deduped, order-preserving, in one helper shared by the filter parse and the URL emission, so the pushed URL cannot disagree with the filter that was applied.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 16:49 UTC
Signed with PGP, not checked
Commit: edfc01a4a97eb171363c5a4b719f37cb2ca078bf
Parent: a668df6
8 files changed, +392 insertions, -88 deletions
@@ -16,7 +16,7 @@
16 16
17 17 # Web framework
18 18 axum = { version = "0.8.8", features = ["macros"] }
19 - axum-extra = { version = "0.10.3", features = ["cookie", "form", "typed-header"] }
19 + axum-extra = { version = "0.10.3", features = ["cookie", "form", "query", "typed-header"] }
20 20
21 21 # OpenAPI spec generation
22 22 utoipa = { version = "5", features = ["axum_extras", "chrono", "uuid"] }
@@ -1,5 +1,31 @@
1 + // Replace the hidden inputs carrying a multi-valued facet.
2 + //
3 + // These facets are one input per selected value rather than one delimited
4 + // input, so the form can express a multi-select without a delimiter
5 + // convention that would break on a value containing the delimiter. The
6 + // visible controls are still single-select, so `values` currently holds at
7 + // most one entry; the DOM shape is what lets the server-side multi-select
8 + // round-trip, and what the checkbox UI will write into directly.
9 + function setFacetValues(facet, values) {
10 + var form = document.getElementById('discover-form');
11 + if (!form) return;
12 + form.querySelectorAll('input[data-facet="' + facet + '"]').forEach(function(el) {
13 + el.remove();
14 + });
15 + values.forEach(function(value) {
16 + if (!value) return;
17 + var input = document.createElement('input');
18 + input.type = 'hidden';
19 + input.name = facet;
20 + input.value = value;
21 + input.className = 'discover-filter';
22 + input.dataset.facet = facet;
23 + form.appendChild(input);
24 + });
25 + }
26 +
1 27 // Sync filter UI state on filter click: update active highlight and write
2 - // the selected filter value into the hidden form input so subsequent
28 + // the selected filter value into the hidden form inputs so subsequent
3 29 // search/sort submissions include it.
4 30 document.body.addEventListener('htmx:beforeRequest', function(evt) {
5 31 if (evt.detail.elt.classList.contains('filter-btn')) {
@@ -20,10 +46,10 @@
20 46
21 47 var hxVals = JSON.parse(evt.detail.elt.getAttribute('hx-vals') || '{}');
22 48 if ('item_type' in hxVals) {
23 - document.getElementById('type-input').value = hxVals.item_type || '';
49 + setFacetValues('item_type', hxVals.item_type ? [hxVals.item_type] : []);
24 50 }
25 51 if ('tag' in hxVals) {
26 - document.getElementById('tag-input').value = hxVals.tag || '';
52 + setFacetValues('tag', hxVals.tag ? [hxVals.tag] : []);
27 53 }
28 54 if ('category' in hxVals) {
29 55 document.getElementById('category-input').value = hxVals.category || '';
@@ -15,14 +15,37 @@
15 15 /// `discover_items`; `count_discover_items` ignores it.
16 16 pub struct DiscoverFilters<'a> {
17 17 pub search: Option<&'a str>,
18 - pub item_type: Option<ItemType>,
19 - pub tag: Option<&'a str>,
18 + /// Selected item types. Empty means unfiltered; multiple values are OR'd.
19 + pub item_types: &'a [ItemType],
20 + /// Selected tag slugs. Empty means unfiltered; multiple values are OR'd,
21 + /// and each matches its own subtree.
22 + pub tags: &'a [String],
20 23 pub min_price: Option<i32>,
21 24 pub max_price: Option<i32>,
22 25 pub sort_by: Option<DiscoverSort>,
26 + /// Single-valued by nature: the three AI-disclosure choices are nested
27 + /// ranges (everything ⊇ human-led ⊇ handmade), not independent values, so
28 + /// OR-ing them would only ever widen back to the looser one.
23 29 pub ai_tier: Option<AiTierFilter>,
24 30 }
25 31
32 + impl DiscoverFilters<'_> {
33 + /// Item-type slugs for the `= ANY($2)` bind.
34 + fn item_type_slugs(&self) -> Vec<String> {
35 + self.item_types.iter().map(|t| t.to_string()).collect()
36 + }
37 +
38 + /// `LIKE` patterns matching each selected tag's descendants, via tagtree so
39 + /// the dot-path convention and the LIKE metacharacter escaping stay in one
40 + /// place. Pairs with [`Self::tags`] as the exact-slug bind.
41 + fn tag_descendant_patterns(&self) -> Vec<String> {
42 + self.tags
43 + .iter()
44 + .map(|slug| tagtree::like_descendant_pattern(slug))
45 + .collect()
46 + }
47 + }
48 +
26 49 // Shared SQL fragments for fuzzy search (trigram + ILIKE fallback).
27 50 // The ILIKE escapes \, %, _ in the search term to prevent LIKE metacharacter interpretation.
28 51
@@ -75,8 +98,23 @@
75 98 term.trim().len() <= 2
76 99 }
77 100
101 + /// The item-type clause. OR within the facet: any selected type matches.
102 + const ITEM_TYPE_CLAUSE: &str = " AND i.item_type = ANY($2)";
103 +
104 + /// The tag clause. OR within the facet, and each selected tag matches its own
105 + /// subtree: `$5` holds the exact slugs, `$6` the `tagtree`-built descendant
106 + /// patterns. LIKE's default escape character is backslash, which is what
107 + /// `tagtree::like_descendant_pattern` escapes with.
108 + const TAG_CLAUSE: &str = r#" AND EXISTS (
109 + SELECT 1 FROM item_tags it2
110 + JOIN tags t2 ON t2.id = it2.tag_id
111 + WHERE it2.item_id = i.id
112 + AND (t2.slug = ANY($5) OR t2.path LIKE ANY($6))
113 + )"#;
114 +
78 115 /// Append discover-item filter clauses to a dynamic query.
79 - /// Parameter positions: $1=search, $2=item_type, $3=min_price, $4=max_price, $5=tag, $6=ai_tier.
116 + /// Parameter positions: $1=search, $2=item_types, $3=min_price, $4=max_price,
117 + /// $5=tag_slugs, $6=tag_descendant_patterns. AI tier is inlined (see below).
80 118 ///
81 119 /// When `short_query` is `true`, the ILIKE-only clause is used instead of the
82 120 /// full trigram + ILIKE clause (trigram matching is unreliable for 1-2 char terms).
@@ -93,8 +131,8 @@
93 131 query.push_str(ITEM_SEARCH_CLAUSE);
94 132 }
95 133 }
96 - if filters.item_type.is_some() {
97 - query.push_str(" AND i.item_type = $2");
134 + if !filters.item_types.is_empty() {
135 + query.push_str(ITEM_TYPE_CLAUSE);
98 136 }
99 137 if filters.min_price.is_some() {
100 138 query.push_str(" AND i.price_cents >= $3");
@@ -102,16 +140,8 @@
102 140 if filters.max_price.is_some() {
103 141 query.push_str(" AND i.price_cents <= $4");
104 142 }
105 - if filters.tag.is_some() {
106 - // Match exact slug or any descendant (e.g. "audio.genre" matches "audio.genre.electronic")
107 - query.push_str(
108 - r#" AND EXISTS (
109 - SELECT 1 FROM item_tags it2
110 - JOIN tags t2 ON t2.id = it2.tag_id
111 - WHERE it2.item_id = i.id
112 - AND (t2.slug = $5 OR t2.path LIKE $5 || '.%')
113 - )"#,
114 - );
143 + if !filters.tags.is_empty() {
144 + query.push_str(TAG_CLAUSE);
115 145 }
116 146 // AI disclosure filter: `Handmade only` narrows to handmade; `Human-led`
117 147 // accepts handmade ∪ assisted. Values are enum-derived constants (not user
@@ -126,16 +156,21 @@
126 156 }
127 157 }
128 158
129 - /// Bind the 5 discover-filter parameters ($1-$5) to a sqlx query.
159 + /// Bind the 6 discover-filter parameters ($1-$6) to a sqlx query.
130 160 /// The AI-tier filter is appended to the WHERE as a literal SQL fragment,
131 161 /// so it occupies no bind position.
162 + ///
163 + /// Array binds are always supplied even when the corresponding clause is
164 + /// omitted, so every discover query has the same bind arity regardless of which
165 + /// filters are active.
132 166 macro_rules! bind_item_discover_filters {
133 167 ($q:expr, $filters:expr, $search_term:expr) => {
134 168 $q.bind($search_term.unwrap_or(""))
135 - .bind($filters.item_type.map(|t| t.to_string()).unwrap_or_default())
169 + .bind($filters.item_type_slugs())
136 170 .bind($filters.min_price.unwrap_or(0))
137 171 .bind($filters.max_price.unwrap_or(i32::MAX))
138 - .bind($filters.tag.unwrap_or(""))
172 + .bind($filters.tags.to_vec())
173 + .bind($filters.tag_descendant_patterns())
139 174 };
140 175 }
141 176
@@ -181,8 +216,8 @@
181 216 query.push_str(ITEM_SEARCH_CLAUSE);
182 217 }
183 218 }
184 - if axis != FacetAxis::ItemType && filters.item_type.is_some() {
185 - query.push_str(" AND i.item_type = $2");
219 + if axis != FacetAxis::ItemType && !filters.item_types.is_empty() {
220 + query.push_str(ITEM_TYPE_CLAUSE);
186 221 }
187 222 if axis != FacetAxis::Price {
188 223 if filters.min_price.is_some() {
@@ -192,18 +227,11 @@
192 227 query.push_str(" AND i.price_cents <= $4");
193 228 }
194 229 }
195 - if axis != FacetAxis::Tag && filters.tag.is_some() {
230 + if axis != FacetAxis::Tag && !filters.tags.is_empty() {
196 231 // Descendant match via the materialized dot-path, matching the result
197 232 // query. This previously used `parent_id`, which matched direct children
198 233 // only, so any tag with grandchildren counted differently than it filtered.
199 - query.push_str(
200 - r#" AND EXISTS (
201 - SELECT 1 FROM item_tags it2
202 - JOIN tags t2 ON t2.id = it2.tag_id
203 - WHERE it2.item_id = i.id
204 - AND (t2.slug = $5 OR t2.path LIKE $5 || '.%')
205 - )"#,
206 - );
234 + query.push_str(TAG_CLAUSE);
207 235 }
208 236 if axis != FacetAxis::AiTier {
209 237 match filters.ai_tier {
@@ -341,7 +369,7 @@
341 369 }
342 370 };
343 371
344 - query.push_str(&format!(" ORDER BY {} LIMIT $6 OFFSET $7", order));
372 + query.push_str(&format!(" ORDER BY {} LIMIT $7 OFFSET $8", order));
345 373
346 374 let items = bind_item_discover_filters!(
347 375 sqlx::query_as::<_, DbDiscoverItemRow>(&query),
@@ -826,7 +854,7 @@
826 854 #[test]
827 855 fn append_filters_no_filters() {
828 856 let filters = DiscoverFilters {
829 - search: None, item_type: None, tag: None,
857 + search: None, item_types: &[], tags: &[],
830 858 min_price: None, max_price: None, sort_by: None, ai_tier: None,
831 859 };
832 860 let mut q = String::from("SELECT 1 WHERE true");
@@ -837,18 +865,52 @@
837 865 #[test]
838 866 fn append_filters_with_item_type() {
839 867 let filters = DiscoverFilters {
840 - search: None, item_type: Some(ItemType::Audio), tag: None,
868 + search: None, item_types: &[ItemType::Audio], tags: &[],
841 869 min_price: None, max_price: None, sort_by: None, ai_tier: None,
842 870 };
843 871 let mut q = String::new();
844 872 append_item_discover_filters(&mut q, &filters, false, false);
845 - assert!(q.contains("i.item_type = $2"));
873 + assert!(q.contains("i.item_type = ANY($2)"));
874 + }
875 +
876 + #[test]
877 + fn append_filters_tags_match_slug_or_subtree() {
878 + let tags = vec!["audio.genre".to_string()];
879 + let filters = DiscoverFilters {
880 + search: None, item_types: &[], tags: &tags,
881 + min_price: None, max_price: None, sort_by: None, ai_tier: None,
882 + };
883 + let mut q = String::new();
884 + append_item_discover_filters(&mut q, &filters, false, false);
885 + // Exact slug OR descendant path, both array-bound so the facet can
886 + // hold several selections at once.
887 + assert!(q.contains("t2.slug = ANY($5)"));
888 + assert!(q.contains("t2.path LIKE ANY($6)"));
889 + }
890 +
891 + #[test]
892 + fn tag_descendant_patterns_escape_like_metacharacters() {
893 + // tagtree owns the escaping; pin that we actually route through it,
894 + // since an unescaped `_` in a slug would silently widen the match.
895 + let tags = vec!["audio.genre_x".to_string()];
896 + let filters = DiscoverFilters {
897 + search: None, item_types: &[], tags: &tags,
898 + min_price: None, max_price: None, sort_by: None, ai_tier: None,
899 + };
900 + let patterns = filters.tag_descendant_patterns();
901 + assert_eq!(patterns.len(), 1);
902 + assert!(
903 + patterns[0].contains("\\_"),
904 + "underscore must be escaped, got {:?}",
905 + patterns[0]
906 + );
907 + assert!(patterns[0].ends_with(".%"));
846 908 }
847 909
848 910 #[test]
849 911 fn append_filters_search_uses_short_clause() {
850 912 let filters = DiscoverFilters {
851 - search: Some("ab"), item_type: None, tag: None,
913 + search: Some("ab"), item_types: &[], tags: &[],
852 914 min_price: None, max_price: None, sort_by: None, ai_tier: None,
853 915 };
854 916 let mut q = String::new();
@@ -860,7 +922,7 @@
860 922 #[test]
861 923 fn append_filters_search_uses_trigram_clause() {
862 924 let filters = DiscoverFilters {
863 - search: Some("hello"), item_type: None, tag: None,
925 + search: Some("hello"), item_types: &[], tags: &[],
864 926 min_price: None, max_price: None, sort_by: None, ai_tier: None,
865 927 };
866 928 let mut q = String::new();
@@ -871,7 +933,7 @@
871 933 #[test]
872 934 fn append_filters_handmade_only_narrows_to_one_tier() {
873 935 let filters = DiscoverFilters {
874 - search: None, item_type: None, tag: None,
936 + search: None, item_types: &[], tags: &[],
875 937 min_price: None, max_price: None, sort_by: None,
876 938 ai_tier: Some(AiTierFilter::HandmadeOnly),
877 939 };
@@ -887,7 +949,7 @@
887 949 // and assisted. A future rename of the literals or a swap to a
888 950 // single-tier match would silently weaken the filter.
889 951 let filters = DiscoverFilters {
890 - search: None, item_type: None, tag: None,
952 + search: None, item_types: &[], tags: &[],
891 953 min_price: None, max_price: None, sort_by: None,
892 954 ai_tier: Some(AiTierFilter::HumanLed),
893 955 };
@@ -179,12 +179,26 @@
179 179
180 180 query.push_str(" GROUP BY t.id, t.name, t.slug ORDER BY count DESC");
181 181
182 + // Same bind layout as the other three facets; see `bind_item_discover_filters!`.
182 183 let counts = sqlx::query_as::<_, DbTagCount>(&query)
183 184 .bind(search_term.as_deref().unwrap_or(""))
184 - .bind(filters.item_type.map(|t| t.to_string()).unwrap_or_default())
185 + .bind(
186 + filters
187 + .item_types
188 + .iter()
189 + .map(|t| t.to_string())
190 + .collect::<Vec<_>>(),
191 + )
185 192 .bind(filters.min_price.unwrap_or(0))
186 193 .bind(filters.max_price.unwrap_or(i32::MAX))
187 - .bind(filters.tag.unwrap_or(""))
194 + .bind(filters.tags.to_vec())
195 + .bind(
196 + filters
197 + .tags
198 + .iter()
199 + .map(|s| tagtree::like_descendant_pattern(s))
200 + .collect::<Vec<_>>(),
201 + )
188 202 .fetch_all(pool)
189 203 .await?;
190 204
@@ -212,8 +212,16 @@
212 212 <option value="price_desc"{% if sort_by == "price_desc" %} selected{% endif %}>Price desc</option>
213 213 {% endif %}
214 214 </select>
215 - <input type="hidden" id="type-input" name="item_type" value="{{ current_type }}" class="discover-filter">
216 - <input type="hidden" id="tag-input" name="tag" value="{{ current_tag }}" class="discover-filter">
215 + {# Multi-valued facets carry one hidden input per selection, so a
216 + selection of several tags round-trips through hx-include without
217 + inventing a delimiter. page-discover.js rewrites the whole group
218 + for a facet on each click (see setFacetValues). #}
219 + {% for value in current_types %}
220 + <input type="hidden" name="item_type" value="{{ value }}" class="discover-filter" data-facet="item_type">
221 + {% endfor %}
222 + {% for value in current_tags %}
223 + <input type="hidden" name="tag" value="{{ value }}" class="discover-filter" data-facet="tag">
224 + {% endfor %}
217 225 <input type="hidden" id="category-input" name="category" value="{{ current_category }}" class="discover-filter">
218 226 <input type="hidden" id="ai-tier-input" name="ai_tier" value="{{ current_ai_tier }}" class="discover-filter">
219 227 </div>
@@ -342,11 +342,11 @@
342 342 .expect("attach tag");
343 343 }
344 344
345 - fn filters_for<'a>(tag: Option<&'a str>) -> makenotwork::db::discover::DiscoverFilters<'a> {
345 + fn filters_for(tags: &[String]) -> makenotwork::db::discover::DiscoverFilters<'_> {
346 346 makenotwork::db::discover::DiscoverFilters {
347 347 search: None,
348 - item_type: None,
349 - tag,
348 + item_types: &[],
349 + tags,
350 350 min_price: None,
351 351 max_price: None,
352 352 sort_by: None,
@@ -368,7 +368,7 @@
368 368 tag_item(&h, &item_id, "audio.genre.electronic").await;
369 369
370 370 // `audio` is two levels above the item's tag.
371 - let counts = makenotwork::db::discover::get_item_type_counts(&h.db, &filters_for(Some("audio")))
371 + let counts = makenotwork::db::discover::get_item_type_counts(&h.db, &filters_for(&["audio".to_string()]))
372 372 .await
373 373 .expect("item type counts");
374 374 let total: i64 = counts.iter().map(|c| c.count).sum();
@@ -401,7 +401,7 @@
401 401 .await
402 402 .expect("set assisted");
403 403
404 - let mut filters = filters_for(None);
404 + let mut filters = filters_for(&[]);
405 405 filters.ai_tier = Some(makenotwork::db::AiTierFilter::HandmadeOnly);
406 406 let counts = makenotwork::db::discover::get_item_type_counts(&h.db, &filters)
407 407 .await
@@ -414,6 +414,128 @@
414 414 );
415 415 }
416 416
417 + // ---------------------------------------------------------------------------
418 + // Faceted multi-select: OR within a facet, AND across facets.
419 + // ---------------------------------------------------------------------------
420 +
421 + /// Repeated `tag=` params are OR'd, and each still matches its own subtree.
422 + #[tokio::test]
423 + async fn repeated_tag_params_are_ored_within_the_facet() {
424 + let mut h = TestHarness::new().await;
425 + let (_, electronic) = make_discoverable_item(&mut h, "orelec", "Electronic Pick", "audio").await;
426 + let (_, ambient) = make_discoverable_item(&mut h, "oramb", "Ambient Pick", "audio").await;
427 + let (_, folk) = make_discoverable_item(&mut h, "orfolk", "Folk Pick", "audio").await;
428 + tag_item(&h, &electronic, "audio.genre.electronic").await;
429 + tag_item(&h, &ambient, "audio.genre.ambient").await;
430 + tag_item(&h, &folk, "audio.genre.folk").await;
431 +
432 + let resp = h
433 + .client
434 + .get("/discover?mode=items&tag=audio.genre.electronic&tag=audio.genre.ambient")
435 + .await;
436 + assert!(resp.status.is_success(), "{}", resp.status);
437 + assert!(resp.text.contains("Electronic Pick"), "first selected tag must match");
438 + assert!(resp.text.contains("Ambient Pick"), "second selected tag must match (OR, not AND)");
439 + assert!(
440 + !resp.text.contains("Folk Pick"),
441 + "an unselected tag must not match"
442 + );
443 + }
444 +
445 + /// Repeated `item_type=` params are OR'd.
446 + #[tokio::test]
447 + async fn repeated_item_type_params_are_ored_within_the_facet() {
448 + let mut h = TestHarness::new().await;
449 + make_discoverable_item(&mut h, "ortaudio", "Audio Thing", "audio").await;
450 + make_discoverable_item(&mut h, "ortsample", "Sample Thing", "sample").await;
451 + make_discoverable_item(&mut h, "ortvideo", "Video Thing", "video").await;
452 +
453 + let resp = h
454 + .client
455 + .get("/discover?mode=items&item_type=audio&item_type=sample")
456 + .await;
457 + assert!(resp.status.is_success(), "{}", resp.status);
458 + assert!(resp.text.contains("Audio Thing"));
459 + assert!(resp.text.contains("Sample Thing"));
460 + assert!(!resp.text.contains("Video Thing"), "unselected type must not match");
461 + }
462 +
463 + /// Different facets AND together, so the two selections intersect.
464 + #[tokio::test]
465 + async fn separate_facets_are_anded_together() {
466 + let mut h = TestHarness::new().await;
467 + let (_, match_both) = make_discoverable_item(&mut h, "andboth", "Matches Both", "audio").await;
468 + let (_, wrong_type) = make_discoverable_item(&mut h, "andtype", "Wrong Type", "video").await;
469 + let (_, wrong_tag) = make_discoverable_item(&mut h, "andtag", "Wrong Tag", "audio").await;
470 + tag_item(&h, &match_both, "audio.genre.electronic").await;
471 + tag_item(&h, &wrong_type, "audio.genre.electronic").await;
472 + tag_item(&h, &wrong_tag, "audio.genre.folk").await;
473 +
474 + let resp = h
475 + .client
476 + .get("/discover?mode=items&item_type=audio&tag=audio.genre.electronic")
477 + .await;
478 + assert!(resp.status.is_success(), "{}", resp.status);
479 + assert!(resp.text.contains("Matches Both"));
480 + assert!(
481 + !resp.text.contains("Wrong Type"),
482 + "tag matched but type did not: facets must AND"
483 + );
484 + assert!(
485 + !resp.text.contains("Wrong Tag"),
486 + "type matched but tag did not: facets must AND"
487 + );
488 + }
489 +
490 + /// A multi-select survives the round-trip through the form's hidden inputs.
491 + ///
492 + /// `hx-include=".discover-filter"` rebuilds the next request from these inputs,
493 + /// so if several selected tags collapsed into one delimited value (or into the
494 + /// first value only) the second interaction would silently drop the filter.
495 + /// One input per value, no delimiter convention.
496 + #[tokio::test]
497 + async fn multi_select_round_trips_through_the_form_inputs() {
498 + let mut h = TestHarness::new().await;
499 + let (_, item) = make_discoverable_item(&mut h, "roundtrip", "Round Trip", "audio").await;
500 + tag_item(&h, &item, "audio.genre.electronic").await;
501 +
502 + let resp = h
503 + .client
504 + .get("/discover?mode=items&tag=audio.genre.electronic&tag=audio.mood.dark")
505 + .await;
506 + assert!(resp.status.is_success(), "{}", resp.status);
507 +
508 + let tag_inputs = resp.text.matches(r#"name="tag""#).count();
509 + assert_eq!(
510 + tag_inputs, 2,
511 + "both selected tags must be carried as separate hidden inputs, got {tag_inputs}"
512 + );
513 + assert!(resp.text.contains(r#"value="audio.genre.electronic""#));
514 + assert!(resp.text.contains(r#"value="audio.mood.dark""#));
515 + assert!(
516 + !resp.text.contains("audio.genre.electronic,audio.mood.dark"),
517 + "selections must not be joined into one delimited value"
518 + );
519 + }
520 +
521 + /// A blank facet value is not a filter.
522 + ///
523 + /// `hx-include` ships every filter input on every request, so an untouched
524 + /// control arrives as `tag=`. That must mean "unfiltered", not "match the empty
525 + /// tag" (which would return nothing).
526 + #[tokio::test]
527 + async fn blank_repeated_params_do_not_filter() {
528 + let mut h = TestHarness::new().await;
529 + make_discoverable_item(&mut h, "blankp", "Still Visible", "audio").await;
530 +
531 + let resp = h.client.get("/discover?mode=items&tag=&item_type=").await;
532 + assert!(resp.status.is_success(), "{}", resp.status);
533 + assert!(
534 + resp.text.contains("Still Visible"),
535 + "blank filter values must not narrow the result set"
536 + );
537 + }
538 +
417 539 /// Tag counts obey the same visibility predicate as the results.
418 540 ///
419 541 /// `get_tag_counts` had no `users` join at all, so it never applied
@@ -441,7 +563,7 @@
441 563 .await
442 564 .expect("quarantine the item");
443 565
444 - let counts = makenotwork::db::tags::get_tag_counts(&h.db, &filters_for(None))
566 + let counts = makenotwork::db::tags::get_tag_counts(&h.db, &filters_for(&[]))
445 567 .await
446 568 .expect("tag counts");
447 569 let leaf = counts
@@ -615,9 +615,12 @@
615 615 /// Active sort key (e.g. `"most_sold"`, `"newest"`, `"price_asc"`).
616 616 pub sort_by: String,
617 617 /// Currently selected item_type filter, or empty for "All".
618 - pub current_type: String,
618 + /// One entry per selected item type; rendered as one hidden input each so a
619 + /// multi-select round-trips through the form without a delimiter convention.
620 + pub current_types: Vec<String>,
619 621 /// Currently selected tag slug filter, or empty for "All".
620 - pub current_tag: String,
622 + /// One entry per selected tag; see `current_types`.
623 + pub current_tags: Vec<String>,
621 624 /// Currently selected category slug filter, or empty for "All".
622 625 pub current_category: String,
623 626 /// Page numbers to render in the pagination bar.