Skip to main content

rustio_admin/view_layer/
render.rs

1//! Deterministic rendering: a saved [`ViewSpec`] plus raw rows in, a typed,
2//! serde-serializable [`RenderedView`] out.
3//!
4//! Rendering reads the spec and the row values and nothing else — no roles are
5//! decided here, no inference runs. Each cell is emitted as a tagged
6//! [`RenderedCell`]; templates switch on `cell.kind` and contain no logic of
7//! their own. Two guarantees the renderer upholds:
8//!
9//! 1. `Hidden`/`DetailOnly` fields are never read from the row, so a sensitive
10//!    value cannot reach the template context or the HTML.
11//! 2. A field consumed by a [`CellComposition`] is not also rendered standalone.
12
13use std::collections::BTreeMap;
14
15use serde::Serialize;
16
17use super::compose::{CellComposition, ComposeStyle};
18use super::modes::ViewMode;
19use super::roles::{FieldRole, SemanticClass};
20use super::spec::{FieldViewSpec, ViewSpec};
21
22// public:
23/// A single row's raw values keyed by field name. The admin's data layer
24/// builds these; the renderer only reads them.
25pub type RowData = BTreeMap<String, String>;
26
27// public:
28/// One rendered cell, ready for the template. Serializes with a `kind` tag
29/// (`primary`, `secondary`, `badge`, `timestamp`, `composed`) so minijinja
30/// templates can switch on `cell.kind` without seeing roles or running logic.
31#[derive(Debug, Clone, PartialEq, Serialize)]
32#[serde(tag = "kind", rename_all = "snake_case")]
33pub enum RenderedCell {
34    /// The row/card title cell.
35    Primary {
36        /// Column label for table headers.
37        label: String,
38        /// The rendered text.
39        value: String,
40    },
41    /// A muted supporting cell.
42    Secondary {
43        /// Column label for table headers.
44        label: String,
45        /// The rendered text.
46        value: String,
47    },
48    /// A pill/chip cell carrying a semantic colour intent.
49    Badge {
50        /// Column label for table headers.
51        label: String,
52        /// The rendered text.
53        value: String,
54        /// Colour intent the template maps to a CSS class.
55        semantic: SemanticClass,
56    },
57    /// A date/time cell.
58    Timestamp {
59        /// Column label for table headers.
60        label: String,
61        /// The rendered text.
62        value: String,
63    },
64    /// Several fields merged into one visual unit.
65    Composed {
66        /// Optional header for the composed cell.
67        label: Option<String>,
68        /// Layout the template applies to `parts`.
69        style: ComposeStyle,
70        /// The merged field values, primary first.
71        parts: Vec<CellPart>,
72    },
73}
74
75// public:
76/// One field inside a composed cell.
77#[derive(Debug, Clone, PartialEq, Serialize)]
78pub struct CellPart {
79    /// The schema field this part came from.
80    pub field: String,
81    /// The rendered value.
82    pub value: String,
83    /// Whether this is the composition's primary field.
84    pub is_primary: bool,
85}
86
87// public:
88/// Everything a list/table/card template needs for one row.
89#[derive(Debug, Clone, PartialEq, Serialize)]
90pub struct RenderedRow {
91    /// The record's primary key, when the caller supplied it (via
92    /// [`render_view_with_ids`]) — lets list/card layouts link to the record.
93    /// `None` for previews and other id-less renders.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub id: Option<i64>,
96    /// The row's cells, in render order.
97    pub cells: Vec<RenderedCell>,
98}
99
100// public:
101/// The view-level context handed to a template. Carries the active mode and
102/// the rows; the template just iterates.
103#[derive(Debug, Clone, PartialEq, Serialize)]
104pub struct RenderedView {
105    /// The model (admin name) being rendered.
106    pub model: String,
107    /// The resolved view mode for this page.
108    pub mode: ViewMode,
109    /// The rendered rows.
110    pub rows: Vec<RenderedRow>,
111}
112
113fn label_for(field: &FieldViewSpec) -> String {
114    field
115        .label
116        .clone()
117        .unwrap_or_else(|| humanize(&field.field_name))
118}
119
120/// "created_at" -> "Created At". Plain and predictable; the designer can
121/// always override with an explicit label.
122fn humanize(name: &str) -> String {
123    name.split('_')
124        .filter(|p| !p.is_empty())
125        .map(|p| {
126            let mut chars = p.chars();
127            match chars.next() {
128                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
129                None => String::new(),
130            }
131        })
132        .collect::<Vec<_>>()
133        .join(" ")
134}
135
136// public:
137/// Render a single row according to a spec.
138///
139/// Composed cells render first (in spec order) and mark their fields consumed;
140/// the remaining `list_fields()` (already filtered to exclude `Hidden`/
141/// `DetailOnly` and sorted by priority) render as simple cells. Hidden fields
142/// are never read from `row`.
143pub fn render_row(spec: &ViewSpec, row: &RowData) -> RenderedRow {
144    let mut cells = Vec::new();
145    let mut consumed: Vec<&str> = Vec::new();
146
147    for comp in &spec.compositions {
148        cells.push(render_composition(comp, row));
149        consumed.extend(comp.all_fields());
150    }
151
152    for field in spec.list_fields() {
153        if consumed.contains(&field.field_name.as_str()) {
154            continue;
155        }
156        let value = row.get(&field.field_name).cloned().unwrap_or_default();
157        let label = label_for(field);
158        let cell = match field.role {
159            FieldRole::Primary => RenderedCell::Primary { label, value },
160            FieldRole::Secondary => RenderedCell::Secondary { label, value },
161            FieldRole::Badge => RenderedCell::Badge {
162                label,
163                value,
164                semantic: field.semantic_class.unwrap_or_default(),
165            },
166            FieldRole::Timestamp => RenderedCell::Timestamp { label, value },
167            // list_fields() already filtered these out, but be explicit.
168            FieldRole::DetailOnly | FieldRole::Hidden => continue,
169        };
170        cells.push(cell);
171    }
172
173    RenderedRow { id: None, cells }
174}
175
176fn render_composition(comp: &CellComposition, row: &RowData) -> RenderedCell {
177    let mut parts = Vec::with_capacity(1 + comp.secondary_fields.len());
178    parts.push(CellPart {
179        field: comp.primary_field.clone(),
180        value: row.get(&comp.primary_field).cloned().unwrap_or_default(),
181        is_primary: true,
182    });
183    for name in &comp.secondary_fields {
184        parts.push(CellPart {
185            field: name.clone(),
186            value: row.get(name).cloned().unwrap_or_default(),
187            is_primary: false,
188        });
189    }
190    RenderedCell::Composed {
191        label: comp.label.clone(),
192        style: comp.style,
193        parts,
194    }
195}
196
197// public:
198/// Render a full page of rows for the resolved mode. Rows carry no id (suitable
199/// for previews). Use [`render_view_with_ids`] when records must be clickable.
200pub fn render_view(spec: &ViewSpec, mode: ViewMode, rows: &[RowData]) -> RenderedView {
201    RenderedView {
202        model: spec.model.clone(),
203        mode,
204        rows: rows.iter().map(|r| render_row(spec, r)).collect(),
205    }
206}
207
208// public:
209/// Like [`render_view`], but each row carries its record id so list/card
210/// layouts can link to `/admin/<model>/<id>/edit`. Used by the live list page.
211pub fn render_view_with_ids(
212    spec: &ViewSpec,
213    mode: ViewMode,
214    rows: &[(i64, RowData)],
215) -> RenderedView {
216    RenderedView {
217        model: spec.model.clone(),
218        mode,
219        rows: rows
220            .iter()
221            .map(|(id, r)| {
222                let mut row = render_row(spec, r);
223                row.id = Some(*id);
224                row
225            })
226            .collect(),
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::view_layer::compose::ComposeStyle;
234    use crate::view_layer::spec::VIEW_SPEC_VERSION;
235
236    fn customer_spec() -> ViewSpec {
237        ViewSpec {
238            model: "customer".into(),
239            default_mode: ViewMode::List,
240            allowed_modes: vec![ViewMode::List, ViewMode::Table],
241            fields: vec![
242                FieldViewSpec::new("full_name", FieldRole::Primary),
243                FieldViewSpec::new("email", FieldRole::Secondary),
244                {
245                    let mut f = FieldViewSpec::new("status", FieldRole::Badge);
246                    f.semantic_class = Some(SemanticClass::Success);
247                    f
248                },
249                FieldViewSpec::new("password_hash", FieldRole::Hidden),
250                FieldViewSpec::new("internal_notes", FieldRole::DetailOnly),
251            ],
252            compositions: vec![],
253            default_filters: vec![],
254            version: VIEW_SPEC_VERSION,
255        }
256    }
257
258    fn customer_row() -> RowData {
259        let mut row = RowData::new();
260        row.insert("full_name".into(), "Nadim Shahin".into());
261        row.insert("email".into(), "nadim@example.com".into());
262        row.insert("status".into(), "active".into());
263        row.insert("password_hash".into(), "$2b$super-secret".into());
264        row.insert("internal_notes".into(), "VIP".into());
265        row
266    }
267
268    #[test]
269    fn hidden_field_value_never_rendered() {
270        let rendered = render_row(&customer_spec(), &customer_row());
271        let leaked = rendered.cells.iter().any(|c| match c {
272            RenderedCell::Primary { value, .. }
273            | RenderedCell::Secondary { value, .. }
274            | RenderedCell::Badge { value, .. }
275            | RenderedCell::Timestamp { value, .. } => value.contains("secret"),
276            RenderedCell::Composed { parts, .. } => {
277                parts.iter().any(|p| p.value.contains("secret"))
278            }
279        });
280        assert!(!leaked, "sensitive value leaked into rendered cells");
281    }
282
283    #[test]
284    fn detail_only_excluded_from_list() {
285        let rendered = render_row(&customer_spec(), &customer_row());
286        let has_notes = rendered
287            .cells
288            .iter()
289            .any(|c| matches!(c, RenderedCell::Secondary { value, .. } if value == "VIP"));
290        assert!(!has_notes);
291    }
292
293    #[test]
294    fn badge_carries_semantic_class() {
295        let rendered = render_row(&customer_spec(), &customer_row());
296        let badge = rendered
297            .cells
298            .iter()
299            .find(|c| matches!(c, RenderedCell::Badge { .. }));
300        match badge {
301            Some(RenderedCell::Badge { semantic, .. }) => {
302                assert_eq!(*semantic, SemanticClass::Success);
303            }
304            _ => panic!("expected a badge cell"),
305        }
306    }
307
308    #[test]
309    fn composed_field_not_rendered_twice() {
310        let mut spec = customer_spec();
311        spec.compositions.push(CellComposition {
312            id: "identity".into(),
313            label: Some("Customer".into()),
314            style: ComposeStyle::Stacked,
315            primary_field: "full_name".into(),
316            secondary_fields: vec!["email".into()],
317        });
318        let rendered = render_row(&spec, &customer_row());
319
320        // full_name and email should only appear inside the composed cell now
321        let standalone_primary = rendered
322            .cells
323            .iter()
324            .any(|c| matches!(c, RenderedCell::Primary { value, .. } if value == "Nadim Shahin"));
325        assert!(!standalone_primary);
326
327        let composed = rendered
328            .cells
329            .iter()
330            .find(|c| matches!(c, RenderedCell::Composed { .. }));
331        assert!(composed.is_some());
332    }
333
334    #[test]
335    fn humanize_handles_snake_case() {
336        assert_eq!(humanize("created_at"), "Created At");
337        assert_eq!(humanize("full_name"), "Full Name");
338    }
339
340    #[test]
341    fn render_view_with_ids_sets_row_ids_and_still_drops_hidden() {
342        let view = super::render_view_with_ids(
343            &customer_spec(),
344            ViewMode::Cards,
345            &[(7, customer_row()), (9, customer_row())],
346        );
347        assert_eq!(view.rows.len(), 2);
348        assert_eq!(view.rows[0].id, Some(7));
349        assert_eq!(view.rows[1].id, Some(9));
350        // hidden value still never appears in any cell
351        let leaked = view.rows.iter().flat_map(|r| &r.cells).any(|c| match c {
352            RenderedCell::Primary { value, .. }
353            | RenderedCell::Secondary { value, .. }
354            | RenderedCell::Badge { value, .. }
355            | RenderedCell::Timestamp { value, .. } => value.contains("secret"),
356            RenderedCell::Composed { parts, .. } => {
357                parts.iter().any(|p| p.value.contains("secret"))
358            }
359        });
360        assert!(!leaked);
361    }
362
363    #[test]
364    fn cell_serializes_with_kind_tag() {
365        let cell = RenderedCell::Badge {
366            label: "Status".into(),
367            value: "active".into(),
368            semantic: SemanticClass::Success,
369        };
370        let json = serde_json::to_value(&cell).unwrap();
371        assert_eq!(json["kind"], "badge");
372        assert_eq!(json["semantic"], "success");
373        assert_eq!(json["value"], "active");
374    }
375
376    // Proves the template contract end-to-end against the SHIPPED partials:
377    // templates switch on `cell.kind` only, primary/badge render, and the
378    // Hidden `password_hash` value never appears in the HTML.
379    #[test]
380    fn shipped_partials_switch_on_kind_and_drop_hidden() {
381        use minijinja::{context, Environment, Value};
382
383        const CELL: &str = include_str!("../../assets/templates/admin/view_layer/_cell.html");
384        const ROW: &str = include_str!("../../assets/templates/admin/view_layer/_row.html");
385
386        let mut env = Environment::new();
387        env.add_template("admin/view_layer/_cell.html", CELL)
388            .unwrap();
389        env.add_template("admin/view_layer/_row.html", ROW).unwrap();
390
391        let rendered = render_row(&customer_spec(), &customer_row());
392        let tmpl = env.get_template("admin/view_layer/_row.html").unwrap();
393        let html = tmpl
394            .render(context! { row => Value::from_serialize(&rendered) })
395            .unwrap();
396
397        assert!(html.contains("av-primary"));
398        assert!(html.contains("Nadim Shahin"));
399        assert!(html.contains("badge--success"));
400        assert!(
401            !html.contains("secret"),
402            "hidden value reached HTML: {html}"
403        );
404        assert!(
405            !html.contains("VIP"),
406            "detail-only value reached HTML: {html}"
407        );
408    }
409}