umbral_admin/widgets.rs
1//! Dashboard widget system for umbral-admin.
2//!
3//! Plugins register widgets via `AdminPlugin::register_widget`. Each widget
4//! has a `key`, `title`, `kind`, `default_span`, optional `permission`, and
5//! an async data function. The admin dashboard renders a 12-column grid of
6//! the user's saved layout (defaulting to all permitted widgets).
7//!
8//! ## Registration shape
9//!
10//! ```rust,ignore
11//! admin.register_widget(Widget {
12//! key: "umbral_total_models",
13//! title: "Total Models".to_string(),
14//! kind: WidgetKind::Kpi,
15//! default_span: Span { cols: 3, rows: 1 },
16//! permission: None,
17//! data: WidgetDataFn::new(|_user| async move {
18//! WidgetPayload::Kpi(KpiPayload {
19//! value: "42".to_string(),
20//! unit: None,
21//! delta: None,
22//! sparkline: None,
23//! })
24//! }),
25//! });
26//! ```
27//!
28//! ## Endpoint contract
29//!
30//! - `GET /admin/api/dashboard/catalog` — `[{key, title, kind, default_span}]`
31//! - `GET /admin/api/dashboard/layout` — user's saved layout or default
32//! - `PUT /admin/api/dashboard/layout` — save user's layout
33//! - `GET /admin/api/dashboard/widgets/{key}/data` — typed payload JSON
34
35use std::future::Future;
36use std::pin::Pin;
37use std::sync::Arc;
38
39use serde::{Deserialize, Serialize};
40use umbral_auth::AuthUser;
41
42// =========================================================================
43// Span
44// =========================================================================
45
46/// Grid span in the 12-column dashboard grid.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Span {
49 /// Number of columns to occupy (1–12).
50 pub cols: u8,
51 /// Number of rows to occupy (1–N).
52 pub rows: u8,
53}
54
55impl Default for Span {
56 fn default() -> Self {
57 Self { cols: 3, rows: 1 }
58 }
59}
60
61// =========================================================================
62// WidgetKind
63// =========================================================================
64
65/// The visual kind of a dashboard widget. Drives how the payload is rendered.
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
67#[serde(rename_all = "lowercase")]
68pub enum WidgetKind {
69 /// Simple single-value KPI (legacy, kept for backwards compat).
70 Kpi,
71 /// Shop-style summary card: title + icon + small unit / subtitle
72 /// + large humanized value + optional growth-vs-previous-period.
73 /// The everyday "Total sales / Orders / Customers" tile.
74 Card,
75 Line,
76 Bar,
77 /// Donut chart — labeled slices summing to 100%. Best for
78 /// low-cardinality breakdowns (status distribution, top N
79 /// regions, mode share) where a bar chart's axes are
80 /// overkill. 3-6 slices reads cleanly; past that switch
81 /// to a bar.
82 Donut,
83 /// Radial gauge — one or more 0–100% tracks rendered as
84 /// concentric arcs (ApexCharts `radialBar`). The everyday
85 /// "progress toward a goal" tile: quota attainment, capacity
86 /// used, completion rate, SLA. A single track reads as one big
87 /// ring with the percent in the centre; 2–4 tracks compare
88 /// related ratios (e.g. per-plan conversion).
89 Radial,
90 /// Heatmap — a 2-D grid of cells colored by magnitude (ApexCharts
91 /// `heatmap`). Best for "activity by time" patterns: day-of-week ×
92 /// hour-of-day signups, cohort retention, per-region load. Each
93 /// row is a series; each cell an `(x, value)` pair.
94 Heatmap,
95 /// Progress bars — a ranked list of labeled horizontal bars, each
96 /// filled relative to the largest value (or an explicit target).
97 /// The "top N by metric" tile: revenue by product, traffic by
98 /// source, completion per category. Pure HTML; no chart library.
99 Progress,
100 Table,
101 Feed,
102}
103
104impl WidgetKind {
105 pub fn as_str(&self) -> &'static str {
106 match self {
107 WidgetKind::Kpi => "kpi",
108 WidgetKind::Card => "card",
109 WidgetKind::Line => "line",
110 WidgetKind::Bar => "bar",
111 WidgetKind::Donut => "donut",
112 WidgetKind::Radial => "radial",
113 WidgetKind::Heatmap => "heatmap",
114 WidgetKind::Progress => "progress",
115 WidgetKind::Table => "table",
116 WidgetKind::Feed => "feed",
117 }
118 }
119}
120
121// =========================================================================
122// Typed payloads
123// =========================================================================
124
125/// KPI card payload.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct KpiPayload {
128 /// The primary metric value (displayed large).
129 pub value: String,
130 /// Optional unit label, e.g. "rows" or "MB".
131 pub unit: Option<String>,
132 /// Optional delta percentage; positive = up, negative = down.
133 pub delta: Option<f64>,
134 /// Optional sparkline data points (values only; x is implicit index).
135 pub sparkline: Option<Vec<f64>>,
136}
137
138// =========================================================================
139// Card payload — the everyday "summary tile" widget.
140// =========================================================================
141
142/// Summary card payload. Renders as:
143///
144/// ```text
145/// ┌──────────────────────────────────────────┐
146/// │ TITLE [icon] │ ← title row (from Widget)
147/// │ │
148/// │ USD 12,438.20 │ ← unit (sm, left) + value (lg, right)
149/// │ │
150/// │ This month ↑ 12.3% vs last month │ ← subtitle + growth
151/// └──────────────────────────────────────────┘
152/// ```
153///
154/// Build with [`CardPayload::new`] + the chained setters; pass to
155/// [`WidgetPayload::Card`].
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct CardPayload {
158 /// Formatted primary value, e.g. "12,438.20" or "12.4K". Use
159 /// [`humanize_number`] for the K/M/B/T compaction.
160 pub value: String,
161 /// Optional unit / context label shown on the left side of the
162 /// value row, e.g. "USD", "rows", "today".
163 pub unit: Option<String>,
164 /// Optional Lucide icon name (e.g. "dollar-sign", "shopping-cart").
165 /// Rendered via the data-lucide attribute the wrapper already
166 /// initializes.
167 pub icon: Option<String>,
168 /// Optional caption below the value, e.g. "This month".
169 pub subtitle: Option<String>,
170 /// Percentage delta vs. the previous period, signed:
171 /// `+12.3` = up 12.3%, `-4.1` = down 4.1%. The renderer picks
172 /// the arrow + color from the sign.
173 pub delta_percent: Option<f64>,
174 /// Optional comparison label, e.g. "vs last month".
175 pub delta_label: Option<String>,
176 /// Optional trend trail — a flat series of N points the
177 /// renderer plots as a fade-right sparkline under the value.
178 /// X is implicit (evenly spaced); Y autoscales between
179 /// min/max. Pair with `growth(...)` so the pill matches the
180 /// trail visually. Keep the series small (7–30 points) —
181 /// anything denser turns into noise at sparkline scale.
182 pub sparkline: Option<Vec<f64>>,
183}
184
185impl CardPayload {
186 /// New card with just a primary value. Caller picks the format
187 /// — strings stay as-is, numbers should be pre-humanized with
188 /// [`humanize_number`] / [`format_thousands`].
189 pub fn new(value: impl Into<String>) -> Self {
190 Self {
191 value: value.into(),
192 unit: None,
193 icon: None,
194 subtitle: None,
195 delta_percent: None,
196 delta_label: None,
197 sparkline: None,
198 }
199 }
200
201 pub fn unit(mut self, unit: impl Into<String>) -> Self {
202 self.unit = Some(unit.into());
203 self
204 }
205
206 pub fn icon(mut self, icon: impl Into<String>) -> Self {
207 self.icon = Some(icon.into());
208 self
209 }
210
211 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
212 self.subtitle = Some(subtitle.into());
213 self
214 }
215
216 /// Compute the delta automatically from current + previous raw
217 /// numbers. Skips the delta when `previous` is zero (no baseline
218 /// to grow from) or non-finite — the renderer just won't show
219 /// the growth row in that case.
220 pub fn growth(mut self, current: f64, previous: f64) -> Self {
221 if previous.is_finite() && previous != 0.0 && current.is_finite() {
222 self.delta_percent = Some(((current - previous) / previous.abs()) * 100.0);
223 }
224 self
225 }
226
227 /// Explicit delta percent (signed) + label. Use when you've
228 /// computed the percentage yourself or want a custom label.
229 pub fn delta(mut self, percent: f64, label: impl Into<String>) -> Self {
230 self.delta_percent = Some(percent);
231 self.delta_label = Some(label.into());
232 self
233 }
234
235 /// Standalone label for the delta — pairs with [`Self::growth`]
236 /// for the common case "auto-compute the percent but customize
237 /// the comparison label" (e.g. `"vs prior 30d"`).
238 pub fn delta_label(mut self, label: impl Into<String>) -> Self {
239 self.delta_label = Some(label.into());
240 self
241 }
242
243 /// Attach a trend trail rendered as a fade-right sparkline
244 /// under the value. Pass 7–30 raw numbers (daily totals,
245 /// hourly counts, etc.); the renderer autoscales and colors
246 /// the stroke to match [`Self::delta_percent`]'s sign.
247 pub fn sparkline(mut self, points: impl IntoIterator<Item = f64>) -> Self {
248 self.sparkline = Some(points.into_iter().collect());
249 self
250 }
251}
252
253/// Humanize a number into a compact display string:
254///
255/// | input | output |
256/// |------------------|------------|
257/// | `42.0` | `"42"` |
258/// | `1_234.5` | `"1,234.50"` |
259/// | `12_438.2` | `"12.4K"` |
260/// | `1_500_000.0` | `"1.50M"` |
261/// | `2_700_000_000.` | `"2.70B"` |
262///
263/// Suitable for card values where horizontal space is scarce.
264pub fn humanize_number(n: f64) -> String {
265 if !n.is_finite() {
266 return "—".to_string();
267 }
268 let abs = n.abs();
269 let sign = if n < 0.0 { "-" } else { "" };
270 if abs < 1000.0 {
271 // Two decimals when there's a fractional part; integer otherwise.
272 if (abs.fract() - 0.0).abs() < f64::EPSILON {
273 return format!("{sign}{}", abs as i64);
274 }
275 return format!("{sign}{:.2}", abs);
276 }
277 if abs < 1_000_000.0 {
278 if abs < 10_000.0 {
279 // Keep the thousands separator at the low end of the K
280 // range — "9,876" reads better than "9.9K" for amounts a
281 // user is likely to mentally verify against the data.
282 return format_thousands(n);
283 }
284 return format!("{sign}{:.1}K", abs / 1_000.0);
285 }
286 if abs < 1_000_000_000.0 {
287 return format!("{sign}{:.2}M", abs / 1_000_000.0);
288 }
289 if abs < 1_000_000_000_000.0 {
290 return format!("{sign}{:.2}B", abs / 1_000_000_000.0);
291 }
292 format!("{sign}{:.2}T", abs / 1_000_000_000_000.0)
293}
294
295/// Format a number with thousands separators and (when fractional)
296/// two decimal places. Use for values where the full digits matter
297/// (currency totals, audit counts) — for compact display use
298/// [`humanize_number`].
299pub fn format_thousands(n: f64) -> String {
300 if !n.is_finite() {
301 return "—".to_string();
302 }
303 let sign = if n < 0.0 { "-" } else { "" };
304 let abs = n.abs();
305 let int_part = abs.trunc() as u128;
306 let frac_part = abs - abs.trunc();
307
308 // Insert commas every 3 digits, right-to-left.
309 let int_str = int_part.to_string();
310 let bytes = int_str.as_bytes();
311 let mut grouped = String::with_capacity(int_str.len() + int_str.len() / 3);
312 for (i, b) in bytes.iter().enumerate() {
313 if i > 0 && (bytes.len() - i) % 3 == 0 {
314 grouped.push(',');
315 }
316 grouped.push(*b as char);
317 }
318
319 if frac_part > 0.0 {
320 format!("{sign}{grouped}.{:02}", (frac_part * 100.0).round() as u64)
321 } else {
322 format!("{sign}{grouped}")
323 }
324}
325
326/// One data series for Line or Bar charts.
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct Series {
329 pub name: String,
330 pub points: Vec<ChartPoint>,
331}
332
333/// X/Y data point. X is a string for flexible labeling.
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct ChartPoint {
336 pub x: String,
337 pub y: f64,
338}
339
340/// Line chart payload.
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct LinePayload {
343 pub series: Vec<Series>,
344 /// Describes what `x` represents; e.g. "date", "category".
345 pub x_type: String,
346}
347
348/// Bar chart payload (same shape as Line).
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct BarPayload {
351 pub series: Vec<Series>,
352 pub x_type: String,
353}
354
355/// One slice of a donut chart.
356#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct DonutSlice {
358 pub label: String,
359 pub value: f64,
360 /// Optional explicit color (CSS hex / rgb / token name).
361 /// `None` falls back to the chart's default palette.
362 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub color: Option<String>,
364}
365
366/// Donut chart payload — categorical breakdown summing to 100%.
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct DonutPayload {
369 pub slices: Vec<DonutSlice>,
370}
371
372impl DonutPayload {
373 pub fn new(slices: Vec<DonutSlice>) -> Self {
374 Self { slices }
375 }
376
377 /// Build slices from `(label, value)` tuples; the chart
378 /// picks colors from its default palette.
379 pub fn from_pairs<L: Into<String>>(pairs: impl IntoIterator<Item = (L, f64)>) -> Self {
380 Self::new(
381 pairs
382 .into_iter()
383 .map(|(label, value)| DonutSlice {
384 label: label.into(),
385 value,
386 color: None,
387 })
388 .collect(),
389 )
390 }
391}
392
393/// One arc of a [`RadialPayload`] gauge — a labeled 0–100% value.
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct RadialTrack {
396 pub label: String,
397 /// Percent in `[0, 100]`. The `RadialPayload` constructors clamp
398 /// this so the arc never overruns the ring.
399 pub value: f64,
400 /// Optional explicit arc color (CSS hex / rgb / token name).
401 /// `None` falls back to the chart's default palette.
402 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub color: Option<String>,
404}
405
406/// Radial gauge payload — one or more 0–100% tracks rendered as
407/// concentric arcs (ApexCharts `radialBar`). Use for "progress toward
408/// a goal" metrics: quota attainment, capacity used, completion rate.
409///
410/// ```ignore
411/// // One ring: 73% of the monthly sales goal.
412/// RadialPayload::goal("Monthly goal", sales, target)
413/// // Compare conversion across plans.
414/// RadialPayload::from_pairs([("Free", 8.0), ("Pro", 21.5), ("Team", 34.0)])
415/// ```
416#[derive(Debug, Clone, Serialize, Deserialize)]
417pub struct RadialPayload {
418 pub tracks: Vec<RadialTrack>,
419}
420
421impl RadialPayload {
422 /// New payload from explicit tracks; each `value` is clamped to
423 /// `[0, 100]` (non-finite -> 0).
424 pub fn new(tracks: Vec<RadialTrack>) -> Self {
425 Self {
426 tracks: tracks
427 .into_iter()
428 .map(|t| RadialTrack {
429 value: clamp_percent(t.value),
430 ..t
431 })
432 .collect(),
433 }
434 }
435
436 /// A single-track gauge — the common case (one big ring with the
437 /// percent in the centre).
438 pub fn single(label: impl Into<String>, percent: f64) -> Self {
439 Self::new(vec![RadialTrack {
440 label: label.into(),
441 value: percent,
442 color: None,
443 }])
444 }
445
446 /// A single-track gauge whose percent is `current / target * 100`
447 /// — the literal "progress toward a goal" shape. A non-positive
448 /// `target` yields 0% (nothing to measure against).
449 pub fn goal(label: impl Into<String>, current: f64, target: f64) -> Self {
450 let pct = if target > 0.0 {
451 current / target * 100.0
452 } else {
453 0.0
454 };
455 Self::single(label, pct)
456 }
457
458 /// Build tracks from `(label, percent)` tuples; the chart picks
459 /// colors from its default palette. Each percent is clamped.
460 pub fn from_pairs<L: Into<String>>(pairs: impl IntoIterator<Item = (L, f64)>) -> Self {
461 Self::new(
462 pairs
463 .into_iter()
464 .map(|(label, value)| RadialTrack {
465 label: label.into(),
466 value,
467 color: None,
468 })
469 .collect(),
470 )
471 }
472}
473
474/// Clamp a percentage into `[0, 100]`; non-finite -> 0.
475fn clamp_percent(v: f64) -> f64 {
476 if v.is_finite() {
477 v.clamp(0.0, 100.0)
478 } else {
479 0.0
480 }
481}
482
483/// One cell in a [`HeatmapRow`] — an x-axis bucket and its magnitude.
484#[derive(Debug, Clone, Serialize, Deserialize)]
485pub struct HeatmapCell {
486 /// X-axis label for this cell (e.g. an hour `"09"`, a month `"Mar"`).
487 pub x: String,
488 /// The value that colors the cell. Higher = hotter.
489 pub y: f64,
490}
491
492/// One row (series) of a [`HeatmapPayload`] — a label plus its cells
493/// across the shared x-axis.
494#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct HeatmapRow {
496 pub name: String,
497 pub cells: Vec<HeatmapCell>,
498}
499
500/// Heatmap payload — a 2-D grid of cells colored by magnitude
501/// (ApexCharts `heatmap`). Every row shares the same ordered x-axis.
502/// Use for "activity by time" patterns (day-of-week × hour), cohort
503/// retention, or per-region load.
504///
505/// ```ignore
506/// HeatmapPayload::from_grid(
507/// ["Mon", "Tue", "Wed"],
508/// ["00-06", "06-12", "12-18", "18-24"],
509/// vec![
510/// vec![2.0, 9.0, 14.0, 6.0],
511/// vec![1.0, 11.0, 17.0, 8.0],
512/// vec![3.0, 13.0, 19.0, 7.0],
513/// ],
514/// )
515/// ```
516#[derive(Debug, Clone, Serialize, Deserialize)]
517pub struct HeatmapPayload {
518 pub rows: Vec<HeatmapRow>,
519}
520
521impl HeatmapPayload {
522 /// New payload from explicit rows.
523 pub fn new(rows: Vec<HeatmapRow>) -> Self {
524 Self { rows }
525 }
526
527 /// Build a rectangular grid from row labels, shared column (x)
528 /// labels, and a `values[row][col]` matrix. A short value row is
529 /// padded with `0.0` and extra values past the columns are dropped,
530 /// so the grid is always rectangular regardless of ragged input.
531 pub fn from_grid<R, C>(
532 row_labels: impl IntoIterator<Item = R>,
533 col_labels: impl IntoIterator<Item = C>,
534 values: Vec<Vec<f64>>,
535 ) -> Self
536 where
537 R: Into<String>,
538 C: Into<String>,
539 {
540 let cols: Vec<String> = col_labels.into_iter().map(Into::into).collect();
541 let rows = row_labels
542 .into_iter()
543 .enumerate()
544 .map(|(r, label)| {
545 let row_vals = values.get(r);
546 let cells = cols
547 .iter()
548 .enumerate()
549 .map(|(c, x)| HeatmapCell {
550 x: x.clone(),
551 y: row_vals.and_then(|v| v.get(c)).copied().unwrap_or(0.0),
552 })
553 .collect();
554 HeatmapRow {
555 name: label.into(),
556 cells,
557 }
558 })
559 .collect();
560 Self { rows }
561 }
562}
563
564/// One row of a [`ProgressPayload`] — a labeled horizontal bar.
565#[derive(Debug, Clone, Serialize, Deserialize)]
566pub struct ProgressItem {
567 pub label: String,
568 /// Pre-formatted value shown at the right of the row (the
569 /// `from_pairs` constructors thousands-group it; `new` takes it
570 /// verbatim).
571 pub display: String,
572 /// Bar fill width, `0–100`, relative to the payload's reference
573 /// (the largest value, or an explicit target).
574 pub percent: f64,
575 /// Optional explicit bar color (CSS hex / rgb / token name).
576 #[serde(default, skip_serializing_if = "Option::is_none")]
577 pub color: Option<String>,
578}
579
580/// Progress-bar list payload — a ranked set of labeled horizontal bars,
581/// each filled relative to the largest value (or an explicit target).
582/// The "top N by metric" tile: revenue by product, traffic by source,
583/// completion per category. Rendered as pure HTML — no chart library.
584///
585/// ```ignore
586/// // Revenue by product; the top product fills the bar.
587/// ProgressPayload::from_pairs([("Pro", 48200.0), ("Team", 31000.0), ("Free", 9400.0)])
588/// // Completion per team, each measured against a 100-task target.
589/// ProgressPayload::from_pairs_of([("Web", 82.0), ("Mobile", 57.0)], 100.0)
590/// ```
591#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct ProgressPayload {
593 pub items: Vec<ProgressItem>,
594}
595
596impl ProgressPayload {
597 /// New payload from explicit items (you set `display` + `percent`).
598 pub fn new(items: Vec<ProgressItem>) -> Self {
599 Self { items }
600 }
601
602 /// From `(label, value)` pairs, with each bar sized relative to the
603 /// LARGEST value (the top item fills the bar). `display` is the
604 /// thousands-grouped value.
605 pub fn from_pairs<L: Into<String>>(pairs: impl IntoIterator<Item = (L, f64)>) -> Self {
606 let items: Vec<(String, f64)> = pairs.into_iter().map(|(l, v)| (l.into(), v)).collect();
607 let reference = items
608 .iter()
609 .map(|(_, v)| *v)
610 .filter(|v| v.is_finite())
611 .fold(0.0_f64, f64::max);
612 Self::build(items, reference)
613 }
614
615 /// From `(label, value)` pairs, with each bar sized against an
616 /// explicit `target` (e.g. a per-row "% of goal"). A non-positive
617 /// target falls back to sizing against the largest value.
618 pub fn from_pairs_of<L: Into<String>>(
619 pairs: impl IntoIterator<Item = (L, f64)>,
620 target: f64,
621 ) -> Self {
622 let items: Vec<(String, f64)> = pairs.into_iter().map(|(l, v)| (l.into(), v)).collect();
623 let reference = if target > 0.0 {
624 target
625 } else {
626 items
627 .iter()
628 .map(|(_, v)| *v)
629 .filter(|v| v.is_finite())
630 .fold(0.0_f64, f64::max)
631 };
632 Self::build(items, reference)
633 }
634
635 /// Shared: turn `(label, value)` + a reference max into rendered
636 /// items. `percent = value / reference * 100`, clamped to
637 /// `[0, 100]`; a zero/non-finite reference yields empty bars.
638 fn build(items: Vec<(String, f64)>, reference: f64) -> Self {
639 let items = items
640 .into_iter()
641 .map(|(label, value)| {
642 let percent = if reference > 0.0 && value.is_finite() {
643 (value / reference * 100.0).clamp(0.0, 100.0)
644 } else {
645 0.0
646 };
647 ProgressItem {
648 label,
649 display: format_thousands(value),
650 percent,
651 color: None,
652 }
653 })
654 .collect();
655 Self { items }
656 }
657}
658
659/// Table widget column descriptor.
660#[derive(Debug, Clone, Serialize, Deserialize)]
661pub struct TableColumn {
662 pub key: String,
663 pub label: String,
664}
665
666/// Table widget payload.
667#[derive(Debug, Clone, Serialize, Deserialize)]
668pub struct TablePayload {
669 pub columns: Vec<TableColumn>,
670 pub rows: Vec<serde_json::Value>,
671 /// Optional "View all →" link in the widget header. Populated
672 /// via [`Self::view_all_for`] (auto-resolves the admin URL
673 /// from a `Model` type) or set explicitly when the target
674 /// isn't a managed admin model.
675 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub view_all_url: Option<String>,
677}
678
679impl TablePayload {
680 /// New payload from columns + rows; no `view_all` link.
681 pub fn new(columns: Vec<TableColumn>, rows: Vec<serde_json::Value>) -> Self {
682 Self {
683 columns,
684 rows,
685 view_all_url: None,
686 }
687 }
688
689 /// Auto-resolve the "View all" link from a `Model` type — the
690 /// admin's changelist URL for that table. Mirrors the pattern
691 /// used by `models![T, U, V]`: rename the struct's
692 /// `#[umbral(table = "...")]` and the link follows automatically.
693 ///
694 /// ```rust,ignore
695 /// WidgetPayload::Table(
696 /// TablePayload::new(columns, rows)
697 /// .view_all_for::<Order>()
698 /// )
699 /// // → "View all →" links to {admin_base}/order/
700 /// ```
701 pub fn view_all_for<T: umbral::orm::Model>(mut self) -> Self {
702 self.view_all_url = Some(format!(
703 "{}/{}/",
704 crate::branding::current().base_path,
705 T::TABLE,
706 ));
707 self
708 }
709
710 /// Explicit URL override — use when the link target isn't a
711 /// managed admin model (an external dashboard, a custom route).
712 pub fn view_all_url(mut self, url: impl Into<String>) -> Self {
713 self.view_all_url = Some(url.into());
714 self
715 }
716}
717
718/// One item in an activity feed.
719#[derive(Debug, Clone, Serialize, Deserialize)]
720pub struct FeedItem {
721 pub actor: String,
722 pub verb: String,
723 pub object: String,
724 pub object_link: Option<String>,
725 pub at: String,
726}
727
728/// Activity feed payload.
729#[derive(Debug, Clone, Serialize, Deserialize)]
730pub struct FeedPayload {
731 pub items: Vec<FeedItem>,
732 /// Optional "View all →" link in the widget header. Same
733 /// shape as [`TablePayload::view_all_url`] — auto-resolve
734 /// from a `Model` via [`Self::view_all_for`].
735 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub view_all_url: Option<String>,
737}
738
739impl FeedPayload {
740 /// New payload from items; no `view_all` link.
741 pub fn new(items: Vec<FeedItem>) -> Self {
742 Self {
743 items,
744 view_all_url: None,
745 }
746 }
747
748 /// Auto-resolve the "View all" link from a `Model` type. The
749 /// recent-signups feed for instance:
750 ///
751 /// ```rust,ignore
752 /// WidgetPayload::Feed(
753 /// FeedPayload::new(items).view_all_for::<AuthUser>()
754 /// )
755 /// // → "View all →" links to {admin_base}/auth_user/
756 /// ```
757 pub fn view_all_for<T: umbral::orm::Model>(mut self) -> Self {
758 self.view_all_url = Some(format!(
759 "{}/{}/",
760 crate::branding::current().base_path,
761 T::TABLE,
762 ));
763 self
764 }
765
766 pub fn view_all_url(mut self, url: impl Into<String>) -> Self {
767 self.view_all_url = Some(url.into());
768 self
769 }
770}
771
772/// Union of all widget payloads. The JSON discriminant is the variant name.
773#[derive(Debug, Clone, Serialize, Deserialize)]
774#[serde(tag = "kind", rename_all = "lowercase")]
775pub enum WidgetPayload {
776 Kpi(KpiPayload),
777 Card(CardPayload),
778 Line(LinePayload),
779 Bar(BarPayload),
780 Donut(DonutPayload),
781 Radial(RadialPayload),
782 Heatmap(HeatmapPayload),
783 Progress(ProgressPayload),
784 Table(TablePayload),
785 Feed(FeedPayload),
786}
787
788/// Quote a CSV field per RFC 4180: wrap in `"` when it contains a comma, a
789/// quote or a newline, and double any embedded quotes.
790///
791/// Also neutralises the spreadsheet-formula injection vector: a cell whose text
792/// starts with `=`, `+`, `-` or `@` is executed as a formula when the file is
793/// opened in Excel or Sheets, so a row whose value someone typed into your app
794/// (`=HYPERLINK(...)`, `=cmd|...`) becomes code running on the machine of
795/// whoever opened the export. Prefixing a tab keeps the text readable while
796/// making it inert.
797fn csv_field(value: &str) -> String {
798 let dangerous = value
799 .chars()
800 .next()
801 .is_some_and(|c| matches!(c, '=' | '+' | '-' | '@'));
802 let value = if dangerous {
803 format!("\t{value}")
804 } else {
805 value.to_string()
806 };
807 if value.contains(',') || value.contains('"') || value.contains('\n') || value.contains('\r') {
808 format!("\"{}\"", value.replace('"', "\"\""))
809 } else {
810 value
811 }
812}
813
814fn csv_row<I: IntoIterator<Item = String>>(cells: I) -> String {
815 cells
816 .into_iter()
817 .map(|c| csv_field(&c))
818 .collect::<Vec<_>>()
819 .join(",")
820}
821
822fn num(v: f64) -> String {
823 if v.fract() == 0.0 {
824 format!("{}", v as i64)
825 } else {
826 format!("{v}")
827 }
828}
829
830impl WidgetPayload {
831 /// The payload as CSV, when it has rows worth exporting.
832 ///
833 /// `None` for the shapes that are a single number or a prose feed — a KPI
834 /// tile is not a table, and pretending otherwise would hand the user a
835 /// one-cell file. Everything with a series, slices, bars or rows exports.
836 pub fn to_csv(&self) -> Option<String> {
837 let mut out = String::new();
838 match self {
839 // A chart is (series, x, y) once flattened, which is exactly what a
840 // spreadsheet wants — one tidy row per point, series as a column so
841 // a multi-series chart round-trips into a pivot table.
842 WidgetPayload::Line(LinePayload { series, .. })
843 | WidgetPayload::Bar(BarPayload { series, .. }) => {
844 out.push_str("series,x,y\n");
845 for s in series {
846 for p in &s.points {
847 out.push_str(&csv_row([s.name.clone(), p.x.clone(), num(p.y)]));
848 out.push('\n');
849 }
850 }
851 }
852 WidgetPayload::Donut(p) => {
853 out.push_str("label,value\n");
854 for s in &p.slices {
855 out.push_str(&csv_row([s.label.clone(), num(s.value)]));
856 out.push('\n');
857 }
858 }
859 WidgetPayload::Radial(p) => {
860 out.push_str("label,value\n");
861 for t in &p.tracks {
862 out.push_str(&csv_row([t.label.clone(), num(t.value)]));
863 out.push('\n');
864 }
865 }
866 WidgetPayload::Progress(p) => {
867 for i in &p.items {
868 out.push_str(&csv_row([
869 i.label.clone(),
870 num(i.percent),
871 i.display.clone(),
872 ]));
873 out.push('\n');
874 }
875 }
876 WidgetPayload::Heatmap(p) => {
877 out.push_str("row,column,value\n");
878 for r in &p.rows {
879 for c in &r.cells {
880 out.push_str(&csv_row([r.name.clone(), c.x.clone(), num(c.y)]));
881 out.push('\n');
882 }
883 }
884 }
885 WidgetPayload::Table(p) => {
886 out.push_str(&csv_row(p.columns.iter().map(|c| c.label.clone())));
887 out.push('\n');
888 for row in &p.rows {
889 let cells = p.columns.iter().map(|c| match row.get(&c.key) {
890 Some(serde_json::Value::String(s)) => s.clone(),
891 Some(serde_json::Value::Null) | None => String::new(),
892 Some(v) => v.to_string(),
893 });
894 out.push_str(&csv_row(cells));
895 out.push('\n');
896 }
897 }
898 // A single number or a prose feed is not a table.
899 WidgetPayload::Kpi(_) | WidgetPayload::Card(_) | WidgetPayload::Feed(_) => return None,
900 }
901 Some(out)
902 }
903}
904
905// =========================================================================
906// WidgetDataFn
907// =========================================================================
908
909/// Per-request parameters a widget's data closure can read.
910/// Sourced from the query string on
911/// `GET /admin/api/dashboard/widgets/<key>/data?<params>`.
912///
913/// Defaults are all `None` — closures that don't care can use
914/// `WidgetDataFn::new(|user| ...)` and ignore params entirely.
915/// Closures that DO care use `WidgetDataFn::with_params` and
916/// branch on `params.period` / `params.start` / `params.end`.
917#[derive(Debug, Clone, Default)]
918pub struct WidgetParams {
919 /// Period preset like `"7d"`, `"30d"`, `"90d"`. The
920 /// rendering side emits chips that pass this through.
921 pub period: Option<String>,
922 /// Explicit ISO start date (`YYYY-MM-DD`) — overrides
923 /// `period` when both are present.
924 pub start: Option<String>,
925 /// Explicit ISO end date (`YYYY-MM-DD`).
926 pub end: Option<String>,
927 /// Catch-all for any other widget-specific query params
928 /// — `?model=order` for a future per-model filter, etc.
929 /// Closures read by `params.raw.get("...")`.
930 pub raw: std::collections::HashMap<String, String>,
931}
932
933impl WidgetParams {
934 /// Build from a `?key=value&...` query string. Recognised
935 /// keys (`period`, `start`, `end`) populate the typed
936 /// fields; the rest land in `raw`.
937 pub fn from_query<S: AsRef<str>>(query: S) -> Self {
938 let mut out = Self::default();
939 for pair in query.as_ref().split('&').filter(|s| !s.is_empty()) {
940 let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
941 let value = urlencoding_decode(v);
942 match k {
943 "period" => out.period = Some(value),
944 "start" => out.start = Some(value),
945 "end" => out.end = Some(value),
946 _ => {
947 out.raw.insert(k.to_string(), value);
948 }
949 }
950 }
951 out
952 }
953
954 /// Number of days the `period` preset represents. `"7d"`
955 /// → 7, `"30d"` → 30, `"90d"` → 90. None for unrecognised /
956 /// missing values so callers fall back to a default.
957 pub fn period_days(&self) -> Option<i64> {
958 let p = self.period.as_deref()?;
959 let digits: String = p.chars().take_while(|c| c.is_ascii_digit()).collect();
960 digits.parse().ok()
961 }
962
963 /// Value of a [`WidgetFilter::choice`] control, by its key.
964 ///
965 /// ```rust,ignore
966 /// let status = params.choice("status").unwrap_or("open");
967 /// ```
968 pub fn choice(&self, key: &str) -> Option<&str> {
969 self.raw
970 .get(key)
971 .map(String::as_str)
972 .filter(|v| !v.is_empty())
973 }
974
975 /// The resolved window as `(start, end)` ISO dates, if a date-range filter
976 /// supplied both. Takes precedence over `period` — a caller who picked
977 /// explicit dates meant them.
978 pub fn date_range(&self) -> Option<(&str, &str)> {
979 match (self.start.as_deref(), self.end.as_deref()) {
980 (Some(s), Some(e)) if !s.is_empty() && !e.is_empty() => Some((s, e)),
981 _ => None,
982 }
983 }
984}
985
986/// Minimal `%XX` → byte decoder; avoids pulling a query-string
987/// crate just for the four chars we need (`+` → space, `%2F` → `/`,
988/// etc.). Anything malformed passes through unchanged.
989fn urlencoding_decode(raw: &str) -> String {
990 let mut out = String::with_capacity(raw.len());
991 let bytes = raw.as_bytes();
992 let mut i = 0;
993 while i < bytes.len() {
994 match bytes[i] {
995 b'+' => {
996 out.push(' ');
997 i += 1;
998 }
999 b'%' if i + 2 < bytes.len() => {
1000 let hi = (bytes[i + 1] as char).to_digit(16);
1001 let lo = (bytes[i + 2] as char).to_digit(16);
1002 if let (Some(h), Some(l)) = (hi, lo) {
1003 out.push(char::from((h as u8) * 16 + l as u8));
1004 i += 3;
1005 } else {
1006 out.push(bytes[i] as char);
1007 i += 1;
1008 }
1009 }
1010 b => {
1011 out.push(b as char);
1012 i += 1;
1013 }
1014 }
1015 }
1016 out
1017}
1018
1019// =========================================================================
1020// WidgetFilter — declarative, per-widget UI controls
1021// =========================================================================
1022
1023/// What kind of control a [`WidgetFilter`] renders.
1024///
1025/// The variants exist because the three shapes reach the data closure by
1026/// different routes: a period lands in `WidgetParams::period`, a date range in
1027/// `start` / `end`, and a choice in `raw[key]`. Keeping that in the type means
1028/// the template never has to guess which query parameter a control writes to.
1029#[derive(Debug, Clone, Serialize, Deserialize)]
1030#[serde(tag = "kind", rename_all = "snake_case")]
1031pub enum WidgetFilterKind {
1032 /// A chip strip of presets — `7d`, `30d`, `90d`. Writes `?period=`.
1033 Period { presets: Vec<FilterOption> },
1034 /// Two `<input type="date">` boxes. Writes `?start=` and `?end=`.
1035 DateRange,
1036 /// A `<select>`. Writes `?<key>=`, readable via `params.choice(key)`.
1037 Choice { options: Vec<FilterOption> },
1038}
1039
1040/// One selectable value in a period or choice filter.
1041#[derive(Debug, Clone, Serialize, Deserialize)]
1042pub struct FilterOption {
1043 pub value: String,
1044 pub label: String,
1045}
1046
1047impl FilterOption {
1048 pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
1049 Self {
1050 value: value.into(),
1051 label: label.into(),
1052 }
1053 }
1054}
1055
1056/// A control the admin renders in a widget's header, declared at registration
1057/// time and rendered for **every** widget kind.
1058///
1059/// Before this existed the only filter in the admin was a `[7d][30d][90d]` chip
1060/// strip hardcoded inside `line.html`, which meant a bar chart or a table could
1061/// not be filtered at all, and no widget could offer anything but a period. A
1062/// filter is now data: the widget declares it, the admin renders it, and the
1063/// value arrives in [`WidgetParams`].
1064///
1065/// ```rust,ignore
1066/// Widget::new("orders_by_status", "Orders", WidgetKind::Bar, data_fn)
1067/// .filter(WidgetFilter::period_default())
1068/// .filter(WidgetFilter::choice("status", "Status", [
1069/// ("open", "Open"), ("paid", "Paid"),
1070/// ]))
1071/// ```
1072///
1073/// The chosen value is **sticky per user**: picking one persists to the user's
1074/// admin preferences, so it survives a reload the same way a period chip does.
1075#[derive(Debug, Clone, Serialize, Deserialize)]
1076pub struct WidgetFilter {
1077 /// Query-parameter name. `"period"` for a period filter, `"start"`/`"end"`
1078 /// are implied by a date range, anything else for a choice.
1079 pub key: String,
1080 /// Label shown beside the control.
1081 pub label: String,
1082 /// Which control to render.
1083 #[serde(flatten)]
1084 pub kind: WidgetFilterKind,
1085 /// Value selected when the user has never touched this filter.
1086 pub default: Option<String>,
1087 /// Resolved value for THIS request — URL, else the user's saved choice,
1088 /// else `default`. Filled in by the handler; ignored at registration.
1089 #[serde(default)]
1090 pub active: Option<String>,
1091 /// Resolved range ends, for a [`WidgetFilterKind::DateRange`] only.
1092 /// Filled in by the handler; ignored at registration.
1093 #[serde(default)]
1094 pub active_start: Option<String>,
1095 #[serde(default)]
1096 pub active_end: Option<String>,
1097 /// Query-string fragment carrying every OTHER filter's current value, e.g.
1098 /// `"&status=paid&period=7d"`. Filled in by the handler.
1099 ///
1100 /// It lives here, computed in Rust, because the alternative was assembling
1101 /// it in the template with `split`/`trim` gymnastics — and a control that
1102 /// gets this wrong silently resets its neighbours the moment you touch it.
1103 #[serde(default)]
1104 pub carry: String,
1105 /// The same fragment without the leading `&`, for a control that starts the
1106 /// query string rather than appending to one (a `<select>` whose own value
1107 /// htmx appends via `hx-include`).
1108 #[serde(default)]
1109 pub carry_lead: String,
1110}
1111
1112impl WidgetFilter {
1113 /// A period chip strip with custom presets.
1114 pub fn period<I, V, L>(presets: I) -> Self
1115 where
1116 I: IntoIterator<Item = (V, L)>,
1117 V: Into<String>,
1118 L: Into<String>,
1119 {
1120 Self {
1121 key: "period".to_string(),
1122 label: "Period".to_string(),
1123 kind: WidgetFilterKind::Period {
1124 presets: presets
1125 .into_iter()
1126 .map(|(v, l)| FilterOption::new(v, l))
1127 .collect(),
1128 },
1129 default: None,
1130 active: None,
1131 active_start: None,
1132 active_end: None,
1133 carry: String::new(),
1134 carry_lead: String::new(),
1135 }
1136 }
1137
1138 /// The conventional `[7d] [30d] [90d]` strip — what `line.html` hardcoded.
1139 pub fn period_default() -> Self {
1140 Self::period([("7d", "7d"), ("30d", "30d"), ("90d", "90d")]).with_default("30d")
1141 }
1142
1143 /// A start/end date pair. Reaches the closure as `params.start` / `params.end`,
1144 /// which take precedence over `period` when both are present.
1145 pub fn date_range() -> Self {
1146 Self {
1147 key: "range".to_string(),
1148 label: "Date range".to_string(),
1149 kind: WidgetFilterKind::DateRange,
1150 default: None,
1151 active: None,
1152 active_start: None,
1153 active_end: None,
1154 carry: String::new(),
1155 carry_lead: String::new(),
1156 }
1157 }
1158
1159 /// A `<select>` writing `?<key>=`. Read it back with `params.choice(key)`.
1160 pub fn choice<I, V, L>(key: impl Into<String>, label: impl Into<String>, options: I) -> Self
1161 where
1162 I: IntoIterator<Item = (V, L)>,
1163 V: Into<String>,
1164 L: Into<String>,
1165 {
1166 Self {
1167 key: key.into(),
1168 label: label.into(),
1169 kind: WidgetFilterKind::Choice {
1170 options: options
1171 .into_iter()
1172 .map(|(v, l)| FilterOption::new(v, l))
1173 .collect(),
1174 },
1175 default: None,
1176 active: None,
1177 active_start: None,
1178 active_end: None,
1179 carry: String::new(),
1180 carry_lead: String::new(),
1181 }
1182 }
1183
1184 /// Pre-select a value on first paint.
1185 pub fn with_default(mut self, value: impl Into<String>) -> Self {
1186 self.default = Some(value.into());
1187 self
1188 }
1189
1190 /// The value in force for this request.
1191 pub fn active_value(&self) -> Option<&str> {
1192 self.active.as_deref().or(self.default.as_deref())
1193 }
1194}
1195
1196pub(crate) type DataFuture = Pin<Box<dyn Future<Output = WidgetPayload> + Send + 'static>>;
1197pub(crate) type DataFnInner =
1198 Arc<dyn Fn(AuthUser, WidgetParams) -> DataFuture + Send + Sync + 'static>;
1199
1200/// Wrapper around the async data closure. Build via
1201/// [`WidgetDataFn::new`] (closure ignores per-request params) or
1202/// [`WidgetDataFn::with_params`] (closure reads `WidgetParams` to
1203/// honour period / date-range filters from the request URL).
1204#[derive(Clone)]
1205pub struct WidgetDataFn(pub(crate) DataFnInner);
1206
1207impl WidgetDataFn {
1208 /// Create from any `async fn(AuthUser) -> WidgetPayload` —
1209 /// per-request params are dropped on the floor. Use when the
1210 /// widget renders the same thing regardless of UI controls
1211 /// (KPI counts, registry sizes, etc.).
1212 pub fn new<F, Fut>(f: F) -> Self
1213 where
1214 F: Fn(AuthUser) -> Fut + Send + Sync + 'static,
1215 Fut: Future<Output = WidgetPayload> + Send + 'static,
1216 {
1217 Self(Arc::new(move |user, _params| Box::pin(f(user))))
1218 }
1219
1220 /// Create from `async fn(AuthUser, WidgetParams) ->
1221 /// WidgetPayload`. Use for filterable widgets — the line
1222 /// chart reads `params.period` to switch between 7d / 30d /
1223 /// 90d views, a future table widget might read
1224 /// `params.raw.get("status")` for status filtering, etc.
1225 pub fn with_params<F, Fut>(f: F) -> Self
1226 where
1227 F: Fn(AuthUser, WidgetParams) -> Fut + Send + Sync + 'static,
1228 Fut: Future<Output = WidgetPayload> + Send + 'static,
1229 {
1230 Self(Arc::new(move |user, params| Box::pin(f(user, params))))
1231 }
1232}
1233
1234impl std::fmt::Debug for WidgetDataFn {
1235 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1236 f.write_str("WidgetDataFn(<fn>)")
1237 }
1238}
1239
1240// =========================================================================
1241// Widget
1242// =========================================================================
1243
1244/// A registered dashboard widget.
1245///
1246/// Register via `AdminPlugin::register_widget(...)`.
1247#[derive(Debug, Clone)]
1248pub struct Widget {
1249 /// URL-safe unique key, e.g. `"umbral_total_models"`.
1250 pub key: &'static str,
1251 /// Human-readable title shown in the widget card header.
1252 pub title: String,
1253 /// Determines which renderer (KPI card, chart, table, feed).
1254 pub kind: WidgetKind,
1255 /// Default grid span when the user hasn't customized.
1256 pub default_span: Span,
1257 /// Optional permission codename. `None` = any staff user may see.
1258 pub permission: Option<&'static str>,
1259 /// Async function that computes and returns the payload.
1260 pub data: WidgetDataFn,
1261 /// Default period preset used by line/bar/etc. widgets that
1262 /// carry a period-chip strip — `"7d"`, `"30d"`, `"90d"`. When
1263 /// `Some`, the handler pre-fills `WidgetParams.period` from
1264 /// this value on first load (no `?period=` in the URL), so
1265 /// the matching chip renders highlighted AND the data
1266 /// closure receives the same period via `params.period_days()`.
1267 /// `None` falls back to whatever the template / data closure
1268 /// chooses as its fallback.
1269 pub default_period: Option<&'static str>,
1270 /// Controls rendered in this widget's header — see [`WidgetFilter`].
1271 ///
1272 /// Empty means no controls, with one compatibility exception: a
1273 /// [`WidgetKind::Line`] with no declared filters still gets the historic
1274 /// `[7d][30d][90d]` strip, because that strip used to be hardcoded in
1275 /// `line.html` and removing it would silently strip controls from every
1276 /// line chart already in the wild.
1277 pub filters: Vec<WidgetFilter>,
1278}
1279
1280impl Widget {
1281 /// Build a widget without naming every field.
1282 ///
1283 /// Prefer this over a struct literal. `Widget` grows fields as the
1284 /// dashboard grows (this is how `filters` arrived), and every new field
1285 /// breaks every literal that spells out the old ones. Going through the
1286 /// builder means the next field costs you nothing.
1287 ///
1288 /// ```rust,ignore
1289 /// Widget::new("orders", "Orders", WidgetKind::Bar, data_fn)
1290 /// .with_span(6, 2)
1291 /// .filter(WidgetFilter::period_default())
1292 /// ```
1293 pub fn new(
1294 key: &'static str,
1295 title: impl Into<String>,
1296 kind: WidgetKind,
1297 data: WidgetDataFn,
1298 ) -> Self {
1299 Self {
1300 key,
1301 title: title.into(),
1302 kind,
1303 default_span: Span::default(),
1304 permission: None,
1305 data,
1306 default_period: None,
1307 filters: Vec::new(),
1308 }
1309 }
1310
1311 /// Gate the widget behind a permission codename.
1312 pub fn with_permission(mut self, codename: &'static str) -> Self {
1313 self.permission = Some(codename);
1314 self
1315 }
1316
1317 /// Add one filter control to the widget header.
1318 pub fn filter(mut self, filter: WidgetFilter) -> Self {
1319 self.filters.push(filter);
1320 self
1321 }
1322
1323 /// Replace the widget's filters wholesale.
1324 pub fn with_filters(mut self, filters: impl IntoIterator<Item = WidgetFilter>) -> Self {
1325 self.filters = filters.into_iter().collect();
1326 self
1327 }
1328
1329 /// The filters to render for this request, including the line-chart
1330 /// compatibility strip described on [`Widget::filters`].
1331 pub(crate) fn effective_filters(&self) -> Vec<WidgetFilter> {
1332 if self.filters.is_empty() && matches!(self.kind, WidgetKind::Line) {
1333 let mut period = WidgetFilter::period_default();
1334 if let Some(d) = self.default_period {
1335 period.default = Some(d.to_string());
1336 }
1337 return vec![period];
1338 }
1339 self.filters.clone()
1340 }
1341
1342 /// Override the default grid span. Lets a caller resize a
1343 /// builtin (or any pre-built widget) at registration time
1344 /// without having to re-construct the whole struct literal:
1345 ///
1346 /// ```rust,ignore
1347 /// .register_widget(builtin_total_models_widget().with_span(6, 2))
1348 /// .register_widget(builtin_recent_users_widget().with_span(6, 2))
1349 /// ```
1350 ///
1351 /// `cols` is clamped at the 12-col grid; `rows` is whatever
1352 /// the dashboard's `auto-rows-[...]` accepts (1 = 120px).
1353 pub fn with_span(mut self, cols: u8, rows: u8) -> Self {
1354 self.default_span = Span { cols, rows };
1355 self
1356 }
1357
1358 /// Pre-select a period chip on the widget — `"7d"`, `"30d"`,
1359 /// `"90d"`. On first load (no `?period=` in the URL), the
1360 /// handler stamps this into `WidgetParams.period` before
1361 /// calling the data closure, so the chip strip highlights
1362 /// the right preset AND the data fn computes the right
1363 /// window. Override on a per-request basis happens via the
1364 /// chip clicks (which send their own `?period=` query).
1365 ///
1366 /// ```ignore
1367 /// shop_daily_sales_chart().with_default_period("7d")
1368 /// // → first paint shows 7d highlighted, 7 days of data;
1369 /// // clicking "30d" hands control to the URL state.
1370 /// ```
1371 pub fn with_default_period(mut self, period: &'static str) -> Self {
1372 self.default_period = Some(period);
1373 self
1374 }
1375}
1376
1377// =========================================================================
1378// WidgetInstance (user's saved layout entry)
1379// =========================================================================
1380
1381/// One entry in a user's saved layout JSON.
1382#[derive(Debug, Clone, Serialize, Deserialize)]
1383pub struct WidgetInstance {
1384 pub key: String,
1385 pub span: Span,
1386}
1387
1388// =========================================================================
1389// Widget catalog entry (API response shape)
1390// =========================================================================
1391
1392/// Serialized catalog entry returned by `GET /admin/api/dashboard/catalog`.
1393#[derive(Debug, Clone, Serialize)]
1394pub struct CatalogEntry {
1395 pub key: &'static str,
1396 pub title: String,
1397 pub kind: String,
1398 pub default_span: Span,
1399}
1400
1401// =========================================================================
1402// Sections — grouped widgets
1403// =========================================================================
1404
1405/// A named group of widgets on the dashboard. Each section renders
1406/// as its own heading + (optional) subtitle + widget grid, so a
1407/// dashboard with 20 widgets reads as themed clusters rather than
1408/// one mega-grid.
1409///
1410/// Build with the chainable API:
1411///
1412/// ```rust,ignore
1413/// use umbral_admin::WidgetSection;
1414///
1415/// let sales = WidgetSection::new("Sales overview")
1416/// .subtitle("Daily KPIs across the storefront")
1417/// .widget(shop_total_sales_widget())
1418/// .widget(shop_orders_widget())
1419/// .widget(shop_avg_order_value_widget());
1420///
1421/// AdminPlugin::default().dashboard_section(sales);
1422/// ```
1423///
1424/// Register multiple sections by chaining `.dashboard_section(...)`.
1425/// Widgets registered via the legacy `.register_widget(...)` end up
1426/// in an implicit final section titled "Widgets" — so existing apps
1427/// keep working without code changes.
1428#[derive(Debug, Clone)]
1429pub struct WidgetSection {
1430 /// Heading shown above the section (e.g. "Sales overview").
1431 pub title: String,
1432 /// Optional descriptive line under the title — keep it short,
1433 /// it's not a paragraph.
1434 pub subtitle: Option<String>,
1435 /// Widgets in this section, rendered in registration order.
1436 pub widgets: Vec<Widget>,
1437}
1438
1439impl WidgetSection {
1440 /// New empty section with just a title.
1441 pub fn new(title: impl Into<String>) -> Self {
1442 Self {
1443 title: title.into(),
1444 subtitle: None,
1445 widgets: Vec::new(),
1446 }
1447 }
1448
1449 /// Add a one-line subtitle under the heading.
1450 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
1451 self.subtitle = Some(subtitle.into());
1452 self
1453 }
1454
1455 /// Append one widget to the section.
1456 pub fn widget(mut self, w: Widget) -> Self {
1457 self.widgets.push(w);
1458 self
1459 }
1460
1461 /// Append many widgets at once (handy for splatting a Vec).
1462 pub fn widgets(mut self, ws: impl IntoIterator<Item = Widget>) -> Self {
1463 self.widgets.extend(ws);
1464 self
1465 }
1466}
1467
1468#[cfg(test)]
1469mod tests {
1470 use super::*;
1471
1472 #[test]
1473 fn radial_kind_serializes_as_radial() {
1474 assert_eq!(WidgetKind::Radial.as_str(), "radial");
1475 }
1476
1477 #[test]
1478 fn radial_single_builds_one_track() {
1479 let p = RadialPayload::single("Monthly goal", 73.0);
1480 assert_eq!(p.tracks.len(), 1);
1481 assert_eq!(p.tracks[0].label, "Monthly goal");
1482 assert_eq!(p.tracks[0].value, 73.0);
1483 assert!(p.tracks[0].color.is_none());
1484 }
1485
1486 #[test]
1487 fn radial_clamps_out_of_range_and_non_finite_percents() {
1488 // Over 100, under 0, and non-finite all clamp into [0, 100].
1489 assert_eq!(RadialPayload::single("over", 150.0).tracks[0].value, 100.0);
1490 assert_eq!(RadialPayload::single("under", -20.0).tracks[0].value, 0.0);
1491 // Non-finite (NaN, ±∞) is meaningless as a percent -> 0.
1492 assert_eq!(RadialPayload::single("nan", f64::NAN).tracks[0].value, 0.0);
1493 assert_eq!(
1494 RadialPayload::single("inf", f64::INFINITY).tracks[0].value,
1495 0.0,
1496 );
1497 }
1498
1499 #[test]
1500 fn radial_goal_is_current_over_target() {
1501 assert_eq!(RadialPayload::goal("g", 73.0, 100.0).tracks[0].value, 73.0);
1502 // current > target clamps to 100 (overachieved, full ring).
1503 assert_eq!(
1504 RadialPayload::goal("g", 120.0, 100.0).tracks[0].value,
1505 100.0
1506 );
1507 // A non-positive target has nothing to measure against -> 0%.
1508 assert_eq!(RadialPayload::goal("g", 5.0, 0.0).tracks[0].value, 0.0);
1509 }
1510
1511 #[test]
1512 fn radial_from_pairs_keeps_order_and_clamps() {
1513 let p = RadialPayload::from_pairs([("Free", 8.0), ("Pro", 150.0), ("Team", 34.0)]);
1514 assert_eq!(p.tracks.len(), 3);
1515 assert_eq!(p.tracks[0].label, "Free");
1516 assert_eq!(p.tracks[1].value, 100.0); // clamped
1517 assert_eq!(p.tracks[2].label, "Team");
1518 }
1519
1520 #[test]
1521 fn radial_payload_serializes_with_kind_tag() {
1522 let payload = WidgetPayload::Radial(RadialPayload::single("Quota", 42.0));
1523 let json = serde_json::to_value(&payload).expect("serialize");
1524 assert_eq!(json["kind"], "radial");
1525 assert_eq!(json["tracks"][0]["label"], "Quota");
1526 assert_eq!(json["tracks"][0]["value"], 42.0);
1527 // No explicit color -> the field is skipped entirely.
1528 assert!(json["tracks"][0].get("color").is_none());
1529 }
1530
1531 #[test]
1532 fn heatmap_kind_serializes_as_heatmap() {
1533 assert_eq!(WidgetKind::Heatmap.as_str(), "heatmap");
1534 }
1535
1536 #[test]
1537 fn heatmap_from_grid_is_rectangular_and_padded() {
1538 // A ragged matrix: row 0 short (padded with 0), row 1 long
1539 // (extra dropped), row 2 exact.
1540 let p = HeatmapPayload::from_grid(
1541 ["Mon", "Tue", "Wed"],
1542 ["AM", "PM"],
1543 vec![vec![3.0], vec![1.0, 2.0, 99.0], vec![4.0, 5.0]],
1544 );
1545 assert_eq!(p.rows.len(), 3);
1546 // Every row has exactly one cell per column label.
1547 for row in &p.rows {
1548 assert_eq!(row.cells.len(), 2, "row `{}` must be rectangular", row.name);
1549 assert_eq!(row.cells[0].x, "AM");
1550 assert_eq!(row.cells[1].x, "PM");
1551 }
1552 assert_eq!(p.rows[0].name, "Mon");
1553 assert_eq!(p.rows[0].cells[1].y, 0.0); // short row padded
1554 assert_eq!(p.rows[1].cells[1].y, 2.0); // extra `99.0` dropped
1555 assert_eq!(p.rows[2].cells[0].y, 4.0);
1556 }
1557
1558 #[test]
1559 fn heatmap_payload_serializes_with_kind_tag() {
1560 let payload = WidgetPayload::Heatmap(HeatmapPayload::from_grid(
1561 ["Row"],
1562 ["a", "b"],
1563 vec![vec![7.0, 8.0]],
1564 ));
1565 let json = serde_json::to_value(&payload).expect("serialize");
1566 assert_eq!(json["kind"], "heatmap");
1567 assert_eq!(json["rows"][0]["name"], "Row");
1568 assert_eq!(json["rows"][0]["cells"][0]["x"], "a");
1569 assert_eq!(json["rows"][0]["cells"][1]["y"], 8.0);
1570 }
1571
1572 #[test]
1573 fn progress_kind_serializes_as_progress() {
1574 assert_eq!(WidgetKind::Progress.as_str(), "progress");
1575 }
1576
1577 #[test]
1578 fn progress_from_pairs_sizes_against_largest_value() {
1579 let p = ProgressPayload::from_pairs([("A", 100.0), ("B", 50.0), ("C", 25.0)]);
1580 assert_eq!(p.items.len(), 3);
1581 // The largest value fills the bar; the rest are proportional.
1582 assert_eq!(p.items[0].percent, 100.0);
1583 assert_eq!(p.items[1].percent, 50.0);
1584 assert_eq!(p.items[2].percent, 25.0);
1585 // `display` is the thousands-grouped value, order preserved.
1586 assert_eq!(p.items[0].label, "A");
1587 assert_eq!(p.items[0].display, "100");
1588 }
1589
1590 #[test]
1591 fn progress_from_pairs_of_sizes_against_target_and_clamps() {
1592 let p = ProgressPayload::from_pairs_of([("Web", 82.0), ("Mobile", 150.0)], 100.0);
1593 assert_eq!(p.items[0].percent, 82.0);
1594 // Over target fills the bar rather than overrunning it.
1595 assert_eq!(p.items[1].percent, 100.0);
1596 }
1597
1598 #[test]
1599 fn progress_non_positive_target_falls_back_to_max() {
1600 // target = 0 -> size against the largest value (40 -> 100%).
1601 let p = ProgressPayload::from_pairs_of([("A", 40.0), ("B", 10.0)], 0.0);
1602 assert_eq!(p.items[0].percent, 100.0);
1603 assert_eq!(p.items[1].percent, 25.0);
1604 }
1605
1606 #[test]
1607 fn progress_payload_serializes_with_kind_tag() {
1608 let payload = WidgetPayload::Progress(ProgressPayload::from_pairs([("Pro", 48200.0)]));
1609 let json = serde_json::to_value(&payload).expect("serialize");
1610 assert_eq!(json["kind"], "progress");
1611 assert_eq!(json["items"][0]["label"], "Pro");
1612 assert_eq!(json["items"][0]["display"], "48,200");
1613 assert_eq!(json["items"][0]["percent"], 100.0);
1614 // No explicit color -> the field is skipped entirely.
1615 assert!(json["items"][0].get("color").is_none());
1616 }
1617}
1618
1619#[cfg(test)]
1620mod csv_tests {
1621 use super::*;
1622
1623 #[test]
1624 fn csv_quotes_commas_quotes_and_newlines() {
1625 assert_eq!(csv_field("plain"), "plain");
1626 assert_eq!(csv_field("a,b"), "\"a,b\"");
1627 assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
1628 assert_eq!(csv_field("two\nlines"), "\"two\nlines\"");
1629 }
1630
1631 /// A cell beginning `=`, `+`, `-` or `@` is executed as a FORMULA when the
1632 /// file is opened in Excel or Sheets. A product name someone typed into your
1633 /// app then becomes code running on the machine of whoever opened the export.
1634 /// The value must survive as readable text, but inert.
1635 #[test]
1636 fn csv_neutralises_spreadsheet_formulas() {
1637 for (payload, still_reads) in [
1638 ("=HYPERLINK(\"http://evil\")", "HYPERLINK"),
1639 ("+1+1", "+1+1"),
1640 ("-2+3", "-2+3"),
1641 ("@SUM(A1)", "@SUM(A1)"),
1642 ] {
1643 let out = csv_field(payload);
1644 // The tab may sit inside the RFC-4180 quoting, so accept either.
1645 assert!(
1646 out.starts_with('\t') || out.starts_with("\"\t"),
1647 "a formula-leading cell must be neutralised, got {out}"
1648 );
1649 assert!(
1650 out.contains(still_reads),
1651 "and must remain readable, got {out}"
1652 );
1653 }
1654 }
1655
1656 #[test]
1657 fn a_bar_payload_exports_one_row_per_point() {
1658 let payload = WidgetPayload::Bar(BarPayload {
1659 series: vec![Series {
1660 name: "sales".into(),
1661 points: vec![
1662 ChartPoint {
1663 x: "Mon".into(),
1664 y: 3.0,
1665 },
1666 ChartPoint {
1667 x: "Tue".into(),
1668 y: 4.5,
1669 },
1670 ],
1671 }],
1672 x_type: "day".into(),
1673 });
1674 let csv = payload.to_csv().expect("a bar chart has rows");
1675 assert_eq!(csv, "series,x,y\nsales,Mon,3\nsales,Tue,4.5\n");
1676 }
1677
1678 #[test]
1679 fn a_kpi_has_nothing_to_export() {
1680 let payload = WidgetPayload::Kpi(KpiPayload {
1681 value: "42".into(),
1682 unit: None,
1683 delta: None,
1684 sparkline: None,
1685 });
1686 assert!(payload.to_csv().is_none());
1687 }
1688}