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 pub order: i32,
35 pub after: Option<String>,
37 pub before: Option<String>,
39 pub under: Option<String>,
41}
42
43impl NavItem {
44 pub fn new(key: impl Into<String>, label: impl Into<Text>, path: impl Into<String>) -> Self {
46 Self {
47 key: key.into(),
48 label: label.into(),
49 path: Some(path.into()),
50 icon: None,
51 permission: None,
52 order: 0,
53 after: None,
54 before: None,
55 under: None,
56 }
57 }
58
59 pub fn section(key: impl Into<String>, label: impl Into<Text>) -> Self {
61 Self {
62 key: key.into(),
63 label: label.into(),
64 path: None,
65 icon: None,
66 permission: None,
67 order: 0,
68 after: None,
69 before: None,
70 under: None,
71 }
72 }
73
74 pub fn icon(mut self, icon: impl Into<String>) -> Self {
75 self.icon = Some(icon.into());
76 self
77 }
78
79 pub fn permission(mut self, permission: impl Into<String>) -> Self {
80 self.permission = Some(permission.into());
81 self
82 }
83
84 pub fn order(mut self, order: i32) -> Self {
85 self.order = order;
86 self
87 }
88
89 pub fn under(mut self, section: impl Into<String>) -> Self {
91 self.under = Some(section.into());
92 self
93 }
94
95 pub fn after(mut self, key: impl Into<String>) -> Self {
97 self.after = Some(key.into());
98 self
99 }
100
101 pub fn before(mut self, key: impl Into<String>) -> Self {
103 self.before = Some(key.into());
104 self
105 }
106}
107
108impl Default for NavItem {
109 fn default() -> Self {
110 Self::section("", "")
111 }
112}
113
114#[derive(Clone, Debug, Default, Serialize, Deserialize)]
117#[serde(deny_unknown_fields, default)]
118#[non_exhaustive]
119pub struct Placement {
120 pub under: Option<String>,
122 pub icon: Option<String>,
123 pub order: i32,
124 pub after: Option<String>,
127 pub before: Option<String>,
129 pub hidden: bool,
131}
132
133impl Placement {
134 pub fn new() -> Self {
135 Self::default()
136 }
137
138 pub fn hidden() -> Self {
140 Self {
141 hidden: true,
142 ..Self::default()
143 }
144 }
145
146 pub fn under(mut self, section: impl Into<String>) -> Self {
147 self.under = Some(section.into());
148 self
149 }
150
151 pub fn icon(mut self, icon: impl Into<String>) -> Self {
152 self.icon = Some(icon.into());
153 self
154 }
155
156 pub fn order(mut self, order: i32) -> Self {
157 self.order = order;
158 self
159 }
160
161 pub fn after(mut self, key: impl Into<String>) -> Self {
163 self.after = Some(key.into());
164 self
165 }
166
167 pub fn before(mut self, key: impl Into<String>) -> Self {
169 self.before = Some(key.into());
170 self
171 }
172}
173
174pub(crate) fn deserialize_placement<'de, D>(deserializer: D) -> Result<Placement, D::Error>
176where
177 D: serde::Deserializer<'de>,
178{
179 #[derive(Deserialize)]
180 #[serde(untagged)]
181 enum Key {
182 Flag(bool),
183 Config(Placement),
184 }
185 Ok(match Option::<Key>::deserialize(deserializer)? {
186 Some(Key::Flag(false)) => Placement::hidden(),
187 Some(Key::Flag(true)) | None => Placement::default(),
188 Some(Key::Config(placement)) => placement,
189 })
190}
191
192#[derive(Clone, Debug)]
194pub(crate) struct NavNode {
195 pub item: NavItem,
196 pub children: Vec<NavNode>,
197}
198
199#[derive(Debug, Default)]
201pub(crate) struct NavTree {
202 pub top: Vec<NavNode>,
203}
204
205impl NavTree {
206 pub(crate) fn build(items: Vec<NavItem>, admin_path: &str) -> Result<Self, String> {
210 let mut seen = std::collections::HashSet::new();
211 for item in &items {
212 if item.key.is_empty() {
213 return Err(format!("menu item `{}` has no key", item.label.source()));
214 }
215 if !seen.insert(item.key.clone()) {
216 return Err(format!("duplicate menu key `{}`", item.key));
217 }
218 }
219 let prefixed = |item: &NavItem| {
220 let mut item = item.clone();
221 item.path = item.path.map(|p| format!("{admin_path}{p}"));
222 item
223 };
224 let mut top: Vec<NavNode> = items
225 .iter()
226 .filter(|i| i.under.is_none())
227 .map(|i| NavNode {
228 item: prefixed(i),
229 children: Vec::new(),
230 })
231 .collect();
232 for item in items.iter().filter(|i| i.under.is_some()) {
233 let under = item.under.as_deref().unwrap_or_default();
234 let parent = top
235 .iter_mut()
236 .find(|n| n.item.key == under)
237 .ok_or_else(|| {
238 format!(
239 "menu item `{}` sits under `{under}`, which is no top-level item",
240 item.key
241 )
242 })?;
243 parent.children.push(NavNode {
244 item: prefixed(item),
245 children: Vec::new(),
246 });
247 }
248 let by_order = |a: &NavNode, b: &NavNode| {
249 a.item
250 .order
251 .cmp(&b.item.order)
252 .then_with(|| a.item.label.source().cmp(b.item.label.source()))
253 };
254 top.sort_by(by_order);
255 anchor(&mut top)?;
256 for node in &mut top {
257 node.children.sort_by(by_order);
258 anchor(&mut node.children)?;
259 }
260 Ok(Self { top })
261 }
262
263 pub(crate) fn visible(&self, allows: &dyn Fn(&str) -> bool) -> Vec<NavNode> {
267 let allowed = |item: &NavItem| item.permission.as_deref().is_none_or(allows);
268 self.top
269 .iter()
270 .filter_map(|node| {
271 let children: Vec<NavNode> = node
272 .children
273 .iter()
274 .filter(|c| allowed(&c.item))
275 .cloned()
276 .collect();
277 let own = node.item.path.is_some() && allowed(&node.item);
278 if !own && children.is_empty() {
279 return None;
280 }
281 if node.item.path.is_some() && !allowed(&node.item) {
282 return None;
283 }
284 Some(NavNode {
285 item: node.item.clone(),
286 children,
287 })
288 })
289 .collect()
290 }
291}
292
293fn anchor(nodes: &mut Vec<NavNode>) -> Result<(), String> {
297 let keys: Vec<String> = nodes.iter().map(|n| n.item.key.clone()).collect();
298 for key in keys {
299 let at = nodes.iter().position(|n| n.item.key == key).unwrap_or(0);
300 let (anchor_key, after) = match (&nodes[at].item.after, &nodes[at].item.before) {
301 (Some(a), _) => (a.clone(), true),
302 (None, Some(b)) => (b.clone(), false),
303 (None, None) => continue,
304 };
305 let target = nodes
306 .iter()
307 .position(|n| n.item.key == anchor_key)
308 .ok_or_else(|| {
309 format!(
310 "menu item `{key}` is placed {} `{anchor_key}`, which is no sibling of it",
311 if after { "after" } else { "before" }
312 )
313 })?;
314 if target == at {
315 return Err(format!("menu item `{key}` is placed next to itself"));
316 }
317 let node = nodes.remove(at);
318 let target = nodes
319 .iter()
320 .position(|n| n.item.key == anchor_key)
321 .unwrap_or(0);
322 nodes.insert(if after { target + 1 } else { target }, node);
323 }
324 Ok(())
325}
326
327pub(crate) const ORDER_PREFERENCE: &str = "menu.order";
329
330pub(crate) fn apply_order(nodes: &mut Vec<NavNode>, order: &str) {
335 let wanted: Vec<&str> = order
336 .split(',')
337 .map(str::trim)
338 .filter(|k| !k.is_empty())
339 .collect();
340 if wanted.is_empty() {
341 return;
342 }
343 let fixed = |key: &str| key == "dashboard" || key == "settings";
344 let mut rest: Vec<NavNode> = std::mem::take(nodes);
345 let mut ordered: Vec<NavNode> = Vec::with_capacity(rest.len());
346 for key in wanted {
347 if fixed(key) {
348 continue;
349 }
350 if let Some(at) = rest.iter().position(|n| n.item.key == key) {
351 ordered.push(rest.remove(at));
352 }
353 }
354 let mut tail: Vec<NavNode> = Vec::new();
355 for node in rest {
356 if node.item.key == "dashboard" {
357 ordered.insert(0, node);
358 } else if node.item.key == "settings" {
359 tail.push(node);
360 } else {
361 ordered.push(node);
362 }
363 }
364 ordered.extend(tail);
365 *nodes = ordered;
366}
367
368pub(crate) fn tab_path(node: &NavNode) -> Option<&str> {
370 node.item
371 .path
372 .as_deref()
373 .or_else(|| node.children.first().and_then(|c| c.item.path.as_deref()))
374}
375
376fn within(path: &str, target: &str, admin_path: &str) -> bool {
378 path == target || (target != admin_path && path.starts_with(&format!("{target}/")))
379}
380
381pub(crate) fn active_top<'a>(
384 visible: &'a [NavNode],
385 admin_path: &str,
386 path: &str,
387) -> Option<&'a NavNode> {
388 let mut best: Option<(&NavNode, usize)> = None;
389 for node in visible {
390 let candidates = node
391 .item
392 .path
393 .iter()
394 .chain(node.children.iter().filter_map(|c| c.item.path.as_ref()));
395 for target in candidates {
396 if within(path, target, admin_path) && best.is_none_or(|(_, len)| target.len() > len) {
397 best = Some((node, target.len()));
398 }
399 }
400 }
401 best.map(|(node, _)| node)
402}
403
404pub(crate) fn section_sidebar(
407 node: &NavNode,
408 admin_path: &str,
409 path: &str,
410 tr: &laterite_core::Translator,
411 icons: crate::icons::Icons<'_>,
412) -> Vec<CategoryView> {
413 if node.children.is_empty() {
414 return Vec::new();
415 }
416 let active = node
417 .children
418 .iter()
419 .filter(|c| {
420 c.item
421 .path
422 .as_deref()
423 .is_some_and(|p| within(path, p, admin_path))
424 })
425 .max_by_key(|c| c.item.path.as_deref().map(str::len).unwrap_or(0))
426 .map(|c| c.item.key.clone());
427 vec![CategoryView::new(
428 tr.t(&node.item.label),
429 node.children
430 .iter()
431 .map(|c| ItemView {
432 label: tr.t(&c.item.label),
433 description: String::new(),
434 path: c.item.path.clone().unwrap_or_default(),
435 icon: icons.render(c.item.icon.as_deref()),
436 active: active.as_deref() == Some(c.item.key.as_str()),
437 })
438 .collect(),
439 )]
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 fn items() -> Vec<NavItem> {
447 vec![
448 NavItem::new("dashboard", "Dashboard", "").order(i32::MIN),
449 NavItem::section("content", "Content").order(10),
450 NavItem::new("acme.posts", "Posts", "/posts")
451 .under("content")
452 .permission("acme.posts")
453 .order(2),
454 NavItem::new("acme.pages", "Pages", "/pages")
455 .under("content")
456 .permission("acme.pages")
457 .order(1),
458 NavItem::new("acme.shop", "Shop", "/shop")
459 .permission("acme.shop")
460 .order(20),
461 NavItem::new("settings", "Settings", "/settings").order(i32::MAX),
462 ]
463 }
464
465 #[test]
466 fn a_tree_sorts_and_prefixes() {
467 let tree = NavTree::build(items(), "/admin").unwrap();
468 let keys: Vec<&str> = tree.top.iter().map(|n| n.item.key.as_str()).collect();
469 assert_eq!(keys, ["dashboard", "content", "acme.shop", "settings"]);
470 let content = &tree.top[1];
471 let entries: Vec<&str> = content
472 .children
473 .iter()
474 .map(|n| n.item.key.as_str())
475 .collect();
476 assert_eq!(
477 entries,
478 ["acme.pages", "acme.posts"],
479 "by order, then label"
480 );
481 assert_eq!(
482 content.children[0].item.path.as_deref(),
483 Some("/admin/pages")
484 );
485 assert_eq!(tab_path(content), Some("/admin/pages"));
486 }
487
488 #[test]
489 fn an_anchor_holds_whatever_the_orders_say() {
490 let mut items = items();
491 items.push(
492 NavItem::new("acme.help", "Help", "/help")
493 .order(0)
494 .after("acme.shop"),
495 );
496 items.push(
497 NavItem::new("acme.tags", "Tags", "/tags")
498 .under("content")
499 .order(99)
500 .before("acme.pages"),
501 );
502 let tree = NavTree::build(items, "/admin").unwrap();
503 let keys: Vec<&str> = tree.top.iter().map(|n| n.item.key.as_str()).collect();
504 assert_eq!(
505 keys,
506 ["dashboard", "content", "acme.shop", "acme.help", "settings"]
507 );
508 let entries: Vec<&str> = tree.top[1]
509 .children
510 .iter()
511 .map(|n| n.item.key.as_str())
512 .collect();
513 assert_eq!(entries, ["acme.tags", "acme.pages", "acme.posts"]);
514
515 let mut lost = self::items();
516 lost.push(NavItem::new("acme.help", "Help", "/help").after("acme.pages"));
517 assert!(
518 NavTree::build(lost, "/admin").is_err(),
519 "an anchor must be a sibling"
520 );
521 }
522
523 #[test]
524 fn an_operators_order_moves_tabs_but_never_the_ends() {
525 let tree = NavTree::build(items(), "/admin").unwrap();
526 let mut visible = tree.visible(&|_| true);
527 apply_order(&mut visible, "settings,acme.shop,gone,dashboard");
528 let keys: Vec<&str> = visible.iter().map(|n| n.item.key.as_str()).collect();
529 assert_eq!(keys, ["dashboard", "acme.shop", "content", "settings"]);
530 apply_order(&mut visible, "");
531 let keys: Vec<&str> = visible.iter().map(|n| n.item.key.as_str()).collect();
532 assert_eq!(
533 keys,
534 ["dashboard", "acme.shop", "content", "settings"],
535 "blank changes nothing"
536 );
537 }
538
539 #[test]
540 fn a_duplicate_key_and_an_unknown_section_are_refused() {
541 let mut dup = items();
542 dup.push(NavItem::new("acme.shop", "Again", "/again"));
543 assert!(NavTree::build(dup, "/admin").is_err());
544 let mut orphan = items();
545 orphan.push(NavItem::new("acme.lost", "Lost", "/lost").under("nowhere"));
546 assert!(NavTree::build(orphan, "/admin").is_err());
547 }
548
549 #[test]
550 fn an_operator_sees_only_what_they_may_open() {
551 let tree = NavTree::build(items(), "/admin").unwrap();
552 let visible = tree.visible(&|code| code == "acme.posts");
553 let keys: Vec<&str> = visible.iter().map(|n| n.item.key.as_str()).collect();
554 assert_eq!(keys, ["dashboard", "content", "settings"], "no shop");
555 assert_eq!(visible[1].children.len(), 1, "only posts under content");
556 assert_eq!(tab_path(&visible[1]), Some("/admin/posts"));
557 let none = tree.visible(&|_| false);
558 let keys: Vec<&str> = none.iter().map(|n| n.item.key.as_str()).collect();
559 assert_eq!(keys, ["dashboard", "settings"], "an empty section goes");
560 }
561
562 #[test]
563 fn the_active_tab_is_the_longest_match() {
564 let tree = NavTree::build(items(), "/admin").unwrap();
565 let visible = tree.visible(&|_| true);
566 let at = |path: &str| active_top(&visible, "/admin", path).map(|n| n.item.key.clone());
567 assert_eq!(at("/admin/posts/3/edit").as_deref(), Some("content"));
568 assert_eq!(at("/admin/shop").as_deref(), Some("acme.shop"));
569 assert_eq!(at("/admin/settings/users").as_deref(), Some("settings"));
570 assert_eq!(at("/admin").as_deref(), Some("dashboard"));
571 assert_eq!(at("/admin/elsewhere"), None);
572 }
573}