From a3f2c451e34a4eb4082a8bf797a5fe5b52ab1578 Mon Sep 17 00:00:00 2001 From: "Bauer, Georg" Date: Wed, 22 Jul 2026 15:35:22 +0200 Subject: [PATCH] fix: small findings with regard to deletes from sidebar --- Cargo.toml | 2 +- crates/bds-ui/src/app.rs | 215 ++++++++++++++++++++++-- crates/bds-ui/src/i18n/mod.rs | 2 + crates/bds-ui/src/i18n/relative_date.rs | 163 ++++++++++++++++++ crates/bds-ui/src/views/sidebar.rs | 166 +++++++++++------- locales/ui/de.ftl | 3 +- locales/ui/en.ftl | 3 +- locales/ui/es.ftl | 3 +- locales/ui/fr.ftl | 3 +- locales/ui/it.ftl | 3 +- specs/action_patterns.allium | 2 + specs/editor_template.allium | 18 +- specs/sidebar_views.allium | 9 +- 13 files changed, 499 insertions(+), 93 deletions(-) create mode 100644 crates/bds-ui/src/i18n/relative_date.rs diff --git a/Cargo.toml b/Cargo.toml index cb22cd7..8aa9f3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ uuid = { version = "1", features = ["v4", "serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" -chrono = { version = "0.4", features = ["serde"] } +chrono = { version = "0.4", features = ["serde", "unstable-locales"] } sha2 = "0.10" md5 = "0.8.1" unicode-normalization = "0.1" diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 3ffb4aa..49c6c75 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -382,6 +382,10 @@ pub enum Message { CreateImport, /// Per sidebar_views.allium ScriptListItemEntry: row-level delete affordance. ScriptDeleteRequested(String), + /// Per sidebar_views.allium TemplateListItemEntry: row-level delete affordance. + TemplateDeleteRequested(String), + /// Per sidebar_views.allium ImportListItemEntry: row-level delete affordance. + ImportDeleteRequested(String), // Conversational AI ChatCreate, @@ -1390,6 +1394,12 @@ impl BdsApp { Message::ScriptDeleteRequested(script_id) => { self.show_script_delete_confirmation(&script_id) } + Message::TemplateDeleteRequested(template_id) => { + self.show_template_delete_confirmation(&template_id) + } + Message::ImportDeleteRequested(definition_id) => { + self.show_import_delete_confirmation(&definition_id) + } Message::ChatCreate => self.create_chat_conversation(), Message::ChatRenameInputChanged(value) => { if let Some(state) = self.active_chat_state_mut() { @@ -4677,17 +4687,7 @@ impl BdsApp { ImportEditorMsg::Execute => self.start_import_execution(&definition_id), ImportEditorMsg::AutoMapTaxonomy => self.start_import_auto_mapping(&definition_id), ImportEditorMsg::DeleteRequested => { - let name = self - .import_editors - .get(&definition_id) - .map(|state| state.definition.name.clone()) - .unwrap_or_default(); - self.active_modal = Some(modal::ModalState::Confirm { - title: t(self.ui_locale, "import.deleteTitle"), - message: tw(self.ui_locale, "import.deleteMessage", &[("name", &name)]), - on_confirm: modal::ConfirmAction::DeleteImport(definition_id), - }); - Task::none() + self.show_import_delete_confirmation(&definition_id) } ImportEditorMsg::SetResolution { kind, @@ -7046,18 +7046,20 @@ impl BdsApp { ("tags", &referencing_tags.to_string()), ], ); - return Task::done(Message::ShowModal(modal::ModalState::Confirm { + self.active_modal = Some(modal::ModalState::Confirm { title, message, on_confirm: modal::ConfirmAction::ForceDeleteTemplate(template_id.to_string()), - })); + }); + return Task::none(); } - Task::done(Message::ShowModal(modal::ModalState::ConfirmDelete { + self.active_modal = Some(modal::ModalState::ConfirmDelete { entity_name: template.title, references: Vec::new(), on_confirm: modal::ConfirmAction::DeleteTemplate(template_id.to_string()), - })) + }); + Task::none() } /// Per editor_script.allium ScriptDeleteAction / action_patterns.allium @@ -7079,6 +7081,29 @@ impl BdsApp { Task::none() } + /// Per sidebar_views.allium ImportListItemEntry: confirming an import + /// deletion must not depend on an open editor tab, so the definition + /// name comes from the database. + fn show_import_delete_confirmation(&mut self, definition_id: &str) -> Task { + let Some(db) = &self.db else { + return Task::none(); + }; + let Ok(definition) = engine::wordpress_import::get_definition(db.conn(), definition_id) + else { + return Task::none(); + }; + self.active_modal = Some(modal::ModalState::Confirm { + title: t(self.ui_locale, "import.deleteTitle"), + message: tw( + self.ui_locale, + "import.deleteMessage", + &[("name", &definition.name)], + ), + on_confirm: modal::ConfirmAction::DeleteImport(definition_id.to_string()), + }); + Task::none() + } + fn delete_script_editor(&mut self, script_id: &str) -> Task { let Some(db) = &self.db else { return Task::none(); @@ -12619,6 +12644,166 @@ mod tests { } } + /// sidebar_views.allium TemplateListItemEntry provides + /// TemplateDeleteRequested(item.template_id): deleting from the sidebar + /// row routes through the same confirm modal as the editor delete button + /// and works without an open editor tab. + #[test] + fn sidebar_template_delete_requires_confirmation_and_works_without_open_tab() { + let (db, project, tmp) = setup(); + let mut app = make_app(db, project, &tmp); + let _ = app.update(Message::CreateTemplate); + let template_id = app.sidebar_templates[0].id.clone(); + + // Close the editor tab: the sidebar row must not depend on one. + let _ = app.update(Message::CloseTab(template_id.clone())); + assert!(!app.tabs.iter().any(|tab| tab.id == template_id)); + + // Requesting deletion only opens the confirm modal; nothing is deleted. + let _ = app.update(Message::TemplateDeleteRequested(template_id.clone())); + assert!(matches!( + app.active_modal, + Some(modal::ModalState::ConfirmDelete { + on_confirm: modal::ConfirmAction::DeleteTemplate(ref id), + .. + }) if id == &template_id + )); + assert!( + bds_core::db::queries::template::get_template_by_id( + app.db.as_ref().unwrap().conn(), + &template_id + ) + .is_ok() + ); + + let _ = app.update(Message::ConfirmModal(modal::ConfirmAction::DeleteTemplate( + template_id.clone(), + ))); + assert!( + bds_core::db::queries::template::get_template_by_id( + app.db.as_ref().unwrap().conn(), + &template_id + ) + .is_err() + ); + assert!(!app.template_editors.contains_key(&template_id)); + } + + /// editor_template.allium TemplateDelete / TemplateListItemEntry + /// DeleteConfirmation: a referenced template prompts the force-delete + /// confirmation with reference counts instead of the plain delete modal. + #[test] + fn sidebar_template_delete_with_references_prompts_force_delete() { + let (db, project, tmp) = setup(); + let mut app = make_app(db, project, &tmp); + let _ = app.update(Message::CreateTemplate); + let template = app.sidebar_templates[0].clone(); + + let referencing_post = post::create_post( + app.db.as_ref().unwrap().conn(), + tmp.path(), + &template.project_id, + "Uses Template", + None, + vec![], + vec![], + None, + Some("en"), + Some(&template.slug), + ) + .unwrap(); + + let _ = app.update(Message::TemplateDeleteRequested(template.id.clone())); + assert!(matches!( + app.active_modal, + Some(modal::ModalState::Confirm { + on_confirm: modal::ConfirmAction::ForceDeleteTemplate(ref id), + .. + }) if id == &template.id + )); + + // Force delete removes the template and clears the post reference. + let _ = app.update(Message::ConfirmModal( + modal::ConfirmAction::ForceDeleteTemplate(template.id.clone()), + )); + assert!( + bds_core::db::queries::template::get_template_by_id( + app.db.as_ref().unwrap().conn(), + &template.id + ) + .is_err() + ); + assert_eq!( + bds_core::db::queries::post::get_post_by_id( + app.db.as_ref().unwrap().conn(), + &referencing_post.id + ) + .unwrap() + .template_slug, + None + ); + } + + /// sidebar_views.allium ChatListItemEntry provides + /// ChatDeleteRequested(item.conversation_id); per DeleteBehaviour and + /// action_patterns.allium the delete is immediate (no confirmation) and + /// must not depend on an open tab. + #[test] + fn sidebar_chat_delete_works_without_open_tab() { + let (db, project, tmp) = setup(); + let mut app = make_app(db, project, &tmp); + let _ = app.update(Message::ChatCreate); + let conversation_id = app.chat_conversations[0].id.clone(); + + let _ = app.update(Message::CloseTab(conversation_id.clone())); + assert!(!app.tabs.iter().any(|tab| tab.id == conversation_id)); + + let _ = app.update(Message::ChatDelete(conversation_id.clone())); + assert!(app.active_modal.is_none()); + assert!(app.chat_conversations.is_empty()); + assert!( + chat::list_conversations(app.db.as_ref().unwrap().conn()) + .unwrap() + .is_empty() + ); + } + + /// sidebar_views.allium ImportListItemEntry provides + /// ImportDeleteRequested(item.definition_id): deleting from the sidebar + /// row confirms first and works without an open editor tab. + #[test] + fn sidebar_import_delete_requires_confirmation_and_works_without_open_tab() { + let (db, project, tmp) = setup(); + let mut app = make_app(db, project, &tmp); + let _ = app.update(Message::CreateImport); + let definition_id = app.sidebar_imports[0].id.clone(); + + let _ = app.update(Message::CloseTab(definition_id.clone())); + assert!(!app.tabs.iter().any(|tab| tab.id == definition_id)); + + let _ = app.update(Message::ImportDeleteRequested(definition_id.clone())); + assert!(matches!( + app.active_modal, + Some(modal::ModalState::Confirm { + on_confirm: modal::ConfirmAction::DeleteImport(ref id), + .. + }) if id == &definition_id + )); + assert!( + wordpress_import::get_definition(app.db.as_ref().unwrap().conn(), &definition_id) + .is_ok() + ); + + let _ = app.update(Message::ConfirmModal(modal::ConfirmAction::DeleteImport( + definition_id.clone(), + ))); + assert!( + wordpress_import::get_definition(app.db.as_ref().unwrap().conn(), &definition_id) + .is_err() + ); + assert!(app.sidebar_imports.is_empty()); + } + #[test] fn rebuild_completion_refreshes_project_metadata_in_settings() { let (db, project, tmp) = setup(); diff --git a/crates/bds-ui/src/i18n/mod.rs b/crates/bds-ui/src/i18n/mod.rs index baced17..28e5991 100644 --- a/crates/bds-ui/src/i18n/mod.rs +++ b/crates/bds-ui/src/i18n/mod.rs @@ -1,3 +1,5 @@ +pub mod relative_date; + use bds_core::i18n::{UiLocale, translate, translate_with}; /// Shorthand for translate in view code. diff --git a/crates/bds-ui/src/i18n/relative_date.rs b/crates/bds-ui/src/i18n/relative_date.rs new file mode 100644 index 0000000..a75c451 --- /dev/null +++ b/crates/bds-ui/src/i18n/relative_date.rs @@ -0,0 +1,163 @@ +use bds_core::i18n::{UiLocale, translate, translate_with}; +use chrono::{DateTime, Datelike, Local, NaiveDate}; + +/// chrono format locale per sidebar_views.allium LocaleMapping +/// (ui_locale → format_locale, e.g. "de" → "de-DE"). +fn format_locale(locale: UiLocale) -> chrono::Locale { + match locale { + UiLocale::En => chrono::Locale::en_US, + UiLocale::De => chrono::Locale::de_DE, + UiLocale::Fr => chrono::Locale::fr_FR, + UiLocale::It => chrono::Locale::it_IT, + UiLocale::Es => chrono::Locale::es_ES, + } +} + +/// Per sidebar_views.allium RelativeDateFormat, with +/// diff_days = (today - timestamp.date).days on local calendar dates: +/// diff_days = 0 → locale time string +/// diff_days = 1 → localized "Yesterday" +/// diff_days < 7 → short weekday name +/// otherwise → short month name + numeric day +pub fn format_relative_date(locale: UiLocale, unix_ms: i64, today: NaiveDate) -> String { + let timestamp = DateTime::from_timestamp_millis(unix_ms) + .unwrap_or_default() + .with_timezone(&Local); + let chrono_locale = format_locale(locale); + let diff_days = (today - timestamp.date_naive()).num_days(); + + if diff_days == 0 { + timestamp.format_localized("%X", chrono_locale).to_string() + } else if diff_days == 1 { + translate(locale, "sidebar.chatYesterday") + } else if diff_days < 7 { + timestamp.format_localized("%a", chrono_locale).to_string() + } else { + let month = timestamp.format_localized("%b", chrono_locale).to_string(); + let day = timestamp.day().to_string(); + translate_with( + locale, + "sidebar.relativeDateMonthDay", + &[("month", &month), ("day", &day)], + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn local_ms(y: i32, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> i64 { + Local + .with_ymd_and_hms(y, mo, d, h, mi, s) + .single() + .expect("unambiguous local time") + .timestamp_millis() + } + + fn day(y: i32, mo: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, mo, d).expect("valid date") + } + + // Reference "today" throughout: Tuesday, 2026-02-17. + const TODAY: (i32, u32, u32) = (2026, 2, 17); + + fn today() -> NaiveDate { + day(TODAY.0, TODAY.1, TODAY.2) + } + + #[test] + fn same_day_shows_locale_time() { + let ts = local_ms(2026, 2, 17, 14, 5, 7); + let en = format_relative_date(UiLocale::En, ts, today()); + assert!(en.contains("05:07"), "expected time string, got {en}"); + assert!(en.contains("PM"), "en-US uses 12-hour clock, got {en}"); + let de = format_relative_date(UiLocale::De, ts, today()); + assert!(de.contains("14:05"), "de-DE uses 24-hour clock, got {de}"); + } + + #[test] + fn one_day_ago_is_yesterday_even_under_24_hours() { + // 23:59 yesterday vs. any time today: calendar-day diff is 1. + let ts = local_ms(2026, 2, 16, 23, 59, 0); + assert_eq!(format_relative_date(UiLocale::En, ts, today()), "Yesterday"); + assert_eq!(format_relative_date(UiLocale::De, ts, today()), "Gestern"); + } + + #[test] + fn two_days_ago_shows_short_weekday() { + // 2026-02-15 is a Sunday. + let ts = local_ms(2026, 2, 15, 12, 0, 0); + assert_eq!(format_relative_date(UiLocale::En, ts, today()), "Sun"); + assert_eq!(format_relative_date(UiLocale::De, ts, today()), "So"); + } + + #[test] + fn six_days_ago_still_shows_weekday() { + // 2026-02-11 is a Wednesday: diff_days = 6 is the last weekday case. + let ts = local_ms(2026, 2, 11, 12, 0, 0); + assert_eq!(format_relative_date(UiLocale::En, ts, today()), "Wed"); + } + + #[test] + fn seven_days_ago_shows_month_and_day() { + // diff_days = 7 is the first month+day case. + let ts = local_ms(2026, 2, 10, 12, 0, 0); + assert_eq!(format_relative_date(UiLocale::En, ts, today()), "Feb 10"); + assert_eq!(format_relative_date(UiLocale::De, ts, today()), "10. Feb"); + } + + #[test] + fn month_day_order_is_day_first_in_romance_locales() { + let ts = local_ms(2026, 2, 10, 12, 0, 0); + for locale in [UiLocale::Fr, UiLocale::It, UiLocale::Es] { + let result = format_relative_date(locale, ts, today()); + assert!( + result.starts_with("10 "), + "expected day-first order for {locale}, got {result}" + ); + } + } + + #[test] + fn distant_past_shows_month_and_day() { + let ts = local_ms(2025, 6, 3, 12, 0, 0); + assert_eq!(format_relative_date(UiLocale::En, ts, today()), "Jun 3"); + } + + #[test] + fn future_timestamp_uses_weekday_branch() { + // Spec formula: diff_days < 7 → weekday; a future date has a + // negative diff and therefore falls into the weekday case. + // 2026-02-20 is a Friday. + let ts = local_ms(2026, 2, 20, 12, 0, 0); + assert_eq!(format_relative_date(UiLocale::En, ts, today()), "Fri"); + } + + #[test] + fn yesterday_is_translated_in_all_locales() { + let ts = local_ms(2026, 2, 16, 12, 0, 0); + for locale in UiLocale::all() { + let result = format_relative_date(*locale, ts, today()); + assert_ne!( + result, "sidebar.chatYesterday", + "missing yesterday translation for {locale}" + ); + assert!(!result.is_empty()); + } + } + + #[test] + fn month_day_pattern_exists_in_all_locales() { + let ts = local_ms(2025, 6, 3, 12, 0, 0); + for locale in UiLocale::all() { + let result = format_relative_date(*locale, ts, today()); + assert_ne!( + result, "sidebar.relativeDateMonthDay", + "missing month-day pattern for {locale}" + ); + assert!(result.contains('3'), "day missing for {locale}: {result}"); + } + } +} diff --git a/crates/bds-ui/src/views/sidebar.rs b/crates/bds-ui/src/views/sidebar.rs index 7fe4ba3..e541093 100644 --- a/crates/bds-ui/src/views/sidebar.rs +++ b/crates/bds-ui/src/views/sidebar.rs @@ -9,6 +9,7 @@ use bds_core::model::{ChatConversation, ImportDefinition, Media, Post, Script, T use crate::app::Message; use crate::components::inputs; +use crate::i18n::relative_date::format_relative_date; use crate::i18n::t; use crate::state::navigation::SidebarView; use crate::state::sidebar_filter::{CalendarYear, MediaFilter, PostFilter}; @@ -287,6 +288,32 @@ fn row_delete_style(_theme: &Theme, status: button::Status) -> button::Style { } } +/// Wrap a sidebar row in a hover overlay carrying its delete button (×). +/// Per sidebar_views.allium *ListItemEntry RowLayout: right-aligned, visible +/// only on row hover, routed to the row's delete message. +fn with_row_delete( + open_button: iced::widget::Button<'static, Message>, + on_delete: Message, +) -> Element<'static, Message> { + let delete_button = button( + text("\u{2715}") // ✕ + .size(11) + .shaping(Shaping::Advanced), + ) + .on_press(on_delete) + .padding([2, 6]) + .style(row_delete_style); + iced::widget::hover( + open_button, + container(delete_button) + .width(Length::Fill) + .height(Length::Fill) + .align_x(iced::alignment::Horizontal::Right) + .align_y(iced::alignment::Vertical::Center) + .padding([0, 4]), + ) +} + /// Month name abbreviation for calendar display. fn month_abbr(month: u32) -> &'static str { match month { @@ -661,6 +688,8 @@ pub fn view( ) -> Element<'static, Message> { let header_text = t(locale, sidebar_view.i18n_key()); let muted = Color::from_rgb(0.50, 0.50, 0.55); + // Reference date for RelativeDateFormat's diff_days (sidebar_views.allium). + let today = chrono::Local::now().date_naive(); let header = text(header_text) .size(13) @@ -1006,42 +1035,33 @@ pub fn view( .size(12) .shaping(Shaping::Advanced) .wrapping(iced::widget::text::Wrapping::None); + // sidebar_views.allium ScriptListItemEntry RowLayout: + // line 2 is the date in relative format, smaller and muted. + let date_text = text(format_relative_date(locale, s.updated_at, today)) + .size(10) + .shaping(Shaping::Advanced) + .color(muted); let style_fn = if is_active { item_active_style } else { item_style }; - let open_button = - button(container(label_text).width(Length::Fill).clip(true)) - .on_press(Message::OpenTab(Tab { - id: s.id.clone(), - tab_type: TabType::Scripts, - title: display_title, - is_transient: true, - is_dirty: false, - })) - .padding([5, 8]) + let open_button = button( + container(column![label_text, date_text].spacing(1)) .width(Length::Fill) - .style(style_fn); - // sidebar_views.allium ScriptListItemEntry: delete button (x), - // visible only on row hover, routed through the confirm modal. - let delete_button = button( - text("\u{2715}") // ✕ - .size(11) - .shaping(Shaping::Advanced), - ) - .on_press(Message::ScriptDeleteRequested(s.id.clone())) - .padding([2, 6]) - .style(row_delete_style); - iced::widget::hover( - open_button, - container(delete_button) - .width(Length::Fill) - .height(Length::Fill) - .align_x(iced::alignment::Horizontal::Right) - .align_y(iced::alignment::Vertical::Center) - .padding([0, 4]), + .clip(true), ) + .on_press(Message::OpenTab(Tab { + id: s.id.clone(), + tab_type: TabType::Scripts, + title: display_title, + is_transient: true, + is_dirty: false, + })) + .padding([5, 8]) + .width(Length::Fill) + .style(style_fn); + with_row_delete(open_button, Message::ScriptDeleteRequested(s.id.clone())) }) .collect(); iced::widget::Column::with_children(items).spacing(1).into() @@ -1059,29 +1079,43 @@ pub fn view( .iter() .map(|tmpl| { let is_active = active_tab == Some(tmpl.id.as_str()); - let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX; + // Reserve room on the right for the hover-revealed delete button. + let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX - 20.0; let display_title = truncate_to_fit(&tmpl.title, text_px); let label_text = text(display_title.clone()) .size(12) .shaping(Shaping::Advanced) .wrapping(iced::widget::text::Wrapping::None); + // sidebar_views.allium TemplateListItemEntry RowLayout: + // line 2 is the date in relative format, smaller and muted. + let date_text = text(format_relative_date(locale, tmpl.updated_at, today)) + .size(10) + .shaping(Shaping::Advanced) + .color(muted); let style_fn = if is_active { item_active_style } else { item_style }; - button(container(label_text).width(Length::Fill).clip(true)) - .on_press(Message::OpenTab(Tab { - id: tmpl.id.clone(), - tab_type: TabType::Templates, - title: display_title, - is_transient: true, - is_dirty: false, - })) - .padding([5, 8]) - .width(Length::Fill) - .style(style_fn) - .into() + let open_button = button( + container(column![label_text, date_text].spacing(1)) + .width(Length::Fill) + .clip(true), + ) + .on_press(Message::OpenTab(Tab { + id: tmpl.id.clone(), + tab_type: TabType::Templates, + title: display_title, + is_transient: true, + is_dirty: false, + })) + .padding([5, 8]) + .width(Length::Fill) + .style(style_fn); + with_row_delete( + open_button, + Message::TemplateDeleteRequested(tmpl.id.clone()), + ) }) .collect(); iced::widget::Column::with_children(items).spacing(1).into() @@ -1104,19 +1138,18 @@ pub fn view( } else { item_style }; - let analyzed = if definition.last_analysis_result.is_some() { - t(locale, "import.sidebar.analyzed") - } else { - t(locale, "import.sidebar.pending") - }; - button( + // sidebar_views.allium ImportListItemEntry RowLayout: + // line 2 is the date in relative format, smaller and muted. + let date_text = + text(format_relative_date(locale, definition.updated_at, today)) + .size(10) + .shaping(Shaping::Advanced) + .color(muted); + let open_button = button( container( column![ text(definition_name).size(12).shaping(Shaping::Advanced), - text(analyzed) - .size(10) - .shaping(Shaping::Advanced) - .color(Color::from_rgb(0.50, 0.50, 0.55)), + date_text, ] .spacing(1), ) @@ -1131,8 +1164,11 @@ pub fn view( })) .padding([5, 8]) .width(Length::Fill) - .style(style) - .into() + .style(style); + with_row_delete( + open_button, + Message::ImportDeleteRequested(definition.id.clone()), + ) }) .collect::>>(); iced::widget::Column::with_children(items).spacing(1).into() @@ -1154,17 +1190,20 @@ pub fn view( } else { item_style }; - let model = conversation - .model - .clone() - .unwrap_or_else(|| t(locale, "chat.model.none")); - button( + // sidebar_views.allium ChatListItemEntry RowLayout: + // line 2 is the date in relative format, smaller and muted. + let date_text = + text(format_relative_date(locale, conversation.updated_at, today)) + .size(10) + .shaping(Shaping::Advanced) + .color(muted); + let open_button = button( container( column![ text(conversation.title.clone()) .size(12) .shaping(Shaping::Advanced), - text(model).size(10).shaping(Shaping::Advanced).color(muted), + date_text, ] .spacing(1), ) @@ -1179,8 +1218,11 @@ pub fn view( })) .padding([5, 8]) .width(Length::Fill) - .style(style) - .into() + .style(style); + // Per action_patterns.allium confirmation_assignments chat + // delete has no confirmation; ChatDelete removes the + // conversation and closes its tab. + with_row_delete(open_button, Message::ChatDelete(conversation.id.clone())) }) .collect::>>(); iced::widget::Column::with_children(items).spacing(1).into() diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index cc01e11..e030cbf 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -329,6 +329,7 @@ tags-nav-manage = Tags verwalten tags-nav-merge = Tags zusammenführen tags-nav-discover = Tags entdecken sidebar-chatYesterday = Gestern +sidebar-relativeDateMonthDay = { $day }. { $month } modal-confirmDelete-title = Löschen bestätigen modal-confirmDelete-warning = Diese Aktion kann nicht rückgängig gemacht werden. modal-confirmDelete-cancel = Abbrechen @@ -763,8 +764,6 @@ import-phase-complete = Import abgeschlossen import-error-wxrRequired = Vor der Analyse eine WXR-Datei auswählen. import-toast-complete = WordPress-Import abgeschlossen. import-toast-mapped = KI hat { $count } Taxonomiebegriffe zugeordnet. -import-sidebar-analyzed = Analyse gespeichert -import-sidebar-pending = Nicht analysiert import-deleteTitle = Importdefinition löschen? import-deleteMessage = „{ $name }“ und die gespeicherte Analyse löschen? Bereits importierte Projektinhalte bleiben erhalten. import-toast-deleted = Importdefinition gelöscht. diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index 5eb59a8..d3d167b 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -329,6 +329,7 @@ tags-nav-manage = Manage Tags tags-nav-merge = Merge Tags tags-nav-discover = Discover Tags sidebar-chatYesterday = Yesterday +sidebar-relativeDateMonthDay = { $month } { $day } modal-confirmDelete-title = Confirm Delete modal-confirmDelete-warning = This action cannot be undone. modal-confirmDelete-cancel = Cancel @@ -763,8 +764,6 @@ import-phase-complete = Import complete import-error-wxrRequired = Select a WXR file before analyzing. import-toast-complete = WordPress import completed. import-toast-mapped = AI mapped { $count } taxonomy terms. -import-sidebar-analyzed = Analysis saved -import-sidebar-pending = Not analyzed import-deleteTitle = Delete import definition? import-deleteMessage = Delete “{ $name }” and its saved analysis? Imported project content is not removed. import-toast-deleted = Import definition deleted. diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index a115317..db568b5 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -329,6 +329,7 @@ tags-nav-manage = Gestionar etiquetas tags-nav-merge = Fusionar etiquetas tags-nav-discover = Descubrir etiquetas sidebar-chatYesterday = Ayer +sidebar-relativeDateMonthDay = { $day } { $month } modal-confirmDelete-title = Confirmar eliminación modal-confirmDelete-warning = Esta acción no se puede deshacer. modal-confirmDelete-cancel = Cancelar @@ -763,8 +764,6 @@ import-phase-complete = Importación completa import-error-wxrRequired = Selecciona un archivo WXR antes de analizar. import-toast-complete = Importación de WordPress completa. import-toast-mapped = La IA asoció { $count } términos de taxonomía. -import-sidebar-analyzed = Análisis guardado -import-sidebar-pending = Sin analizar import-deleteTitle = ¿Eliminar la definición de importación? import-deleteMessage = ¿Eliminar «{ $name }» y su análisis guardado? El contenido ya importado se conservará. import-toast-deleted = Definición de importación eliminada. diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index 938ee66..e35087c 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -329,6 +329,7 @@ tags-nav-manage = Gérer les tags tags-nav-merge = Fusionner les tags tags-nav-discover = Découvrir les tags sidebar-chatYesterday = Hier +sidebar-relativeDateMonthDay = { $day } { $month } modal-confirmDelete-title = Confirmer la suppression modal-confirmDelete-warning = Cette action est irréversible. modal-confirmDelete-cancel = Annuler @@ -763,8 +764,6 @@ import-phase-complete = Import terminé import-error-wxrRequired = Sélectionnez un fichier WXR avant l’analyse. import-toast-complete = Import WordPress terminé. import-toast-mapped = L’IA a associé { $count } termes de taxonomie. -import-sidebar-analyzed = Analyse enregistrée -import-sidebar-pending = Non analysé import-deleteTitle = Supprimer la définition d’import ? import-deleteMessage = Supprimer « { $name } » et son analyse enregistrée ? Le contenu déjà importé sera conservé. import-toast-deleted = Définition d’import supprimée. diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index 31eb66d..fc07b59 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -329,6 +329,7 @@ tags-nav-manage = Gestisci tag tags-nav-merge = Unisci tag tags-nav-discover = Scopri tag sidebar-chatYesterday = Ieri +sidebar-relativeDateMonthDay = { $day } { $month } modal-confirmDelete-title = Conferma eliminazione modal-confirmDelete-warning = Questa azione non può essere annullata. modal-confirmDelete-cancel = Annulla @@ -763,8 +764,6 @@ import-phase-complete = Importazione completata import-error-wxrRequired = Seleziona un file WXR prima dell’analisi. import-toast-complete = Importazione WordPress completata. import-toast-mapped = L’IA ha associato { $count } termini di tassonomia. -import-sidebar-analyzed = Analisi salvata -import-sidebar-pending = Non analizzato import-deleteTitle = Eliminare la definizione di importazione? import-deleteMessage = Eliminare “{ $name }” e l’analisi salvata? I contenuti già importati resteranno invariati. import-toast-deleted = Definizione di importazione eliminata. diff --git a/specs/action_patterns.allium b/specs/action_patterns.allium index 9be282d..8e3decd 100644 --- a/specs/action_patterns.allium +++ b/specs/action_patterns.allium @@ -211,6 +211,8 @@ config { confirmation_assignments: Set = { ConfirmationAssignment(action: "post_delete", pattern: system_confirm), ConfirmationAssignment(action: "post_discard", pattern: system_confirm), + ConfirmationAssignment(action: "template_delete", pattern: confirm_delete_modal), + -- shows template title, no reference list ConfirmationAssignment(action: "template_delete_with_references", pattern: system_confirm), ConfirmationAssignment(action: "media_delete", pattern: confirm_delete_modal), -- shows linked posts diff --git a/specs/editor_template.allium b/specs/editor_template.allium index 9c6a5a4..c25fbf2 100644 --- a/specs/editor_template.allium +++ b/specs/editor_template.allium @@ -40,6 +40,7 @@ surface TemplateEditorSurface { when editor.can_publish TemplateValidateRequested(editor.template_id) TemplateDeleteRequested(editor.template_id) + TemplateDeleteConfirmed(editor.template_id) TemplateForceDeleteConfirmed(editor.template_id) @guarantee HeaderLayout @@ -119,9 +120,20 @@ rule TemplateDelete { -- and M tags. Force delete?" (action_patterns.allium -- confirmation_assignments: template_delete_with_references) else: - DeleteTemplateRequested(target) - closeTab(template_id) - -- no confirmation when nothing references the template + ConfirmDeleteModalOpened(template_id) + -- custom modal (not a native dialog): template title, no + -- reference list; Cancel + Delete (destructive red style) + -- — action_patterns.allium + -- confirmation_assignments: template_delete +} + +rule TemplateDeleteExecute { + when: TemplateDeleteConfirmed(template_id) + let target = template/Template{id: template_id} + ensures: DeleteTemplateRequested(target) + -- DB record + .liquid file on disk + ensures: closeTab(template_id) + -- sidebar removes the item reactively } rule TemplateForceDelete { diff --git a/specs/sidebar_views.allium b/specs/sidebar_views.allium index 0c9e12e..0ba9573 100644 --- a/specs/sidebar_views.allium +++ b/specs/sidebar_views.allium @@ -412,8 +412,13 @@ surface TemplateListItemEntry { -- Row is focusable with tabIndex and has aria-label from title. @guarantee DeleteConfirmation - -- If template is referenced by posts or tags, shows confirmation dialog - -- with reference counts before force-delete. + -- Delete always confirms before executing. Unreferenced templates + -- use the confirm-delete modal (template title, no reference list); + -- templates referenced by posts or tags use the force-delete + -- confirmation dialog with reference counts. + -- See editor_template.allium TemplateDelete and action_patterns.allium + -- confirmation_assignments (template_delete, + -- template_delete_with_references). @guarantee CreateDefaults -- New templates default to: kind=post, content='', enabled=true.