Shared card filter panel: News set browser gets every Library filter

Extract the filter fields and LibraryFilter-building state into
src/cardFilter.tsx (CardFilterPanel + useCardFilter) and use it in the
Library, Card Search and News set browser, replacing three diverging
copies. search_reference now honors in_deck/location too, so every
filter works in every tab.

Closes #13
This commit is contained in:
2026-10-23 22:46:45 -05:00
parent 89ef5d0848
commit e4956d8231
6 changed files with 332 additions and 415 deletions

View File

@@ -11,8 +11,29 @@ use crate::Catalog;
/// Append the card-data predicates shared by [`Catalog::search_library`] and
/// [`Catalog::search_reference`] — everything except `in_deck`/`location`/
/// `spares_only`, which read the library tables. Caller has already
/// written the `FROM ... WHERE <ownership predicate>` prefix.
/// `spares_only`, which read the library tables (see
/// [`push_collection_filters`]). Caller has already written the
/// `FROM ... WHERE <ownership predicate>` prefix.
/// Append the predicates that read the library tables: `in_deck`, `location`
/// and `spares_only`. Expects `library_cards` joined as `lc` (a LEFT JOIN is
/// fine — an unowned card has no location, so a location filter drops it).
fn push_collection_filters(sql: &mut String, vals: &mut Vec<Value>, filter: &LibraryFilter) {
if let Some(deck_id) = filter.in_deck {
sql.push_str(
" AND EXISTS (SELECT 1 FROM deck_cards dc
WHERE dc.oracle_id = oc.oracle_id AND dc.deck_id = ?)",
);
vals.push(Value::Integer(deck_id));
}
if let Some(loc) = filter.location.as_ref().map(|l| l.trim()).filter(|l| !l.is_empty()) {
sql.push_str(" AND LOWER(lc.location) LIKE ?");
vals.push(Value::Text(format!("%{}%", loc.to_lowercase())));
}
if filter.spares_only {
sql.push_str(SPARES_PREDICATE);
}
}
fn push_card_filters(
sql: &mut String,
vals: &mut Vec<Value>,
@@ -223,21 +244,7 @@ impl Catalog {
);
let mut vals: Vec<Value> = Vec::new();
push_card_filters(&mut sql, &mut vals, filter, self.has_universes_beyond, self.has_vanilla);
if let Some(deck_id) = filter.in_deck {
sql.push_str(
" AND EXISTS (SELECT 1 FROM deck_cards dc
WHERE dc.oracle_id = oc.oracle_id AND dc.deck_id = ?)",
);
vals.push(Value::Integer(deck_id));
}
if let Some(loc) = filter.location.as_ref().map(|l| l.trim()).filter(|l| !l.is_empty()) {
sql.push_str(" AND LOWER(lc.location) LIKE ?");
vals.push(Value::Text(format!("%{}%", loc.to_lowercase())));
}
if filter.spares_only {
sql.push_str(SPARES_PREDICATE);
}
push_collection_filters(&mut sql, &mut vals, filter);
sql.push_str(" ORDER BY oc.name COLLATE NOCASE");
@@ -253,8 +260,7 @@ impl Catalog {
}
/// Search the *whole* reference db (every downloaded card, owned or not)
/// by the same card-data criteria as [`Catalog::search_library`], minus
/// `in_deck`/`location` (library-specific — silently ignored if set).
/// by the same criteria as [`Catalog::search_library`], plus `not_owned`.
/// Results are still [`LibraryCard`]s so ownership/deck context is
/// visible (`quantity_owned` is 0 and `decks` is empty for unowned
/// cards), capped at `limit` and ordered by Scryfall's EDHREC popularity
@@ -290,9 +296,7 @@ impl Catalog {
if filter.not_owned {
sql.push_str(" AND COALESCE(lc.quantity_owned, 0) = 0");
}
if filter.spares_only {
sql.push_str(SPARES_PREDICATE);
}
push_collection_filters(&mut sql, &mut vals, filter);
sql.push_str(" ORDER BY ");
// Same reasoning as `NameIndex::search`: a card whose name *is* the query
@@ -841,6 +845,28 @@ mod tests {
assert_eq!(bolt.quantity_owned, 0);
}
#[test]
fn search_reference_honors_in_deck_and_location() {
let fx = test_support::make();
let cat = &fx.catalog;
let deck = cat.create_deck("Burn", None).unwrap();
cat.set_deck_card(deck.id, "lightning-bolt", 4, false, false).unwrap();
cat.add_owned("sol-ring", 1).unwrap();
cat.set_location("sol-ring", "Box 1").unwrap();
let in_deck = cat.search_reference(&LibraryFilter {
in_deck: Some(deck.id),
..Default::default()
}, 50).unwrap();
assert_eq!(names(&in_deck), vec!["Lightning Bolt"], "unowned deck cards count too");
let boxed = cat.search_reference(&LibraryFilter {
location: Some("box".into()),
..Default::default()
}, 50).unwrap();
assert_eq!(names(&boxed), vec!["Sol Ring"]);
}
#[test]
fn search_reference_not_owned_excludes_owned_cards() {
let fx = test_support::make();
@@ -970,23 +996,6 @@ mod tests {
assert_eq!(capped.len(), 2);
}
#[test]
fn search_reference_ignores_in_deck_and_location() {
let fx = test_support::make();
let cat = &fx.catalog;
let deck = cat.create_deck("Some Deck", None).unwrap();
cat.set_deck_card(deck.id, "lightning-bolt", 1, false, false).unwrap();
// Both library-only fields are set but should have no effect — the
// whole (unowned) fixture is still returned.
let got = cat.search_reference(&LibraryFilter {
in_deck: Some(deck.id),
location: Some("Box 1".into()),
..Default::default()
}, 50).unwrap();
assert_eq!(got.len(), 5);
}
/// `horizontal` is read by nearly every card query (see
/// `Catalog::horizontal_col`), so a reference.db built before that column
/// shipped must not break `search_library`/`search_reference`/