1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
19#[serde(rename_all = "lowercase")]
20pub enum Size {
21 Third,
23 #[default]
25 Half,
26 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
55pub 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 pub fn base(&self) -> &str {
70 &self.shell.base
71 }
72 pub fn t(&self, text: &Text) -> String {
74 self.shell.tt(text)
75 }
76}
77
78#[laterite_core::strata::async_trait]
80pub trait Widget: Send + Sync {
81 fn code(&self) -> &str;
83 fn label(&self) -> Text;
85 fn description(&self) -> Option<Text> {
87 None
88 }
89 fn permission(&self) -> Option<&str> {
92 None
93 }
94 fn size(&self) -> Size {
96 Size::Half
97 }
98 fn refresh(&self) -> Option<u32> {
101 None
102 }
103 async fn render(&self, cx: &WidgetCx<'_>) -> Result<Markup, String>;
106}
107
108#[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 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
147pub const HIERARCHY: &str = "backend.dashboard";
149
150pub(crate) fn hierarchy() -> Hierarchy {
151 Hierarchy::new(HIERARCHY)
152 .policy(Policy::Adjustable)
153 .operator_adjustable()
154}
155
156pub(crate) const PREFERENCE: &str = "dashboard.layout";
158
159#[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
178pub(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 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
222pub(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
244pub(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(®s, 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(®s, 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(®s, 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(®s, 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 ®s[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}