diff --git a/ROADMAP.md b/ROADMAP.md index 81f6106..ba79252 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,7 +18,7 @@ Features that build directly on what exists today. - Surface "due soon" subscriptions on the Overview dashboard. - One-click "Pay now" to create a real transaction from a subscription. -- [ ] **Transaction search** +- [x] **Transaction search** - Search Recent Transactions by description, merchant, category, or amount. - [ ] **Auto calculate charges for Ecocash** diff --git a/src/lib.rs b/src/lib.rs index 710329d..915183d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,3 +10,4 @@ pub mod prefs; pub mod projection; pub mod seed; pub mod subscriptions; +pub mod transaction_filter; diff --git a/src/transaction_filter.rs b/src/transaction_filter.rs new file mode 100644 index 0000000..a742f02 --- /dev/null +++ b/src/transaction_filter.rs @@ -0,0 +1,309 @@ +//! Search, filters and sort order for the transactions list. Works on the +//! loaded transactions, so it composes with the date filter and paging. +use crate::commands::{Account, Category, Transaction}; +use std::cmp::Ordering; + +/// Category filter value that matches transactions without a category. +pub const UNCATEGORIZED: &str = "none"; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TransactionFilter { + /// Words that must all appear in the description, merchant, notes, + /// category, account or amount. Case-insensitive. + pub search: String, + /// `income` or `expense`. + pub kind: Option, + pub account_id: Option, + /// A category id, or [`UNCATEGORIZED`]. + pub category_id: Option, +} + +impl TransactionFilter { + pub fn is_empty(&self) -> bool { + self.search.trim().is_empty() && self.kind.is_none() && self.account_id.is_none() && self.category_id.is_none() + } + + pub fn matches(&self, t: &Transaction, accounts: &[Account], categories: &[Category]) -> bool { + if self.kind.as_ref().is_some_and(|k| *k != t.transaction_type) { + return false; + } + if self.account_id.as_ref().is_some_and(|a| *a != t.account_id) { + return false; + } + match self.category_id.as_deref() { + None => {} + Some(UNCATEGORIZED) => { + if category_name(t, categories).is_some() { + return false; + } + } + Some(id) => { + if t.category_id.as_deref() != Some(id) { + return false; + } + } + } + let terms: Vec = self.search.split_whitespace().map(str::to_lowercase).collect(); + if terms.is_empty() { + return true; + } + let haystack = [ + Some(t.description.as_str()), + t.merchant.as_deref(), + t.notes.as_deref(), + category_name(t, categories), + account_name(t, accounts), + ] + .into_iter() + .flatten() + .collect::>() + .join("\n") + .to_lowercase(); + let amount = amount_text(t.amount); + terms.iter().all(|term| { + haystack.contains(term.as_str()) || { + let digits: String = term.trim_start_matches('$').chars().filter(|c| *c != ',').collect(); + !digits.is_empty() && amount.contains(&digits) + } + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortKey { + Date, + Description, + Category, + Account, + Amount, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Sort { + pub key: SortKey, + pub descending: bool, +} + +impl Default for Sort { + /// Newest first, the order transactions are loaded in. + fn default() -> Self { + Self { key: SortKey::Date, descending: true } + } +} + +impl Sort { + /// The order after clicking a column header: the same column flips + /// direction, a new one starts newest/largest first for dates and amounts + /// and A–Z for text. + pub fn toggled(self, key: SortKey) -> Self { + if key == self.key { + Self { key, descending: !self.descending } + } else { + Self { key, descending: matches!(key, SortKey::Date | SortKey::Amount) } + } + } + + /// Arrow for the column header of `key`, empty for the other columns. + pub fn arrow(self, key: SortKey) -> &'static str { + match (key == self.key, self.descending) { + (false, _) => "", + (true, true) => " ↓", + (true, false) => " ↑", + } + } + + fn compare(self, a: &Transaction, b: &Transaction, accounts: &[Account], categories: &[Category]) -> Ordering { + let text = |s: Option<&str>| s.unwrap_or("").to_lowercase(); + let order = match self.key { + SortKey::Date => a.transaction_date.cmp(&b.transaction_date), + SortKey::Description => text(Some(&a.description)).cmp(&text(Some(&b.description))), + SortKey::Category => text(category_name(a, categories)).cmp(&text(category_name(b, categories))), + SortKey::Account => text(account_name(a, accounts)).cmp(&text(account_name(b, accounts))), + SortKey::Amount => signed(a).cmp(&signed(b)), + }; + if self.descending { + order.reverse() + } else { + order + } + } +} + +/// The transactions that pass `filter`, in `sort` order. Ties keep their +/// loaded order (newest first). +pub fn apply<'a>( + transactions: impl IntoIterator, + filter: &TransactionFilter, + sort: Sort, + accounts: &[Account], + categories: &[Category], +) -> Vec<&'a Transaction> { + let mut rows: Vec<&Transaction> = + transactions.into_iter().filter(|t| filter.matches(t, accounts, categories)).collect(); + if sort != Sort::default() { + rows.sort_by(|a, b| sort.compare(a, b, accounts, categories)); + } + rows +} + +fn category_name<'a>(t: &Transaction, categories: &'a [Category]) -> Option<&'a str> { + let id = t.category_id.as_deref()?; + categories.iter().find(|c| c.id == id).map(|c| c.name.as_str()) +} + +fn account_name<'a>(t: &Transaction, accounts: &'a [Account]) -> Option<&'a str> { + accounts.iter().find(|a| a.id == t.account_id).map(|a| a.name.as_str()) +} + +fn signed(t: &Transaction) -> i64 { + if t.transaction_type == "expense" { + -t.amount + } else { + t.amount + } +} + +fn amount_text(cents: i64) -> String { + let abs = cents.unsigned_abs(); + format!("{}.{:02}", abs / 100, abs % 100) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tx(id: &str, date: &str, kind: &str, amount: i64, description: &str, account: &str, category: Option<&str>) -> Transaction { + Transaction { + id: id.into(), + account_id: account.into(), + category_id: category.map(String::from), + transaction_type: kind.into(), + amount, + currency: "USD".into(), + description: description.into(), + merchant: None, + notes: None, + transaction_date: date.into(), + created_at: String::new(), + updated_at: String::new(), + } + } + + fn account(id: &str, name: &str) -> Account { + Account { + id: id.into(), + name: name.into(), + account_type: "bank".into(), + currency: "USD".into(), + balance: 0, + color: None, + icon: None, + is_active: true, + sort_order: 0, + created_at: String::new(), + updated_at: String::new(), + } + } + + fn category(id: &str, name: &str) -> Category { + Category { + id: id.into(), + name: name.into(), + parent_id: None, + category_type: "expense".into(), + icon: None, + color: None, + sort_order: 0, + created_at: String::new(), + updated_at: String::new(), + } + } + + fn fixture() -> (Vec, Vec, Vec) { + let mut coffee = tx("1", "2026-03-03", "expense", 450, "Coffee", "bank", Some("food")); + coffee.merchant = Some("Blue Bottle".into()); + let txs = vec![ + coffee, + tx("2", "2026-03-02", "income", 250000, "Salary", "bank", Some("pay")), + tx("3", "2026-03-01", "expense", 125050, "Rent", "cash", None), + tx("4", "2026-02-28", "expense", 2000, "Lunch", "cash", Some("gone")), + ]; + let accounts = vec![account("bank", "Main Bank"), account("cash", "Wallet")]; + let categories = vec![category("food", "Food"), category("pay", "Paycheck")]; + (txs, accounts, categories) + } + + fn ids(rows: &[&Transaction]) -> Vec { + rows.iter().map(|t| t.id.clone()).collect() + } + + fn run(filter: &TransactionFilter, sort: Sort) -> Vec { + let (txs, accounts, categories) = fixture(); + ids(&apply(&txs, filter, sort, &accounts, &categories)) + } + + fn search(text: &str) -> Vec { + run(&TransactionFilter { search: text.into(), ..Default::default() }, Sort::default()) + } + + #[test] + fn an_empty_filter_keeps_everything_in_order() { + assert!(TransactionFilter::default().is_empty()); + assert_eq!(run(&TransactionFilter::default(), Sort::default()), ["1", "2", "3", "4"]); + } + + #[test] + fn search_looks_at_every_text_field() { + assert_eq!(search("coffee"), ["1"], "description, any case"); + assert_eq!(search("bottle"), ["1"], "merchant"); + assert_eq!(search("paycheck"), ["2"], "category name"); + assert_eq!(search("wallet"), ["3", "4"], "account name"); + assert_eq!(search("wallet lunch"), ["4"], "every word has to match"); + assert_eq!(search("nothing"), Vec::::new()); + } + + #[test] + fn search_matches_amounts() { + assert_eq!(search("4.50"), ["1"]); + assert_eq!(search("$1,250"), ["3"]); + assert_eq!(search("2500"), ["2"]); + } + + #[test] + fn filters_by_type_account_and_category() { + let kind = TransactionFilter { kind: Some("income".into()), ..Default::default() }; + assert_eq!(run(&kind, Sort::default()), ["2"]); + let acct = TransactionFilter { account_id: Some("cash".into()), ..Default::default() }; + assert_eq!(run(&acct, Sort::default()), ["3", "4"]); + let cat = TransactionFilter { category_id: Some("food".into()), ..Default::default() }; + assert_eq!(run(&cat, Sort::default()), ["1"]); + } + + #[test] + fn uncategorized_includes_deleted_categories() { + let filter = TransactionFilter { category_id: Some(UNCATEGORIZED.into()), ..Default::default() }; + assert_eq!(run(&filter, Sort::default()), ["3", "4"]); + } + + #[test] + fn sorts_by_each_column() { + let all = TransactionFilter::default(); + let by = |key, descending| run(&all, Sort { key, descending }); + assert_eq!(by(SortKey::Date, false), ["4", "3", "2", "1"]); + assert_eq!(by(SortKey::Description, false), ["1", "4", "3", "2"]); + assert_eq!(by(SortKey::Amount, true), ["2", "1", "4", "3"], "income first, biggest expense last"); + assert_eq!(by(SortKey::Account, false), ["1", "2", "3", "4"]); + assert_eq!(by(SortKey::Category, false), ["3", "4", "1", "2"], "uncategorized first"); + } + + #[test] + fn clicking_a_header_toggles_the_order() { + let sort = Sort::default(); + assert_eq!(sort.toggled(SortKey::Date), Sort { key: SortKey::Date, descending: false }); + assert_eq!(sort.toggled(SortKey::Description), Sort { key: SortKey::Description, descending: false }); + assert_eq!(sort.toggled(SortKey::Amount), Sort { key: SortKey::Amount, descending: true }); + assert_eq!(sort.arrow(SortKey::Date), " ↓"); + assert_eq!(sort.toggled(SortKey::Date).arrow(SortKey::Date), " ↑"); + assert_eq!(sort.arrow(SortKey::Amount), ""); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 019b484..c727218 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -13,6 +13,7 @@ mod settings; mod sidebar; mod subscriptions; mod theme_bridge; +mod transaction_filter_control; mod transactions; mod widgets; diff --git a/src/ui/transaction_filter_control.rs b/src/ui/transaction_filter_control.rs new file mode 100644 index 0000000..b44c8d7 --- /dev/null +++ b/src/ui/transaction_filter_control.rs @@ -0,0 +1,107 @@ +//! Search box plus type, account and category dropdowns for the transactions +//! list. Rebuilt whenever the accounts or categories change, keeping the +//! current selection where it still exists. +use super::widgets::{choice, choice_value, text_input_with, text_of}; +use chelete_lib::commands::{Account, Category}; +use chelete_lib::transaction_filter::{TransactionFilter, UNCATEGORIZED}; +use gpui_kit::base::input::{InputEvent, InputState}; +use gpui_kit::{div, prelude::*, rems, App, Context, Div, Entity, Subscription, Window}; +use gpui_omarchy::{input, select, ChoiceState}; + +const ALL: &str = "all"; + +pub struct TransactionFilterControl { + pub search: Entity, + pub kind: Entity, + pub account: Entity, + pub category: Entity, + /// The account and category lists the dropdowns were built from. + options: Vec<(String, String)>, +} + +impl TransactionFilterControl { + /// Fresh controls with `keep` pre-selected where it still applies. + pub fn new( + keep: &TransactionFilter, + accounts: &[Account], + categories: &[Category], + window: &mut Window, + cx: &mut Context, + ) -> Self { + let kinds = vec![ + (ALL.to_string(), "All Types".to_string()), + ("income".to_string(), "Income".to_string()), + ("expense".to_string(), "Expense".to_string()), + ]; + let mut account_items = vec![(ALL.to_string(), "All Accounts".to_string())]; + account_items.extend(accounts.iter().map(|a| (a.id.clone(), a.name.clone()))); + let mut category_items = vec![ + (ALL.to_string(), "All Categories".to_string()), + (UNCATEGORIZED.to_string(), "Uncategorized".to_string()), + ]; + category_items.extend(categories.iter().map(|c| (c.id.clone(), c.name.clone()))); + + let pick = |items: &[(String, String)], value: &Option| -> String { + value.clone().filter(|v| items.iter().any(|(id, _)| id == v)).unwrap_or_else(|| ALL.to_string()) + }; + let kind = pick(&kinds, &keep.kind); + let account = pick(&account_items, &keep.account_id); + let category = pick(&category_items, &keep.category_id); + Self { + search: text_input_with("Search transactions", &keep.search, window, cx), + kind: choice(kinds, Some(&kind), window, cx), + options: options(accounts, categories), + account: choice(account_items, Some(&account), window, cx), + category: choice(category_items, Some(&category), window, cx), + } + } + + /// Whether the dropdowns are out of date with these lists. + pub fn is_stale(&self, accounts: &[Account], categories: &[Category]) -> bool { + self.options != options(accounts, categories) + } + + pub fn filter(&self, cx: &App) -> TransactionFilter { + let picked = |state: &Entity| choice_value(state, cx).filter(|v| v != ALL); + TransactionFilter { + search: text_of(&self.search, cx), + kind: picked(&self.kind), + account_id: picked(&self.account), + category_id: picked(&self.category), + } + } + + /// Re-render `owner` whenever the search text or a dropdown changes. + pub fn observe(&self, cx: &mut Context, on_change: impl Fn(&mut V, &mut Context) + Clone + 'static) -> Vec { + let typed = on_change.clone(); + let mut subs = vec![cx.subscribe(&self.search, move |this, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + typed(this, cx); + } + })]; + for state in [&self.kind, &self.account, &self.category] { + let changed = on_change.clone(); + subs.push(cx.observe(state, move |this, _, cx| changed(this, cx))); + } + subs + } + + pub fn render(&self, window: &mut Window, cx: &mut App) -> Div { + div() + .flex() + .items_center() + .gap(rems(0.5)) + .child(div().w(rems(16.)).child(input("tx-search", &self.search, window, cx))) + .child(div().w(rems(9.)).child(select("tx-kind", &self.kind, window, cx))) + .child(div().w(rems(11.)).child(select("tx-account", &self.account, window, cx))) + .child(div().w(rems(11.)).child(select("tx-category", &self.category, window, cx))) + } +} + +fn options(accounts: &[Account], categories: &[Category]) -> Vec<(String, String)> { + accounts + .iter() + .map(|a| (a.id.clone(), a.name.clone())) + .chain(categories.iter().map(|c| (c.id.clone(), c.name.clone()))) + .collect() +} diff --git a/src/ui/transactions.rs b/src/ui/transactions.rs index 0cdc486..f01eca1 100644 --- a/src/ui/transactions.rs +++ b/src/ui/transactions.rs @@ -3,6 +3,7 @@ use super::csv_import::{CsvImportDialog, CsvImportEvent}; use super::date_field; use super::date_filter_control::DateFilterControl; use super::icons::category_icon; +use super::transaction_filter_control::TransactionFilterControl; use super::widgets::*; use chelete_lib::commands::{ self, Account, Category, CreateTransactionRequest, ExportData, Transaction, UpdateTransactionRequest, @@ -12,6 +13,7 @@ use chelete_lib::date_filter::is_date_in_range; use chelete_lib::export::ExportFormat; use chelete_lib::format::{format_money, parse_cents}; use chelete_lib::paging::{page_slice, Pager}; +use chelete_lib::transaction_filter::{self, Sort, SortKey, TransactionFilter}; use gpui_kit::base::input::InputState; use gpui_kit::component::date_picker::DatePicker; use gpui_kit::component::date_picker::DatePickerState; @@ -40,6 +42,8 @@ pub struct TransactionsScreen { accounts: Vec, categories: Vec, date_filter: DateFilterControl, + filters: TransactionFilterControl, + sort: Sort, per_page: Entity, applied_per_page: usize, page: usize, @@ -48,6 +52,7 @@ pub struct TransactionsScreen { view_focus: gpui_kit::FocusHandle, mappings_path: PathBuf, _dialog_sub: Option, + _filter_subs: Vec, _subs: Vec, } @@ -69,12 +74,15 @@ impl TransactionsScreen { } cx.notify(); })); + let filters = TransactionFilterControl::new(&TransactionFilter::default(), &[], &[], window, cx); let mut screen = Self { db, transactions: Vec::new(), accounts: Vec::new(), categories: Vec::new(), date_filter, + filters, + sort: Sort::default(), per_page, applied_per_page: 25, page: 1, @@ -83,12 +91,47 @@ impl TransactionsScreen { view_focus: cx.focus_handle(), mappings_path: chelete_lib::import::session::SavedMappings::default_path(), _dialog_sub: None, + _filter_subs: Vec::new(), _subs: subs, }; screen.load(); + screen.rebuild_filters(&TransactionFilter::default(), window, cx); screen } + /// Recreate the search and filter controls, e.g. after the accounts or + /// categories changed or to clear them. + fn rebuild_filters(&mut self, keep: &TransactionFilter, window: &mut Window, cx: &mut Context) { + self.filters = TransactionFilterControl::new(keep, &self.accounts, &self.categories, window, cx); + self._filter_subs = self.filters.observe(cx, |this: &mut Self, cx| { + this.page = 1; + cx.notify(); + }); + } + + fn clear_filters(&mut self, window: &mut Window, cx: &mut Context) { + self.rebuild_filters(&TransactionFilter::default(), window, cx); + self.page = 1; + cx.notify(); + } + + fn sort_by(&mut self, key: SortKey, cx: &mut Context) { + self.sort = self.sort.toggled(key); + self.page = 1; + cx.notify(); + } + + /// A clickable column header showing the sort direction. + fn sort_header(&self, label: &str, key: SortKey, right: bool, cx: &mut Context) -> impl IntoElement { + div() + .id(SharedString::from(format!("sort-{}", label.to_lowercase()))) + .flex() + .when(right, |d| d.justify_end()) + .cursor_pointer() + .child(format!("{label}{}", self.sort.arrow(key))) + .on_click(cx.listener(move |this, _, _, cx| this.sort_by(key, cx))) + } + fn load(&mut self) { self.transactions = commands::get_transactions(&self.db).unwrap_or_default(); self.accounts = commands::get_accounts(&self.db).unwrap_or_default(); @@ -109,10 +152,8 @@ impl TransactionsScreen { fn filtered(&self, cx: &gpui_kit::App) -> Vec<&Transaction> { let (start, end) = self.date_filter.bounds(chrono::Local::now().date_naive(), cx); - self.transactions - .iter() - .filter(|t| is_date_in_range(&t.transaction_date, &start, &end)) - .collect() + let in_range = self.transactions.iter().filter(|t| is_date_in_range(&t.transaction_date, &start, &end)); + transaction_filter::apply(in_range, &self.filters.filter(cx), self.sort, &self.accounts, &self.categories) } fn pager(&self, cx: &gpui_kit::App) -> Pager { @@ -282,8 +323,13 @@ fn short_date(iso: &str) -> String { impl Render for TransactionsScreen { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.filters.is_stale(&self.accounts, &self.categories) { + let keep = self.filters.filter(cx); + self.rebuild_filters(&keep, window, cx); + } let t = cx.omarchy().clone(); let today = chrono::Local::now().date_naive(); + let filtering = !self.filters.filter(cx).is_empty(); let filtered = self.filtered(cx); let pager = self.pager(cx); let rows: Vec = page_slice(&filtered, &pager).iter().map(|t| (*t).clone()).collect(); @@ -333,12 +379,12 @@ impl Render for TransactionsScreen { let screen = cx.entity(); move |_, cx| screen.update(cx, |s, cx| s.toggle_page(cx)) }, cx))) - .child(cell(Some(6.), "Date")) - .child(cell(None, "Description")) + .child(cell(Some(6.), self.sort_header("Date", SortKey::Date, false, cx))) + .child(cell(None, self.sort_header("Description", SortKey::Description, false, cx))) .child(cell(Some(6.), "Type")) - .child(cell(None, "Category")) - .child(cell(None, "Account")) - .child(cell(Some(8.), div().flex().justify_end().child("Amount"))) + .child(cell(None, self.sort_header("Category", SortKey::Category, false, cx))) + .child(cell(None, self.sort_header("Account", SortKey::Account, false, cx))) + .child(cell(Some(8.), self.sort_header("Amount", SortKey::Amount, true, cx))) .child(cell(Some(7.5), "")); let mut list = div().flex().flex_col().border_1().border_color(t.border).child(header); for tx in rows { @@ -416,7 +462,19 @@ impl Render for TransactionsScreen { Dialog::Import(i) => i.clone().into_any_element(), Dialog::View(tx) => self.view_dialog(tx, cx).into_any_element(), }); - let filter_row = self.date_filter.render(today, window, cx); + let filter_row = div() + .flex() + .flex_wrap() + .items_center() + .gap(rems(0.5)) + .child(self.filters.render(window, cx)) + .child(self.date_filter.render(today, window, cx)) + .when(filtering, |row| { + row.child( + button("clear-filters", "Clear", ButtonVariant::Outline, cx) + .on_click(cx.listener(|this, _, window, cx| this.clear_filters(window, cx))), + ) + }); div() .id("transactions") @@ -894,6 +952,98 @@ mod tests { cx.update(|_, cx| assert_eq!(screen.read(cx).page, 1)); } + fn pick(state: Entity, index: usize, cx: &mut VisualTestContext) { + cx.update(|_, cx| state.update(cx, |c, cx| c.set_selected(Some(index), cx))); + } + + #[gpui_kit::test] + fn search_narrows_the_list_and_resets_to_page_one(cx: &mut TestAppContext) { + let (screen, _db, cx) = setup(cx); + let word = cx.update(|_, cx| screen.read(cx).transactions()[0].description.clone()); + cx.update(|_, cx| screen.update(cx, |s, cx| { s.page = 2; cx.notify(); })); + // Type for real so the input reports the change, as it does for a user. + let search = cx.update(|_, cx| screen.read(cx).filters.search.clone()); + cx.update(|window, cx| search.update(cx, |s, cx| s.focus(window, cx))); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.simulate_input(&word.to_uppercase()); + cx.update(|_, cx| { + let s = screen.read(cx); + let shown = s.filtered(cx); + assert!(!shown.is_empty() && shown.len() < 25); + assert!(shown.iter().any(|t| t.description == word)); + assert_eq!(s.page, 1); + }); + } + + #[gpui_kit::test] + fn type_and_account_filters_combine(cx: &mut TestAppContext) { + let (screen, db, cx) = setup(cx); + let account = commands::get_accounts(&db).unwrap().remove(0); + let (kind, acct) = cx.update(|_, cx| (screen.read(cx).filters.kind.clone(), screen.read(cx).filters.account.clone())); + pick(kind, 2, cx); // Expense + pick(acct, 1, cx); // the first account + cx.update(|_, cx| { + let shown = screen.read(cx).filtered(cx); + assert!(!shown.is_empty()); + assert!(shown.iter().all(|t| t.transaction_type == "expense" && t.account_id == account.id)); + }); + } + + #[gpui_kit::test] + fn clicking_amount_sorts_and_clicking_again_reverses(cx: &mut TestAppContext) { + let (screen, _db, cx) = setup(cx); + let amounts = |screen: &Entity, cx: &mut VisualTestContext| -> Vec { + cx.update(|_, cx| screen.read(cx).filtered(cx).iter().map(|t| signed(t)).collect()) + }; + cx.update(|_, cx| screen.update(cx, |s, cx| s.sort_by(SortKey::Amount, cx))); + let desc = amounts(&screen, cx); + assert!(desc.windows(2).all(|w| w[0] >= w[1]), "largest first"); + cx.update(|_, cx| screen.update(cx, |s, cx| s.sort_by(SortKey::Amount, cx))); + let asc = amounts(&screen, cx); + assert!(asc.windows(2).all(|w| w[0] <= w[1]), "smallest first"); + } + + #[gpui_kit::test] + fn clear_resets_every_filter(cx: &mut TestAppContext) { + let (screen, _db, cx) = setup(cx); + let (search, kind) = cx.update(|_, cx| (screen.read(cx).filters.search.clone(), screen.read(cx).filters.kind.clone())); + type_into(&search, "zzz-no-match", cx); + pick(kind, 1, cx); + cx.update(|_, cx| assert!(screen.read(cx).filtered(cx).is_empty())); + cx.update(|window, cx| screen.update(cx, |s, cx| s.clear_filters(window, cx))); + cx.update(|_, cx| { + let s = screen.read(cx); + assert!(s.filters.filter(cx).is_empty()); + assert_eq!(s.filtered(cx).len(), 25); + }); + } + + #[gpui_kit::test] + fn new_categories_keep_the_chosen_filters(cx: &mut TestAppContext) { + let (screen, db, cx) = setup(cx); + let account = commands::get_accounts(&db).unwrap().remove(0); + let acct = cx.update(|_, cx| screen.read(cx).filters.account.clone()); + pick(acct, 1, cx); + commands::create_category( + &db, + chelete_lib::commands::CreateCategoryRequest { + name: "Pets".into(), + parent_id: None, + category_type: "expense".into(), + icon: None, + color: None, + }, + ) + .unwrap(); + cx.update(|_, cx| screen.update(cx, |s, cx| s.reload(cx))); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.update(|_, cx| { + let s = screen.read(cx); + assert!(!s.filters.is_stale(&s.accounts, &s.categories), "dropdowns rebuilt"); + assert_eq!(s.filters.filter(cx).account_id, Some(account.id.clone()), "selection kept"); + }); + } + #[gpui_kit::test] fn per_page_paginates_the_list(cx: &mut TestAppContext) { let (screen, _db, cx) = setup(cx);