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
Commit: edfc01a4a97eb171363c5a4b719f37cb2ca078bf
Parent: a668df6
8 files changed, +392 insertions, -88 deletions
@@ -16,7 +16,7 @@ async-trait = "0.1"
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"] }
@@ -15,14 +15,37 @@ use crate::error::Result;
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 @@ pub(crate) fn is_short_query(term: &str) -> bool {
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 @@ fn append_item_discover_filters(
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 @@ fn append_item_discover_filters(
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 @@ fn append_item_discover_filters(
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 @@ pub(crate) fn append_facet_filters(
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 @@ pub(crate) fn append_facet_filters(
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 @@ pub async fn discover_items(
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 @@ mod tests {
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 @@ mod tests {
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: &[],
869 + min_price: None, max_price: None, sort_by: None, ai_tier: None,
870 + };
871 + let mut q = String::new();
872 + append_item_discover_filters(&mut q, &filters, false, false);
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,
841 881 min_price: None, max_price: None, sort_by: None, ai_tier: None,
842 882 };
843 883 let mut q = String::new();
844 884 append_item_discover_filters(&mut q, &filters, false, false);
845 - assert!(q.contains("i.item_type = $2"));
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 @@ mod tests {
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 @@ mod tests {
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 @@ mod tests {
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 @@ pub async fn get_tag_counts(
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
@@ -1,6 +1,7 @@
1 1 //! Discover/search page with filterable, paginated items and projects.
2 2
3 - use axum::extract::{Query, State};
3 + use axum::extract::State;
4 + use axum_extra::extract::Query;
4 5 use axum::http::HeaderMap;
5 6 use axum::response::IntoResponse;
6 7 use axum::Json;
@@ -41,8 +42,8 @@ type FacetBundle = (
41 42 /// another for up to `FACET_CACHE_TTL`.
42 43 type FacetKey = (
43 44 Option<String>,
44 - Option<ItemType>,
45 - Option<String>,
45 + Vec<ItemType>,
46 + Vec<String>,
46 47 Option<i32>,
47 48 Option<i32>,
48 49 Option<AiTierFilter>,
@@ -67,8 +68,8 @@ static FACET_CACHE: OnceLock<Mutex<HashMap<FacetKey, (Instant, FacetBundle)>>> =
67 68 async fn cached_facets(db: &PgPool, filters: &db::discover::DiscoverFilters<'_>) -> Result<FacetBundle> {
68 69 let key: FacetKey = (
69 70 filters.search.map(str::to_string),
70 - filters.item_type,
71 - filters.tag.map(str::to_string),
71 + filters.item_types.to_vec(),
72 + filters.tags.to_vec(),
72 73 filters.min_price,
73 74 filters.max_price,
74 75 filters.ai_tier,
@@ -124,8 +125,14 @@ where
124 125 #[derive(Debug, Deserialize)]
125 126 pub struct DiscoverQuery {
126 127 pub q: Option<String>,
127 - pub item_type: Option<String>,
128 - pub tag: Option<String>,
128 + /// Repeated `item_type=` params, OR'd together. A single value (what the
129 + /// pre-multi-select UI sends) parses as a one-element vec, so old links and
130 + /// bookmarks keep working.
131 + #[serde(default)]
132 + pub item_type: Vec<String>,
133 + /// Repeated `tag=` params, OR'd together; each matches its own subtree.
134 + #[serde(default)]
135 + pub tag: Vec<String>,
129 136 pub category: Option<String>,
130 137 #[serde(default, deserialize_with = "empty_string_as_none")]
131 138 pub min_price: Option<i32>,
@@ -155,11 +162,19 @@ impl DiscoverQuery {
155 162 }
156 163 }
157 164
165 + // Multi-valued facets emit one param per selection, deduped and blank-free,
166 + // so the address bar states exactly the filter that was applied.
167 + fn push_each(parts: &mut Vec<String>, key: &str, values: &[String]) {
168 + for v in dedup_nonempty(values) {
169 + parts.push(format!("{key}={}", urlencoding::encode(v)));
170 + }
171 + }
172 +
158 173 let mut parts = Vec::new();
159 174 push_str(&mut parts, "mode", self.mode.as_ref());
160 175 push_str(&mut parts, "q", self.q.as_ref());
161 - push_str(&mut parts, "item_type", self.item_type.as_ref());
162 - push_str(&mut parts, "tag", self.tag.as_ref());
176 + push_each(&mut parts, "item_type", &self.item_type);
177 + push_each(&mut parts, "tag", &self.tag);
163 178 push_str(&mut parts, "category", self.category.as_ref());
164 179 push_str(&mut parts, "ai_tier", self.ai_tier.as_ref());
165 180 push_str(&mut parts, "has_source", self.has_source.as_ref());
@@ -234,8 +249,8 @@ mod page_url_tests {
234 249 fn query() -> DiscoverQuery {
235 250 DiscoverQuery {
236 251 q: None,
237 - item_type: None,
238 - tag: None,
252 + item_type: Vec::new(),
253 + tag: Vec::new(),
239 254 category: None,
240 255 min_price: None,
241 256 max_price: None,
@@ -257,16 +272,41 @@ mod page_url_tests {
257 272 // hx-include ships every filter on every request, so most arrive blank.
258 273 let q = DiscoverQuery {
259 274 q: Some(String::new()),
260 - tag: Some(" ".to_string()),
275 + tag: vec![" ".to_string()],
261 276 category: Some(String::new()),
262 277 mode: Some("items".to_string()),
263 - item_type: Some("preset".to_string()),
278 + item_type: vec!["preset".to_string()],
264 279 ..query()
265 280 };
266 281 assert_eq!(q.to_page_url(), "/discover?mode=items&item_type=preset");
267 282 }
268 283
269 284 #[test]
285 + fn multi_select_facets_emit_one_param_per_value() {
286 + let q = DiscoverQuery {
287 + mode: Some("items".to_string()),
288 + tag: vec!["audio.genre.electronic".to_string(), "audio.mood.dark".to_string()],
289 + item_type: vec!["audio".to_string(), "sample".to_string()],
290 + ..query()
291 + };
292 + assert_eq!(
293 + q.to_page_url(),
294 + "/discover?mode=items&item_type=audio&item_type=sample\
295 + &tag=audio.genre.electronic&tag=audio.mood.dark"
296 + );
297 + }
298 +
299 + #[test]
300 + fn repeated_facet_values_are_deduped_in_the_url() {
301 + // A doubled selection must not inflate the URL or the SQL bind arrays.
302 + let q = DiscoverQuery {
303 + tag: vec!["a.b.c".to_string(), "a.b.c".to_string(), String::new()],
304 + ..query()
305 + };
306 + assert_eq!(q.to_page_url(), "/discover?tag=a.b.c");
307 + }
308 +
309 + #[test]
270 310 fn values_are_percent_encoded() {
271 311 let q = DiscoverQuery {
272 312 q: Some("field recording & tape".to_string()),
@@ -309,8 +349,8 @@ mod page_url_tests {
309 349 /// the filter-chip rendering need exactly this and derived it independently
310 350 /// (audit Run 17 Architecture) — the logic now lives in one place.
311 351 struct DiscoverFilterSelection<'a> {
312 - item_type: Option<ItemType>,
313 - tag: Option<&'a str>,
352 + item_types: Vec<ItemType>,
353 + tags: Vec<String>,
314 354 search: Option<&'a str>,
315 355 category: Option<&'a str>,
316 356 ai_tier: Option<db::AiTierFilter>,
@@ -320,8 +360,18 @@ struct DiscoverFilterSelection<'a> {
320 360 impl DiscoverQuery {
321 361 fn filter_selection(&self) -> DiscoverFilterSelection<'_> {
322 362 DiscoverFilterSelection {
323 - item_type: self.item_type.as_deref().filter(|s| !s.is_empty()).and_then(|s| s.parse().ok()),
324 - tag: self.tag.as_deref().filter(|s| !s.is_empty()),
363 + // Blank entries are dropped: `hx-include` ships every filter input
364 + // on every request, so an unselected control arrives as `item_type=`.
365 + // Unparseable values are dropped rather than erroring, matching the
366 + // prior single-value behaviour.
367 + item_types: dedup_nonempty(&self.item_type)
368 + .into_iter()
369 + .filter_map(|s| s.parse().ok())
370 + .collect(),
371 + tags: dedup_nonempty(&self.tag)
372 + .into_iter()
373 + .map(str::to_string)
374 + .collect(),
325 375 search: self.q.as_deref().filter(|s| !s.trim().is_empty()),
326 376 category: self.category.as_deref().filter(|s| !s.is_empty()),
327 377 ai_tier: self.ai_tier.as_deref().filter(|s| !s.is_empty()).and_then(|s| s.parse().ok()),
@@ -330,6 +380,21 @@ impl DiscoverQuery {
330 380 }
331 381 }
332 382
383 + /// Drop blank entries and duplicates while preserving order.
384 + ///
385 + /// Duplicates are dropped so a doubled `?tag=x&tag=x` cannot inflate the bind
386 + /// arrays; order is preserved so the pushed URL is stable across a round-trip
387 + /// and doesn't churn browser history.
388 + fn dedup_nonempty(values: &[String]) -> Vec<&str> {
389 + let mut seen = std::collections::HashSet::new();
390 + values
391 + .iter()
392 + .map(|s| s.trim())
393 + .filter(|s| !s.is_empty())
394 + .filter(|s| seen.insert(*s))
395 + .collect()
396 + }
397 +
333 398 async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<DiscoverData> {
334 399 // Clamp the upper bound too (UX MINOR, Run #23): an unbounded page yields a
335 400 // giant OFFSET = one expensive deep scan per request. Matches the git/admin
@@ -340,8 +405,8 @@ async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<Dis
340 405 let mode = query.mode.as_deref().unwrap_or("projects");
341 406
342 407 let f = query.filter_selection();
343 - let item_type_filter = f.item_type;
344 - let tag_filter = f.tag;
408 + let item_type_filter = f.item_types;
409 + let tag_filter = f.tags;
345 410 let search_filter = f.search;
346 411 let category_filter = f.category;
347 412 let ai_tier_filter = f.ai_tier;
@@ -381,8 +446,8 @@ async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<Dis
381 446 let (min_price, max_price) = sanitize_price_range(query.min_price, query.max_price);
382 447 let filters = DiscoverFilters {
383 448 search: search_filter,
384 - item_type: item_type_filter,
385 - tag: tag_filter,
449 + item_types: &item_type_filter,
450 + tags: &tag_filter,
386 451 min_price,
387 452 max_price,
388 453 sort_by: sort_filter,
@@ -514,8 +579,8 @@ pub(super) async fn discover(
514 579 let csrf_token = get_csrf_token(&session).await;
515 580 let f = query.filter_selection();
516 581 let search_filter = f.search;
517 - let tag_filter = f.tag;
518 - let item_type_filter = f.item_type;
582 + let tag_filter = f.tags;
583 + let item_type_filter = f.item_types;
519 584 let has_source_code = f.has_source_code;
520 585 let data = fetch_discover_data(&db, &query).await?;
521 586
@@ -560,8 +625,8 @@ pub(super) async fn discover(
560 625 let viewer_id = maybe_user.as_ref().map(|u| u.id);
561 626 let facet_filters = DiscoverFilters {
562 627 search: search_filter,
563 - item_type: item_type_filter,
564 - tag: tag_filter,
628 + item_types: &item_type_filter,
629 + tags: &tag_filter,
565 630 min_price: query.min_price,
566 631 max_price: query.max_price,
567 632 sort_by: None,
@@ -584,12 +649,12 @@ pub(super) async fn discover(
584 649 name: "All".to_string(),
585 650 value: String::new(),
586 651 count: data.total_count,
587 - active: item_type_filter.is_none(),
652 + active: item_type_filter.is_empty(),
588 653 id: String::new(),
589 654 following: false,
590 655 }];
591 656 for tc in type_counts {
592 - let active = item_type_filter.is_some_and(|t| t.to_string() == tc.category);
657 + let active = item_type_filter.iter().any(|t| t.to_string() == tc.category);
593 658 type_filters.push(FilterCategory {
594 659 value: tc.category.clone(),
595 660 name: tc.category,
@@ -604,7 +669,7 @@ pub(super) async fn discover(
604 669 name: "All".to_string(),
605 670 value: String::new(),
606 671 count: data.total_count,
607 - active: tag_filter.is_none(),
672 + active: tag_filter.is_empty(),
608 673 id: String::new(),
609 674 following: false,
610 675 }];
@@ -613,7 +678,7 @@ pub(super) async fn discover(
613 678 name: tc.tag_name.clone(),
614 679 value: tc.tag_slug.to_string(),
615 680 count: tc.count as u32,
616 - active: tag_filter == Some(tc.tag_slug.as_str()),
681 + active: tag_filter.iter().any(|t| t == &tc.tag_slug),
617 682 id: tc.tag_id.to_string(),
618 683 following: followed_tag_ids.contains(&tc.tag_id),
619 684 });
@@ -673,14 +738,18 @@ pub(super) async fn discover(
673 738 PriceFilter { label: "$100+".to_string(), count: price_counts.over_100 as u32 },
674 739 ];
675 740
676 - let current_type = query.item_type.unwrap_or_default();
677 - let current_tag = query.tag.unwrap_or_default();
741 + // The mobile filter badge counts *groups* with an active selection, not
742 + // individual values: picking three tags is still one filter in use.
743 + let current_types: Vec<String> =
744 + dedup_nonempty(&query.item_type).into_iter().map(str::to_string).collect();
745 + let current_tags: Vec<String> =
746 + dedup_nonempty(&query.tag).into_iter().map(str::to_string).collect();
678 747 let current_category = query.category.unwrap_or_default();
679 748 let current_ai_tier = query.ai_tier.unwrap_or_default();
680 749
681 750 let active_filter_count = [
682 - !current_type.is_empty(),
683 - !current_tag.is_empty(),
751 + !current_types.is_empty(),
752 + !current_tags.is_empty(),
684 753 !current_category.is_empty(),
685 754 !current_ai_tier.is_empty(),
686 755 has_source_code,
@@ -705,8 +774,8 @@ pub(super) async fn discover(
705 774 total_pages: data.total_pages,
706 775 search_query: query.q.unwrap_or_default(),
707 776 sort_by: query.sort.unwrap_or_default(),
708 - current_type,
709 - current_tag,
777 + current_types,
778 + current_tags,
710 779 current_category,
711 780 pagination_range: data.pagination_range,
712 781 showing_start: data.showing_start,
@@ -615,9 +615,12 @@ pub struct DiscoverTemplate {
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.
@@ -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 || '';
@@ -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 @@ async fn tag_item(h: &TestHarness, item_id: &str, slug: &str) {
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 @@ async fn facet_counts_match_the_whole_tag_subtree() {
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 @@ async fn facet_counts_apply_the_ai_tier_filter() {
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 @@ async fn facet_counts_apply_the_ai_tier_filter() {
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 @@ async fn tag_counts_exclude_sandbox_and_unscanned_items() {
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