use laterite_core::Text;
use serde::{Deserialize, Serialize};
use crate::settings::{CategoryView, ItemView};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
#[non_exhaustive]
pub struct NavItem {
pub key: String,
pub label: Text,
pub path: Option<String>,
pub icon: Option<String>,
pub permission: Option<String>,
pub order: i32,
pub after: Option<String>,
pub before: Option<String>,
pub under: Option<String>,
}
impl NavItem {
pub fn new(key: impl Into<String>, label: impl Into<Text>, path: impl Into<String>) -> Self {
Self {
key: key.into(),
label: label.into(),
path: Some(path.into()),
icon: None,
permission: None,
order: 0,
after: None,
before: None,
under: None,
}
}
pub fn section(key: impl Into<String>, label: impl Into<Text>) -> Self {
Self {
key: key.into(),
label: label.into(),
path: None,
icon: None,
permission: None,
order: 0,
after: None,
before: None,
under: None,
}
}
pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn permission(mut self, permission: impl Into<String>) -> Self {
self.permission = Some(permission.into());
self
}
pub fn order(mut self, order: i32) -> Self {
self.order = order;
self
}
pub fn under(mut self, section: impl Into<String>) -> Self {
self.under = Some(section.into());
self
}
pub fn after(mut self, key: impl Into<String>) -> Self {
self.after = Some(key.into());
self
}
pub fn before(mut self, key: impl Into<String>) -> Self {
self.before = Some(key.into());
self
}
}
impl Default for NavItem {
fn default() -> Self {
Self::section("", "")
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
#[non_exhaustive]
pub struct Placement {
pub under: Option<String>,
pub icon: Option<String>,
pub order: i32,
pub after: Option<String>,
pub before: Option<String>,
pub hidden: bool,
}
impl Placement {
pub fn new() -> Self {
Self::default()
}
pub fn hidden() -> Self {
Self {
hidden: true,
..Self::default()
}
}
pub fn under(mut self, section: impl Into<String>) -> Self {
self.under = Some(section.into());
self
}
pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn order(mut self, order: i32) -> Self {
self.order = order;
self
}
pub fn after(mut self, key: impl Into<String>) -> Self {
self.after = Some(key.into());
self
}
pub fn before(mut self, key: impl Into<String>) -> Self {
self.before = Some(key.into());
self
}
}
pub(crate) fn deserialize_placement<'de, D>(deserializer: D) -> Result<Placement, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Key {
Flag(bool),
Config(Placement),
}
Ok(match Option::<Key>::deserialize(deserializer)? {
Some(Key::Flag(false)) => Placement::hidden(),
Some(Key::Flag(true)) | None => Placement::default(),
Some(Key::Config(placement)) => placement,
})
}
#[derive(Clone, Debug)]
pub(crate) struct NavNode {
pub item: NavItem,
pub children: Vec<NavNode>,
}
#[derive(Debug, Default)]
pub(crate) struct NavTree {
pub top: Vec<NavNode>,
}
impl NavTree {
pub(crate) fn build(items: Vec<NavItem>, admin_path: &str) -> Result<Self, String> {
let mut seen = std::collections::HashSet::new();
for item in &items {
if item.key.is_empty() {
return Err(format!("menu item `{}` has no key", item.label.source()));
}
if !seen.insert(item.key.clone()) {
return Err(format!("duplicate menu key `{}`", item.key));
}
}
let prefixed = |item: &NavItem| {
let mut item = item.clone();
item.path = item.path.map(|p| format!("{admin_path}{p}"));
item
};
let mut top: Vec<NavNode> = items
.iter()
.filter(|i| i.under.is_none())
.map(|i| NavNode {
item: prefixed(i),
children: Vec::new(),
})
.collect();
for item in items.iter().filter(|i| i.under.is_some()) {
let under = item.under.as_deref().unwrap_or_default();
let parent = top
.iter_mut()
.find(|n| n.item.key == under)
.ok_or_else(|| {
format!(
"menu item `{}` sits under `{under}`, which is no top-level item",
item.key
)
})?;
parent.children.push(NavNode {
item: prefixed(item),
children: Vec::new(),
});
}
let by_order = |a: &NavNode, b: &NavNode| {
a.item
.order
.cmp(&b.item.order)
.then_with(|| a.item.label.source().cmp(b.item.label.source()))
};
top.sort_by(by_order);
anchor(&mut top)?;
for node in &mut top {
node.children.sort_by(by_order);
anchor(&mut node.children)?;
}
Ok(Self { top })
}
pub(crate) fn visible(&self, allows: &dyn Fn(&str) -> bool) -> Vec<NavNode> {
let allowed = |item: &NavItem| item.permission.as_deref().is_none_or(allows);
self.top
.iter()
.filter_map(|node| {
let children: Vec<NavNode> = node
.children
.iter()
.filter(|c| allowed(&c.item))
.cloned()
.collect();
let own = node.item.path.is_some() && allowed(&node.item);
if !own && children.is_empty() {
return None;
}
if node.item.path.is_some() && !allowed(&node.item) {
return None;
}
Some(NavNode {
item: node.item.clone(),
children,
})
})
.collect()
}
}
fn anchor(nodes: &mut Vec<NavNode>) -> Result<(), String> {
let keys: Vec<String> = nodes.iter().map(|n| n.item.key.clone()).collect();
for key in keys {
let at = nodes.iter().position(|n| n.item.key == key).unwrap_or(0);
let (anchor_key, after) = match (&nodes[at].item.after, &nodes[at].item.before) {
(Some(a), _) => (a.clone(), true),
(None, Some(b)) => (b.clone(), false),
(None, None) => continue,
};
let target = nodes
.iter()
.position(|n| n.item.key == anchor_key)
.ok_or_else(|| {
format!(
"menu item `{key}` is placed {} `{anchor_key}`, which is no sibling of it",
if after { "after" } else { "before" }
)
})?;
if target == at {
return Err(format!("menu item `{key}` is placed next to itself"));
}
let node = nodes.remove(at);
let target = nodes
.iter()
.position(|n| n.item.key == anchor_key)
.unwrap_or(0);
nodes.insert(if after { target + 1 } else { target }, node);
}
Ok(())
}
pub(crate) const ORDER_PREFERENCE: &str = "menu.order";
pub(crate) fn apply_order(nodes: &mut Vec<NavNode>, order: &str) {
let wanted: Vec<&str> = order
.split(',')
.map(str::trim)
.filter(|k| !k.is_empty())
.collect();
if wanted.is_empty() {
return;
}
let fixed = |key: &str| key == "dashboard" || key == "settings";
let mut rest: Vec<NavNode> = std::mem::take(nodes);
let mut ordered: Vec<NavNode> = Vec::with_capacity(rest.len());
for key in wanted {
if fixed(key) {
continue;
}
if let Some(at) = rest.iter().position(|n| n.item.key == key) {
ordered.push(rest.remove(at));
}
}
let mut tail: Vec<NavNode> = Vec::new();
for node in rest {
if node.item.key == "dashboard" {
ordered.insert(0, node);
} else if node.item.key == "settings" {
tail.push(node);
} else {
ordered.push(node);
}
}
ordered.extend(tail);
*nodes = ordered;
}
pub(crate) fn tab_path(node: &NavNode) -> Option<&str> {
node.item
.path
.as_deref()
.or_else(|| node.children.first().and_then(|c| c.item.path.as_deref()))
}
fn within(path: &str, target: &str, admin_path: &str) -> bool {
path == target || (target != admin_path && path.starts_with(&format!("{target}/")))
}
pub(crate) fn active_top<'a>(
visible: &'a [NavNode],
admin_path: &str,
path: &str,
) -> Option<&'a NavNode> {
let mut best: Option<(&NavNode, usize)> = None;
for node in visible {
let candidates = node
.item
.path
.iter()
.chain(node.children.iter().filter_map(|c| c.item.path.as_ref()));
for target in candidates {
if within(path, target, admin_path) && best.is_none_or(|(_, len)| target.len() > len) {
best = Some((node, target.len()));
}
}
}
best.map(|(node, _)| node)
}
pub(crate) fn section_sidebar(
node: &NavNode,
admin_path: &str,
path: &str,
tr: &laterite_core::Translator,
icons: crate::icons::Icons<'_>,
) -> Vec<CategoryView> {
if node.children.is_empty() {
return Vec::new();
}
let active = node
.children
.iter()
.filter(|c| {
c.item
.path
.as_deref()
.is_some_and(|p| within(path, p, admin_path))
})
.max_by_key(|c| c.item.path.as_deref().map(str::len).unwrap_or(0))
.map(|c| c.item.key.clone());
vec![CategoryView::new(
tr.t(&node.item.label),
node.children
.iter()
.map(|c| ItemView {
label: tr.t(&c.item.label),
description: String::new(),
path: c.item.path.clone().unwrap_or_default(),
icon: icons.render(c.item.icon.as_deref()),
active: active.as_deref() == Some(c.item.key.as_str()),
})
.collect(),
)]
}
#[cfg(test)]
mod tests {
use super::*;
fn items() -> Vec<NavItem> {
vec![
NavItem::new("dashboard", "Dashboard", "").order(i32::MIN),
NavItem::section("content", "Content").order(10),
NavItem::new("acme.posts", "Posts", "/posts")
.under("content")
.permission("acme.posts")
.order(2),
NavItem::new("acme.pages", "Pages", "/pages")
.under("content")
.permission("acme.pages")
.order(1),
NavItem::new("acme.shop", "Shop", "/shop")
.permission("acme.shop")
.order(20),
NavItem::new("settings", "Settings", "/settings").order(i32::MAX),
]
}
#[test]
fn a_tree_sorts_and_prefixes() {
let tree = NavTree::build(items(), "/admin").unwrap();
let keys: Vec<&str> = tree.top.iter().map(|n| n.item.key.as_str()).collect();
assert_eq!(keys, ["dashboard", "content", "acme.shop", "settings"]);
let content = &tree.top[1];
let entries: Vec<&str> = content
.children
.iter()
.map(|n| n.item.key.as_str())
.collect();
assert_eq!(
entries,
["acme.pages", "acme.posts"],
"by order, then label"
);
assert_eq!(
content.children[0].item.path.as_deref(),
Some("/admin/pages")
);
assert_eq!(tab_path(content), Some("/admin/pages"));
}
#[test]
fn an_anchor_holds_whatever_the_orders_say() {
let mut items = items();
items.push(
NavItem::new("acme.help", "Help", "/help")
.order(0)
.after("acme.shop"),
);
items.push(
NavItem::new("acme.tags", "Tags", "/tags")
.under("content")
.order(99)
.before("acme.pages"),
);
let tree = NavTree::build(items, "/admin").unwrap();
let keys: Vec<&str> = tree.top.iter().map(|n| n.item.key.as_str()).collect();
assert_eq!(
keys,
["dashboard", "content", "acme.shop", "acme.help", "settings"]
);
let entries: Vec<&str> = tree.top[1]
.children
.iter()
.map(|n| n.item.key.as_str())
.collect();
assert_eq!(entries, ["acme.tags", "acme.pages", "acme.posts"]);
let mut lost = self::items();
lost.push(NavItem::new("acme.help", "Help", "/help").after("acme.pages"));
assert!(
NavTree::build(lost, "/admin").is_err(),
"an anchor must be a sibling"
);
}
#[test]
fn an_operators_order_moves_tabs_but_never_the_ends() {
let tree = NavTree::build(items(), "/admin").unwrap();
let mut visible = tree.visible(&|_| true);
apply_order(&mut visible, "settings,acme.shop,gone,dashboard");
let keys: Vec<&str> = visible.iter().map(|n| n.item.key.as_str()).collect();
assert_eq!(keys, ["dashboard", "acme.shop", "content", "settings"]);
apply_order(&mut visible, "");
let keys: Vec<&str> = visible.iter().map(|n| n.item.key.as_str()).collect();
assert_eq!(
keys,
["dashboard", "acme.shop", "content", "settings"],
"blank changes nothing"
);
}
#[test]
fn a_duplicate_key_and_an_unknown_section_are_refused() {
let mut dup = items();
dup.push(NavItem::new("acme.shop", "Again", "/again"));
assert!(NavTree::build(dup, "/admin").is_err());
let mut orphan = items();
orphan.push(NavItem::new("acme.lost", "Lost", "/lost").under("nowhere"));
assert!(NavTree::build(orphan, "/admin").is_err());
}
#[test]
fn an_operator_sees_only_what_they_may_open() {
let tree = NavTree::build(items(), "/admin").unwrap();
let visible = tree.visible(&|code| code == "acme.posts");
let keys: Vec<&str> = visible.iter().map(|n| n.item.key.as_str()).collect();
assert_eq!(keys, ["dashboard", "content", "settings"], "no shop");
assert_eq!(visible[1].children.len(), 1, "only posts under content");
assert_eq!(tab_path(&visible[1]), Some("/admin/posts"));
let none = tree.visible(&|_| false);
let keys: Vec<&str> = none.iter().map(|n| n.item.key.as_str()).collect();
assert_eq!(keys, ["dashboard", "settings"], "an empty section goes");
}
#[test]
fn the_active_tab_is_the_longest_match() {
let tree = NavTree::build(items(), "/admin").unwrap();
let visible = tree.visible(&|_| true);
let at = |path: &str| active_top(&visible, "/admin", path).map(|n| n.item.key.clone());
assert_eq!(at("/admin/posts/3/edit").as_deref(), Some("content"));
assert_eq!(at("/admin/shop").as_deref(), Some("acme.shop"));
assert_eq!(at("/admin/settings/users").as_deref(), Some("settings"));
assert_eq!(at("/admin").as_deref(), Some("dashboard"));
assert_eq!(at("/admin/elsewhere"), None);
}
}