Skip to main content

laterite_admin/settings/
mod.rs

1//! Descriptor-driven settings screens, and the settings store behind them.
2//!
3//! A module registers a [`SettingsItem`] for each settings model it wants an
4//! operator to edit: its storage `code` (the [`store::SettingsModel`] `CODE`), a
5//! `category` it groups under, and the fields to render. The framework mounts one
6//! index that lists every registered item grouped by category, and one generic
7//! form per item that reads and writes the model's JSON value through the
8//! [`store`]. No per-model controller is needed, exactly as a single settings
9//! controller serves every settings model.
10//!
11//! Values are stored as one JSON object per code. Field names are JSON keys, not
12//! SQL identifiers, and the value is written through a parameterized upsert, so
13//! nothing here builds SQL from user input.
14
15pub mod brand;
16pub mod migrations;
17pub mod store;
18
19pub use brand::BrandSetting;
20pub use migrations::{migrations, MODULE_ID};
21pub use store::{get, load, save, set, SettingsError, SettingsModel};
22
23/// The `laterite.settings` module: the framework's settings store table.
24pub struct SettingsModule;
25
26impl laterite_core::Module for SettingsModule {
27    fn id(&self) -> laterite_core::ModuleId {
28        laterite_core::ModuleId::new(MODULE_ID)
29    }
30    fn migrations(&self) -> laterite_core::MigrationSet {
31        migrations()
32    }
33}
34
35use std::collections::HashMap;
36
37use askama::Template;
38use axum::response::{IntoResponse, Redirect, Response};
39use laterite_core::validation::Mode;
40use laterite_core::{t, Text, Translator};
41use serde::Serialize;
42use serde_json::{Map, Value};
43
44use crate::{render, render_error, AdminState};
45
46/// One editable field of a settings model.
47///
48/// A settings screen renders [`FormField`](crate::form::FormField)s, the same
49/// descriptors a list or form screen uses: the module declares which fields its
50/// settings model has, and the framework renders and stores them through the
51/// field-type registry. There is no settings-specific field vocabulary.
52pub use crate::form::FormField;
53
54/// A settings model surfaced in the admin: a storage `code`, a `category` and
55/// `order` that place it in the index, and the fields to edit.
56#[derive(Debug, Clone, Serialize)]
57#[non_exhaustive]
58pub struct SettingsItem {
59    /// Storage key. Matches the model's `SettingsModel::CODE`.
60    ///
61    /// This is a storage key and never a URL. It is dotted, like the module
62    /// identity it derives from, and a dotted identifier in a path is how these
63    /// screens spent months at `/settings/rainmill.discovery.robots`. Where the
64    /// screen mounts is [`SettingsItem::route`], resolved at boot through the
65    /// module's namespace like every other contributed screen.
66    pub code: String,
67    /// The item's label, description, and category heading, localized at render.
68    pub label: Text,
69    pub description: Text,
70    /// Group heading in the index.
71    pub category: Text,
72    /// A fuller explanation, shown as a note at the top of this screen and
73    /// nowhere else. Where [`SettingsItem::description`] must stay short enough
74    /// to read in a sidebar, this has room to say why the screen exists.
75    pub hint: Option<Text>,
76    /// Ask before leaving this screen with changes unsaved. Default `true`.
77    pub confirm_leave: bool,
78    /// Weight within the category (lower sorts first).
79    pub order: i32,
80    /// Icon name shown beside the item in the context sidebar (a Lucide name
81    /// such as `users` or `shield`). `None` falls back to a generic glyph.
82    pub icon: Option<String>,
83    /// Permission required to edit, enforced by middleware. `None` means any
84    /// authenticated operator.
85    pub permission: Option<String>,
86    /// When set, the item links to this route (e.g. a resource list) instead of
87    /// its settings form. Used to place list/form screens (like Administrators)
88    /// in the settings menu rather than the main menu.
89    pub link: Option<String>,
90    pub fields: Vec<FormField>,
91    /// Where this screen mounts, relative to the admin root, resolved at boot
92    /// from the contributing module's namespace. `None` until then.
93    pub(crate) route: Option<String>,
94}
95
96/// The longest a settings description may be: two lines of the context sidebar,
97/// which renders at 211 pixels and 12px type, so roughly 38 characters a line.
98/// The reference system's own descriptions run 27 to 90 characters with a median
99/// of 53, so this sits inside the range a mature panel actually uses.
100pub(crate) const MAX_DESCRIPTION: usize = 72;
101
102impl SettingsItem {
103    /// A settings screen for `code` (the model's `SettingsModel::CODE`), titled
104    /// `label`, editing `fields`.
105    ///
106    /// Everything else takes a default and is opted into through a builder
107    /// method, so a field added later costs existing callers nothing.
108    pub fn new(code: impl Into<String>, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
109        Self {
110            code: code.into(),
111            label: label.into(),
112            description: Text::from(""),
113            category: Text::from(""),
114            hint: None,
115            confirm_leave: true,
116            order: 0,
117            icon: None,
118            permission: None,
119            link: None,
120            fields,
121            route: None,
122        }
123    }
124
125    /// The sentence under the item's name in the index and sidebar.
126    ///
127    /// Keep it to two lines in the sidebar, which is 72 characters at the
128    /// panel's width. A description longer than that stops being a label and
129    /// starts being a lecture in a column 211 pixels wide; put the explanation
130    /// in [`SettingsItem::hint`], where the reader has already chosen to look.
131    pub fn description(mut self, text: impl Into<Text>) -> Self {
132        let text = text.into();
133        debug_assert!(
134            text.source().chars().count() <= MAX_DESCRIPTION,
135            "settings description is {} characters, over the {MAX_DESCRIPTION} that \
136             fit two sidebar lines; shorten it or move the detail to `hint`: {:?}",
137            text.source().chars().count(),
138            text.source(),
139        );
140        self.description = text;
141        self
142    }
143
144    /// A fuller explanation for the top of this screen. Unlike the description,
145    /// it has no length limit: the reader has opened the screen.
146    pub fn hint(mut self, text: impl Into<Text>) -> Self {
147        self.hint = Some(text.into());
148        self
149    }
150
151    /// Whether leaving this screen with changes unsaved asks first. Default
152    /// `true`.
153    pub fn confirm_leave(mut self, ask: bool) -> Self {
154        self.confirm_leave = ask;
155        self
156    }
157
158    /// The group heading this item sorts under.
159    pub fn category(mut self, text: impl Into<Text>) -> Self {
160        self.category = text.into();
161        self
162    }
163
164    /// Weight within the category; lower sorts first.
165    pub fn order(mut self, order: i32) -> Self {
166        self.order = order;
167        self
168    }
169
170    /// A Lucide icon name shown beside the item.
171    pub fn icon(mut self, icon: impl Into<String>) -> Self {
172        self.icon = Some(icon.into());
173        self
174    }
175
176    /// The permission required to edit, enforced by middleware.
177    pub fn permission(mut self, permission: impl Into<String>) -> Self {
178        self.permission = Some(permission.into());
179        self
180    }
181
182    /// Point the item at an existing route instead of its own settings form, so
183    /// a list screen can sit in the settings menu.
184    pub fn link(mut self, link: impl Into<String>) -> Self {
185        self.link = Some(link.into());
186        self
187    }
188
189    /// Records where this screen mounts. Called once at boot, after the
190    /// contributing module's namespace has been resolved.
191    pub(crate) fn mount_at(&mut self, route: impl Into<String>) {
192        self.route = Some(route.into());
193    }
194
195    /// Mounts this screen at a fixed path rather than a module namespace.
196    ///
197    /// For the panel's own settings, which belong to no vendor: they configure
198    /// the admin itself, so they sit under `/settings` with a plain name
199    /// (`/settings/branding`) rather than carrying `laterite` through a URL an
200    /// operator reads. A module's settings live in the module's namespace.
201    pub(crate) fn at(mut self, route: impl Into<String>) -> Self {
202        self.route = Some(route.into());
203        self
204    }
205
206    /// The item's own screen, relative to the admin root, ignoring any `link`.
207    ///
208    /// Before boot resolves it, this falls back to a path under `/settings`
209    /// built from the code's segments rather than the dotted code itself, so
210    /// even the fallback is a URL rather than an identifier. See
211    /// [`SettingsItem::at`] for why the panel's own settings pin theirs.
212    pub(crate) fn route(&self) -> String {
213        match &self.route {
214            Some(route) => route.clone(),
215            None => format!("/settings/{}", self.code.replace('.', "/")),
216        }
217    }
218
219    /// Where this item leads, resolved under the admin mount (`admin_path`): its
220    /// `link` target if set, else its own settings screen. Both are authored
221    /// relative to the admin root, so this prepends the mount.
222    pub fn path(&self, admin_path: &str) -> String {
223        match &self.link {
224            Some(link) => format!("{admin_path}{link}"),
225            None => format!("{admin_path}{}", self.route()),
226        }
227    }
228}
229
230/// Renders the settings index: a prompt to pick a section. The context sidebar
231/// itself is resolved by the auth guard and rendered by the shell.
232pub(crate) fn index(shell: crate::Shell) -> Response {
233    render(SettingsIndexTemplate { shell })
234}
235
236/// Renders the edit form for one item, populated from its stored value. The
237/// context sidebar (with this item active) comes from the shell.
238pub(crate) async fn edit_form(
239    state: &AdminState,
240    item: &SettingsItem,
241    shell: crate::Shell,
242) -> Response {
243    let mut stored = match store::get(&state.db, &item.code).await {
244        Ok(value) => value.unwrap_or_else(|| Value::Object(Map::new())),
245        Err(_) => return render_error(),
246    };
247    // Prefill unset fields from config so they show the current effective value
248    // rather than opening blank. Display only; nothing is written.
249    prefill_from_config(item, &mut stored, &state.app_name);
250    render(build(
251        item,
252        &state.field_types,
253        state.overrides.as_ref(),
254        None,
255        &stored,
256        &shell,
257    ))
258}
259
260/// Persists submitted values as the item's JSON object, then returns to the index.
261pub(crate) async fn update(
262    state: &AdminState,
263    item: &SettingsItem,
264    data: HashMap<String, String>,
265    shell: crate::Shell,
266    session: &crate::session::SessionHandle,
267    user: &laterite_auth::AuthenticatedUser,
268) -> Response {
269    // A field type that refuses its input refuses the save: the screen says so
270    // rather than storing a value every later read has to defend against.
271    let value = match collect(item, &state.field_types, &data) {
272        Ok(value) => value,
273        Err(message) => {
274            let stored = store::get(&state.db, &item.code)
275                .await
276                .ok()
277                .flatten()
278                .unwrap_or(Value::Null);
279            return render(build(
280                item,
281                &state.field_types,
282                state.overrides.as_ref(),
283                Some(message),
284                &stored,
285                &shell,
286            ));
287        }
288    };
289    match store::set(&state.db, &item.code, &value).await {
290        Ok(()) => {
291            // The brand is cached for display; a save to it must invalidate the
292            // cache so the next page reflects the new name.
293            if item.code == brand::BrandSetting::CODE {
294                state.invalidate_brand();
295            }
296            // The stored value can hold secrets, so the audit records which
297            // settings model changed, not the new contents.
298            crate::audit::record(
299                state,
300                user,
301                "backend.settings.update",
302                Some("settings"),
303                Some(item.code.as_str()),
304                None,
305                None,
306            )
307            .await;
308            session.push_flash(crate::session::FlashLevel::Success, t!("Settings saved."));
309            // Back to the screen that was saved, not the index. Saving is not
310            // leaving: an operator adjusting one setting usually adjusts the
311            // next, and being thrown back to the menu loses their place.
312            Redirect::to(&item.path(&state.admin_path)).into_response()
313        }
314        Err(_) => render(build(
315            item,
316            &state.field_types,
317            state.overrides.as_ref(),
318            Some(t!("Could not save. Please try again.")),
319            &value,
320            &shell,
321        )),
322    }
323}
324
325/// Builds the JSON object to store from the submitted form data, typing each
326/// field through its field type.
327///
328/// The same `to_attr` a descriptor form writes through, so a switch stores a
329/// bool, a repeater stores an array of objects, and a module's own field type
330/// behaves identically here and on a form. A type that refuses its input refuses
331/// the save, naming the field.
332fn collect(
333    item: &SettingsItem,
334    types: &crate::field::FieldRegistry,
335    data: &HashMap<String, String>,
336) -> Result<Value, Text> {
337    let mut object = Map::new();
338    for field in &item.fields {
339        let Some(field_type) = types.get(&field.field_type) else {
340            return Err(t!("This screen uses a field type that is not registered."));
341        };
342        let opts = field_type
343            .resolve_options(&field.options, types)
344            .map_err(|_| t!("This screen has a malformed field."))?;
345        let submitted = crate::field::SubmittedField::new(&field.name, data);
346        // Settings always write every declared key: a settings blob has no
347        // previous row to fall back to the way a table column does.
348        let value = match field_type.to_attr(&submitted, &opts, Mode::Update) {
349            Ok(Some(value)) => attr_to_json(value),
350            Ok(None) => Value::String(String::new()),
351            Err(_) => return Err(t!("Check the values and try again.")),
352        };
353        object.insert(field.name.clone(), value);
354    }
355    Ok(Value::Object(object))
356}
357
358/// A stored attribute as the JSON a settings blob holds.
359fn attr_to_json(value: laterite_core::AttrValue) -> Value {
360    use laterite_core::AttrValue;
361    match value {
362        AttrValue::Null => Value::String(String::new()),
363        AttrValue::Bool(b) => Value::Bool(b),
364        AttrValue::Int(i) => Value::from(i),
365        AttrValue::Float(f) => Value::from(f),
366        AttrValue::Json(j) => j,
367        other => Value::String(other.to_text().unwrap_or_default()),
368    }
369}
370
371/// Builds the context-sidebar groups: items grouped by category and ordered
372/// deterministically, with `active_code` (if any) marked. Categories sort by
373/// their lowest item `order`, then name; items by `order`, then label. This is
374/// the simple-weight stage; relative-anchor ordering with an operator override
375/// is a later refinement.
376pub(crate) fn sidebar_groups(
377    items: &[SettingsItem],
378    admin_path: &str,
379    active_code: Option<&str>,
380    tr: &Translator,
381    icons: crate::icons::Icons<'_>,
382) -> Vec<CategoryView> {
383    // Group by the localized category heading (a shared source localizes identically).
384    let mut by_category: HashMap<String, Vec<&SettingsItem>> = HashMap::new();
385    for item in items {
386        by_category
387            .entry(tr.t(&item.category))
388            .or_default()
389            .push(item);
390    }
391    let mut groups: Vec<CategoryView> = by_category
392        .into_iter()
393        .map(|(category, mut items)| {
394            // Order by weight, then the label's source for a stable tie-break.
395            items.sort_by(|a, b| {
396                a.order
397                    .cmp(&b.order)
398                    .then_with(|| a.label.source().cmp(b.label.source()))
399            });
400            CategoryView {
401                min_order: items.iter().map(|i| i.order).min().unwrap_or(0),
402                name: category,
403                items: items
404                    .iter()
405                    .map(|i| ItemView {
406                        label: tr.t(&i.label),
407                        description: tr.t(&i.description),
408                        path: i.path(admin_path),
409                        icon: icons.render(i.icon.as_deref()),
410                        active: active_code == Some(i.code.as_str()),
411                    })
412                    .collect(),
413            }
414        })
415        .collect();
416    groups.sort_by(|a, b| {
417        a.min_order
418            .cmp(&b.min_order)
419            .then_with(|| a.name.cmp(&b.name))
420    });
421    groups
422}
423
424/// Prefills unset display fields from configuration before the form renders, so
425/// a field shows the current effective value rather than opening blank. The
426/// brand's application name prefills from the configured `app.name` when no brand
427/// setting is saved. This is display only: it writes nothing, so a later config
428/// change still propagates (persisting it would freeze the value). It is not a
429/// database seeder; that is a separate, deferred capability.
430fn prefill_from_config(item: &SettingsItem, stored: &mut Value, app_name: &str) {
431    if item.code != brand::BrandSetting::CODE {
432        return;
433    }
434    if let Value::Object(map) = stored {
435        let blank = map
436            .get("app_name")
437            .and_then(Value::as_str)
438            .unwrap_or("")
439            .trim()
440            .is_empty();
441        if blank {
442            map.insert("app_name".to_string(), Value::String(app_name.to_string()));
443        }
444    }
445}
446
447fn build(
448    item: &SettingsItem,
449    types: &crate::field::FieldRegistry,
450    overrides: &dyn crate::field::OverrideResolver,
451    error: Option<Text>,
452    stored: &Value,
453    shell: &crate::Shell,
454) -> SettingsFormTemplate {
455    let fields = item
456        .fields
457        .iter()
458        .map(|f| {
459            let label = shell.tt(&f.label);
460            let control = match types.get(&f.field_type) {
461                Some(ft) => {
462                    let opts = ft
463                        .resolve_options(&f.options, types)
464                        .unwrap_or_else(|_| crate::field::ResolvedOptions::none());
465                    let value = match stored.get(&f.name) {
466                        Some(Value::Array(rows)) => {
467                            crate::field::FieldValue::Json(Value::Array(rows.clone()))
468                        }
469                        Some(Value::String(text)) => {
470                            crate::field::FieldValue::Text(ft.to_control(Some(text), &opts))
471                        }
472                        Some(Value::Null) | None => {
473                            crate::field::FieldValue::Text(ft.to_control(None, &opts))
474                        }
475                        Some(other) => crate::field::FieldValue::Text(
476                            ft.to_control(Some(&other.to_string()), &opts),
477                        ),
478                    };
479                    let cx = crate::field::FieldCx {
480                        name: &f.name,
481                        id: &f.name,
482                        label: &label,
483                        value: &value,
484                        required: false,
485                        max_length: crate::form::max_length(&f.rules),
486                        opts: &opts,
487                        base: &shell.base,
488                        i18n: shell.i18n(),
489                    };
490                    let scope = crate::field::OverrideScope {
491                        surface: crate::field::Surface::Field,
492                        view_key: &f.field_type,
493                        resource: Some(&item.code),
494                        field: Some(&f.name),
495                    };
496                    crate::field::render_field(ft.as_ref(), overrides, &scope, &cx).into_string()
497                }
498                None => String::new(),
499            };
500            FieldView {
501                label,
502                help: f.help.as_ref().map(|h| shell.tt(h)),
503                control,
504                layout: crate::form::layout_classes(f.span, f.break_row),
505                attrs: crate::form::behaviour_attrs(f),
506            }
507        })
508        .collect();
509    SettingsFormTemplate {
510        title: shell.tt(&item.label),
511        description: shell.tt(&item.description),
512        hint: item.hint.as_ref().map(|h| shell.tt(h)),
513        action: item.path(&shell.base),
514        error: error.map(|e| shell.tt(&e)),
515        fields,
516        leave_off: !item.confirm_leave,
517        shell: shell.clone(),
518    }
519}
520
521/// One category block in the context sidebar. Rendered by the shell.
522#[derive(Clone)]
523pub(crate) struct CategoryView {
524    pub(crate) name: String,
525    min_order: i32,
526    pub(crate) items: Vec<ItemView>,
527}
528
529/// One item in the context sidebar.
530#[derive(Clone)]
531pub(crate) struct ItemView {
532    pub(crate) label: String,
533    pub(crate) description: String,
534    pub(crate) path: String,
535    /// Inline SVG markup for the item's icon, rendered raw in the template.
536    pub(crate) icon: String,
537    /// Whether this is the item currently open, so the sidebar highlights it.
538    pub(crate) active: bool,
539}
540
541#[derive(Template)]
542#[template(path = "settings_index.html")]
543struct SettingsIndexTemplate {
544    shell: crate::Shell,
545}
546
547struct FieldView {
548    label: String,
549    help: Option<String>,
550    /// The control, rendered by the field's own type.
551    control: String,
552    /// Wrapper classes for the span and a row break, each led by a space.
553    layout: String,
554    /// The wrapper's preset and trigger attributes, each led by a space.
555    attrs: String,
556}
557
558#[derive(Template)]
559#[template(path = "settings_form.html")]
560struct SettingsFormTemplate {
561    shell: crate::Shell,
562    title: String,
563    description: String,
564    /// The item's fuller explanation, localized. Supersedes `description` on
565    /// this screen, which is written for a sidebar.
566    hint: Option<String>,
567    action: String,
568    error: Option<String>,
569    fields: Vec<FieldView>,
570    /// Leaving with changes unsaved does not ask.
571    leave_off: bool,
572}
573
574#[cfg(test)]
575mod field_system_tests {
576    use super::*;
577
578    #[test]
579    #[should_panic(expected = "over the 72")]
580    fn a_lecture_of_a_description_is_refused() {
581        // The shape the discovery plugin shipped with: 108 characters, four
582        // lines in a 211-pixel sidebar.
583        SettingsItem::new("acme.test", "Test", Vec::new()).description(
584            "What search engines and other crawlers may visit. The admin panel \
585             is always excluded, whatever you put here.",
586        );
587    }
588
589    #[test]
590    fn a_label_sized_description_passes_and_a_hint_carries_the_rest() {
591        let item = SettingsItem::new("acme.test", "Test", Vec::new())
592            .description("Which crawlers may visit, and what they may not.")
593            .hint(
594                "The admin panel is always excluded, whatever you put here, and a \
595                   crawler obeys only the block that names it.",
596            );
597        assert!(item.description.source().chars().count() <= MAX_DESCRIPTION);
598        // The hint has no ceiling: the reader has opened the screen.
599        assert!(item.hint.is_some());
600    }
601
602    fn item(fields: Vec<FormField>) -> SettingsItem {
603        SettingsItem {
604            code: "acme.test".to_string(),
605            label: Text::new(""),
606            description: Text::new(""),
607            category: Text::new(""),
608            hint: None,
609            confirm_leave: true,
610            order: 0,
611            icon: None,
612            permission: None,
613            link: None,
614            fields,
615            route: None,
616        }
617    }
618
619    fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
620        pairs
621            .iter()
622            .map(|(k, v)| (k.to_string(), v.to_string()))
623            .collect()
624    }
625
626    /// Settings and forms now share one field system, so a settings screen gets
627    /// every registered type and stores what that type says it stores.
628    #[test]
629    fn settings_store_what_the_field_type_says() {
630        let cfg = item(vec![
631            FormField::text("name", "Name"),
632            FormField::switch("enabled", "Enabled"),
633            FormField::select("freq", "Frequency", vec![("daily", "Daily")]),
634            FormField::date("expires", "Expires"),
635        ]);
636        let stored = collect(
637            &cfg,
638            &crate::field::builtin_registry(),
639            &submitted(&[
640                ("name", "Acme"),
641                ("enabled", "on"),
642                ("freq", "daily"),
643                ("expires", "2026-12-31"),
644            ]),
645        )
646        .unwrap();
647
648        assert_eq!(stored["name"], "Acme");
649        // A bool, not the string "on": the switch field type decided that.
650        assert_eq!(stored["enabled"], serde_json::json!(true));
651        assert_eq!(stored["freq"], "daily");
652        assert_eq!(stored["expires"], "2026-12-31");
653    }
654
655    /// An unticked switch is absent from the submission and must store false,
656    /// or the box could never be cleared.
657    #[test]
658    fn an_unticked_switch_clears() {
659        let cfg = item(vec![FormField::switch("enabled", "Enabled")]);
660        let stored = collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).unwrap();
661        assert_eq!(stored["enabled"], serde_json::json!(false));
662    }
663
664    /// The repeater works in settings because it is a field type, not a
665    /// settings-only widget: this is the whole point of the unification.
666    #[test]
667    fn a_repeater_works_in_settings() {
668        let cfg = item(vec![FormField::repeater(
669            "rules",
670            "Rules",
671            vec![
672                FormField::text("path", "Path"),
673                FormField::switch("allow", "Allow"),
674            ],
675        )]);
676        let stored = collect(
677            &cfg,
678            &crate::field::builtin_registry(),
679            &submitted(&[
680                ("rules[0][path]", "/private"),
681                ("rules[1][path]", "/public"),
682                ("rules[1][allow]", "on"),
683            ]),
684        )
685        .unwrap();
686
687        let rows = stored["rules"].as_array().unwrap();
688        assert_eq!(rows.len(), 2);
689        assert_eq!(rows[0]["path"], "/private");
690        assert_eq!(rows[0]["allow"], serde_json::json!(false));
691        assert_eq!(rows[1]["allow"], serde_json::json!(true));
692    }
693
694    #[test]
695    fn a_screen_naming_an_unregistered_type_refuses_rather_than_rendering_blank() {
696        let cfg = item(vec![FormField::of("x", "X", "acme.nope")]);
697        assert!(collect(&cfg, &crate::field::builtin_registry(), &submitted(&[])).is_err());
698    }
699
700    #[test]
701    fn the_form_renders_each_control_through_its_type() {
702        let cfg = item(vec![
703            FormField::switch("enabled", "Enabled"),
704            FormField::repeater("rules", "Rules", vec![FormField::text("path", "Path")]),
705        ]);
706        let html = build(
707            &cfg,
708            &crate::field::builtin_registry(),
709            &crate::field::NoOverrides,
710            None,
711            &serde_json::json!({ "enabled": true, "rules": [{ "path": "/a" }] }),
712            &crate::Shell::test(),
713        )
714        .render()
715        .unwrap();
716
717        assert!(html.contains(r#"type="checkbox""#));
718        assert!(html.contains(r#"name="rules[0][path]""#));
719        assert!(html.contains(r#"value="/a""#));
720        assert!(html.contains("lat-repeater__blank"));
721    }
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727    use laterite_core::Db;
728
729    #[test]
730    fn brand_form_prefills_app_name_from_config_when_unset() {
731        let brand = brand::settings_item();
732        // Unset: the field prefills from the configured application name.
733        let mut unset = Value::Object(Map::new());
734        prefill_from_config(&brand, &mut unset, "Configured Name");
735        assert_eq!(unset["app_name"], serde_json::json!("Configured Name"));
736        // Already set: the stored value is left untouched.
737        let mut set = serde_json::json!({ "app_name": "Acme" });
738        prefill_from_config(&brand, &mut set, "Configured Name");
739        assert_eq!(set["app_name"], serde_json::json!("Acme"));
740        // A non-brand item is not prefilled.
741        let mut other = Value::Object(Map::new());
742        prefill_from_config(&item(), &mut other, "Configured Name");
743        assert!(other.get("app_name").is_none());
744    }
745
746    fn item() -> SettingsItem {
747        SettingsItem {
748            code: "test.log".to_string(),
749            label: "Log Settings".into(),
750            description: "What the log records.".into(),
751            category: "Logs".into(),
752            hint: None,
753            confirm_leave: true,
754            order: 10,
755            icon: None,
756            permission: None,
757            link: None,
758            fields: vec![
759                FormField::switch("log_events", "Log events"),
760                FormField::switch("log_requests", "Log requests"),
761                FormField::text("retention_days", "Retention (days)"),
762            ],
763            route: None,
764        }
765    }
766
767    fn state(db: Db) -> AdminState {
768        AdminState::new(
769            laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
770            db,
771        )
772    }
773
774    /// A fresh test database with the settings table migrated in, on whichever
775    /// backend the run targets. Hold the returned guard for the test's lifetime.
776    async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
777        laterite_core::testing::connect_test(&[migrations()]).await
778    }
779
780    fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
781        pairs
782            .iter()
783            .map(|(k, v)| (k.to_string(), v.to_string()))
784            .collect()
785    }
786
787    #[test]
788    fn group_orders_categories_then_items() {
789        let items = vec![
790            SettingsItem {
791                code: "b".into(),
792                label: "Beta".into(),
793                description: String::new().into(),
794                category: "System".into(),
795                hint: None,
796                confirm_leave: true,
797                order: 20,
798                icon: None,
799                permission: None,
800                link: None,
801                fields: vec![],
802                route: None,
803            },
804            SettingsItem {
805                code: "a".into(),
806                label: "Alpha".into(),
807                description: String::new().into(),
808                category: "System".into(),
809                hint: None,
810                confirm_leave: true,
811                order: 10,
812                icon: None,
813                permission: None,
814                link: None,
815                fields: vec![],
816                route: None,
817            },
818            SettingsItem {
819                code: "l".into(),
820                label: "Logs".into(),
821                description: String::new().into(),
822                category: "Logs".into(),
823                hint: None,
824                confirm_leave: true,
825                order: 5,
826                icon: None,
827                permission: None,
828                link: None,
829                fields: vec![],
830                route: None,
831            },
832        ];
833        let groups = sidebar_groups(
834            &items,
835            "/admin",
836            None,
837            &Translator::new("en"),
838            crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
839        );
840        // "Logs" (min order 5) comes before "System" (min order 10).
841        assert_eq!(groups[0].name, "Logs");
842        assert_eq!(groups[1].name, "System");
843        // Within "System", Alpha (10) before Beta (20).
844        assert_eq!(groups[1].items[0].label, "Alpha");
845        assert_eq!(groups[1].items[1].label, "Beta");
846        // Nothing is active when no code is given.
847        assert!(groups.iter().flat_map(|g| &g.items).all(|i| !i.active));
848    }
849
850    #[test]
851    fn group_marks_only_the_active_item() {
852        let items = vec![item()];
853        let groups = sidebar_groups(
854            &items,
855            "/admin",
856            Some("test.log"),
857            &Translator::new("en"),
858            crate::icons::Icons::new(&laterite_core::icons::IconSet::new(), "/s.svg"),
859        );
860        let active: Vec<&str> = groups
861            .iter()
862            .flat_map(|g| &g.items)
863            .filter(|i| i.active)
864            .map(|i| i.label.as_str())
865            .collect();
866        assert_eq!(active, ["Log Settings"]);
867    }
868
869    #[test]
870    fn settings_model_item_path_is_its_form() {
871        // Unresolved (no module namespace yet), the fallback still builds a
872        // path from the code's segments rather than using the dotted key.
873        assert_eq!(item().path("/admin"), "/admin/settings/test/log");
874        // The mount is honoured, so a relocated panel keeps consistent links.
875        assert_eq!(item().path("/manage"), "/manage/settings/test/log");
876
877        // Once boot resolves it into the module's namespace, that wins.
878        let mut resolved = item();
879        resolved.mount_at("/acme/logging/log");
880        assert_eq!(resolved.path("/admin"), "/admin/acme/logging/log");
881    }
882
883    #[test]
884    fn link_item_path_follows_the_link() {
885        let admins = crate::builtin_settings()
886            .into_iter()
887            .find(|i| i.code == "backend.administrators")
888            .unwrap();
889        assert_eq!(admins.category.source(), "Users");
890        assert!(admins.link.is_some());
891        assert!(admins.fields.is_empty());
892        // links to the resource list, not a settings form
893        assert_eq!(admins.path("/admin"), "/admin/users");
894        assert!(crate::builtin_settings()
895            .iter()
896            .any(|i| i.code == "backend.roles"));
897    }
898
899    #[tokio::test]
900    async fn update_persists_typed_values() {
901        let (db, _guard) = test_db().await;
902        let st = state(db.clone());
903
904        let it = item();
905        let resp = update(
906            &st,
907            &it,
908            // log_requests is absent, as an unchecked checkbox would be.
909            data(&[("log_events", "on"), ("retention_days", "30")]),
910            crate::Shell::test(),
911            &crate::session::SessionHandle::from_blob(None),
912            &crate::audit::test_actor(),
913        )
914        .await;
915        assert_eq!(resp.status(), axum::http::StatusCode::SEE_OTHER);
916
917        let stored = store::get(&db, "test.log").await.unwrap().unwrap();
918        assert_eq!(stored["log_events"], serde_json::json!(true));
919        assert_eq!(stored["log_requests"], serde_json::json!(false));
920        assert_eq!(stored["retention_days"], serde_json::json!("30"));
921    }
922
923    #[test]
924    fn index_renders() {
925        let resp = index(crate::Shell::test());
926        assert_eq!(resp.status(), axum::http::StatusCode::OK);
927    }
928
929    #[tokio::test]
930    async fn edit_form_renders_for_unset_item() {
931        let (db, _guard) = test_db().await;
932        let st = state(db);
933        // No stored value yet: the form still renders (fields fall back to defaults).
934        let it = item();
935        let resp = edit_form(&st, &it, crate::Shell::test()).await;
936        assert_eq!(resp.status(), axum::http::StatusCode::OK);
937    }
938}