1use 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
22pub type RowData = BTreeMap<String, String>;
26
27#[derive(Debug, Clone, PartialEq, Serialize)]
32#[serde(tag = "kind", rename_all = "snake_case")]
33pub enum RenderedCell {
34 Primary {
36 label: String,
38 value: String,
40 },
41 Secondary {
43 label: String,
45 value: String,
47 },
48 Badge {
50 label: String,
52 value: String,
54 semantic: SemanticClass,
56 },
57 Timestamp {
59 label: String,
61 value: String,
63 },
64 Composed {
66 label: Option<String>,
68 style: ComposeStyle,
70 parts: Vec<CellPart>,
72 },
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize)]
78pub struct CellPart {
79 pub field: String,
81 pub value: String,
83 pub is_primary: bool,
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize)]
90pub struct RenderedRow {
91 #[serde(skip_serializing_if = "Option::is_none")]
95 pub id: Option<i64>,
96 pub cells: Vec<RenderedCell>,
98}
99
100#[derive(Debug, Clone, PartialEq, Serialize)]
104pub struct RenderedView {
105 pub model: String,
107 pub mode: ViewMode,
109 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
120fn 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
136pub 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 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
197pub 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
208pub 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 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 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 #[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}