Skip to main content

laterite_admin/
ui.rs

1//! Typed admin chrome for a screen that composes a page by hand: the same
2//! buttons, toolbars and badges the framework's own screens render, built as
3//! values and rendered through the shell, so a module never writes the class
4//! names itself.
5
6use crate::form::escape_attr;
7use crate::html::Markup;
8use crate::Shell;
9use laterite_core::Text;
10
11/// How a button reads: the one action on a page, a secondary one, a
12/// destructive one, or a quiet one in a row.
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14pub enum Style {
15    Primary,
16    #[default]
17    Secondary,
18    Danger,
19    Ghost,
20}
21
22impl Style {
23    fn class(self) -> &'static str {
24        match self {
25            Style::Primary => "lat-btn--primary",
26            Style::Secondary => "lat-btn--secondary",
27            Style::Danger => "lat-btn--danger",
28            Style::Ghost => "lat-btn--ghost",
29        }
30    }
31}
32
33/// What a button does: opens a page, or posts to a path with the request
34/// token, asking first when a confirmation is set.
35#[derive(Clone, Debug)]
36enum Action {
37    Link(String),
38    Post(String),
39}
40
41/// A button: a link styled as one, or a one-field form that posts.
42#[derive(Clone, Debug)]
43pub struct Button {
44    label: Text,
45    action: Action,
46    style: Style,
47    small: bool,
48    icon: Option<String>,
49    confirm: Option<Text>,
50}
51
52impl Button {
53    /// A button that opens `href`.
54    pub fn link(label: impl Into<Text>, href: impl Into<String>) -> Self {
55        Self {
56            label: label.into(),
57            action: Action::Link(href.into()),
58            style: Style::Secondary,
59            small: false,
60            icon: None,
61            confirm: None,
62        }
63    }
64
65    /// A button that posts to `action` with the request token.
66    pub fn post(label: impl Into<Text>, action: impl Into<String>) -> Self {
67        Self {
68            label: label.into(),
69            action: Action::Post(action.into()),
70            style: Style::Secondary,
71            small: false,
72            icon: None,
73            confirm: None,
74        }
75    }
76
77    pub fn style(mut self, style: Style) -> Self {
78        self.style = style;
79        self
80    }
81
82    pub fn primary(self) -> Self {
83        self.style(Style::Primary)
84    }
85
86    pub fn danger(self) -> Self {
87        self.style(Style::Danger)
88    }
89
90    pub fn ghost(self) -> Self {
91        self.style(Style::Ghost)
92    }
93
94    /// The row-sized button.
95    pub fn small(mut self) -> Self {
96        self.small = true;
97        self
98    }
99
100    /// An icon from the icon set before the label.
101    pub fn icon(mut self, name: impl Into<String>) -> Self {
102        self.icon = Some(name.into());
103        self
104    }
105
106    /// Asks this question before a post goes.
107    pub fn confirm(mut self, question: impl Into<Text>) -> Self {
108        self.confirm = Some(question.into());
109        self
110    }
111
112    pub fn render(&self, shell: &Shell) -> Markup {
113        let mut class = format!("lat-btn {}", self.style.class());
114        if self.small {
115            class.push_str(" lat-btn--sm");
116        }
117        let icon = self
118            .icon
119            .as_deref()
120            .map(|name| format!(r#"<span class="lat-btn__icon">{}</span>"#, shell.icon(name)))
121            .unwrap_or_default();
122        let label = escape_attr(&shell.tt(&self.label));
123        let html = match &self.action {
124            Action::Link(href) => format!(
125                r#"<a class="{class}" href="{}">{icon}{label}</a>"#,
126                escape_attr(href)
127            ),
128            Action::Post(action) => {
129                let confirm = self
130                    .confirm
131                    .as_ref()
132                    .map(|q| format!(r#" data-lat-confirm="{}""#, escape_attr(&shell.tt(q))))
133                    .unwrap_or_default();
134                format!(
135                    r#"<form method="post" action="{}"><input type="hidden" name="_csrf" value="{}"><button class="{class}" type="submit"{confirm}>{icon}{label}</button></form>"#,
136                    escape_attr(action),
137                    escape_attr(&shell.csrf_token)
138                )
139            }
140        };
141        Markup::from_override(html)
142    }
143}
144
145/// A row of buttons under a heading, as a page's or a section's toolbar.
146#[derive(Clone, Debug, Default)]
147pub struct Toolbar {
148    title: Option<Text>,
149    buttons: Vec<Button>,
150    section: bool,
151}
152
153impl Toolbar {
154    /// A page toolbar headed by `title`.
155    pub fn new(title: impl Into<Text>) -> Self {
156        Self {
157            title: Some(title.into()),
158            buttons: Vec::new(),
159            section: false,
160        }
161    }
162
163    /// A section's toolbar, smaller, headed by `title`.
164    pub fn section(title: impl Into<Text>) -> Self {
165        Self {
166            title: Some(title.into()),
167            buttons: Vec::new(),
168            section: true,
169        }
170    }
171
172    pub fn button(mut self, button: Button) -> Self {
173        self.buttons.push(button);
174        self
175    }
176
177    pub fn render(&self, shell: &Shell) -> Markup {
178        let class = if self.section {
179            "lat-toolbar lat-toolbar--section"
180        } else {
181            "lat-toolbar"
182        };
183        let title = match &self.title {
184            Some(title) if self.section => format!("<h2>{}</h2>", escape_attr(&shell.tt(title))),
185            Some(title) => format!("<h1>{}</h1>", escape_attr(&shell.tt(title))),
186            None => String::new(),
187        };
188        let actions = if self.buttons.is_empty() {
189            String::new()
190        } else {
191            let inner: String = self
192                .buttons
193                .iter()
194                .map(|b| b.render(shell).into_string())
195                .collect();
196            format!(r#"<div class="lat-toolbar__actions">{inner}</div>"#)
197        };
198        Markup::from_override(format!(r#"<div class="{class}">{title}{actions}</div>"#))
199    }
200}
201
202/// A small label beside a value: a state, a count, a note.
203#[derive(Clone, Debug)]
204pub struct Badge {
205    label: Text,
206}
207
208impl Badge {
209    pub fn new(label: impl Into<Text>) -> Self {
210        Self {
211            label: label.into(),
212        }
213    }
214
215    pub fn render(&self, shell: &Shell) -> Markup {
216        Markup::from_override(format!(
217            r#"<span class="lat-badge">{}</span>"#,
218            escape_attr(&shell.tt(&self.label))
219        ))
220    }
221}
222
223/// A card: the panel's framed block, around whatever it is given.
224#[derive(Clone, Debug)]
225pub struct Card {
226    body: Markup,
227}
228
229impl Card {
230    pub fn new(body: Markup) -> Self {
231        Self { body }
232    }
233
234    pub fn render(&self) -> Markup {
235        Markup::from_override(format!(
236            r#"<div class="lat-card">{}</div>"#,
237            self.body.clone().into_string()
238        ))
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn a_link_button_carries_its_style_size_and_icon() {
248        let shell = Shell::test();
249        let html = Button::link("New post", "/admin/posts/new")
250            .primary()
251            .small()
252            .icon("plus")
253            .render(&shell)
254            .into_string();
255        assert!(
256            html.starts_with(
257                r#"<a class="lat-btn lat-btn--primary lat-btn--sm" href="/admin/posts/new">"#
258            ),
259            "{html}"
260        );
261        assert!(
262            html.contains("lat-btn__icon") && html.ends_with("New post</a>"),
263            "{html}"
264        );
265    }
266
267    #[test]
268    fn a_post_button_is_a_form_with_the_token_and_asks_first() {
269        let shell = Shell::test();
270        let html = Button::post("Rebuild", "/admin/reports/rebuild")
271            .danger()
272            .confirm("Rebuild every report?")
273            .render(&shell)
274            .into_string();
275        assert!(
276            html.starts_with(r#"<form method="post" action="/admin/reports/rebuild">"#),
277            "{html}"
278        );
279        assert!(
280            html.contains(r#"name="_csrf""#)
281                && html.contains(r#"data-lat-confirm="Rebuild every report?""#),
282            "{html}"
283        );
284        assert!(
285            html.contains("lat-btn--danger") && !html.contains("lat-btn--sm"),
286            "{html}"
287        );
288    }
289
290    #[test]
291    fn a_section_toolbar_heads_its_buttons_and_text_is_escaped() {
292        let shell = Shell::test();
293        let html = Toolbar::section("Notes <b>")
294            .button(Button::link("Add", "/x"))
295            .render(&shell)
296            .into_string();
297        assert!(html.starts_with(r#"<div class="lat-toolbar lat-toolbar--section"><h2>Notes &lt;b&gt;</h2><div class="lat-toolbar__actions">"#), "{html}");
298        assert_eq!(
299            Badge::new("Stale").render(&shell).into_string(),
300            r#"<span class="lat-badge">Stale</span>"#
301        );
302        assert_eq!(
303            Card::new(Markup::from_override("<p>x</p>".to_string()))
304                .render()
305                .into_string(),
306            r#"<div class="lat-card"><p>x</p></div>"#
307        );
308    }
309}