Skip to main content

umbral_admin/
views.rs

1//! Custom admin views — developer-registered widget pages mounted at
2//! arbitrary paths under the admin base (e.g. `/admin/reports/sales/`).
3//! A view renders the existing dashboard widget kinds inside the admin
4//! chrome. See `docs/superpowers/specs/2026-07-01-admin-custom-views-design.md`.
5//!
6//! # Naming convention
7//!
8//! Rust does not permit two methods with the same name on the same type
9//! even if one takes `self` (consuming) and the other `&self` (borrowing).
10//! To avoid the `E0592` duplicate-definition error, setter/builder methods
11//! use a `with_` prefix (`with_subtitle`, `with_icon`, …) while the
12//! read-only accessors use the bare field name (`subtitle()`, `icon()`, …).
13//! The one-shot "mark as hidden" setter is named `hide()` so `hidden()`
14//! can remain the boolean accessor.
15
16use crate::widgets::WidgetSection;
17
18/// A registered admin page that is not tied to a model. Renders one or
19/// more [`WidgetSection`]s (the same cards/charts the dashboard uses)
20/// inside the admin chrome, mounted at `{admin_base}/{path}`.
21#[derive(Debug, Clone)]
22pub struct AdminView {
23    path: String,
24    title: String,
25    subtitle: Option<String>,
26    icon: Option<String>,
27    group: Option<String>,
28    permission: Option<String>,
29    hidden: bool,
30    sections: Vec<WidgetSection>,
31}
32
33/// Normalize a developer-supplied path to the canonical `a/b/c` form
34/// (no leading/trailing slashes, no empty segments).
35fn normalize_path(raw: &str) -> String {
36    raw.split('/')
37        .filter(|seg| !seg.is_empty())
38        .collect::<Vec<_>>()
39        .join("/")
40}
41
42impl AdminView {
43    /// Start a view. `path` is the subpath under the admin base
44    /// (`"reports/sales"` → `/admin/reports/sales/`); `title` is the page
45    /// heading and the default sidebar label.
46    pub fn new(path: impl Into<String>, title: impl Into<String>) -> Self {
47        Self {
48            path: normalize_path(&path.into()),
49            title: title.into(),
50            subtitle: None,
51            icon: None,
52            group: None,
53            permission: None,
54            hidden: false,
55            sections: Vec::new(),
56        }
57    }
58
59    /// Optional caption under the page heading.
60    pub fn with_subtitle(mut self, subtitle: impl Into<String>) -> Self {
61        self.subtitle = Some(subtitle.into());
62        self
63    }
64
65    /// Lucide icon name for the sidebar entry.
66    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
67        self.icon = Some(icon.into());
68        self
69    }
70
71    /// Sidebar group heading. Defaults to "Pages" when unset.
72    pub fn with_group(mut self, group: impl Into<String>) -> Self {
73        self.group = Some(group.into());
74        self
75    }
76
77    /// Permission codename gate (e.g. `"reports.view_sales"`). Unset = any staff.
78    pub fn with_permission(mut self, codename: impl Into<String>) -> Self {
79        self.permission = Some(codename.into());
80        self
81    }
82
83    /// Keep the view routable but hide it from the sidebar.
84    pub fn hide(mut self) -> Self {
85        self.hidden = true;
86        self
87    }
88
89    /// Append one widget section.
90    pub fn section(mut self, section: WidgetSection) -> Self {
91        self.sections.push(section);
92        self
93    }
94
95    /// Append many widget sections.
96    pub fn add_sections(mut self, sections: impl IntoIterator<Item = WidgetSection>) -> Self {
97        self.sections.extend(sections);
98        self
99    }
100
101    // --- accessors used by the crate (route mount, handler, sidebar) ---
102
103    pub(crate) fn path(&self) -> &str {
104        &self.path
105    }
106
107    /// Stable key for the per-route handler + sidebar active-state. Equals the normalized path.
108    pub(crate) fn slug(&self) -> &str {
109        &self.path
110    }
111
112    pub(crate) fn title(&self) -> &str {
113        &self.title
114    }
115
116    pub(crate) fn subtitle(&self) -> Option<&str> {
117        self.subtitle.as_deref()
118    }
119
120    pub(crate) fn icon(&self) -> Option<&str> {
121        self.icon.as_deref()
122    }
123
124    pub(crate) fn group(&self) -> Option<&str> {
125        self.group.as_deref()
126    }
127
128    pub(crate) fn permission(&self) -> Option<&str> {
129        self.permission.as_deref()
130    }
131
132    pub(crate) fn hidden(&self) -> bool {
133        self.hidden
134    }
135
136    pub(crate) fn sections(&self) -> &[WidgetSection] {
137        &self.sections
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::widgets::WidgetSection;
145
146    #[test]
147    fn normalizes_path_and_slug() {
148        let v = AdminView::new("/reports/sales/", "Sales");
149        assert_eq!(
150            v.path(),
151            "reports/sales",
152            "leading/trailing slashes stripped"
153        );
154        assert_eq!(
155            v.slug(),
156            "reports/sales",
157            "slug mirrors the normalized path"
158        );
159        assert_eq!(v.title(), "Sales");
160
161        let v2 = AdminView::new("reports//sales", "X");
162        assert_eq!(v2.path(), "reports/sales", "double slashes collapsed");
163    }
164
165    #[test]
166    fn defaults_are_sane() {
167        let v = AdminView::new("tools/x", "X");
168        assert!(v.subtitle().is_none());
169        assert!(v.icon().is_none());
170        assert!(
171            v.group().is_none(),
172            "group defaults to None (renders under 'Pages')"
173        );
174        assert!(v.permission().is_none(), "no permission = any staff");
175        assert!(!v.hidden(), "shown in sidebar by default");
176        assert!(v.sections().is_empty());
177    }
178
179    #[test]
180    fn builders_populate_fields() {
181        // Note: builder methods use `with_` prefix (e.g. `with_subtitle`) and `hide()` to
182        // avoid Rust E0592 duplicate-definition conflicts with the same-named `&self` accessors.
183        let v = AdminView::new("reports/sales", "Sales")
184            .with_subtitle("Revenue")
185            .with_icon("bar-chart")
186            .with_group("Reports")
187            .with_permission("reports.view_sales")
188            .hide()
189            .section(WidgetSection::new("This month"));
190        assert_eq!(v.subtitle(), Some("Revenue"));
191        assert_eq!(v.icon(), Some("bar-chart"));
192        assert_eq!(v.group(), Some("Reports"));
193        assert_eq!(v.permission(), Some("reports.view_sales"));
194        assert!(v.hidden());
195        assert_eq!(v.sections().len(), 1);
196    }
197
198    #[test]
199    fn add_sections_appends() {
200        let v2 = AdminView::new("x", "X")
201            .add_sections(vec![WidgetSection::new("A"), WidgetSection::new("B")]);
202        assert_eq!(v2.sections().len(), 2, "add_sections appends all");
203    }
204}