use rustlavel::prelude::*;
#[derive(Model, Default, Debug, Clone)]
#[model(table = "menu_items")]
pub struct MenuItem {
#[model(primary_key, generated)]
pub id: i64,
pub location: String,
pub parent_id: Option<i64>,
pub label: String,
pub route: Option<String>,
pub url: Option<String>,
pub icon: Option<String>,
pub permission: Option<String>,
pub sort_order: i64,
pub is_active: bool,
pub target: Option<String>,
}
impl MenuItem {
pub fn in_location(location: &str) -> QueryBuilder {
MenuItem::query()
.filter("location", location)
.order_by("sort_order", rustlavel::db::Direction::Asc)
.order_by("id", rustlavel::db::Direction::Asc)
}
pub fn href(&self) -> String {
if let Some(url) = self.url.as_deref().filter(|u| !u.is_empty()) {
return url.to_string();
}
self.route.clone().unwrap_or_else(|| "#".into())
}
pub fn is_external(&self) -> bool {
self.url.as_deref().is_some_and(|u| u.starts_with("http://") || u.starts_with("https://"))
}
}
pub struct Node {
pub item: MenuItem,
pub children: Vec<Node>,
pub depth: usize,
}
pub fn tree(items: Vec<MenuItem>) -> Vec<Node> {
let ids: std::collections::BTreeSet<i64> = items.iter().map(|item| item.id).collect();
let mut by_parent: std::collections::BTreeMap<Option<i64>, Vec<MenuItem>> = Default::default();
for item in items {
let parent = item.parent_id.filter(|id| ids.contains(id));
by_parent.entry(parent).or_default().push(item);
}
build(&mut by_parent, None, 0)
}
fn build(
by_parent: &mut std::collections::BTreeMap<Option<i64>, Vec<MenuItem>>,
parent: Option<i64>,
depth: usize,
) -> Vec<Node> {
if depth > 8 {
return Vec::new();
}
let Some(children) = by_parent.remove(&parent) else {
return Vec::new();
};
children
.into_iter()
.map(|item| {
let id = item.id;
Node { item, children: build(by_parent, Some(id), depth + 1), depth }
})
.collect()
}
pub fn flatten(nodes: &[Node]) -> Vec<&Node> {
let mut out = Vec::new();
for node in nodes {
out.push(node);
out.extend(flatten(&node.children));
}
out
}
pub fn depth_of(nodes: &[Node]) -> usize {
nodes.iter().map(|n| 1 + depth_of(&n.children)).max().unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn item(id: i64, parent: Option<i64>, label: &str) -> MenuItem {
MenuItem {
id,
location: "sidebar".into(),
parent_id: parent,
label: label.into(),
is_active: true,
..Default::default()
}
}
#[test]
fn a_flat_list_becomes_a_tree_in_order() {
let nodes = tree(vec![
item(1, None, "Dashboard"),
item(2, None, "Projects"),
item(3, Some(2), "All Projects"),
item(4, Some(3), "Archived"),
]);
assert_eq!(nodes.len(), 2);
assert_eq!(depth_of(&nodes), 3);
let flat: Vec<&str> = flatten(&nodes).iter().map(|n| n.item.label.as_str()).collect();
assert_eq!(flat, vec!["Dashboard", "Projects", "All Projects", "Archived"]);
assert_eq!(flatten(&nodes)[3].depth, 2);
}
#[test]
fn an_orphan_is_promoted_rather_than_dropped() {
let nodes = tree(vec![item(1, None, "Dashboard"), item(9, Some(404), "Stray")]);
let labels: Vec<&str> = nodes.iter().map(|n| n.item.label.as_str()).collect();
assert_eq!(labels, vec!["Dashboard", "Stray"]);
}
#[test]
fn a_cycle_does_not_recurse_forever() {
let nodes = tree(vec![item(1, Some(2), "A"), item(2, Some(1), "B")]);
assert!(nodes.is_empty(), "a cycle has no top level to draw from");
}
#[test]
fn a_url_wins_over_a_route_and_an_external_link_is_recognised() {
let mut menu = item(1, None, "Docs");
menu.route = Some("/dashboard".into());
assert_eq!(menu.href(), "/dashboard");
assert!(!menu.is_external());
menu.url = Some("https://example.com/docs".into());
assert_eq!(menu.href(), "https://example.com/docs");
assert!(menu.is_external());
}
}