Skip to main content

laterite_admin/
widget.rs

1//! Dashboard widgets: the blocks the dashboard is made of, contributed by the
2//! framework and by modules, each gated by its own permission and placed by the
3//! arrangement engine, with every operator free to choose and order their own.
4//!
5//! A widget is a [`Widget`] registered through
6//! [`AdminRegistry::add_widget`](crate::AdminRegistry). It renders a fragment
7//! of markup for one operator; the dashboard frames it, and a widget that
8//! declares a refresh interval is fetched again on its own, without the page.
9
10use crate::html::Markup;
11use laterite_auth::AuthenticatedUser;
12use laterite_core::arrange::{Arranged, Hierarchy, Policy, Position};
13use laterite_core::{Db, Text};
14use serde::{Deserialize, Serialize};
15use std::sync::Arc;
16
17/// How wide a widget sits in the dashboard's six-column grid.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
19#[serde(rename_all = "lowercase")]
20pub enum Size {
21    /// A third of the row.
22    Third,
23    /// Half the row.
24    #[default]
25    Half,
26    /// The whole row.
27    Full,
28}
29
30impl Size {
31    pub(crate) fn class(self) -> &'static str {
32        match self {
33            Self::Third => "lat-widget--third",
34            Self::Half => "lat-widget--half",
35            Self::Full => "lat-widget--full",
36        }
37    }
38    pub(crate) fn key(self) -> &'static str {
39        match self {
40            Self::Third => "third",
41            Self::Half => "half",
42            Self::Full => "full",
43        }
44    }
45    pub(crate) fn parse(key: &str) -> Option<Self> {
46        match key {
47            "third" => Some(Self::Third),
48            "half" => Some(Self::Half),
49            "full" => Some(Self::Full),
50            _ => None,
51        }
52    }
53}
54
55/// What a widget renders from: the request's operator and shell, the
56/// database, and the size the operator chose for it.
57pub struct WidgetCx<'a> {
58    pub(crate) state: &'a crate::AdminState,
59    pub shell: &'a crate::Shell,
60    pub user: &'a AuthenticatedUser,
61    pub size: Size,
62}
63
64impl WidgetCx<'_> {
65    pub fn db(&self) -> &Db {
66        &self.state.db
67    }
68    /// The admin's mount path, for a link into the panel.
69    pub fn base(&self) -> &str {
70        &self.shell.base
71    }
72    /// Localizes a string for this operator.
73    pub fn t(&self, text: &Text) -> String {
74        self.shell.tt(text)
75    }
76}
77
78/// A block on the dashboard.
79#[laterite_core::strata::async_trait]
80pub trait Widget: Send + Sync {
81    /// `vendor.name`, unique across modules.
82    fn code(&self) -> &str;
83    /// Shown as the widget's heading and in the Manage dialog.
84    fn label(&self) -> Text;
85    /// A line under the label in the Manage dialog.
86    fn description(&self) -> Option<Text> {
87        None
88    }
89    /// The permission an operator needs to see it; `None` shows it to every
90    /// operator.
91    fn permission(&self) -> Option<&str> {
92        None
93    }
94    /// The width it takes until an operator chooses another.
95    fn size(&self) -> Size {
96        Size::Half
97    }
98    /// Seconds between refreshes of its body, fetched on its own; `None` renders
99    /// once with the page.
100    fn refresh(&self) -> Option<u32> {
101        None
102    }
103    /// The widget's body. An `Err` renders as a notice inside the frame, never
104    /// as a failed page.
105    async fn render(&self, cx: &WidgetCx<'_>) -> Result<Markup, String>;
106}
107
108/// A registered widget with its place among the others.
109#[derive(Clone)]
110pub struct WidgetReg {
111    pub widget: Arc<dyn Widget>,
112    pub position: Position,
113    label: String,
114}
115
116impl WidgetReg {
117    pub fn new(widget: Arc<dyn Widget>) -> Self {
118        let label = widget.label().source().to_string();
119        Self {
120            widget,
121            position: Position::new().slot(laterite_core::arrange::slot::APPLICATION),
122            label,
123        }
124    }
125    /// Where it sits by default. See [`laterite_core::arrange`].
126    pub fn position(mut self, position: Position) -> Self {
127        self.position = position;
128        self
129    }
130    pub fn code(&self) -> &str {
131        self.widget.code()
132    }
133}
134
135impl Arranged for WidgetReg {
136    fn key(&self) -> &str {
137        self.widget.code()
138    }
139    fn position(&self) -> &Position {
140        &self.position
141    }
142    fn label(&self) -> &str {
143        &self.label
144    }
145}
146
147/// The dashboard's hierarchy: deployments and operators may rearrange it.
148pub const HIERARCHY: &str = "backend.dashboard";
149
150pub(crate) fn hierarchy() -> Hierarchy {
151    Hierarchy::new(HIERARCHY)
152        .policy(Policy::Adjustable)
153        .operator_adjustable()
154}
155
156/// The preference holding an operator's own dashboard.
157pub(crate) const PREFERENCE: &str = "dashboard.layout";
158
159/// An operator's dashboard: which widgets, in what order, at what size.
160#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
161pub(crate) struct Layout {
162    pub widgets: Vec<Placed>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
166pub(crate) struct Placed {
167    pub code: String,
168    #[serde(default)]
169    pub size: Size,
170}
171
172impl Layout {
173    pub(crate) fn parse(json: &str) -> Option<Self> {
174        serde_json::from_str(json).ok()
175    }
176}
177
178/// The widgets an operator may see, in the arranged order.
179pub(crate) fn available<'a>(
180    regs: &'a [WidgetReg],
181    deployment: Option<&[String]>,
182    allows: &dyn Fn(&str) -> bool,
183) -> Vec<&'a WidgetReg> {
184    let mut items: Vec<&WidgetReg> = regs
185        .iter()
186        .filter(|r| r.widget.permission().is_none_or(allows))
187        .collect();
188    // Arrange by index, since the engine wants owned items.
189    let keyed: Vec<Keyed<'_>> = items
190        .iter()
191        .enumerate()
192        .map(|(i, r)| Keyed { at: i, reg: r })
193        .collect();
194    let overrides = laterite_core::arrange::Overrides {
195        deployment,
196        operator: None,
197    };
198    if let Ok(arranged) = laterite_core::arrange::arrange(&hierarchy(), keyed, overrides) {
199        let order: Vec<usize> = arranged.into_iter().map(|k| k.at).collect();
200        items = order.into_iter().map(|i| items[i]).collect();
201    }
202    items
203}
204
205struct Keyed<'a> {
206    at: usize,
207    reg: &'a WidgetReg,
208}
209
210impl Arranged for Keyed<'_> {
211    fn key(&self) -> &str {
212        self.reg.key()
213    }
214    fn position(&self) -> &Position {
215        self.reg.position()
216    }
217    fn label(&self) -> &str {
218        self.reg.label()
219    }
220}
221
222/// The widgets the dashboard shows: the operator's layout where one is saved,
223/// restricted to what they may see, else every available widget at its own
224/// size.
225pub(crate) fn resolve<'a>(
226    layout: Option<&Layout>,
227    available: &[&'a WidgetReg],
228) -> Vec<(&'a WidgetReg, Size)> {
229    match layout {
230        Some(layout) => layout
231            .widgets
232            .iter()
233            .filter_map(|p| {
234                available
235                    .iter()
236                    .find(|r| r.code() == p.code)
237                    .map(|r| (*r, p.size))
238            })
239            .collect(),
240        None => available.iter().map(|r| (*r, r.widget.size())).collect(),
241    }
242}
243
244/// The frame around a widget's body: the heading, the size class, the refresh
245/// attributes, and the body or the notice a failed render leaves.
246pub(crate) fn frame(
247    reg: &WidgetReg,
248    size: Size,
249    base: &str,
250    label: &str,
251    body: Result<Markup, String>,
252    failed: &str,
253) -> String {
254    let code = crate::form::escape_attr(reg.code());
255    let refresh = reg
256        .widget
257        .refresh()
258        .map(|s| {
259            format!(
260                r#" hx-get="{base}/dashboard/widget/{code}?size={}" hx-trigger="every {s}s" hx-swap="innerHTML""#,
261                size.key()
262            )
263        })
264        .unwrap_or_default();
265    let inner = match body {
266        Ok(markup) => markup.into_string(),
267        Err(e) => {
268            tracing::warn!(widget = reg.code(), error = %e, "widget failed to render");
269            format!(
270                r#"<p class="lat-widget__failed">{}</p>"#,
271                crate::form::escape_attr(failed)
272            )
273        }
274    };
275    format!(
276        r#"<section class="lat-widget {}" data-widget="{code}" aria-labelledby="widget-{code}"><h2 class="lat-widget__title" id="widget-{code}">{}</h2><div class="lat-widget__body"{refresh}>{inner}</div></section>"#,
277        size.class(),
278        crate::form::escape_attr(label),
279    )
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    struct Fixed(&'static str, Option<&'static str>, Size);
287
288    #[laterite_core::strata::async_trait]
289    impl Widget for Fixed {
290        fn code(&self) -> &str {
291            self.0
292        }
293        fn label(&self) -> Text {
294            Text::dynamic(self.0)
295        }
296        fn permission(&self) -> Option<&str> {
297            self.1
298        }
299        fn size(&self) -> Size {
300            self.2
301        }
302        async fn render(&self, _cx: &WidgetCx<'_>) -> Result<Markup, String> {
303            Markup::from_template(&Body).map_err(|e| e.to_string())
304        }
305    }
306
307    #[derive(askama::Template)]
308    #[template(source = "<p>x</p>", ext = "html")]
309    struct Body;
310
311    fn regs() -> Vec<WidgetReg> {
312        vec![
313            WidgetReg::new(Arc::new(Fixed(
314                "acme.sales",
315                Some("acme.sales"),
316                Size::Full,
317            )))
318            .position(Position::new().slot(500)),
319            WidgetReg::new(Arc::new(Fixed("backend.welcome", None, Size::Half)))
320                .position(Position::new().slot(0)),
321            WidgetReg::new(Arc::new(Fixed("backend.status", None, Size::Third)))
322                .position(Position::new().slot(900)),
323        ]
324    }
325
326    #[test]
327    fn available_filters_by_permission_and_arranges_by_slot() {
328        let regs = regs();
329        let none = available(&regs, None, &|_| false);
330        assert_eq!(
331            none.iter().map(|r| r.code()).collect::<Vec<_>>(),
332            vec!["backend.welcome", "backend.status"]
333        );
334        let all = available(&regs, None, &|_| true);
335        assert_eq!(
336            all.iter().map(|r| r.code()).collect::<Vec<_>>(),
337            vec!["backend.welcome", "acme.sales", "backend.status"]
338        );
339        let moved = ["backend.status".to_string()];
340        let deployed = available(&regs, Some(&moved), &|_| true);
341        assert_eq!(deployed[0].code(), "backend.status");
342    }
343
344    #[test]
345    fn a_layout_keeps_its_order_and_sizes_and_drops_what_it_may_not_show() {
346        let regs = regs();
347        let avail = available(&regs, None, &|_| false);
348        let layout = Layout::parse(
349            r#"{"widgets":[{"code":"backend.status","size":"full"},{"code":"acme.sales"},{"code":"gone"},{"code":"backend.welcome","size":"third"}]}"#,
350        )
351        .unwrap();
352        let shown = resolve(Some(&layout), &avail);
353        assert_eq!(
354            shown
355                .iter()
356                .map(|(r, s)| (r.code(), *s))
357                .collect::<Vec<_>>(),
358            vec![
359                ("backend.status", Size::Full),
360                ("backend.welcome", Size::Third)
361            ]
362        );
363        let default = resolve(None, &avail);
364        assert_eq!(default[0].1, Size::Half);
365        assert_eq!(default[1].1, Size::Third);
366    }
367
368    #[test]
369    fn a_failed_body_renders_a_notice_inside_the_frame() {
370        let regs = regs();
371        let html = frame(
372            &regs[1],
373            Size::Half,
374            "/admin",
375            "Welcome",
376            Err("boom".into()),
377            "This widget could not be shown.",
378        );
379        assert!(html.contains("lat-widget--half"));
380        assert!(html.contains("This widget could not be shown."));
381        assert!(!html.contains("boom"));
382        assert!(!html.contains("hx-get"));
383    }
384}