1use laterite_core::Text;
12use serde::{Deserialize, Serialize};
13
14use crate::settings::{CategoryView, ItemView};
15
16#[derive(Clone, Debug, Serialize, Deserialize)]
18#[serde(deny_unknown_fields, default)]
19#[non_exhaustive]
20pub struct NavItem {
21 pub key: String,
24 pub label: Text,
25 pub path: Option<String>,
28 pub icon: Option<String>,
30 pub permission: Option<String>,
33 #[serde(deserialize_with = "laterite_core::arrange::deserialize_slot")]
37 pub slot: i32,
38 pub order: i32,
40 pub after: Option<String>,
42 pub before: Option<String>,
44 pub under: Option<String>,
46}
47
48impl laterite_core::arrange::Arranged for NavNode {
49 fn key(&self) -> &str {
50 &self.item.key
51 }
52 fn position(&self) -> &laterite_core::arrange::Position {
53 &self.position
54 }
55 fn label(&self) -> &str {
56 self.item.label.source()
57 }
58}
59
60impl NavItem {
61 pub fn new(key: impl Into<String>, label: impl Into<Text>, path: impl Into<String>) -> Self {
63 Self {
64 key: key.into(),
65 label: label.into(),
66 path: Some(path.into()),
67 icon: None,
68 permission: None,
69 slot: laterite_core::arrange::slot::APPLICATION,
70 order: 0,
71 after: None,
72 before: None,
73 under: None,
74 }
75 }
76
77 pub fn section(key: impl Into<String>, label: impl Into<Text>) -> Self {
79 Self {
80 key: key.into(),
81 label: label.into(),
82 path: None,
83 icon: None,
84 permission: None,
85 slot: laterite_core::arrange::slot::APPLICATION,
86 order: 0,
87 after: None,
88 before: None,
89 under: None,
90 }
91 }
92
93 pub fn icon(mut self, icon: impl Into<String>) -> Self {
94 self.icon = Some(icon.into());
95 self
96 }
97
98 pub fn permission(mut self, permission: impl Into<String>) -> Self {
99 self.permission = Some(permission.into());
100 self
101 }
102
103 pub fn order(mut self, order: i32) -> Self {
104 self.order = order;
105 self
106 }
107
108 pub fn slot(mut self, slot: i32) -> Self {
110 self.slot = slot;
111 self
112 }
113
114 pub fn under(mut self, section: impl Into<String>) -> Self {
116 self.under = Some(section.into());
117 self
118 }
119
120 pub fn after(mut self, key: impl Into<String>) -> Self {
122 self.after = Some(key.into());
123 self
124 }
125
126 pub fn before(mut self, key: impl Into<String>) -> Self {
128 self.before = Some(key.into());
129 self
130 }
131}
132
133impl Default for NavItem {
134 fn default() -> Self {
135 Self::section("", "")
136 }
137}
138
139#[derive(Clone, Debug, Serialize, Deserialize)]
142#[serde(deny_unknown_fields, default)]
143#[non_exhaustive]
144pub struct Placement {
145 pub under: Option<String>,
147 pub icon: Option<String>,
148 #[serde(deserialize_with = "laterite_core::arrange::deserialize_slot")]
150 pub slot: i32,
151 pub order: i32,
152 pub after: Option<String>,
155 pub before: Option<String>,
157 pub hidden: bool,
159}
160
161impl Default for Placement {
162 fn default() -> Self {
163 Self {
164 under: None,
165 icon: None,
166 slot: laterite_core::arrange::slot::APPLICATION,
167 order: 0,
168 after: None,
169 before: None,
170 hidden: false,
171 }
172 }
173}
174
175impl Placement {
176 pub fn new() -> Self {
177 Self::default()
178 }
179
180 pub fn hidden() -> Self {
182 Self {
183 hidden: true,
184 ..Self::default()
185 }
186 }
187
188 pub fn under(mut self, section: impl Into<String>) -> Self {
189 self.under = Some(section.into());
190 self
191 }
192
193 pub fn icon(mut self, icon: impl Into<String>) -> Self {
194 self.icon = Some(icon.into());
195 self
196 }
197
198 pub fn order(mut self, order: i32) -> Self {
199 self.order = order;
200 self
201 }
202
203 pub fn slot(mut self, slot: i32) -> Self {
205 self.slot = slot;
206 self
207 }
208
209 pub fn after(mut self, key: impl Into<String>) -> Self {
211 self.after = Some(key.into());
212 self
213 }
214
215 pub fn before(mut self, key: impl Into<String>) -> Self {
217 self.before = Some(key.into());
218 self
219 }
220}
221
222pub(crate) fn deserialize_placement<'de, D>(deserializer: D) -> Result<Placement, D::Error>
224where
225 D: serde::Deserializer<'de>,
226{
227 #[derive(Deserialize)]
228 #[serde(untagged)]
229 enum Key {
230 Flag(bool),
231 Config(Placement),
232 }
233 Ok(match Option::<Key>::deserialize(deserializer)? {
234 Some(Key::Flag(false)) => Placement::hidden(),
235 Some(Key::Flag(true)) | None => Placement::default(),
236 Some(Key::Config(placement)) => placement,
237 })
238}
239
240#[derive(Clone, Debug)]
242pub(crate) struct NavNode {
243 pub item: NavItem,
244 pub children: Vec<NavNode>,
245 pub position: laterite_core::arrange::Position,
247}
248
249impl NavNode {
250 fn leaf(item: NavItem) -> Self {
251 let mut position = laterite_core::arrange::Position::new()
252 .slot(item.slot)
253 .order(item.order);
254 if let Some(after) = &item.after {
255 position = position.after(after.clone());
256 }
257 if let Some(before) = &item.before {
258 position = position.before(before.clone());
259 }
260 Self {
261 item,
262 children: Vec::new(),
263 position,
264 }
265 }
266}
267
268pub(crate) fn top_hierarchy() -> laterite_core::arrange::Hierarchy {
271 laterite_core::arrange::Hierarchy::new(TOP_HIERARCHY)
272 .operator_adjustable()
273 .pin_first("dashboard")
274 .pin_last("settings")
275}
276
277pub const TOP_HIERARCHY: &str = "backend.nav";
279
280#[derive(Debug, Default)]
282pub(crate) struct NavTree {
283 pub top: Vec<NavNode>,
284}
285
286impl NavTree {
287 #[cfg(test)]
291 pub(crate) fn build(items: Vec<NavItem>, admin_path: &str) -> Result<Self, String> {
292 Self::build_with(items, admin_path, &std::collections::BTreeMap::new())
293 }
294
295 pub(crate) fn build_with(
298 items: Vec<NavItem>,
299 admin_path: &str,
300 order: &std::collections::BTreeMap<String, Vec<String>>,
301 ) -> Result<Self, String> {
302 use laterite_core::arrange::{arrange, Hierarchy, Overrides};
303 let mut seen = std::collections::HashSet::new();
304 for item in &items {
305 if item.key.is_empty() {
306 return Err(format!("menu item `{}` has no key", item.label.source()));
307 }
308 if !seen.insert(item.key.clone()) {
309 return Err(format!("duplicate menu key `{}`", item.key));
310 }
311 }
312 let prefixed = |item: &NavItem| {
313 let mut item = item.clone();
314 item.path = item.path.map(|p| format!("{admin_path}{p}"));
315 item
316 };
317 let mut top: Vec<NavNode> = items
318 .iter()
319 .filter(|i| i.under.is_none())
320 .map(|i| NavNode::leaf(prefixed(i)))
321 .collect();
322 for item in items.iter().filter(|i| i.under.is_some()) {
323 let under = item.under.as_deref().unwrap_or_default();
324 let parent = top
325 .iter_mut()
326 .find(|n| n.item.key == under)
327 .ok_or_else(|| {
328 format!(
329 "menu item `{}` sits under `{under}`, which is no top-level item",
330 item.key
331 )
332 })?;
333 parent.children.push(NavNode::leaf(prefixed(item)));
334 }
335 let overrides = |key: &str| Overrides {
336 deployment: order.get(key).map(Vec::as_slice),
337 operator: None,
338 };
339 let top = arrange(&top_hierarchy(), top, overrides(TOP_HIERARCHY))
340 .map_err(|e| format!("menu: {e}"))?;
341 let mut arranged = Vec::with_capacity(top.len());
342 for mut node in top {
343 let key = format!("{TOP_HIERARCHY}.{}", node.item.key);
344 let children = std::mem::take(&mut node.children);
345 node.children = arrange(&Hierarchy::new(&key), children, overrides(&key))
346 .map_err(|e| format!("menu, under `{}`: {e}", node.item.key))?;
347 arranged.push(node);
348 }
349 Ok(Self { top: arranged })
350 }
351
352 pub(crate) fn visible(&self, allows: &dyn Fn(&str) -> bool) -> Vec<NavNode> {
356 let allowed = |item: &NavItem| item.permission.as_deref().is_none_or(allows);
357 self.top
358 .iter()
359 .filter_map(|node| {
360 let children: Vec<NavNode> = node
361 .children
362 .iter()
363 .filter(|c| allowed(&c.item))
364 .cloned()
365 .collect();
366 let own = node.item.path.is_some() && allowed(&node.item);
367 if !own && children.is_empty() {
368 return None;
369 }
370 if node.item.path.is_some() && !allowed(&node.item) {
371 return None;
372 }
373 Some(NavNode {
374 item: node.item.clone(),
375 children,
376 position: node.position.clone(),
377 })
378 })
379 .collect()
380 }
381}
382
383pub(crate) const ORDER_PREFERENCE: &str = "menu.order";
385
386pub(crate) const MOVES_PREFERENCE: &str = "menu.moves";
389
390pub(crate) fn order_preference(hierarchy: &str) -> String {
393 if hierarchy == TOP_HIERARCHY {
394 ORDER_PREFERENCE.to_string()
395 } else {
396 format!("{ORDER_PREFERENCE}.{hierarchy}")
397 }
398}
399
400pub(crate) const OPERATOR_HIERARCHIES: [&str; 2] = [TOP_HIERARCHY, crate::settings::HIERARCHY];
402
403pub(crate) fn operator_moves(
406 stored: &str,
407) -> std::collections::BTreeMap<String, laterite_core::arrange::Move> {
408 let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(stored)
409 else {
410 return Default::default();
411 };
412 map.into_iter()
413 .filter_map(|(key, place)| {
414 let hierarchy = match place.as_str()? {
415 "nav" => TOP_HIERARCHY,
416 "settings" => crate::settings::HIERARCHY,
417 _ => return None,
418 };
419 let mut m = laterite_core::arrange::Move::default();
420 m.hierarchy = hierarchy.to_string();
421 Some((key, m))
422 })
423 .collect()
424}
425
426pub(crate) fn apply_order(nodes: &mut Vec<NavNode>, order: &str) {
431 let wanted = laterite_core::arrange::parse_sequence(order);
432 if wanted.is_empty() {
433 return;
434 }
435 let taken = std::mem::take(nodes);
436 *nodes = laterite_core::arrange::arrange(
437 &top_hierarchy(),
438 taken,
439 laterite_core::arrange::Overrides {
440 deployment: None,
441 operator: Some(&wanted),
442 },
443 )
444 .unwrap_or_default();
445}
446
447pub(crate) fn tab_path(node: &NavNode) -> Option<&str> {
449 node.item
450 .path
451 .as_deref()
452 .or_else(|| node.children.first().and_then(|c| c.item.path.as_deref()))
453}
454
455fn within(path: &str, target: &str, admin_path: &str) -> bool {
457 path == target || (target != admin_path && path.starts_with(&format!("{target}/")))
458}
459
460pub(crate) fn active_top<'a>(
463 visible: &'a [NavNode],
464 admin_path: &str,
465 path: &str,
466) -> Option<&'a NavNode> {
467 let mut best: Option<(&NavNode, usize)> = None;
468 for node in visible {
469 let candidates = node
470 .item
471 .path
472 .iter()
473 .chain(node.children.iter().filter_map(|c| c.item.path.as_ref()));
474 for target in candidates {
475 if within(path, target, admin_path) && best.is_none_or(|(_, len)| target.len() > len) {
476 best = Some((node, target.len()));
477 }
478 }
479 }
480 best.map(|(node, _)| node)
481}
482
483pub(crate) fn section_sidebar(
486 node: &NavNode,
487 admin_path: &str,
488 path: &str,
489 tr: &laterite_core::Translator,
490 icons: crate::icons::Icons<'_>,
491) -> Vec<CategoryView> {
492 if node.children.is_empty() {
493 return Vec::new();
494 }
495 let active = node
496 .children
497 .iter()
498 .filter(|c| {
499 c.item
500 .path
501 .as_deref()
502 .is_some_and(|p| within(path, p, admin_path))
503 })
504 .max_by_key(|c| c.item.path.as_deref().map(str::len).unwrap_or(0))
505 .map(|c| c.item.key.clone());
506 vec![CategoryView::new(
507 tr.t(&node.item.label),
508 node.children
509 .iter()
510 .map(|c| ItemView {
511 key: c.item.key.clone(),
512 label: tr.t(&c.item.label),
513 description: String::new(),
514 path: c.item.path.clone().unwrap_or_default(),
515 icon: icons.render(c.item.icon.as_deref()),
516 active: active.as_deref() == Some(c.item.key.as_str()),
517 })
518 .collect(),
519 )]
520}
521
522pub(crate) fn apply_moves(
529 items: &mut Vec<NavItem>,
530 settings: &mut Vec<crate::settings::SettingsItem>,
531 moves: &std::collections::BTreeMap<String, laterite_core::arrange::Move>,
532) {
533 use laterite_core::arrange::slot;
534 for (key, spec) in moves {
535 let to_settings = spec.hierarchy == crate::settings::HIERARCHY;
536 let to_nav = spec.hierarchy == TOP_HIERARCHY
537 || spec.hierarchy.starts_with(&format!("{TOP_HIERARCHY}."));
538 if !to_settings && !to_nav {
539 tracing::warn!(key = %key, hierarchy = %spec.hierarchy, "a menu move names a hierarchy nothing renders");
540 continue;
541 }
542 let section = spec
543 .hierarchy
544 .strip_prefix(&format!("{TOP_HIERARCHY}."))
545 .map(str::to_string);
546 if let Some(at) = items.iter().position(|i| &i.key == key) {
547 if to_nav {
548 let item = &mut items[at];
549 item.under = section;
550 if let Some(slot) = spec.slot {
551 item.slot = slot;
552 }
553 if let Some(order) = spec.order {
554 item.order = order;
555 }
556 continue;
557 }
558 let Some(path) = items[at].path.clone() else {
559 tracing::warn!(key = %key, "a section has no page of its own and cannot move into settings");
560 continue;
561 };
562 let item = items.remove(at);
563 let band = spec.slot.unwrap_or(item.slot);
564 let mut moved = crate::settings::SettingsItem::new(
565 item.key.clone(),
566 item.label.clone(),
567 Vec::new(),
568 )
569 .category(laterite_core::Text::dynamic(
570 spec.category
571 .clone()
572 .unwrap_or_else(|| title(&slot::name(band))),
573 ))
574 .slot(band)
575 .order(spec.order.unwrap_or(item.order))
576 .link(path);
577 if let Some(icon) = item.icon.clone() {
578 moved = moved.icon(icon);
579 }
580 if let Some(permission) = item.permission.clone() {
581 moved = moved.permission(permission);
582 }
583 settings.push(moved);
584 continue;
585 }
586 if let Some(at) = settings.iter().position(|i| &i.code == key) {
587 if to_settings {
588 let item = &mut settings[at];
589 if let Some(slot) = spec.slot {
590 item.slot = slot;
591 }
592 if let Some(order) = spec.order {
593 item.order = order;
594 }
595 if let Some(category) = &spec.category {
596 item.category = laterite_core::Text::dynamic(category.clone());
597 }
598 continue;
599 }
600 let item = settings.remove(at);
601 let path = item.link.clone().unwrap_or_else(|| item.route());
602 let mut moved = NavItem::new(item.code.clone(), item.label.clone(), path)
603 .slot(spec.slot.unwrap_or(item.slot))
604 .order(spec.order.unwrap_or(item.order));
605 moved.icon = item.icon.clone();
606 moved.permission = item.permission.clone();
607 moved.under = section;
608 items.push(moved);
609 continue;
610 }
611 tracing::warn!(key = %key, "a menu move names an entry nothing declared");
612 }
613}
614
615fn title(name: &str) -> String {
617 let mut chars = name.chars();
618 match chars.next() {
619 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
620 None => String::new(),
621 }
622}
623
624impl NavTree {
625 pub(crate) fn log_arrangement(&self) {
628 let top: Vec<String> = self
629 .top
630 .iter()
631 .map(|n| {
632 format!(
633 "{} ({})",
634 n.item.key,
635 laterite_core::arrange::slot::name(n.item.slot)
636 )
637 })
638 .collect();
639 tracing::info!(hierarchy = TOP_HIERARCHY, arrangement = %top.join(" > "));
640 for node in &self.top {
641 if node.children.is_empty() {
642 continue;
643 }
644 let keys: Vec<&str> = node.children.iter().map(|c| c.item.key.as_str()).collect();
645 tracing::info!(hierarchy = %format!("{TOP_HIERARCHY}.{}", node.item.key), arrangement = %keys.join(" > "));
646 }
647 }
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653
654 fn items() -> Vec<NavItem> {
655 vec![
656 NavItem::new("dashboard", "Dashboard", "").order(i32::MIN),
657 NavItem::section("content", "Content").order(10),
658 NavItem::new("acme.posts", "Posts", "/posts")
659 .under("content")
660 .permission("acme.posts")
661 .order(2),
662 NavItem::new("acme.pages", "Pages", "/pages")
663 .under("content")
664 .permission("acme.pages")
665 .order(1),
666 NavItem::new("acme.shop", "Shop", "/shop")
667 .permission("acme.shop")
668 .order(20),
669 NavItem::new("settings", "Settings", "/settings").order(i32::MAX),
670 ]
671 }
672
673 #[test]
674 fn a_tree_sorts_and_prefixes() {
675 let tree = NavTree::build(items(), "/admin").unwrap();
676 let keys: Vec<&str> = tree.top.iter().map(|n| n.item.key.as_str()).collect();
677 assert_eq!(keys, ["dashboard", "content", "acme.shop", "settings"]);
678 let content = &tree.top[1];
679 let entries: Vec<&str> = content
680 .children
681 .iter()
682 .map(|n| n.item.key.as_str())
683 .collect();
684 assert_eq!(
685 entries,
686 ["acme.pages", "acme.posts"],
687 "by order, then label"
688 );
689 assert_eq!(
690 content.children[0].item.path.as_deref(),
691 Some("/admin/pages")
692 );
693 assert_eq!(tab_path(content), Some("/admin/pages"));
694 }
695
696 #[test]
697 fn an_anchor_holds_whatever_the_orders_say() {
698 let mut items = items();
699 items.push(
700 NavItem::new("acme.help", "Help", "/help")
701 .order(0)
702 .after("acme.shop"),
703 );
704 items.push(
705 NavItem::new("acme.tags", "Tags", "/tags")
706 .under("content")
707 .order(99)
708 .before("acme.pages"),
709 );
710 let tree = NavTree::build(items, "/admin").unwrap();
711 let keys: Vec<&str> = tree.top.iter().map(|n| n.item.key.as_str()).collect();
712 assert_eq!(
713 keys,
714 ["dashboard", "content", "acme.shop", "acme.help", "settings"]
715 );
716 let entries: Vec<&str> = tree.top[1]
717 .children
718 .iter()
719 .map(|n| n.item.key.as_str())
720 .collect();
721 assert_eq!(entries, ["acme.tags", "acme.pages", "acme.posts"]);
722
723 let mut lost = self::items();
724 lost.push(NavItem::new("acme.help", "Help", "/help").after("acme.pages"));
725 assert!(
726 NavTree::build(lost, "/admin").is_err(),
727 "an anchor must be a sibling"
728 );
729 }
730
731 #[test]
732 fn an_operators_order_moves_tabs_but_never_the_ends() {
733 let tree = NavTree::build(items(), "/admin").unwrap();
734 let mut visible = tree.visible(&|_| true);
735 apply_order(&mut visible, "settings,acme.shop,gone,dashboard");
736 let keys: Vec<&str> = visible.iter().map(|n| n.item.key.as_str()).collect();
737 assert_eq!(keys, ["dashboard", "acme.shop", "content", "settings"]);
738 apply_order(&mut visible, "");
739 let keys: Vec<&str> = visible.iter().map(|n| n.item.key.as_str()).collect();
740 assert_eq!(
741 keys,
742 ["dashboard", "acme.shop", "content", "settings"],
743 "blank changes nothing"
744 );
745 }
746
747 #[test]
748 fn a_duplicate_key_and_an_unknown_section_are_refused() {
749 let mut dup = items();
750 dup.push(NavItem::new("acme.shop", "Again", "/again"));
751 assert!(NavTree::build(dup, "/admin").is_err());
752 let mut orphan = items();
753 orphan.push(NavItem::new("acme.lost", "Lost", "/lost").under("nowhere"));
754 assert!(NavTree::build(orphan, "/admin").is_err());
755 }
756
757 #[test]
758 fn an_operator_sees_only_what_they_may_open() {
759 let tree = NavTree::build(items(), "/admin").unwrap();
760 let visible = tree.visible(&|code| code == "acme.posts");
761 let keys: Vec<&str> = visible.iter().map(|n| n.item.key.as_str()).collect();
762 assert_eq!(keys, ["dashboard", "content", "settings"], "no shop");
763 assert_eq!(visible[1].children.len(), 1, "only posts under content");
764 assert_eq!(tab_path(&visible[1]), Some("/admin/posts"));
765 let none = tree.visible(&|_| false);
766 let keys: Vec<&str> = none.iter().map(|n| n.item.key.as_str()).collect();
767 assert_eq!(keys, ["dashboard", "settings"], "an empty section goes");
768 }
769
770 #[test]
771 fn the_active_tab_is_the_longest_match() {
772 let tree = NavTree::build(items(), "/admin").unwrap();
773 let visible = tree.visible(&|_| true);
774 let at = |path: &str| active_top(&visible, "/admin", path).map(|n| n.item.key.clone());
775 assert_eq!(at("/admin/posts/3/edit").as_deref(), Some("content"));
776 assert_eq!(at("/admin/shop").as_deref(), Some("acme.shop"));
777 assert_eq!(at("/admin/settings/users").as_deref(), Some("settings"));
778 assert_eq!(at("/admin").as_deref(), Some("dashboard"));
779 assert_eq!(at("/admin/elsewhere"), None);
780 }
781}