use std::collections::HashMap;
use askama::Template;
use axum::response::{IntoResponse, Redirect, Response};
use serde_json::{Map, Value};
use crate::{render, render_error, AdminState};
#[derive(Debug, Clone, Copy)]
pub enum SettingsWidget {
Text,
Textarea,
Switch,
}
#[derive(Debug, Clone)]
pub struct SettingsField {
pub name: String,
pub label: String,
pub widget: SettingsWidget,
pub help: Option<String>,
}
impl SettingsField {
pub fn text(name: &str, label: &str) -> Self {
Self {
name: name.to_string(),
label: label.to_string(),
widget: SettingsWidget::Text,
help: None,
}
}
pub fn textarea(name: &str, label: &str) -> Self {
Self {
widget: SettingsWidget::Textarea,
..Self::text(name, label)
}
}
pub fn switch(name: &str, label: &str) -> Self {
Self {
widget: SettingsWidget::Switch,
..Self::text(name, label)
}
}
pub fn help(mut self, text: &str) -> Self {
self.help = Some(text.to_string());
self
}
}
#[derive(Debug, Clone)]
pub struct SettingsItem {
pub code: String,
pub label: String,
pub description: String,
pub category: String,
pub order: i32,
pub icon: Option<String>,
pub permission: Option<String>,
pub link: Option<String>,
pub fields: Vec<SettingsField>,
}
impl SettingsItem {
pub fn path(&self) -> String {
self.link
.clone()
.unwrap_or_else(|| format!("/admin/settings/{}", self.code))
}
}
pub(crate) fn index(shell: crate::Shell) -> Response {
render(SettingsIndexTemplate { shell })
}
pub(crate) async fn edit_form(
state: &AdminState,
item: &SettingsItem,
shell: crate::Shell,
) -> Response {
let stored = match laterite_settings::get(&state.db, &item.code).await {
Ok(value) => value.unwrap_or_else(|| Value::Object(Map::new())),
Err(_) => return render_error(),
};
render(build(item, None, &stored, &shell))
}
pub(crate) async fn update(
state: &AdminState,
item: &SettingsItem,
data: HashMap<String, String>,
shell: crate::Shell,
) -> Response {
let value = collect(item, &data);
match laterite_settings::set(&state.db, &item.code, &value).await {
Ok(()) => Redirect::to("/admin/settings").into_response(),
Err(_) => render(build(
item,
Some("Could not save. Please try again.".to_string()),
&value,
&shell,
)),
}
}
fn collect(item: &SettingsItem, data: &HashMap<String, String>) -> Value {
let mut object = Map::new();
for field in &item.fields {
let value = match field.widget {
SettingsWidget::Text | SettingsWidget::Textarea => {
Value::String(data.get(&field.name).cloned().unwrap_or_default())
}
SettingsWidget::Switch => Value::Bool(is_checked(data.get(&field.name))),
};
object.insert(field.name.clone(), value);
}
Value::Object(object)
}
fn is_checked(raw: Option<&String>) -> bool {
matches!(raw.map(String::as_str), Some("on" | "true" | "1"))
}
pub(crate) fn sidebar_groups(
items: &[SettingsItem],
active_code: Option<&str>,
) -> Vec<CategoryView> {
let mut by_category: HashMap<&str, Vec<&SettingsItem>> = HashMap::new();
for item in items {
by_category.entry(&item.category).or_default().push(item);
}
let mut groups: Vec<CategoryView> = by_category
.into_iter()
.map(|(category, mut items)| {
items.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.label.cmp(&b.label)));
CategoryView {
min_order: items.iter().map(|i| i.order).min().unwrap_or(0),
name: category.to_string(),
items: items
.iter()
.map(|i| ItemView {
label: i.label.clone(),
description: i.description.clone(),
path: i.path(),
icon: crate::icons::svg(i.icon.as_deref()),
active: active_code == Some(i.code.as_str()),
})
.collect(),
}
})
.collect();
groups.sort_by(|a, b| {
a.min_order
.cmp(&b.min_order)
.then_with(|| a.name.cmp(&b.name))
});
groups
}
fn build(
item: &SettingsItem,
error: Option<String>,
stored: &Value,
shell: &crate::Shell,
) -> SettingsFormTemplate {
let fields = item
.fields
.iter()
.map(|f| {
let current = stored.get(&f.name);
FieldView {
name: f.name.clone(),
label: f.label.clone(),
help: f.help.clone(),
textarea: matches!(f.widget, SettingsWidget::Textarea),
switch: matches!(f.widget, SettingsWidget::Switch),
checked: current.and_then(Value::as_bool).unwrap_or(false),
value: match current {
Some(Value::String(s)) => s.clone(),
Some(Value::Null) | None => String::new(),
Some(other) => other.to_string(),
},
}
})
.collect();
SettingsFormTemplate {
shell: shell.clone(),
title: item.label.clone(),
description: item.description.clone(),
action: item.path(),
error,
fields,
}
}
#[derive(Clone)]
pub(crate) struct CategoryView {
pub(crate) name: String,
min_order: i32,
pub(crate) items: Vec<ItemView>,
}
#[derive(Clone)]
pub(crate) struct ItemView {
pub(crate) label: String,
pub(crate) description: String,
pub(crate) path: String,
pub(crate) icon: &'static str,
pub(crate) active: bool,
}
#[derive(Template)]
#[template(path = "settings_index.html")]
struct SettingsIndexTemplate {
shell: crate::Shell,
}
struct FieldView {
name: String,
label: String,
help: Option<String>,
value: String,
textarea: bool,
switch: bool,
checked: bool,
}
#[derive(Template)]
#[template(path = "settings_form.html")]
struct SettingsFormTemplate {
shell: crate::Shell,
title: String,
description: String,
action: String,
error: Option<String>,
fields: Vec<FieldView>,
}
#[cfg(test)]
mod tests {
use super::*;
use laterite_core::Db;
fn item() -> SettingsItem {
SettingsItem {
code: "test.log".to_string(),
label: "Log Settings".to_string(),
description: "What the log records.".to_string(),
category: "Logs".to_string(),
order: 10,
icon: None,
permission: None,
link: None,
fields: vec![
SettingsField::switch("log_events", "Log events"),
SettingsField::switch("log_requests", "Log requests"),
SettingsField::text("retention_days", "Retention (days)"),
],
}
}
fn state(db: Db) -> AdminState {
AdminState::new(
laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
db,
)
}
async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
laterite_core::testing::connect_test(&[laterite_settings::migrations()]).await
}
fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn group_orders_categories_then_items() {
let items = vec![
SettingsItem {
code: "b".into(),
label: "Beta".into(),
description: String::new(),
category: "System".into(),
order: 20,
icon: None,
permission: None,
link: None,
fields: vec![],
},
SettingsItem {
code: "a".into(),
label: "Alpha".into(),
description: String::new(),
category: "System".into(),
order: 10,
icon: None,
permission: None,
link: None,
fields: vec![],
},
SettingsItem {
code: "l".into(),
label: "Logs".into(),
description: String::new(),
category: "Logs".into(),
order: 5,
icon: None,
permission: None,
link: None,
fields: vec![],
},
];
let groups = sidebar_groups(&items, None);
assert_eq!(groups[0].name, "Logs");
assert_eq!(groups[1].name, "System");
assert_eq!(groups[1].items[0].label, "Alpha");
assert_eq!(groups[1].items[1].label, "Beta");
assert!(groups.iter().flat_map(|g| &g.items).all(|i| !i.active));
}
#[test]
fn group_marks_only_the_active_item() {
let items = vec![item()];
let groups = sidebar_groups(&items, Some("test.log"));
let active: Vec<&str> = groups
.iter()
.flat_map(|g| &g.items)
.filter(|i| i.active)
.map(|i| i.label.as_str())
.collect();
assert_eq!(active, ["Log Settings"]);
}
#[test]
fn settings_model_item_path_is_its_form() {
assert_eq!(item().path(), "/admin/settings/test.log");
}
#[test]
fn link_item_path_follows_the_link() {
let admins = crate::builtin_settings()
.into_iter()
.find(|i| i.code == "backend.administrators")
.unwrap();
assert_eq!(admins.category, "Users");
assert!(admins.link.is_some());
assert!(admins.fields.is_empty());
assert_eq!(admins.path(), "/admin/users");
assert!(crate::builtin_settings()
.iter()
.any(|i| i.code == "backend.roles"));
}
#[tokio::test]
async fn update_persists_typed_values() {
let (db, _guard) = test_db().await;
let st = state(db.clone());
let it = item();
let resp = update(
&st,
&it,
data(&[("log_events", "on"), ("retention_days", "30")]),
crate::Shell::test(),
)
.await;
assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
let stored = laterite_settings::get(&db, "test.log")
.await
.unwrap()
.unwrap();
assert_eq!(stored["log_events"], serde_json::json!(true));
assert_eq!(stored["log_requests"], serde_json::json!(false));
assert_eq!(stored["retention_days"], serde_json::json!("30"));
}
#[test]
fn index_renders() {
let resp = index(crate::Shell::test());
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn edit_form_renders_for_unset_item() {
let (db, _guard) = test_db().await;
let st = state(db);
let it = item();
let resp = edit_form(&st, &it, crate::Shell::test()).await;
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
}