use std::collections::BTreeMap;
use serde::Serialize;
use super::compose::{CellComposition, ComposeStyle};
use super::modes::ViewMode;
use super::roles::{FieldRole, SemanticClass};
use super::spec::{FieldViewSpec, ViewSpec};
pub type RowData = BTreeMap<String, String>;
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RenderedCell {
Primary {
label: String,
value: String,
},
Secondary {
label: String,
value: String,
},
Badge {
label: String,
value: String,
semantic: SemanticClass,
},
Timestamp {
label: String,
value: String,
},
Composed {
label: Option<String>,
style: ComposeStyle,
parts: Vec<CellPart>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct CellPart {
pub field: String,
pub value: String,
pub is_primary: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RenderedRow {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<i64>,
pub cells: Vec<RenderedCell>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RenderedView {
pub model: String,
pub mode: ViewMode,
pub rows: Vec<RenderedRow>,
}
fn label_for(field: &FieldViewSpec) -> String {
field
.label
.clone()
.unwrap_or_else(|| humanize(&field.field_name))
}
fn humanize(name: &str) -> String {
name.split('_')
.filter(|p| !p.is_empty())
.map(|p| {
let mut chars = p.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn render_row(spec: &ViewSpec, row: &RowData) -> RenderedRow {
let mut cells = Vec::new();
let mut consumed: Vec<&str> = Vec::new();
for comp in &spec.compositions {
cells.push(render_composition(comp, row));
consumed.extend(comp.all_fields());
}
for field in spec.list_fields() {
if consumed.contains(&field.field_name.as_str()) {
continue;
}
let value = row.get(&field.field_name).cloned().unwrap_or_default();
let label = label_for(field);
let cell = match field.role {
FieldRole::Primary => RenderedCell::Primary { label, value },
FieldRole::Secondary => RenderedCell::Secondary { label, value },
FieldRole::Badge => RenderedCell::Badge {
label,
value,
semantic: field.semantic_class.unwrap_or_default(),
},
FieldRole::Timestamp => RenderedCell::Timestamp { label, value },
FieldRole::DetailOnly | FieldRole::Hidden => continue,
};
cells.push(cell);
}
RenderedRow { id: None, cells }
}
fn render_composition(comp: &CellComposition, row: &RowData) -> RenderedCell {
let mut parts = Vec::with_capacity(1 + comp.secondary_fields.len());
parts.push(CellPart {
field: comp.primary_field.clone(),
value: row.get(&comp.primary_field).cloned().unwrap_or_default(),
is_primary: true,
});
for name in &comp.secondary_fields {
parts.push(CellPart {
field: name.clone(),
value: row.get(name).cloned().unwrap_or_default(),
is_primary: false,
});
}
RenderedCell::Composed {
label: comp.label.clone(),
style: comp.style,
parts,
}
}
pub fn render_view(spec: &ViewSpec, mode: ViewMode, rows: &[RowData]) -> RenderedView {
RenderedView {
model: spec.model.clone(),
mode,
rows: rows.iter().map(|r| render_row(spec, r)).collect(),
}
}
pub fn render_view_with_ids(
spec: &ViewSpec,
mode: ViewMode,
rows: &[(i64, RowData)],
) -> RenderedView {
RenderedView {
model: spec.model.clone(),
mode,
rows: rows
.iter()
.map(|(id, r)| {
let mut row = render_row(spec, r);
row.id = Some(*id);
row
})
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::view_layer::compose::ComposeStyle;
use crate::view_layer::spec::VIEW_SPEC_VERSION;
fn customer_spec() -> ViewSpec {
ViewSpec {
model: "customer".into(),
default_mode: ViewMode::List,
allowed_modes: vec![ViewMode::List, ViewMode::Table],
fields: vec![
FieldViewSpec::new("full_name", FieldRole::Primary),
FieldViewSpec::new("email", FieldRole::Secondary),
{
let mut f = FieldViewSpec::new("status", FieldRole::Badge);
f.semantic_class = Some(SemanticClass::Success);
f
},
FieldViewSpec::new("password_hash", FieldRole::Hidden),
FieldViewSpec::new("internal_notes", FieldRole::DetailOnly),
],
compositions: vec![],
default_filters: vec![],
version: VIEW_SPEC_VERSION,
}
}
fn customer_row() -> RowData {
let mut row = RowData::new();
row.insert("full_name".into(), "Nadim Shahin".into());
row.insert("email".into(), "nadim@example.com".into());
row.insert("status".into(), "active".into());
row.insert("password_hash".into(), "$2b$super-secret".into());
row.insert("internal_notes".into(), "VIP".into());
row
}
#[test]
fn hidden_field_value_never_rendered() {
let rendered = render_row(&customer_spec(), &customer_row());
let leaked = rendered.cells.iter().any(|c| match c {
RenderedCell::Primary { value, .. }
| RenderedCell::Secondary { value, .. }
| RenderedCell::Badge { value, .. }
| RenderedCell::Timestamp { value, .. } => value.contains("secret"),
RenderedCell::Composed { parts, .. } => {
parts.iter().any(|p| p.value.contains("secret"))
}
});
assert!(!leaked, "sensitive value leaked into rendered cells");
}
#[test]
fn detail_only_excluded_from_list() {
let rendered = render_row(&customer_spec(), &customer_row());
let has_notes = rendered
.cells
.iter()
.any(|c| matches!(c, RenderedCell::Secondary { value, .. } if value == "VIP"));
assert!(!has_notes);
}
#[test]
fn badge_carries_semantic_class() {
let rendered = render_row(&customer_spec(), &customer_row());
let badge = rendered
.cells
.iter()
.find(|c| matches!(c, RenderedCell::Badge { .. }));
match badge {
Some(RenderedCell::Badge { semantic, .. }) => {
assert_eq!(*semantic, SemanticClass::Success);
}
_ => panic!("expected a badge cell"),
}
}
#[test]
fn composed_field_not_rendered_twice() {
let mut spec = customer_spec();
spec.compositions.push(CellComposition {
id: "identity".into(),
label: Some("Customer".into()),
style: ComposeStyle::Stacked,
primary_field: "full_name".into(),
secondary_fields: vec!["email".into()],
});
let rendered = render_row(&spec, &customer_row());
let standalone_primary = rendered
.cells
.iter()
.any(|c| matches!(c, RenderedCell::Primary { value, .. } if value == "Nadim Shahin"));
assert!(!standalone_primary);
let composed = rendered
.cells
.iter()
.find(|c| matches!(c, RenderedCell::Composed { .. }));
assert!(composed.is_some());
}
#[test]
fn humanize_handles_snake_case() {
assert_eq!(humanize("created_at"), "Created At");
assert_eq!(humanize("full_name"), "Full Name");
}
#[test]
fn render_view_with_ids_sets_row_ids_and_still_drops_hidden() {
let view = super::render_view_with_ids(
&customer_spec(),
ViewMode::Cards,
&[(7, customer_row()), (9, customer_row())],
);
assert_eq!(view.rows.len(), 2);
assert_eq!(view.rows[0].id, Some(7));
assert_eq!(view.rows[1].id, Some(9));
let leaked = view.rows.iter().flat_map(|r| &r.cells).any(|c| match c {
RenderedCell::Primary { value, .. }
| RenderedCell::Secondary { value, .. }
| RenderedCell::Badge { value, .. }
| RenderedCell::Timestamp { value, .. } => value.contains("secret"),
RenderedCell::Composed { parts, .. } => {
parts.iter().any(|p| p.value.contains("secret"))
}
});
assert!(!leaked);
}
#[test]
fn cell_serializes_with_kind_tag() {
let cell = RenderedCell::Badge {
label: "Status".into(),
value: "active".into(),
semantic: SemanticClass::Success,
};
let json = serde_json::to_value(&cell).unwrap();
assert_eq!(json["kind"], "badge");
assert_eq!(json["semantic"], "success");
assert_eq!(json["value"], "active");
}
#[test]
fn shipped_partials_switch_on_kind_and_drop_hidden() {
use minijinja::{context, Environment, Value};
const CELL: &str = include_str!("../../assets/templates/admin/view_layer/_cell.html");
const ROW: &str = include_str!("../../assets/templates/admin/view_layer/_row.html");
let mut env = Environment::new();
env.add_template("admin/view_layer/_cell.html", CELL)
.unwrap();
env.add_template("admin/view_layer/_row.html", ROW).unwrap();
let rendered = render_row(&customer_spec(), &customer_row());
let tmpl = env.get_template("admin/view_layer/_row.html").unwrap();
let html = tmpl
.render(context! { row => Value::from_serialize(&rendered) })
.unwrap();
assert!(html.contains("av-primary"));
assert!(html.contains("Nadim Shahin"));
assert!(html.contains("badge--success"));
assert!(
!html.contains("secret"),
"hidden value reached HTML: {html}"
);
assert!(
!html.contains("VIP"),
"detail-only value reached HTML: {html}"
);
}
}