Skip to main content

laterite_admin/
nav.rs

1//! The admin's menu: a registry of items that modules, resources and screens
2//! contribute as data, resolved once at boot into a tree and filtered per
3//! request by what the operator may see.
4//!
5//! A top-level item is a tab in the top bar. An item placed under another
6//! makes that one a section: the tab leads to the section's first visible
7//! entry and the entries show in the context sidebar. Every item carries the
8//! permission its target requires, so an operator never sees a tab that would
9//! refuse them.
10
11use laterite_core::Text;
12use serde::{Deserialize, Serialize};
13
14use crate::settings::{CategoryView, ItemView};
15
16/// One menu entry: a tab, or an entry under a section.
17#[derive(Clone, Debug, Serialize, Deserialize)]
18#[serde(deny_unknown_fields, default)]
19#[non_exhaustive]
20pub struct NavItem {
21    /// Unique among every item (`acme.posts`): what another item names in
22    /// `under`.
23    pub key: String,
24    pub label: Text,
25    /// The path under the admin mount (`/posts`). A section may have none: its
26    /// tab then leads to its first visible entry.
27    pub path: Option<String>,
28    /// An icon name from the icon set.
29    pub icon: Option<String>,
30    /// What the operator must hold to see the entry. `None` shows it to every
31    /// signed-in operator; a section with no path shows when any entry does.
32    pub permission: Option<String>,
33    /// Sorts among siblings, lowest first; equal orders sort by label.
34    pub order: i32,
35    /// Sits right after the sibling with this key, whatever the orders say.
36    pub after: Option<String>,
37    /// Sits right before the sibling with this key, whatever the orders say.
38    pub before: Option<String>,
39    /// The key of the item this sits under; `None` is the top bar.
40    pub under: Option<String>,
41}
42
43impl NavItem {
44    /// An entry leading to `path`.
45    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    /// A section: a tab that holds entries and leads to the first one.
60    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    /// Places the entry under the section with this key.
90    pub fn under(mut self, section: impl Into<String>) -> Self {
91        self.under = Some(section.into());
92        self
93    }
94
95    /// Sits right after the sibling with this key.
96    pub fn after(mut self, key: impl Into<String>) -> Self {
97        self.after = Some(key.into());
98        self
99    }
100
101    /// Sits right before the sibling with this key.
102    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/// Where a resource or a screen sits in the menu: under which section, with
115/// which icon, at which position; or out of the menu altogether.
116#[derive(Clone, Debug, Default, Serialize, Deserialize)]
117#[serde(deny_unknown_fields, default)]
118#[non_exhaustive]
119pub struct Placement {
120    /// The key of the section to sit under; `None` is the top bar.
121    pub under: Option<String>,
122    pub icon: Option<String>,
123    pub order: i32,
124    /// Sits right after the sibling with this key (a resource's key is
125    /// `resource:` and its path, a screen's `screen:` and its path).
126    pub after: Option<String>,
127    /// Sits right before the sibling with this key.
128    pub before: Option<String>,
129    /// Mounted and reachable by link, with no menu entry.
130    pub hidden: bool,
131}
132
133impl Placement {
134    pub fn new() -> Self {
135        Self::default()
136    }
137
138    /// Out of the menu: the routes mount, a link elsewhere reaches them.
139    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    /// Sits right after the sibling with this key.
162    pub fn after(mut self, key: impl Into<String>) -> Self {
163        self.after = Some(key.into());
164        self
165    }
166
167    /// Sits right before the sibling with this key.
168    pub fn before(mut self, key: impl Into<String>) -> Self {
169        self.before = Some(key.into());
170        self
171    }
172}
173
174/// `menu: false` hides; `menu: { under, icon, order, after, before }` places.
175pub(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/// One resolved item: its entries beneath it, paths under the admin mount.
193#[derive(Clone, Debug)]
194pub(crate) struct NavNode {
195    pub item: NavItem,
196    pub children: Vec<NavNode>,
197}
198
199/// The menu as a tree, built once at boot.
200#[derive(Debug, Default)]
201pub(crate) struct NavTree {
202    pub top: Vec<NavNode>,
203}
204
205impl NavTree {
206    /// Resolves `items` into a tree under `admin_path`. Refuses a duplicate
207    /// key, an entry under a key nobody declared, and an entry under an entry
208    /// (one level of sections).
209    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    /// The tree as one operator sees it: entries they may not open are gone,
264    /// and a section with nothing left beneath it and no page of its own goes
265    /// too.
266    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
293/// Moves each anchored sibling next to its anchor, in order of appearance,
294/// so `after: acme.posts` holds whatever the orders say. An anchor that is
295/// no sibling is refused.
296fn 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
327/// The preference key holding an operator's own order for the top bar.
328pub(crate) const ORDER_PREFERENCE: &str = "menu.order";
329
330/// Puts the top-level nodes in the operator's own order: the keys in `order`
331/// (comma-separated) first, as listed, then the rest as they were. Dashboard
332/// stays first and Settings last whatever the order says, and a key that is
333/// no tab is ignored, so a stale preference never hides anything.
334pub(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
368/// The path a top-level node's tab leads to: its own, or its first entry's.
369pub(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
376/// Whether `path` is on `target` or beneath it.
377fn within(path: &str, target: &str, admin_path: &str) -> bool {
378    path == target || (target != admin_path && path.starts_with(&format!("{target}/")))
379}
380
381/// The key of the top-level node `path` falls under, by the longest match
382/// over its own path and its entries' paths.
383pub(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
404/// The context sidebar for a section: its entries as one group, the one
405/// `path` falls under marked active.
406pub(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}