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