fix: small findings with regard to deletes from sidebar

This commit is contained in:
Bauer, Georg
2026-07-22 15:35:22 +02:00
parent cc520d517a
commit a3f2c451e3
13 changed files with 499 additions and 93 deletions

View File

@@ -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<Message> {
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<Message> {
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();

View File

@@ -1,3 +1,5 @@
pub mod relative_date;
use bds_core::i18n::{UiLocale, translate, translate_with};
/// Shorthand for translate in view code.

View File

@@ -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}");
}
}
}

View File

@@ -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::<Vec<Element<'static, Message>>>();
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::<Vec<Element<'static, Message>>>();
iced::widget::Column::with_children(items).spacing(1).into()