diff --git a/app/src/conn.rs b/app/src/conn.rs index 95053d21..8860b6b6 100644 --- a/app/src/conn.rs +++ b/app/src/conn.rs @@ -11,7 +11,7 @@ use crate::modifier::{ activity_swap_position, add_new_tx, add_new_tx_methods, delete_tx, }; use crate::ui_helper::{Autofiller, Stepper, Verifier}; -use crate::utils::month_name_to_num; +use crate::utils::parse_month_year; use crate::views::{ ActivityView, ChartView, SearchView, SummaryView, TxViewGroup, get_activity_view, get_chart_view, get_search_txs, get_summary, get_txs, @@ -78,7 +78,6 @@ impl DbConn { cache: Cache { tags: HashMap::new(), tx_methods: HashMap::new(), - txs: None, details: HashSet::new(), }, }; @@ -98,7 +97,6 @@ impl DbConn { cache: Cache { tags: HashMap::new(), tx_methods: HashMap::new(), - txs: None, details: HashSet::new(), }, } @@ -212,10 +210,7 @@ impl DbConn { let result = self.conn.transaction::(|conn| { let mut db_conn = MutDbConn::new(conn, &self.cache); - let year_num = year.parse::().unwrap(); - let month_num = month_name_to_num(month); - - let date = NaiveDate::from_ymd_opt(year_num, month_num, 1).unwrap(); + let date = parse_month_year(month, year)?; get_txs(date, nature, &mut db_conn) })?; @@ -257,24 +252,13 @@ impl DbConn { year: &'a str, nature: FetchNature, ) -> Result { - let (summary, txs) = self - .conn - .transaction::<(SummaryView, Option>>), Error, _>(|conn| { - let mut db_conn = MutDbConn::new(conn, &self.cache); - - let year_num = year.parse::().unwrap(); - let month_num = month_name_to_num(month); - - let date = NaiveDate::from_ymd_opt(year_num, month_num, 1).unwrap(); - - get_summary(date, nature, &mut db_conn) - })?; + self.conn.transaction::(|conn| { + let mut db_conn = MutDbConn::new(conn, &self.cache); - if let Some(txs) = txs { - self.cache.set_txs(txs); - } + let date = parse_month_year(month, year)?; - Ok(summary) + get_summary(date, nature, &mut db_conn) + }) } pub fn get_chart_view_with_str<'a>( @@ -286,10 +270,7 @@ impl DbConn { let result = self.conn.transaction::(|conn| { let mut db_conn = MutDbConn::new(conn, &self.cache); - let year_num = year.parse::().unwrap(); - let month_num = month_name_to_num(month); - - let date = NaiveDate::from_ymd_opt(year_num, month_num, 1).unwrap(); + let date = parse_month_year(month, year)?; let tx_view = get_txs(date, nature, &mut db_conn)?; @@ -309,10 +290,7 @@ impl DbConn { let result = self.conn.transaction::(|conn| { let mut db_conn = MutDbConn::new(conn, &self.cache); - let year_num = year.parse::().unwrap(); - let month_num = month_name_to_num(month); - - let date = NaiveDate::from_ymd_opt(year_num, month_num, 1).unwrap(); + let date = parse_month_year(month, year)?; let activity_view = get_activity_view(date, &mut db_conn)?; @@ -361,6 +339,19 @@ impl DbConn { Ok(()) } + pub fn rename_tag(&mut self, old_name: &str, new_name: &str) -> Result<()> { + let new_tag = self.conn.transaction::(|conn| { + let mut db_conn = MutDbConn::new(conn, &self.cache); + + Ok(Tag::update_name(old_name, new_name, &mut db_conn)?) + })?; + + let tag = self.cache.tags.get_mut(&new_tag.id).unwrap(); + tag.name = new_name.to_string(); + + Ok(()) + } + pub fn set_new_tx_method_positions(&mut self, new_format: &[String]) -> Result<()> { let mut new_method_positions = Vec::new(); @@ -397,6 +388,11 @@ impl DbConn { self.cache.get_methods() } + #[must_use] + pub fn get_tags_sorted(&self) -> Vec<&Tag> { + self.cache.get_tags_sorted() + } + #[must_use] pub fn get_tx_methods_cumulative(&self) -> Vec { let mut methods: Vec = self diff --git a/app/src/migration.rs b/app/src/migration.rs index d3e14881..226e6b35 100644 --- a/app/src/migration.rs +++ b/app/src/migration.rs @@ -14,6 +14,7 @@ use std::io::Write; use crate::conn::{DbConn, MutDbConn}; use crate::modifier::add_new_tx_methods; use crate::utils::parse_amount_nature_cent; +use crate::utils::split_tags; #[derive(QueryableByName)] struct ColumnInfo { @@ -283,19 +284,10 @@ fn migrate_tx( Some(db_conn.cache().get_method_id(to_method).unwrap()) }; - let mut tag_list = Vec::new(); + let mut tag_list = split_tags(tags); - if tags.is_empty() { + if tag_list.is_empty() { tag_list.push("Unknown".to_string()); - } else { - let split_tags = tags.split(',').collect::>(); - - for tag in split_tags { - let trimmed_tag = tag.trim(); - if !trimmed_tag.is_empty() { - tag_list.push(trimmed_tag.to_string()); - } - } } let new_tx = NewTx::new( diff --git a/app/src/modifier/new_activity.rs b/app/src/modifier/new_activity.rs index c8232189..3e903c12 100644 --- a/app/src/modifier/new_activity.rs +++ b/app/src/modifier/new_activity.rs @@ -4,25 +4,18 @@ use rex_db::models::{ ActivityNature, ActivityTxTag, FullTx, NewActivity, NewActivityTx, NewSearch, NewTx, Tag, }; +use crate::utils::split_tags; + pub(crate) fn activity_new_tx(tx: &NewTx, tags: &str, conn: &mut impl ConnCache) -> Result<()> { let activity_type = ActivityNature::AddTx; let new_activity = NewActivity::new(activity_type).insert(conn)?; let added_tx = NewActivityTx::new_from_new_tx(tx, new_activity.id).insert(conn)?; - let mut tag_list = Vec::new(); + let mut tag_list = split_tags(tags); - if tags.is_empty() { + if tag_list.is_empty() { tag_list.push("Unknown".to_string()); - } else { - let split_tags = tags.split(',').collect::>(); - - for tag in split_tags { - let trimmed_tag = tag.trim(); - if !trimmed_tag.is_empty() { - tag_list.push(trimmed_tag.to_string()); - } - } } let mut tx_tags = Vec::new(); @@ -80,24 +73,16 @@ pub(crate) fn activity_edit_tx( NewActivityTx::new_from_full_tx(old_tx, false, new_activity.id).insert(conn)?; let mut old_tag_list = Vec::new(); - let mut new_tag_list = Vec::new(); for tag in &old_tx.tags { let tag = ActivityTxTag::new(old_tx_activity.id, tag.id); old_tag_list.push(tag); } - if tags.is_empty() { + let mut new_tag_list = split_tags(tags); + + if new_tag_list.is_empty() { new_tag_list.push("Unknown".to_string()); - } else { - let split_tags = tags.split(',').collect::>(); - - for tag in split_tags { - let trimmed_tag = tag.trim(); - if !trimmed_tag.is_empty() { - new_tag_list.push(trimmed_tag.to_string()); - } - } } let mut new_tags = Vec::new(); diff --git a/app/src/modifier/new_tx.rs b/app/src/modifier/new_tx.rs index 7b44b391..b618a056 100644 --- a/app/src/modifier/new_tx.rs +++ b/app/src/modifier/new_tx.rs @@ -3,6 +3,7 @@ use rex_db::ConnCache; use rex_db::models::{Balance, NewTag, NewTx, Tag, Tx, TxTag, TxType}; use crate::modifier::tidy_balances; +use crate::utils::split_tags; pub(crate) fn add_new_tx( tx: NewTx, @@ -15,19 +16,10 @@ pub(crate) fn add_new_tx( let to_method = tx.to_method; let amount = tx.amount; let tx_type = tx.tx_type; - let mut tag_list = Vec::new(); + let mut tag_list = split_tags(tags); - if tags.is_empty() { + if tag_list.is_empty() { tag_list.push("Unknown".to_string()); - } else { - let split_tags = tags.split(',').collect::>(); - - for tag in split_tags { - let trimmed_tag = tag.trim(); - if !trimmed_tag.is_empty() { - tag_list.push(trimmed_tag.to_string()); - } - } } let mut current_balance = Balance::get_balance_map(date.date(), db_conn)?; diff --git a/app/src/modifier/shared.rs b/app/src/modifier/shared.rs index b328f7b2..661f3199 100644 --- a/app/src/modifier/shared.rs +++ b/app/src/modifier/shared.rs @@ -217,14 +217,15 @@ pub fn parse_search_fields<'a>( let tags = if tags.is_empty() { None } else { - let tags = tags.split(',').map(str::trim).collect::>(); - let tags = tags - .iter() - .map(|t| db_conn.cache().get_tag_id(t)) - .filter_map(Result::ok) - .collect::>(); - - Some(tags) + let tag_names = tags.split(',').map(str::trim).filter(|s| !s.is_empty()); + + let mut tag_ids = Vec::new(); + + for t in tag_names { + tag_ids.push(db_conn.cache().get_tag_id(t)?); + } + + Some(tag_ids) }; let search_tx = NewSearch::new( diff --git a/app/src/utils.rs b/app/src/utils.rs index bc20f105..bc8a7a1d 100644 --- a/app/src/utils.rs +++ b/app/src/utils.rs @@ -1,22 +1,40 @@ -use anyhow::Result; +use anyhow::{Result, anyhow}; +use chrono::NaiveDate; use rex_db::models::AmountNature; use rex_shared::models::{Cent, Dollar}; -pub fn month_name_to_num(name: &str) -> u32 { +pub fn split_tags(input: &str) -> Vec { + input + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect() +} + +pub fn parse_month_year(month: &str, year: &str) -> Result { + let year_num = year.parse::()?; + let month_num = month_name_to_num(month)?; + + NaiveDate::from_ymd_opt(year_num, month_num, 1) + .ok_or_else(|| anyhow!("Invalid date: {year}-{month}-01")) +} + +pub fn month_name_to_num(name: &str) -> Result { match name { - "January" => 1, - "February" => 2, - "March" => 3, - "April" => 4, - "May" => 5, - "June" => 6, - "July" => 7, - "August" => 8, - "September" => 9, - "October" => 10, - "November" => 11, - "December" => 12, - _ => panic!("Invalid month name {name}"), + "January" => Ok(1), + "February" => Ok(2), + "March" => Ok(3), + "April" => Ok(4), + "May" => Ok(5), + "June" => Ok(6), + "July" => Ok(7), + "August" => Ok(8), + "September" => Ok(9), + "October" => Ok(10), + "November" => Ok(11), + "December" => Ok(12), + _ => Err(anyhow!("Invalid month name {name}")), } } @@ -84,24 +102,23 @@ mod tests { #[test] fn test_month_name_to_num_all_months() { - assert_eq!(month_name_to_num("January"), 1); - assert_eq!(month_name_to_num("February"), 2); - assert_eq!(month_name_to_num("March"), 3); - assert_eq!(month_name_to_num("April"), 4); - assert_eq!(month_name_to_num("May"), 5); - assert_eq!(month_name_to_num("June"), 6); - assert_eq!(month_name_to_num("July"), 7); - assert_eq!(month_name_to_num("August"), 8); - assert_eq!(month_name_to_num("September"), 9); - assert_eq!(month_name_to_num("October"), 10); - assert_eq!(month_name_to_num("November"), 11); - assert_eq!(month_name_to_num("December"), 12); - } - - #[test] - #[should_panic(expected = "Invalid month name")] - fn test_month_name_to_num_invalid_panics() { - month_name_to_num("NotAMonth"); + assert_eq!(month_name_to_num("January").unwrap(), 1); + assert_eq!(month_name_to_num("February").unwrap(), 2); + assert_eq!(month_name_to_num("March").unwrap(), 3); + assert_eq!(month_name_to_num("April").unwrap(), 4); + assert_eq!(month_name_to_num("May").unwrap(), 5); + assert_eq!(month_name_to_num("June").unwrap(), 6); + assert_eq!(month_name_to_num("July").unwrap(), 7); + assert_eq!(month_name_to_num("August").unwrap(), 8); + assert_eq!(month_name_to_num("September").unwrap(), 9); + assert_eq!(month_name_to_num("October").unwrap(), 10); + assert_eq!(month_name_to_num("November").unwrap(), 11); + assert_eq!(month_name_to_num("December").unwrap(), 12); + } + + #[test] + fn test_month_name_to_num_invalid_errors() { + assert!(month_name_to_num("NotAMonth").is_err()); } #[test] diff --git a/app/src/views/summary_view.rs b/app/src/views/summary_view.rs index ffc41e20..564b6de2 100644 --- a/app/src/views/summary_view.rs +++ b/app/src/views/summary_view.rs @@ -50,39 +50,14 @@ impl FullSummary { } } -type CacheTxs = HashMap>; - pub(crate) fn get_summary( date: NaiveDate, nature: FetchNature, conn: &mut impl ConnCache, -) -> Result<(SummaryView, Option)> { +) -> Result { let txs = FullTx::get_txs(date, nature, conn)?; - let mut create_map = false; - if let FetchNature::All = nature { - create_map = true; - } - - if create_map { - let mut map = HashMap::with_capacity(txs.len()); - - for tx in &txs { - let unique_value = month_year_to_unique(date.month() as i32, date.year()); - - map.entry(unique_value) - .or_insert_with(Vec::new) - .push(tx.clone()); - } - - let summary_view = SummaryView { txs, nature }; - - return Ok((summary_view, Some(map))); - } - - let summary_view = SummaryView { txs, nature }; - - Ok((summary_view, None)) + Ok(SummaryView { txs, nature }) } impl SummaryView { diff --git a/app/tests/parse.rs b/app/tests/parse.rs index 94766b69..ac3f4ff8 100644 --- a/app/tests/parse.rs +++ b/app/tests/parse.rs @@ -81,19 +81,12 @@ fn parse_search_fields_amount_nature_variants() { } #[test] -fn parse_search_fields_nonexistent_tags_filtered() { +fn parse_search_fields_nonexistent_tags_errors() { let file_name = "test_parse_search_tags.sqlite"; let db_conn = create_test_db(file_name); - // Non-existent tags are silently skipped by parse_search_fields - let search = - parse_search_fields("", "", "", "", "", "", "FakeTag, AnotherFake", &db_conn).unwrap(); - // tags get filtered to empty because no tag exists in cache (except "Unknown") - assert!( - search.tags.is_none() || search.tags.as_ref().unwrap().is_empty(), - "Non-existent tags should be filtered out, got: {:?}", - search.tags - ); + let result = parse_search_fields("", "", "", "", "", "", "FakeTag", &db_conn); + assert!(result.is_err(), "Non-existent tags should return an error"); drop(db_conn); fs::remove_file(file_name).unwrap(); diff --git a/app/tests/tag_rename.rs b/app/tests/tag_rename.rs new file mode 100644 index 00000000..688003b7 --- /dev/null +++ b/app/tests/tag_rename.rs @@ -0,0 +1,86 @@ +use std::fs; + +use crate::common::add_tx; +use crate::common::create_test_db; + +mod common; + +#[test] +fn rename_tag_updates_name() { + let file_name = "test_tag_rename.sqlite"; + let mut db_conn = create_test_db(file_name); + + let tx = add_tx( + &mut db_conn, + "2025-06-15", + "Weekly shopping", + "Cash", + "", + "50.00", + "Expense", + "Groceries", + ); + + let tag_id = { + let tags = db_conn.get_tags_sorted(); + let groc_tag = tags.iter().find(|t| t.name == "Groceries").unwrap(); + assert_eq!(groc_tag.name, "Groceries"); + groc_tag.id + }; + + db_conn.rename_tag("Groceries", "Food").unwrap(); + + let tags = db_conn.get_tags_sorted(); + let renamed = tags.iter().find(|t| t.id == tag_id).unwrap(); + assert_eq!(renamed.name, "Food"); + + let old_exists = tags.iter().any(|t| t.name == "Groceries"); + assert!(!old_exists); + + drop(tags); + + let full_tx = db_conn.fetch_tx_with_id(tx.id).unwrap(); + let tx_tag_names: Vec<&str> = full_tx.tags.iter().map(|t| t.name.as_str()).collect(); + assert!(tx_tag_names.contains(&"Food")); + assert!(!tx_tag_names.contains(&"Groceries")); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn rename_tag_to_existing_fails() { + let file_name = "test_tag_rename_dup.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2025-06-15", + "Groceries", + "Cash", + "", + "50.00", + "Expense", + "Groceries", + ); + add_tx( + &mut db_conn, + "2025-06-16", + "Food expense", + "Cash", + "", + "30.00", + "Expense", + "Food", + ); + + let result = db_conn.rename_tag("Groceries", "Food"); + assert!(result.is_err()); + + let tags = db_conn.get_tags_sorted(); + let still_groceries = tags.iter().any(|t| t.name == "Groceries"); + assert!(still_groceries); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} diff --git a/db/src/lib.rs b/db/src/lib.rs index 2b5122e1..1cdd0829 100644 --- a/db/src/lib.rs +++ b/db/src/lib.rs @@ -6,9 +6,8 @@ use diesel::prelude::*; use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; use std::collections::{HashMap, HashSet}; -use crate::models::{FullTx, Tag, TxMethod}; +use crate::models::{Tag, TxMethod}; -// pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("../db/src/migrations"); pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("src/migrations"); pub trait ConnCache { @@ -20,7 +19,6 @@ pub trait ConnCache { pub struct Cache { pub tags: HashMap, pub tx_methods: HashMap, - pub txs: Option>>, pub details: HashSet, } @@ -70,20 +68,6 @@ impl Cache { self.details.insert(details); } - // TODO: Start using cache - pub fn set_txs(&mut self, txs: HashMap>) { - self.txs = Some(txs); - } - - #[must_use] - pub fn get_txs(&self, id: i32) -> Option<&Vec> { - if let Some(txs) = &self.txs { - return txs.get(&id); - } - - None - } - #[must_use] pub fn get_methods(&self) -> Vec<&TxMethod> { let mut methods = self.tx_methods.values().collect::>(); diff --git a/db/src/models/activity_tx_tags.rs b/db/src/models/activity_tx_tags.rs index 6cf8c974..e1a740e4 100644 --- a/db/src/models/activity_tx_tags.rs +++ b/db/src/models/activity_tx_tags.rs @@ -34,10 +34,4 @@ impl ActivityTxTag { .values(txs) .execute(db_conn.conn()) } - - pub fn delete_by_tx_id(tx_id_value: i32, db_conn: &mut impl ConnCache) -> Result { - use crate::schema::tx_tags::dsl::{tx_id, tx_tags}; - - diesel::delete(tx_tags.filter(tx_id.eq(tx_id_value))).execute(db_conn.conn()) - } } diff --git a/db/src/models/balances.rs b/db/src/models/balances.rs index 47dc0f8a..dcb39092 100644 --- a/db/src/models/balances.rs +++ b/db/src/models/balances.rs @@ -265,10 +265,11 @@ impl Balance { .select(Balance::as_select()) .load(db_conn.conn())?; - assert!( - (balance_list.len() == db_conn.cache().tx_methods.len()), - "Final balances are not set for all transaction methods" - ); + if balance_list.len() != db_conn.cache().tx_methods.len() { + return Err(Error::QueryBuilderError(Box::new(std::io::Error::other( + "Final balances are not set for all transaction methods", + )))); + } let balance_map = balance_list.into_iter().map(|b| (b.method_id, b)).collect(); diff --git a/db/src/models/tags.rs b/db/src/models/tags.rs index 333a8653..9efe2b60 100644 --- a/db/src/models/tags.rs +++ b/db/src/models/tags.rs @@ -47,4 +47,17 @@ impl Tag { tags.filter(name.eq(n)).first(db_conn.conn()).optional() } + + pub fn update_name( + old_name: &str, + new_name: &str, + db_conn: &mut impl ConnCache, + ) -> Result { + use crate::schema::tags::dsl::{name, tags}; + + diesel::update(tags.filter(name.eq(old_name))) + .set(name.eq(new_name)) + .returning(Self::as_returning()) + .get_result(db_conn.conn()) + } } diff --git a/tui/src/key_checker/key_handler.rs b/tui/src/key_checker/key_handler.rs index cb42a6db..fa7d03d7 100644 --- a/tui/src/key_checker/key_handler.rs +++ b/tui/src/key_checker/key_handler.rs @@ -358,7 +358,6 @@ impl<'a> InputKeyHandler<'a> { match status { Ok(()) => { - // TODO: Update cache? self.go_home_reset(); // We just added a new TX, select the month tab again + reload the data of balance and table widgets to get updated data *self.home_tab = HomeTab::Months; @@ -401,9 +400,6 @@ impl<'a> InputKeyHandler<'a> { let target_tx = self.home_txs.get_tx(index); self.conn.delete_tx(target_tx)?; - // INFO: maybe can reduce fetches by directly deleted from TX list? - // TODO: Update cache? - // Transaction deleted so reload the data again self.reload_home_table(false)?; self.reload_chart_data()?; @@ -851,8 +847,11 @@ impl<'a> InputKeyHandler<'a> { ConfigChoices::RenameTxMethod => { *self.popup_status = PopupType::new_choice_methods(self.conn, self.theme)?; } + ConfigChoices::RenameTag => { + *self.popup_status = PopupType::new_choice_tags(self.conn, self.theme)?; + } ConfigChoices::AddNewTxMethod => { - *self.popup_status = PopupType::new_input(None); + *self.popup_status = PopupType::new_input(None, false); } } } @@ -861,7 +860,14 @@ impl<'a> InputKeyHandler<'a> { return Err(anyhow!("Popup choice should not have been None")); }; - *self.popup_status = PopupType::new_input(Some(choice)); + *self.popup_status = PopupType::new_input(Some(choice), false); + } + ChoicePopupState::Tags => { + let Some(choice) = self.popup_status.get_choice_tag() else { + return Err(anyhow!("Popup choice should not have been None")); + }; + + *self.popup_status = PopupType::new_input(Some(choice), true); } } diff --git a/tui/src/pages/popups/choice.rs b/tui/src/pages/popups/choice.rs index 519d7c9c..fc43b3e5 100644 --- a/tui/src/pages/popups/choice.rs +++ b/tui/src/pages/popups/choice.rs @@ -34,12 +34,12 @@ impl ChoicePopup { title = "Configuration"; message = "Select an option to configure"; - y_value = 12; + y_value = 13; constraints = vec![ Constraint::Length(4), Constraint::Min(1), - Constraint::Length(7), + Constraint::Length(8), ]; } ChoicePopupState::ConfigForced => { @@ -65,6 +65,22 @@ impl ChoicePopup { y_value = 20; } + constraints = vec![ + Constraint::Length(4), + Constraint::Length(1), + Constraint::Length((self.table.items.len() + 2) as u16), + ]; + } + ChoicePopupState::Tags => { + title = "Rename Tag"; + message = "Select a tag to rename"; + + y_value = 5 + self.table.items.len() as u16 + 2; + + if y_value > 20 { + y_value = 20; + } + constraints = vec![ Constraint::Length(4), Constraint::Length(1), diff --git a/tui/src/pages/popups/input.rs b/tui/src/pages/popups/input.rs index 2aea412c..aa707a7c 100644 --- a/tui/src/pages/popups/input.rs +++ b/tui/src/pages/popups/input.rs @@ -14,10 +14,18 @@ impl InputPopup { let x_value = 50; let y_value = 7; - let title = if self.modifying_method.is_none() { + let title = if self.modifying_method.is_none() && self.modifying_tag.is_none() { "New Method" + } else if self.modifying_tag.is_some() { + "Rename Tag" } else { - "Rename to" + "Rename Method" + }; + + let block_name = if self.modifying_tag.is_some() { + "Tag name" + } else { + "Method name" }; let title = Span::styled(title, Style::default().add_modifier(Modifier::BOLD)); @@ -46,7 +54,7 @@ impl InputPopup { let input_section = Paragraph::new(input_text) .style(Style::default().bg(theme.background()).fg(theme.text())) - .block(styled_block("Method name", theme)) + .block(styled_block(block_name, theme)) .alignment(Alignment::Left); let status_section = Paragraph::new(status_text) diff --git a/tui/src/pages/popups/models.rs b/tui/src/pages/popups/models.rs index 7a01e3c3..8a60f489 100644 --- a/tui/src/pages/popups/models.rs +++ b/tui/src/pages/popups/models.rs @@ -66,6 +66,7 @@ pub struct InputPopup { pub cursor_position: usize, pub status: String, pub modifying_method: Option, + pub modifying_tag: Option, } pub struct ChoiceDetails { @@ -91,6 +92,7 @@ pub enum ChoicePopupState { Delete, Config, TxMethods, + Tags, ConfigForced, } @@ -116,6 +118,8 @@ pub enum ConfigChoices { NewLocation, #[strum(to_string = "Set backup paths for app data")] BackupPaths, + #[strum(to_string = "Rename a Tag")] + RenameTag, } impl InfoPopup { @@ -536,12 +540,61 @@ impl PopupType { } } - pub fn new_input(modifying: Option) -> Self { + pub fn new_choice_tags(conn: &mut DbConn, theme: &Theme) -> Result { + let tags: Vec<_> = conn + .get_tags_sorted() + .into_iter() + .filter(|t| t.name != "Unknown") + .collect(); + + if tags.is_empty() { + return Err(anyhow!( + "There needs to be at least 1 tag existing for this option" + )); + } + + let choices = tags + .iter() + .map(|c| ChoiceDetails { + text: c.name.clone(), + color: theme.positive(), + }) + .collect(); + + let table = tags.iter().map(|m| vec![m.name.clone()]).collect(); + let mut table_data = TableData::new(table); + table_data.state.select(Some(0)); + + Ok(PopupType::Choice(ChoicePopup { + table: table_data, + choices, + showing: ChoicePopupState::Tags, + })) + } + + pub fn get_choice_tag(&self) -> Option { + match self { + PopupType::Choice(choice) => { + let selected = choice.table.state.selected().unwrap(); + Some(choice.choices[selected].text.clone()) + } + _ => None, + } + } + + pub fn new_input(modifying: Option, is_tag: bool) -> Self { + let (modifying_method, modifying_tag) = if is_tag { + (None, modifying) + } else { + (modifying, None) + }; + PopupType::Input(InputPopup { text: String::new(), cursor_position: 0, status: String::from("All good"), - modifying_method: modifying, + modifying_method, + modifying_tag, }) } @@ -549,36 +602,71 @@ impl PopupType { if let PopupType::Input(input) = self { add_char_to(char, &mut input.cursor_position, &mut input.text); - let tx_methods = conn.get_tx_methods_sorted(); + if input.modifying_tag.is_some() { + let tags = conn.get_tags_sorted(); - if tx_methods.iter().any(|m| m.name == input.text) { - input.status = String::from("Cannot use existing method name"); - } else if RESTRICTED.iter().any(|m| m == &input.text) { - input.status = String::from("Cannot use this text as method name"); + if tags.iter().any(|t| t.name == input.text) { + input.status = String::from("Cannot use existing tag name"); + } else if input.text.is_empty() { + input.status = String::from("Tag name cannot be empty"); + } else { + input.status = String::from("All good"); + } } else { - input.status = String::from("All good"); + let tx_methods = conn.get_tx_methods_sorted(); + + if tx_methods.iter().any(|m| m.name == input.text) { + input.status = String::from("Cannot use existing method name"); + } else if RESTRICTED.iter().any(|m| m == &input.text) { + input.status = String::from("Cannot use this text as method name"); + } else if input.text.is_empty() { + input.status = String::from("Method name cannot be empty"); + } else { + input.status = String::from("All good"); + } } } } pub fn accept_input(&mut self, conn: &mut DbConn) -> Result { if let PopupType::Input(input) = self { - let tx_methods = conn.get_tx_methods_sorted(); + if let Some(modifying) = &input.modifying_tag { + let tags = conn.get_tags_sorted(); - if tx_methods.iter().any(|m| m.name == input.text) { - input.status = String::from("Cannot use existing method name"); - return Ok(false); - } + if tags.iter().any(|t| t.name == input.text) { + input.status = String::from("Cannot use existing tag name"); + return Ok(false); + } - if RESTRICTED.iter().any(|m| m == &input.text) { - input.status = String::from("Cannot use this text as method name"); - return Ok(false); - } + if input.text.is_empty() { + input.status = String::from("Tag name cannot be empty"); + return Ok(false); + } - if let Some(modifying) = &input.modifying_method { - conn.rename_tx_method(modifying, &input.text)?; + conn.rename_tag(modifying, &input.text)?; } else { - conn.add_new_methods(std::slice::from_ref(&input.text))?; + let tx_methods = conn.get_tx_methods_sorted(); + + if tx_methods.iter().any(|m| m.name == input.text) { + input.status = String::from("Cannot use existing method name"); + return Ok(false); + } + + if RESTRICTED.iter().any(|m| m == &input.text) { + input.status = String::from("Cannot use this text as method name"); + return Ok(false); + } + + if input.text.is_empty() { + input.status = String::from("Method name cannot be empty"); + return Ok(false); + } + + if let Some(modifying) = &input.modifying_method { + conn.rename_tx_method(modifying, &input.text)?; + } else { + conn.add_new_methods(std::slice::from_ref(&input.text))?; + } } Ok(true)