//! Settings → Menus: the navigation, edited rather than written.
//!
//! The sidebar in `partials/nav.rl.html` is still the fallback. This page
//! edits rows in `menu_items`, and the layout draws them when there are any —
//! so an application that never opens this screen behaves exactly as before.
use rustlavel::prelude::*;
use crate::models::menu_item::{self, MenuItem};
use crate::support::{page, stats};
use super::users_controller::with_current_user;
/// The menus that exist. A fixed list rather than a table: a location is
/// somewhere the layout draws, and inventing one at runtime would name a place
/// no template renders.
pub const LOCATIONS: &[(&str, &str)] =
&[("sidebar", "Sidebar Menu"), ("portal", "Portal Menu"), ("topbar", "Topbar Menu")];
/// The icons a menu item may use, by name.
///
/// Names rather than markup, and a closed list rather than a free field: a
/// column holding `<svg>` is a column holding whatever somebody pasted, and
/// this application renders it into a page with no `unsafe-inline`.
pub const ICONS: &[(&str, &str)] = &[
("home", r#"<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M10 2.5 2 8.6V17h5v-4.5h6V17h5V8.6L10 2.5Z"/></svg>"#),
("user", r#"<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M10 9a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm-7 8.5a7 7 0 0 1 14 0H3Z"/></svg>"#),
("cog", r#"<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.34 2.5a1 1 0 0 1 .98-.8h1.36a1 1 0 0 1 .98.8l.2 1a6.5 6.5 0 0 1 1.3.75l.96-.34a1 1 0 0 1 1.2.45l.68 1.18a1 1 0 0 1-.22 1.25l-.76.66a6.6 6.6 0 0 1 0 1.5l.76.66a1 1 0 0 1 .22 1.25l-.68 1.18a1 1 0 0 1-1.2.45l-.96-.34c-.4.31-.84.56-1.3.75l-.2 1a1 1 0 0 1-.98.8H9.32a1 1 0 0 1-.98-.8l-.2-1a6.5 6.5 0 0 1-1.3-.75l-.96.34a1 1 0 0 1-1.2-.45l-.68-1.18a1 1 0 0 1 .22-1.25l.76-.66a6.6 6.6 0 0 1 0-1.5l-.76-.66a1 1 0 0 1-.22-1.25l.68-1.18a1 1 0 0 1 1.2-.45l.96.34c.4-.31.84-.56 1.3-.75l.2-1ZM10 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" clip-rule="evenodd"/></svg>"#),
("document", r#"<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M4.5 3.5A1.5 1.5 0 0 1 6 2h4.6L15.5 6.9V16.5A1.5 1.5 0 0 1 14 18H6a1.5 1.5 0 0 1-1.5-1.5v-13ZM10.5 3.6V7h3.4l-3.4-3.4Z" clip-rule="evenodd"/></svg>"#),
("menu", r#"<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M3 5.25A.75.75 0 0 1 3.75 4.5h12.5a.75.75 0 0 1 0 1.5H3.75A.75.75 0 0 1 3 5.25Zm0 4.75a.75.75 0 0 1 .75-.75h12.5a.75.75 0 0 1 0 1.5H3.75A.75.75 0 0 1 3 10Zm0 4.75a.75.75 0 0 1 .75-.75h12.5a.75.75 0 0 1 0 1.5H3.75a.75.75 0 0 1-.75-.75Z"/></svg>"#),
("chart-bar", r#"<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M3 16.5V9h3v7.5H3Zm5.5 0V3.5h3v13h-3Zm5.5 0V12h3v4.5h-3Z"/></svg>"#),
("folder", r#"<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M2 5.5A1.5 1.5 0 0 1 3.5 4h3.38a1.5 1.5 0 0 1 1.06.44l1.12 1.12H16.5A1.5 1.5 0 0 1 18 7v7.5a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 2 14.5v-9Z"/></svg>"#),
("shield", r#"<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10 1.5 3.5 4v5.2c0 3.9 2.6 7.5 6.5 9.3 3.9-1.8 6.5-5.4 6.5-9.3V4L10 1.5Z" clip-rule="evenodd"/></svg>"#),
("lock", r#"<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10 1.5A3.5 3.5 0 0 0 6.5 5v2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-.5V5A3.5 3.5 0 0 0 10 1.5ZM12 7V5a2 2 0 1 0-4 0v2h4Z" clip-rule="evenodd"/></svg>"#),
("bell", r#"<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M10 2a5 5 0 0 0-5 5v3l-1.5 3h13L15 10V7a5 5 0 0 0-5-5Zm-2 13a2 2 0 1 0 4 0H8Z"/></svg>"#),
];
/// The markup for an icon name, or nothing.
pub fn icon(name: Option<&str>) -> &'static str {
let Some(name) = name else { return "" };
ICONS.iter().find(|(key, _)| *key == name).map(|(_, svg)| *svg).unwrap_or("")
}
pub struct MenuController;
impl MenuController {
/// `GET /admin/menus` and `GET /admin/menus/{location}`
pub async fn index(req: Request) -> Result<Response> {
let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
let location = location_of(&req);
let search = req.query("q").unwrap_or_default().trim().to_lowercase();
let items = MenuItem::get(&db, MenuItem::in_location(&location)).await?;
let total = items.len() as i64;
let active = items.iter().filter(|item| item.is_active).count() as i64;
// Items that *have* children, not items that have no parent. Counting
// the top level and calling it "parents" makes a flat menu report that
// every item is a parent, which is the opposite of what the number is
// for.
let parents = items
.iter()
.filter(|item| items.iter().any(|child| child.parent_id == Some(item.id)))
.count() as i64;
let nodes = menu_item::tree(items);
let depth = menu_item::depth_of(&nodes) as i64;
let rows: Vec<Json> = menu_item::flatten(&nodes)
.iter()
.filter(|node| {
search.is_empty()
|| node.item.label.to_lowercase().contains(&search)
|| node.item.route.as_deref().unwrap_or_default().to_lowercase().contains(&search)
})
.map(|node| {
Json::object([
("id", Json::from(node.item.id)),
("label", Json::from(node.item.label.as_str())),
("route", Json::from(node.item.route.clone().unwrap_or_default())),
("has_route", Json::from(node.item.route.is_some())),
("href", Json::from(node.item.href())),
("icon", Json::from(icon(node.item.icon.as_deref()))),
("has_icon", Json::from(!icon(node.item.icon.as_deref()).is_empty())),
("is_active", Json::from(node.item.is_active)),
("is_parent", Json::from(!node.children.is_empty())),
("depth", Json::from(node.depth as i64)),
// The indent is a class rather than an inline width: this
// page is served under a policy with no `unsafe-inline`,
// and a `style="padding-left:..."` would be dropped in
// silence.
("indent", Json::from(indent(node.depth))),
])
})
.collect();
let cards = Json::Array(vec![
stats::card("Total Items", total, stats::BRAND, stats::ICON_LIST),
stats::card("Active Items", active, stats::GOOD, stats::ICON_CHECK),
stats::card("Parent Items", parents, stats::PEOPLE, stats::ICON_FOLDER),
stats::card("Max Depth", depth, stats::TIMED, stats::ICON_LAYERS),
]);
let mut context = page::shell(&req, "menus").await;
context = with_current_user(context, &req, &db).await?;
context = context
.with("items", Json::Array(rows))
.with("stats", cards)
.with("location", Json::from(location.as_str()))
.with("locations", Json::Array(location_options(&location)))
.with("icons", Json::Array(icon_options()))
.with("q", Json::from(req.query("q").unwrap_or_default().to_string()))
.with("empty", Json::from(total == 0));
req.view("admin/menus/index", &context)
}
/// `GET /admin/menus/{location}/create`
pub async fn create(req: Request) -> Result<Response> {
let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
let location = location_of(&req);
let items = MenuItem::get(&db, MenuItem::in_location(&location)).await?;
let mut context = page::shell(&req, "menus").await;
context = with_current_user(context, &req, &db).await?;
context = Self::form_context(context, &location, &items, None);
req.view("admin/menus/form", &context)
}
/// `GET /admin/menus/item/{id}/edit`
pub async fn edit(req: Request) -> Result<Response> {
let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
let id = req.param("id").and_then(|id| id.parse::<i64>().ok()).unwrap_or_default();
let Some(item) = MenuItem::find(&db, id).await? else { return Ok(Response::not_found()) };
let items = MenuItem::get(&db, MenuItem::in_location(&item.location)).await?;
let mut context = page::shell(&req, "menus").await;
context = with_current_user(context, &req, &db).await?;
context = Self::form_context(context, &item.location.clone(), &items, Some(&item));
req.view("admin/menus/form", &context)
}
/// `POST /admin/menus/{location}` — create.
pub async fn store(mut req: Request) -> Result<Response> {
let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
let location = location_of(&req);
let mut item = MenuItem { location: location.clone(), ..Default::default() };
if let Some(response) = Self::fill(&mut req, &db, &mut item, None).await? {
return Ok(response);
}
// Appended rather than inserted: a new item at the top would move
// everything a person had already arranged.
item.sort_order = MenuItem::in_location(&location).count(&db).await? + 1;
item.insert(&db).await?;
if let Some(audit) = crate::support::audit::of(&req, "menus.created") {
audit
.on("MenuItem", item.id)
.describe(format!("Added \"{}\" to the {location} menu", item.label))
.record()
.await;
}
page::flash(&req, "success", format!("\"{}\" has been added.", item.label));
Ok(Response::see_other(format!("/admin/menus/{location}")))
}
/// `POST /admin/menus/item/{id}` — update.
pub async fn update(mut req: Request) -> Result<Response> {
let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
let id = req.param("id").and_then(|id| id.parse::<i64>().ok()).unwrap_or_default();
let Some(mut item) = MenuItem::find(&db, id).await? else { return Ok(Response::not_found()) };
let before = item.label.clone();
if let Some(response) = Self::fill(&mut req, &db, &mut item, Some(id)).await? {
return Ok(response);
}
item.update(&db).await?;
if let Some(audit) = crate::support::audit::of(&req, "menus.updated") {
audit
.on("MenuItem", id)
.describe(format!("Edited the menu item \"{before}\""))
.changed(Json::from(before.as_str()), Json::from(item.label.as_str()))
.record()
.await;
}
page::flash(&req, "success", format!("\"{}\" has been saved.", item.label));
Ok(Response::see_other(format!("/admin/menus/{}", item.location)))
}
/// `POST /admin/menus/item/{id}/toggle` — the switch on each row.
pub async fn toggle(req: Request) -> Result<Response> {
let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
let id = req.param("id").and_then(|id| id.parse::<i64>().ok()).unwrap_or_default();
let Some(mut item) = MenuItem::find(&db, id).await? else { return Ok(Response::not_found()) };
item.is_active = !item.is_active;
item.update(&db).await?;
if let Some(audit) = crate::support::audit::of(&req, "menus.updated") {
let state = if item.is_active { "Enabled" } else { "Disabled" };
audit.on("MenuItem", id).describe(format!("{state} the menu item \"{}\"", item.label)).record().await;
}
Ok(Response::see_other(format!("/admin/menus/{}", item.location)))
}
/// `POST /admin/menus/item/{id}/delete`
///
/// The children are promoted to this item's parent rather than deleted
/// with it. Removing one entry from a menu should not silently take a
/// whole branch, and a person who wanted the branch gone can say so.
pub async fn destroy(req: Request) -> Result<Response> {
let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
let id = req.param("id").and_then(|id| id.parse::<i64>().ok()).unwrap_or_default();
let Some(item) = MenuItem::find(&db, id).await? else { return Ok(Response::not_found()) };
let promoted = db
.table("menu_items")
.filter("parent_id", id)
.update(&db, &[("parent_id", item.parent_id.map_or(rustlavel::db::Value::Null, rustlavel::db::Value::from))])
.await?;
MenuItem::query().filter("id", id).delete(&db).await?;
if let Some(audit) = crate::support::audit::of(&req, "menus.deleted") {
audit
.on("MenuItem", id)
.describe(format!("Removed \"{}\" from the {} menu", item.label, item.location))
.with("promoted_children", Json::from(promoted as i64))
.record()
.await;
}
page::flash(&req, "success", format!("\"{}\" has been removed.", item.label));
Ok(Response::see_other(format!("/admin/menus/{}", item.location)))
}
/// `POST /admin/menus/{location}/clear-cache`
///
/// The menu is read fresh on every request, so there is no menu cache to
/// clear — what this drops is the *settings* cache, which is the one that
/// holds a value across processes and the one people mean when a change
/// they made is not showing.
pub async fn clear_cache(req: Request) -> Result<Response> {
let location = location_of(&req);
if let Some(settings) = req.state::<crate::support::settings::Settings>() {
settings.forget();
}
if let Some(audit) = crate::support::audit::of(&req, "cache.cleared") {
audit.describe("Cleared the settings cache").record().await;
}
page::flash(&req, "success", "The cache has been cleared.");
Ok(Response::see_other(format!("/admin/menus/{location}")))
}
/// `POST /admin/menus/{location}/reorder` — the drag-and-drop result.
///
/// The whole order arrives at once, as `order=3,7,1,...`, because a drag
/// that moves one row changes the position of every row after it, and
/// sending one number back would be sending a position that is already
/// stale.
pub async fn reorder(mut req: Request) -> Result<Response> {
let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
let location = location_of(&req);
let order = req.input("order").unwrap_or_default();
// Scoped to this location: an id from another menu, submitted by hand,
// must not be reordered into it.
let mine: std::collections::BTreeSet<i64> =
MenuItem::get(&db, MenuItem::in_location(&location)).await?.iter().map(|i| i.id).collect();
let mut position = 0i64;
for id in order.split(',').filter_map(|id| id.trim().parse::<i64>().ok()) {
if !mine.contains(&id) {
continue;
}
position += 1;
db.table("menu_items")
.filter("id", id)
.update(&db, &[("sort_order", position.into())])
.await?;
}
if let Some(audit) = crate::support::audit::of(&req, "menus.updated") {
audit
.describe(format!("Reordered the {location} menu"))
.with("items", Json::from(position))
.record()
.await;
}
Ok(Response::see_other(format!("/admin/menus/{location}")))
}
/// Read the form into an item. Returns a rendered form when it does not
/// validate, so the caller can hand it straight back.
async fn fill(
req: &mut Request,
db: &Database,
item: &mut MenuItem,
editing: Option<i64>,
) -> Result<Option<Response>> {
let label = req.input("label").unwrap_or_default().trim().to_string();
let route = req.input("route").unwrap_or_default().trim().to_string();
let url = req.input("url").unwrap_or_default().trim().to_string();
let icon_name = req.input("icon").unwrap_or_default();
let permission = req.input("permission").unwrap_or_default().trim().to_string();
let parent = req.input("parent_id").and_then(|p| p.parse::<i64>().ok());
let mut errors = page::check(&[("label", &label)], &[("label", "required|max:80")]);
if route.is_empty() && url.is_empty() {
errors.add("route", "Give the item somewhere to go: a route, or a URL.");
}
// An icon name that is not in the list would render as nothing, which
// looks like a bug rather than like a choice.
if !icon_name.is_empty() && icon(Some(&icon_name)).is_empty() {
errors.add("icon", "That is not one of the icons on this page.");
}
// Its own parent, or its own descendant: either makes a loop that the
// tree builder would then have to survive rather than never see.
if let (Some(parent_id), Some(id)) = (parent, editing) {
if parent_id == id || is_descendant(db, parent_id, id).await? {
errors.add("parent_id", "An item cannot sit inside itself.");
}
}
if !errors.is_empty() {
let items = MenuItem::get(db, MenuItem::in_location(&item.location)).await?;
let mut context = page::errors(page::shell(req, "menus").await, &errors);
context = with_current_user(context, req, db).await?;
context = Self::form_context(context, &item.location.clone(), &items, editing.map(|_| &*item));
return Ok(Some(req.view("admin/menus/form", &context)?));
}
item.label = label;
item.route = (!route.is_empty()).then_some(route);
item.url = (!url.is_empty()).then_some(url);
item.icon = (!icon_name.is_empty()).then_some(icon_name);
item.permission = (!permission.is_empty()).then_some(permission);
item.parent_id = parent;
item.is_active = req.input("is_active").is_some();
item.target = req.input("target").filter(|t| t == "_blank");
Ok(None)
}
fn form_context(context: ViewContext, location: &str, items: &[MenuItem], item: Option<&MenuItem>) -> ViewContext {
let current = item.map(|i| i.id);
let parents: Vec<Json> = items
.iter()
.filter(|candidate| Some(candidate.id) != current)
.map(|candidate| {
Json::object([
("id", Json::from(candidate.id)),
("label", Json::from(candidate.label.as_str())),
("selected", Json::from(item.and_then(|i| i.parent_id) == Some(candidate.id))),
])
})
.collect();
let chosen = item.and_then(|i| i.icon.clone()).unwrap_or_default();
let icons: Vec<Json> = ICONS
.iter()
.map(|(name, svg)| {
Json::object([
("name", Json::from(*name)),
("svg", Json::from(*svg)),
("selected", Json::from(chosen == *name)),
])
})
.collect();
context
.with("location", Json::from(location))
.with("editing", Json::from(item.is_some()))
.with("action", Json::from(match item {
Some(existing) => format!("/admin/menus/item/{}", existing.id),
None => format!("/admin/menus/{location}"),
}))
.with("heading", Json::from(match item {
Some(_) => "Edit menu item",
None => "Add menu item",
}))
.with("label", Json::from(item.map(|i| i.label.clone()).unwrap_or_default()))
.with("route", Json::from(item.and_then(|i| i.route.clone()).unwrap_or_default()))
.with("url", Json::from(item.and_then(|i| i.url.clone()).unwrap_or_default()))
.with("permission", Json::from(item.and_then(|i| i.permission.clone()).unwrap_or_default()))
.with("is_active", Json::from(item.is_none_or(|i| i.is_active)))
.with("new_tab", Json::from(item.and_then(|i| i.target.clone()).as_deref() == Some("_blank")))
.with("has_icon", Json::from(!chosen.is_empty()))
.with("parents", Json::Array(parents))
.with("icons", Json::Array(icons))
}
}
/// Whether `candidate` sits under `ancestor`. Walks up from the candidate,
/// which is the shorter way and cannot loop for longer than the tree is deep.
async fn is_descendant(db: &Database, candidate: i64, ancestor: i64) -> Result<bool> {
let mut at = Some(candidate);
for _ in 0..16 {
let Some(id) = at else { return Ok(false) };
if id == ancestor {
return Ok(true);
}
at = MenuItem::find(db, id).await?.and_then(|item| item.parent_id);
}
Ok(true)
}
/// The location from the path, defaulting to the sidebar and refusing one that
/// no template draws.
fn location_of(req: &Request) -> String {
let asked = req.param("location").unwrap_or("sidebar");
LOCATIONS
.iter()
.find(|(key, _)| *key == asked)
.map(|(key, _)| (*key).to_string())
.unwrap_or_else(|| "sidebar".to_string())
}
fn location_options(current: &str) -> Vec<Json> {
LOCATIONS
.iter()
.map(|(key, label)| {
Json::object([
("key", Json::from(*key)),
("label", Json::from(*label)),
("current", Json::from(*key == current)),
])
})
.collect()
}
fn icon_options() -> Vec<Json> {
ICONS
.iter()
.map(|(name, svg)| Json::object([("name", Json::from(*name)), ("svg", Json::from(*svg))]))
.collect()
}
/// The left padding for a row at this depth, as a class name.
fn indent(depth: usize) -> &'static str {
match depth {
0 => "pl-0",
1 => "pl-6",
2 => "pl-12",
3 => "pl-16",
4 => "pl-20",
_ => "pl-24",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_unknown_icon_name_renders_nothing_rather_than_markup() {
assert!(icon(Some("home")).starts_with("<svg"));
assert_eq!(icon(Some("<script>alert(1)</script>")), "");
assert_eq!(icon(Some("nope")), "");
assert_eq!(icon(None), "");
}
#[test]
fn the_indent_stops_growing_rather_than_running_off_the_page() {
assert_eq!(indent(0), "pl-0");
assert_eq!(indent(2), "pl-12");
assert_eq!(indent(99), "pl-24");
}
}