use crate::html::Markup;
use laterite_auth::AuthenticatedUser;
use laterite_core::arrange::{Arranged, Hierarchy, Policy, Position};
use laterite_core::{Db, Text};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Size {
Third,
#[default]
Half,
Full,
}
impl Size {
pub(crate) fn class(self) -> &'static str {
match self {
Self::Third => "lat-widget--third",
Self::Half => "lat-widget--half",
Self::Full => "lat-widget--full",
}
}
pub(crate) fn key(self) -> &'static str {
match self {
Self::Third => "third",
Self::Half => "half",
Self::Full => "full",
}
}
pub(crate) fn parse(key: &str) -> Option<Self> {
match key {
"third" => Some(Self::Third),
"half" => Some(Self::Half),
"full" => Some(Self::Full),
_ => None,
}
}
}
pub struct WidgetCx<'a> {
pub(crate) state: &'a crate::AdminState,
pub shell: &'a crate::Shell,
pub user: &'a AuthenticatedUser,
pub size: Size,
}
impl WidgetCx<'_> {
pub fn db(&self) -> &Db {
&self.state.db
}
pub fn base(&self) -> &str {
&self.shell.base
}
pub fn t(&self, text: &Text) -> String {
self.shell.tt(text)
}
}
#[laterite_core::strata::async_trait]
pub trait Widget: Send + Sync {
fn code(&self) -> &str;
fn label(&self) -> Text;
fn description(&self) -> Option<Text> {
None
}
fn permission(&self) -> Option<&str> {
None
}
fn size(&self) -> Size {
Size::Half
}
fn refresh(&self) -> Option<u32> {
None
}
async fn render(&self, cx: &WidgetCx<'_>) -> Result<Markup, String>;
}
#[derive(Clone)]
pub struct WidgetReg {
pub widget: Arc<dyn Widget>,
pub position: Position,
label: String,
}
impl WidgetReg {
pub fn new(widget: Arc<dyn Widget>) -> Self {
let label = widget.label().source().to_string();
Self {
widget,
position: Position::new().slot(laterite_core::arrange::slot::APPLICATION),
label,
}
}
pub fn position(mut self, position: Position) -> Self {
self.position = position;
self
}
pub fn code(&self) -> &str {
self.widget.code()
}
}
impl Arranged for WidgetReg {
fn key(&self) -> &str {
self.widget.code()
}
fn position(&self) -> &Position {
&self.position
}
fn label(&self) -> &str {
&self.label
}
}
pub const HIERARCHY: &str = "backend.dashboard";
pub(crate) fn hierarchy() -> Hierarchy {
Hierarchy::new(HIERARCHY)
.policy(Policy::Adjustable)
.operator_adjustable()
}
pub(crate) const PREFERENCE: &str = "dashboard.layout";
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct Layout {
pub widgets: Vec<Placed>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct Placed {
pub code: String,
#[serde(default)]
pub size: Size,
}
impl Layout {
pub(crate) fn parse(json: &str) -> Option<Self> {
serde_json::from_str(json).ok()
}
}
pub(crate) fn available<'a>(
regs: &'a [WidgetReg],
deployment: Option<&[String]>,
allows: &dyn Fn(&str) -> bool,
) -> Vec<&'a WidgetReg> {
let mut items: Vec<&WidgetReg> = regs
.iter()
.filter(|r| r.widget.permission().is_none_or(allows))
.collect();
let keyed: Vec<Keyed<'_>> = items
.iter()
.enumerate()
.map(|(i, r)| Keyed { at: i, reg: r })
.collect();
let overrides = laterite_core::arrange::Overrides {
deployment,
operator: None,
};
if let Ok(arranged) = laterite_core::arrange::arrange(&hierarchy(), keyed, overrides) {
let order: Vec<usize> = arranged.into_iter().map(|k| k.at).collect();
items = order.into_iter().map(|i| items[i]).collect();
}
items
}
struct Keyed<'a> {
at: usize,
reg: &'a WidgetReg,
}
impl Arranged for Keyed<'_> {
fn key(&self) -> &str {
self.reg.key()
}
fn position(&self) -> &Position {
self.reg.position()
}
fn label(&self) -> &str {
self.reg.label()
}
}
pub(crate) fn resolve<'a>(
layout: Option<&Layout>,
available: &[&'a WidgetReg],
) -> Vec<(&'a WidgetReg, Size)> {
match layout {
Some(layout) => layout
.widgets
.iter()
.filter_map(|p| {
available
.iter()
.find(|r| r.code() == p.code)
.map(|r| (*r, p.size))
})
.collect(),
None => available.iter().map(|r| (*r, r.widget.size())).collect(),
}
}
pub(crate) fn frame(
reg: &WidgetReg,
size: Size,
base: &str,
label: &str,
body: Result<Markup, String>,
failed: &str,
) -> String {
let code = crate::form::escape_attr(reg.code());
let refresh = reg
.widget
.refresh()
.map(|s| {
format!(
r#" hx-get="{base}/dashboard/widget/{code}?size={}" hx-trigger="every {s}s" hx-swap="innerHTML""#,
size.key()
)
})
.unwrap_or_default();
let inner = match body {
Ok(markup) => markup.into_string(),
Err(e) => {
tracing::warn!(widget = reg.code(), error = %e, "widget failed to render");
format!(
r#"<p class="lat-widget__failed">{}</p>"#,
crate::form::escape_attr(failed)
)
}
};
format!(
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>"#,
size.class(),
crate::form::escape_attr(label),
)
}
#[cfg(test)]
mod tests {
use super::*;
struct Fixed(&'static str, Option<&'static str>, Size);
#[laterite_core::strata::async_trait]
impl Widget for Fixed {
fn code(&self) -> &str {
self.0
}
fn label(&self) -> Text {
Text::dynamic(self.0)
}
fn permission(&self) -> Option<&str> {
self.1
}
fn size(&self) -> Size {
self.2
}
async fn render(&self, _cx: &WidgetCx<'_>) -> Result<Markup, String> {
Markup::from_template(&Body).map_err(|e| e.to_string())
}
}
#[derive(askama::Template)]
#[template(source = "<p>x</p>", ext = "html")]
struct Body;
fn regs() -> Vec<WidgetReg> {
vec![
WidgetReg::new(Arc::new(Fixed(
"acme.sales",
Some("acme.sales"),
Size::Full,
)))
.position(Position::new().slot(500)),
WidgetReg::new(Arc::new(Fixed("backend.welcome", None, Size::Half)))
.position(Position::new().slot(0)),
WidgetReg::new(Arc::new(Fixed("backend.status", None, Size::Third)))
.position(Position::new().slot(900)),
]
}
#[test]
fn available_filters_by_permission_and_arranges_by_slot() {
let regs = regs();
let none = available(®s, None, &|_| false);
assert_eq!(
none.iter().map(|r| r.code()).collect::<Vec<_>>(),
vec!["backend.welcome", "backend.status"]
);
let all = available(®s, None, &|_| true);
assert_eq!(
all.iter().map(|r| r.code()).collect::<Vec<_>>(),
vec!["backend.welcome", "acme.sales", "backend.status"]
);
let moved = ["backend.status".to_string()];
let deployed = available(®s, Some(&moved), &|_| true);
assert_eq!(deployed[0].code(), "backend.status");
}
#[test]
fn a_layout_keeps_its_order_and_sizes_and_drops_what_it_may_not_show() {
let regs = regs();
let avail = available(®s, None, &|_| false);
let layout = Layout::parse(
r#"{"widgets":[{"code":"backend.status","size":"full"},{"code":"acme.sales"},{"code":"gone"},{"code":"backend.welcome","size":"third"}]}"#,
)
.unwrap();
let shown = resolve(Some(&layout), &avail);
assert_eq!(
shown
.iter()
.map(|(r, s)| (r.code(), *s))
.collect::<Vec<_>>(),
vec![
("backend.status", Size::Full),
("backend.welcome", Size::Third)
]
);
let default = resolve(None, &avail);
assert_eq!(default[0].1, Size::Half);
assert_eq!(default[1].1, Size::Third);
}
#[test]
fn a_failed_body_renders_a_notice_inside_the_frame() {
let regs = regs();
let html = frame(
®s[1],
Size::Half,
"/admin",
"Welcome",
Err("boom".into()),
"This widget could not be shown.",
);
assert!(html.contains("lat-widget--half"));
assert!(html.contains("This widget could not be shown."));
assert!(!html.contains("boom"));
assert!(!html.contains("hx-get"));
}
}