use std::collections::HashSet; use std::time::{Duration, Instant}; use bds_core::engine::menu::{MenuItem, MenuItemKind}; use bds_core::i18n::UiLocale; use iced::widget::{ Space, button, column, container, mouse_area, row, scrollable, svg, text, text_input, tooltip, }; use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Point, Theme}; use uuid::Uuid; use crate::app::Message; use crate::components::inputs::{ self, danger_button, field_style, primary_button, secondary_button, }; use crate::i18n::t; pub const HOME_ID: &str = "menu-home"; pub const DRAG_EXPAND_DELAY: Duration = Duration::from_millis(450); const HOME_ICON: &[u8] = include_bytes!("../../assets/icons/menu-home.svg"); const PAGE_ICON: &[u8] = include_bytes!("../../assets/icons/menu-page.svg"); const SUBMENU_ICON: &[u8] = include_bytes!("../../assets/icons/menu-submenu.svg"); const CATEGORY_ICON: &[u8] = include_bytes!("../../assets/icons/menu-category.svg"); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DraftKind { Page, Category, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MoveDirection { Up, Down, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DropPosition { Before, Inside, After, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MenuEditError { MissingDraft, MissingChoice, CategoryRequired, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PageOption { pub id: String, pub title: String, pub slug: String, } #[derive(Debug, Clone, PartialEq)] pub struct MenuNode { pub id: String, pub kind: MenuItemKind, pub label: String, pub slug: Option, pub children: Vec, } impl MenuNode { fn from_item(item: MenuItem) -> Self { let id = if item.kind == MenuItemKind::Home { HOME_ID.to_string() } else { Uuid::new_v4().to_string() }; Self { id, kind: item.kind, label: item.label, slug: item.slug, children: item.children.into_iter().map(Self::from_item).collect(), } } fn to_item(&self) -> MenuItem { MenuItem { kind: self.kind.clone(), label: self.label.clone(), slug: self.slug.clone(), children: self.children.iter().map(Self::to_item).collect(), } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct MenuDraft { pub item_id: String, pub kind: DraftKind, pub query: String, pub validation_failed: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MenuEditorStatus { NotLoaded, Ready, Saving, LoadFailed, } #[derive(Debug, Clone)] pub struct MenuEditorState { pub project_id: Option, pub items: Vec, pub selected_id: Option, pub draft: Option, pub pages: Vec, pub categories: Vec, pub dirty: bool, pub status: MenuEditorStatus, pub error: Option, pub collapsed: HashSet, pub dragging_id: Option, pub drop_target: Option<(String, DropPosition)>, pub hover_expand: Option<(String, Instant)>, } pub fn load( db: &bds_core::db::Database, project_id: &str, data_dir: &std::path::Path, ) -> Result { let items = bds_core::engine::menu::read_menu(data_dir).map_err(|error| error.to_string())?; let mut pages = bds_core::db::queries::post::list_posts_by_project(db.conn(), project_id) .map_err(|error| error.to_string())? .into_iter() .filter(|post| { post.categories .iter() .any(|category| category.eq_ignore_ascii_case("page")) }) .map(|post| PageOption { id: post.id, title: post.title, slug: post.slug, }) .collect::>(); pages.sort_by(|left, right| { left.title .to_lowercase() .cmp(&right.title.to_lowercase()) .then_with(|| left.slug.cmp(&right.slug)) }); let mut categories = bds_core::engine::meta::read_categories_json(data_dir) .map_err(|error| error.to_string())?; categories.sort_by_key(|name| name.to_lowercase()); Ok(MenuEditorState::from_persisted( project_id.to_string(), items, pages, categories, )) } impl Default for MenuEditorState { fn default() -> Self { Self { project_id: None, items: Vec::new(), selected_id: None, draft: None, pages: Vec::new(), categories: Vec::new(), dirty: false, status: MenuEditorStatus::NotLoaded, error: None, collapsed: HashSet::new(), dragging_id: None, drop_target: None, hover_expand: None, } } } impl MenuEditorState { pub fn from_persisted( project_id: String, items: Vec, pages: Vec, categories: Vec, ) -> Self { Self::ready( project_id, items.into_iter().map(MenuNode::from_item).collect(), pages, categories, ) } pub fn ready( project_id: String, items: Vec, pages: Vec, categories: Vec, ) -> Self { let selected_id = items.first().map(|item| item.id.clone()); Self { project_id: Some(project_id), items, selected_id, pages, categories, status: MenuEditorStatus::Ready, ..Self::default() } } pub fn persisted_items(&self) -> Vec { self.items.iter().map(MenuNode::to_item).collect() } pub fn start_draft(&mut self, kind: DraftKind) -> bool { if self.draft.is_some() || self.status != MenuEditorStatus::Ready { return false; } let id = Uuid::new_v4().to_string(); let node = MenuNode { id: id.clone(), kind: match kind { DraftKind::Page => MenuItemKind::Page, DraftKind::Category => MenuItemKind::CategoryArchive, }, label: String::new(), slug: None, children: Vec::new(), }; let (parent, index) = insertion_target(&self.items, self.selected_id.as_deref()); insert_at(&mut self.items, &parent, index, node); if let Some(selected) = self.selected_id.as_deref() { self.collapsed.remove(selected); } self.selected_id = Some(id.clone()); self.draft = Some(MenuDraft { item_id: id, kind, query: String::new(), validation_failed: false, }); true } pub fn draft_changed(&mut self, query: String) { if let Some(draft) = &mut self.draft { draft.query = query; draft.validation_failed = false; } } pub fn choose_page(&mut self, page_id: &str) -> Result<(), MenuEditError> { let page = self .pages .iter() .find(|page| page.id == page_id) .cloned() .ok_or(MenuEditError::MissingChoice)?; let draft = self .draft .as_ref() .filter(|draft| draft.kind == DraftKind::Page) .ok_or(MenuEditError::MissingDraft)?; let path = find_path(&self.items, &draft.item_id).ok_or(MenuEditError::MissingDraft)?; let item = item_at_mut(&mut self.items, &path).ok_or(MenuEditError::MissingDraft)?; item.kind = MenuItemKind::Page; item.label = page.title; item.slug = Some(page.slug); self.draft = None; self.dirty = true; Ok(()) } pub fn submit_submenu(&mut self, default_label: &str) -> Result<(), MenuEditError> { let draft = self .draft .as_ref() .filter(|draft| draft.kind == DraftKind::Page) .ok_or(MenuEditError::MissingDraft)?; let label = if draft.query.trim().is_empty() { default_label.to_string() } else { draft.query.trim().to_string() }; let path = find_path(&self.items, &draft.item_id).ok_or(MenuEditError::MissingDraft)?; let item = item_at_mut(&mut self.items, &path).ok_or(MenuEditError::MissingDraft)?; item.kind = MenuItemKind::Submenu; item.label = label; item.slug = None; self.draft = None; self.dirty = true; Ok(()) } /// Resolve an existing or new category and finalize its draft item. /// Returns the canonical category name and whether metadata must be created. pub fn submit_category(&mut self) -> Result<(String, bool), MenuEditError> { let Some(draft) = self .draft .as_mut() .filter(|draft| draft.kind == DraftKind::Category) else { return Err(MenuEditError::MissingDraft); }; let query = draft.query.trim(); if query.is_empty() { draft.validation_failed = true; return Err(MenuEditError::CategoryRequired); } let existing = self .categories .iter() .find(|name| name.eq_ignore_ascii_case(query)) .cloned(); let name = existing.clone().unwrap_or_else(|| query.to_string()); let is_new = existing.is_none(); let item_id = draft.item_id.clone(); let path = find_path(&self.items, &item_id).ok_or(MenuEditError::MissingDraft)?; let item = item_at_mut(&mut self.items, &path).ok_or(MenuEditError::MissingDraft)?; item.kind = MenuItemKind::CategoryArchive; item.label = name.clone(); item.slug = Some(name.clone()); if is_new { self.categories.push(name.clone()); self.categories.sort_by_key(|name| name.to_lowercase()); } self.draft = None; self.dirty = true; Ok((name, is_new)) } pub fn choose_category(&mut self, name: &str) -> Result<(), MenuEditError> { if !self.categories.iter().any(|category| category == name) { return Err(MenuEditError::MissingChoice); } self.draft_changed(name.to_string()); self.submit_category().map(|_| ()) } pub fn cancel_draft(&mut self) -> bool { let Some(draft) = self.draft.take() else { return false; }; let removed = remove_by_id(&mut self.items, &draft.item_id).is_some(); self.selected_id = self.items.first().map(|item| item.id.clone()); removed } pub fn move_selected(&mut self, direction: MoveDirection) -> bool { let Some(id) = self.selected_id.clone() else { return false; }; if id == HOME_ID || self.draft.is_some() { return false; } let Some(path) = find_path(&self.items, &id) else { return false; }; let Some((&index, parent_path)) = path.split_last() else { return false; }; let siblings = children_at_mut(&mut self.items, parent_path); let Some(siblings) = siblings else { return false; }; let target = match direction { MoveDirection::Up => { let minimum = usize::from(parent_path.is_empty()); if index <= minimum { return false; } index - 1 } MoveDirection::Down => { if index + 1 >= siblings.len() { return false; } index + 1 } }; siblings.swap(index, target); self.dirty = true; true } pub fn indent_selected(&mut self) -> bool { let Some(id) = self.selected_id.clone() else { return false; }; if id == HOME_ID || self.draft.is_some() { return false; } let Some(path) = find_path(&self.items, &id) else { return false; }; let Some((&index, parent_path)) = path.split_last() else { return false; }; if index == 0 { return false; } let mut preceding = parent_path.to_vec(); preceding.push(index - 1); if item_at(&self.items, &preceding).is_none_or(|item| item.kind != MenuItemKind::Submenu) { return false; } let Some(item) = remove_at(&mut self.items, &path) else { return false; }; let Some(parent) = item_at_mut(&mut self.items, &preceding) else { return false; }; parent.children.push(item); self.collapsed.remove(&parent.id); self.dirty = true; true } pub fn unindent_selected(&mut self) -> bool { let Some(id) = self.selected_id.clone() else { return false; }; if id == HOME_ID || self.draft.is_some() { return false; } let Some(path) = find_path(&self.items, &id) else { return false; }; if path.len() < 2 { return false; } let parent_path = &path[..path.len() - 1]; let parent_id = item_at(&self.items, parent_path).map(|item| item.id.clone()); if parent_id.as_deref() == Some(HOME_ID) { return false; } let Some(item) = remove_at(&mut self.items, &path) else { return false; }; let grandparent = &parent_path[..parent_path.len() - 1]; let parent_index = parent_path[parent_path.len() - 1]; insert_at(&mut self.items, grandparent, parent_index + 1, item); self.dirty = true; true } pub fn delete_selected(&mut self) -> bool { let Some(id) = self.selected_id.clone() else { return false; }; if id == HOME_ID { return false; } let removed = remove_by_id(&mut self.items, &id).is_some(); if removed { self.draft = None; self.selected_id = self.items.first().map(|item| item.id.clone()); self.dirty = true; } removed } pub fn drop_item(&mut self, dragged_id: &str, target_id: &str, position: DropPosition) -> bool { if dragged_id.is_empty() || target_id.is_empty() || dragged_id == target_id || dragged_id == HOME_ID || self.draft.is_some() { return false; } let Some(drag_path) = find_path(&self.items, dragged_id) else { return false; }; let Some(target_path) = find_path(&self.items, target_id) else { return false; }; if target_path.starts_with(&drag_path) { return false; } if position == DropPosition::Inside && item_at(&self.items, &target_path) .is_none_or(|item| item.kind != MenuItemKind::Submenu) { return false; } if position == DropPosition::Before && target_id == HOME_ID { return false; } let Some(item) = remove_at(&mut self.items, &drag_path) else { return false; }; let Some(next_target) = find_path(&self.items, target_id) else { return false; }; match position { DropPosition::Inside => insert_at(&mut self.items, &next_target, 0, item), DropPosition::Before | DropPosition::After => { let Some((&index, parent)) = next_target.split_last() else { return false; }; let index = index + usize::from(position == DropPosition::After); insert_at(&mut self.items, parent, index, item); } } self.selected_id = Some(dragged_id.to_string()); self.dirty = true; true } pub fn drag_over(&mut self, target: String, position: DropPosition, now: Instant) { if self.dragging_id.is_none() { return; } self.drop_target = Some((target.clone(), position)); let should_expand = position == DropPosition::Inside && self.collapsed.contains(&target) && find_path(&self.items, &target) .and_then(|path| item_at(&self.items, &path)) .is_some_and(|item| item.kind == MenuItemKind::Submenu); if should_expand { if self .hover_expand .as_ref() .is_none_or(|(id, _)| id != &target) { self.hover_expand = Some((target, now)); } } else { self.hover_expand = None; } } pub fn expand_hovered(&mut self, now: Instant) -> bool { let Some((id, started)) = self.hover_expand.clone() else { return false; }; if now.duration_since(started) < DRAG_EXPAND_DELAY { return false; } self.collapsed.remove(&id); self.hover_expand = None; true } } #[derive(Debug, Clone)] pub enum MenuEditorMsg { Reload, Select(String), StartDraft(DraftKind), DraftChanged(String), ChoosePage(String), ChooseCategory(String), SubmitDraft, CancelDraft, Move(MoveDirection), Indent, Unindent, Delete, Save, ToggleExpanded(String), DragStart(String), DragOver(String, DropPosition), DragLeave(String), Drop, DragCancel, ExpandTick, } fn insertion_target(items: &[MenuNode], selected_id: Option<&str>) -> (Vec, usize) { let Some(id) = selected_id else { return (Vec::new(), items.len()); }; let Some(path) = find_path(items, id) else { return (Vec::new(), items.len()); }; if item_at(items, &path).is_some_and(|item| item.kind == MenuItemKind::Submenu) { return (path, 0); } let Some((&index, parent)) = path.split_last() else { return (Vec::new(), items.len()); }; (parent.to_vec(), index + 1) } fn find_path(items: &[MenuNode], id: &str) -> Option> { fn search(items: &[MenuNode], id: &str, path: &mut Vec) -> bool { for (index, item) in items.iter().enumerate() { path.push(index); if item.id == id || search(&item.children, id, path) { return true; } path.pop(); } false } let mut path = Vec::new(); search(items, id, &mut path).then_some(path) } fn item_at<'a>(items: &'a [MenuNode], path: &[usize]) -> Option<&'a MenuNode> { let (&first, rest) = path.split_first()?; let item = items.get(first)?; if rest.is_empty() { Some(item) } else { item_at(&item.children, rest) } } fn item_at_mut<'a>(items: &'a mut [MenuNode], path: &[usize]) -> Option<&'a mut MenuNode> { let (&first, rest) = path.split_first()?; let item = items.get_mut(first)?; if rest.is_empty() { Some(item) } else { item_at_mut(&mut item.children, rest) } } fn children_at_mut<'a>( items: &'a mut Vec, path: &[usize], ) -> Option<&'a mut Vec> { if path.is_empty() { return Some(items); } item_at_mut(items, path).map(|item| &mut item.children) } fn insert_at(items: &mut Vec, parent: &[usize], index: usize, item: MenuNode) { if let Some(children) = children_at_mut(items, parent) { children.insert(index.min(children.len()), item); } } fn remove_at(items: &mut Vec, path: &[usize]) -> Option { let (&index, parent) = path.split_last()?; let children = children_at_mut(items, parent)?; (index < children.len()).then(|| children.remove(index)) } fn remove_by_id(items: &mut Vec, id: &str) -> Option { let path = find_path(items, id)?; remove_at(items, &path) } fn kind_label(locale: UiLocale, kind: &MenuItemKind) -> String { t( locale, match kind { MenuItemKind::Home => "menuEditor.kindHome", MenuItemKind::Page => "menuEditor.kindPage", MenuItemKind::Submenu => "menuEditor.kindSubmenu", MenuItemKind::CategoryArchive => "menuEditor.kindCategory", }, ) } fn kind_icon(kind: &MenuItemKind) -> iced::widget::Svg<'static> { let bytes = match kind { MenuItemKind::Home => HOME_ICON, MenuItemKind::Page => PAGE_ICON, MenuItemKind::Submenu => SUBMENU_ICON, MenuItemKind::CategoryArchive => CATEGORY_ICON, }; svg(svg::Handle::from_memory(bytes)) .width(Length::Fixed(18.0)) .height(Length::Fixed(18.0)) } pub fn view(state: &MenuEditorState, locale: UiLocale) -> Element<'_, Message> { let title = text(t(locale, "menuEditor.title")).size(24); let description = text(t(locale, "menuEditor.description")) .size(13) .color(Color::from_rgb8(0xA8, 0xA8, 0xA8)); let header = inputs::card(column![title, description].spacing(6)); let add_disabled = state.draft.is_some() || state.status != MenuEditorStatus::Ready; let selected_is_home = state.selected_id.as_deref() == Some(HOME_ID); let actions_disabled = selected_is_home || state.selected_id.is_none() || state.draft.is_some() || state.status != MenuEditorStatus::Ready; let toolbar = inputs::toolbar( vec![ toolbar_icon( locale, "menuEditor.addEntry", "+", MenuEditorMsg::StartDraft(DraftKind::Page), !add_disabled, false, ), toolbar_icon( locale, "menuEditor.addCategory", "▣", MenuEditorMsg::StartDraft(DraftKind::Category), !add_disabled, false, ), toolbar_icon( locale, "menuEditor.moveUp", "↑", MenuEditorMsg::Move(MoveDirection::Up), !actions_disabled, false, ), toolbar_icon( locale, "menuEditor.moveDown", "↓", MenuEditorMsg::Move(MoveDirection::Down), !actions_disabled, false, ), toolbar_icon( locale, "menuEditor.indent", "→", MenuEditorMsg::Indent, !actions_disabled, false, ), toolbar_icon( locale, "menuEditor.unindent", "←", MenuEditorMsg::Unindent, !actions_disabled, false, ), toolbar_icon( locale, "common.delete", "×", MenuEditorMsg::Delete, !actions_disabled, true, ), ], vec![toolbar_icon( locale, "common.save", "◆", MenuEditorMsg::Save, state.dirty && state.draft.is_none() && state.status == MenuEditorStatus::Ready, false, )], ); let content: Element<'_, Message> = match state.status { MenuEditorStatus::NotLoaded => centered_status(locale, "menuEditor.loading"), MenuEditorStatus::LoadFailed => { let detail = state.error.clone().unwrap_or_default(); container( column![ text(t(locale, "menuEditor.loadFailed")), text(detail).size(12) ] .spacing(8) .align_x(Alignment::Center), ) .center_x(Length::Fill) .center_y(Length::Fill) .into() } MenuEditorStatus::Ready | MenuEditorStatus::Saving => { if state.items.is_empty() { centered_status(locale, "menuEditor.empty") } else { scrollable(tree_view(state, locale)) .height(Length::Fill) .into() } } }; let content = inputs::card(content).height(Length::Fill); container(column![header, toolbar, content].spacing(12)) .padding(16) .width(Length::Fill) .height(Length::Fill) .into() } fn toolbar_icon( locale: UiLocale, key: &str, glyph: &'static str, message: MenuEditorMsg, enabled: bool, destructive: bool, ) -> Element<'static, Message> { let style = if !enabled { secondary_button } else if destructive { danger_button } else if key == "common.save" { primary_button } else { secondary_button }; let mut control = button(text(glyph).size(18)) .width(Length::Fixed(38.0)) .height(Length::Fixed(34.0)) .style(style); if enabled { control = control.on_press(Message::MenuEditor(message)); } tooltip( control, text(t(locale, key)).size(12), tooltip::Position::Bottom, ) .gap(4) .into() } fn centered_status(locale: UiLocale, key: &str) -> Element<'_, Message> { container( text(t(locale, key)) .size(14) .color(Color::from_rgb8(0xA8, 0xA8, 0xA8)), ) .center_x(Length::Fill) .center_y(Length::Fill) .into() } fn tree_view<'a>(state: &'a MenuEditorState, locale: UiLocale) -> Element<'a, Message> { let mut rows = column![].spacing(4).width(Length::Fill); for item in &state.items { rows = rows.push(tree_item(state, item, 0, locale)); } rows.into() } fn tree_item<'a>( state: &'a MenuEditorState, item: &'a MenuNode, depth: usize, locale: UiLocale, ) -> Element<'a, Message> { let selected = state.selected_id.as_deref() == Some(&item.id); let is_submenu = item.kind == MenuItemKind::Submenu; let collapsed = state.collapsed.contains(&item.id); let id = item.id.clone(); let toggle: Element<'_, Message> = if is_submenu { button(text(if collapsed { "▸" } else { "▾" }).size(14)) .on_press(Message::MenuEditor(MenuEditorMsg::ToggleExpanded( id.clone(), ))) .padding(4) .style(secondary_button) .into() } else { Space::new(Length::Fixed(28.0), 0).into() }; let drag: Element<'_, Message> = if item.id == HOME_ID { Space::new(Length::Fixed(24.0), 0).into() } else { tooltip( mouse_area(text("⠿").width(Length::Fixed(24.0))) .on_press(Message::MenuEditor(MenuEditorMsg::DragStart(id.clone()))) .interaction(iced::mouse::Interaction::Grab), text(t(locale, "menuEditor.drag")).size(12), tooltip::Position::Top, ) .gap(4) .into() }; let label = if item.label.is_empty() { t(locale, "menuEditor.draft") } else { item.label.clone() }; let label_button = button( row![ kind_icon(&item.kind), text(label).size(14), Space::new(Length::Fill, 0), text(kind_label(locale, &item.kind)) .size(11) .color(Color::from_rgb8(0x9D, 0xA5, 0xB4)) ] .align_y(Alignment::Center), ) .on_press(Message::MenuEditor(MenuEditorMsg::Select(id.clone()))) .padding([8, 10]) .width(Length::Fill) .style(move |_theme: &Theme, _status| button::Style { background: selected.then_some(Background::Color(Color::from_rgb8(0x26, 0x4F, 0x78))), text_color: Color::from_rgb8(0xE4, 0xE4, 0xE4), border: Border { radius: 5.0.into(), ..Border::default() }, ..button::Style::default() }); let row_content = row![ Space::new(Length::Fixed(depth as f32 * 22.0), 0), toggle, drag, label_button ] .spacing(4) .align_y(Alignment::Center) .height(Length::Fixed(46.0)); let target = state .drop_target .as_ref() .filter(|(target, _)| target == &item.id) .map(|(_, position)| *position); let target_id = id.clone(); let drop_row = mouse_area( container(row_content) .padding(Padding::from([1, 4])) .width(Length::Fill) .style(move |_theme| { let color = Color::from_rgb8(0x00, 0x7F, 0xD4); let mut border = Border { radius: 5.0.into(), ..Border::default() }; if target == Some(DropPosition::Inside) { border.color = color; border.width = 1.0; } container::Style { border, ..container::Style::default() } }), ) .on_move(move |point: Point| { let position = if point.y < 14.0 { DropPosition::Before } else if point.y > 32.0 { DropPosition::After } else { DropPosition::Inside }; Message::MenuEditor(MenuEditorMsg::DragOver(target_id.clone(), position)) }) .on_exit(Message::MenuEditor(MenuEditorMsg::DragLeave(id.clone()))) .on_release(Message::MenuEditor(MenuEditorMsg::Drop)); let mut result = column![].spacing(3).width(Length::Fill); if target == Some(DropPosition::Before) { result = result.push(drop_indicator(depth)); } result = result.push(drop_row); if state .draft .as_ref() .is_some_and(|draft| draft.item_id == item.id) { result = result.push(draft_editor(state, depth + 1, locale)); } if is_submenu && !collapsed { for child in &item.children { result = result.push(tree_item(state, child, depth + 1, locale)); } } if target == Some(DropPosition::After) { result = result.push(drop_indicator(depth)); } result.into() } fn drop_indicator(depth: usize) -> Element<'static, Message> { row![ Space::new(Length::Fixed(depth as f32 * 22.0 + 60.0), 0), container(Space::new(Length::Fill, Length::Fixed(2.0))).style(|_theme| { container::Style { background: Some(Background::Color(Color::from_rgb8(0x00, 0x7F, 0xD4))), ..container::Style::default() } }) ] .into() } fn draft_editor<'a>( state: &'a MenuEditorState, depth: usize, locale: UiLocale, ) -> Element<'a, Message> { let draft = state.draft.as_ref().expect("draft editor requires a draft"); let placeholder = match draft.kind { DraftKind::Page => "menuEditor.pagePlaceholder", DraftKind::Category => "menuEditor.categoryPlaceholder", }; let input = text_input(&t(locale, placeholder), &draft.query) .on_input(|value| Message::MenuEditor(MenuEditorMsg::DraftChanged(value))) .on_submit(Message::MenuEditor(MenuEditorMsg::SubmitDraft)) .padding([8, 10]) .style(field_style) .width(Length::Fill); let mut choices = column![].spacing(4); match draft.kind { DraftKind::Page => { for page in state .pages .iter() .filter(|page| page_matches_query(page, &draft.query)) { choices = choices.push( button(text(page.title.clone())) .on_press(Message::MenuEditor(MenuEditorMsg::ChoosePage( page.id.clone(), ))) .style(secondary_button) .width(Length::Fill), ); } } DraftKind::Category => { for category in state .categories .iter() .filter(|name| category_matches_query(name, &draft.query)) { choices = choices.push( button(text(category.clone())) .on_press(Message::MenuEditor(MenuEditorMsg::ChooseCategory( category.clone(), ))) .style(secondary_button) .width(Length::Fill), ); } } } let submit_label = match draft.kind { DraftKind::Page => "menuEditor.createSubmenu", DraftKind::Category => "menuEditor.useCategory", }; let actions = row![ button(text(t(locale, submit_label))) .on_press(Message::MenuEditor(MenuEditorMsg::SubmitDraft)) .style(primary_button), button(text(t(locale, "common.cancel"))) .on_press(Message::MenuEditor(MenuEditorMsg::CancelDraft)) .style(secondary_button), ] .spacing(8); let mut editor = column![input, choices, actions].spacing(8); if draft.validation_failed { editor = editor.push( text(t(locale, "menuEditor.categoryRequired")) .size(12) .color(Color::from_rgb8(0xF4, 0x87, 0x71)), ); } container(row![ Space::new(Length::Fixed(depth as f32 * 22.0 + 60.0), 0), editor.width(Length::Fill) ]) .padding([8, 8]) .into() } fn page_matches_query(page: &PageOption, query: &str) -> bool { let query = query.trim().to_lowercase(); query.is_empty() || page.title.to_lowercase().contains(&query) || page.slug.to_lowercase().contains(&query) } fn category_matches_query(category: &str, query: &str) -> bool { let query = query.trim().to_lowercase(); query.is_empty() || category.to_lowercase().contains(&query) } #[cfg(test)] mod tests { use super::*; #[test] fn inserts_after_a_leaf_and_as_first_child_of_a_submenu() { let mut state = fixture(); state.selected_id = Some("page-one".into()); state.start_draft(DraftKind::Page); state.draft_changed("Nested".into()); state.submit_submenu("New submenu").unwrap(); assert_eq!( labels(&state.items), vec!["Home", "One", "Nested", "Section"] ); state.selected_id = Some("section".into()); state.start_draft(DraftKind::Page); state.choose_page("page-two").unwrap(); assert_eq!(labels(&state.items[3].children), vec!["Two"]); } #[test] fn cancelling_a_draft_removes_it() { let mut state = fixture(); state.selected_id = Some("page-one".into()); state.start_draft(DraftKind::Category); assert!(state.draft.is_some()); state.cancel_draft(); assert_eq!(labels(&state.items), vec!["Home", "One", "Section"]); } #[test] fn moves_indents_and_unindents_with_home_protected() { let mut state = fixture(); state.selected_id = Some("section".into()); assert!(state.move_selected(MoveDirection::Up)); assert_eq!(labels(&state.items), vec!["Home", "Section", "One"]); assert!(!state.move_selected(MoveDirection::Up)); assert!(state.move_selected(MoveDirection::Down)); assert_eq!(labels(&state.items), vec!["Home", "One", "Section"]); assert!(state.move_selected(MoveDirection::Up)); state.selected_id = Some("page-one".into()); assert!(state.indent_selected()); assert_eq!(labels(&state.items[1].children), vec!["One"]); assert!(state.unindent_selected()); assert_eq!(labels(&state.items), vec!["Home", "Section", "One"]); state.selected_id = Some(HOME_ID.into()); assert!(!state.move_selected(MoveDirection::Down)); assert!(!state.delete_selected()); } #[test] fn drag_drop_supports_positions_and_rejects_invalid_targets() { let mut state = fixture(); assert!(state.drop_item("page-one", "section", DropPosition::Inside)); let section = find_path(&state.items, "section").unwrap(); assert_eq!( labels(&item_at(&state.items, §ion).unwrap().children), vec!["One"] ); assert!(state.drop_item("page-one", "section", DropPosition::Before)); assert_eq!(labels(&state.items), vec!["Home", "One", "Section"]); assert!(state.drop_item("page-one", "section", DropPosition::After)); assert_eq!(labels(&state.items), vec!["Home", "Section", "One"]); assert!(state.drop_item("page-one", "section", DropPosition::Before)); assert!(!state.drop_item("section", "page-one", DropPosition::Inside)); assert!(!state.drop_item("section", "section", DropPosition::After)); assert!(!state.drop_item("section", HOME_ID, DropPosition::Before)); assert!(!state.drop_item(HOME_ID, "section", DropPosition::After)); state.items[2] .children .push(node("child", MenuItemKind::Submenu, "Child", None)); assert!(!state.drop_item("section", "child", DropPosition::Inside)); } #[test] fn category_drafts_validate_and_normalize_existing_names() { let mut state = fixture(); state.start_draft(DraftKind::Category); assert!(state.submit_category().is_err()); assert!(state.draft.as_ref().unwrap().validation_failed); state.draft_changed(" ARTICLES ".into()); assert_eq!(state.submit_category().unwrap(), ("articles".into(), false)); state.start_draft(DraftKind::Category); state.draft_changed(" Long Form ".into()); assert_eq!(state.submit_category().unwrap(), ("Long Form".into(), true)); assert_eq!(state.categories, vec!["articles", "Long Form"]); } #[test] fn collapsed_submenus_expand_only_after_the_drag_delay() { let mut state = fixture(); state.collapsed.insert("section".into()); state.dragging_id = Some("page-one".into()); let started = Instant::now(); state.drag_over("section".into(), DropPosition::Inside, started); assert!(!state.expand_hovered(started + DRAG_EXPAND_DELAY - Duration::from_millis(1))); assert!(state.collapsed.contains("section")); assert!(state.expand_hovered(started + DRAG_EXPAND_DELAY)); assert!(!state.collapsed.contains("section")); } #[test] fn draft_search_matches_bds2_title_slug_and_trimmed_category_rules() { let page = PageOption { id: "page".into(), title: "About Us".into(), slug: "company-profile".into(), }; assert!(page_matches_query(&page, " ABOUT ")); assert!(page_matches_query(&page, "profile")); assert!(!page_matches_query(&page, "contact")); assert!(category_matches_query("Long Form", " long ")); } fn fixture() -> MenuEditorState { MenuEditorState::ready( "project".into(), vec![ node(HOME_ID, MenuItemKind::Home, "Home", None), node("page-one", MenuItemKind::Page, "One", Some("one")), node("section", MenuItemKind::Submenu, "Section", None), ], vec![PageOption { id: "page-two".into(), title: "Two".into(), slug: "two".into(), }], vec!["articles".into()], ) } fn node(id: &str, kind: MenuItemKind, label: &str, slug: Option<&str>) -> MenuNode { MenuNode { id: id.into(), kind, label: label.into(), slug: slug.map(str::to_string), children: Vec::new(), } } fn labels(items: &[MenuNode]) -> Vec<&str> { items.iter().map(|item| item.label.as_str()).collect() } }