polyspec_crudui_generator/
detail.rs1use polyspec_crudui_validator::compose::member_ordered;
4use serde_json::{json, Value};
5
6use crate::list::{actions_html, build_display, cell_html, ListContext, ListOptions};
7use crate::render::{element, escape};
8use crate::util::join_class;
9use crate::{FormError, FormResult};
10
11pub type DetailOptions<'a> = ListOptions<'a>;
14
15pub fn build_detail(
17 spec: &Value,
18 record: &Value,
19 options: &DetailOptions<'_>,
20) -> FormResult<Value> {
21 let spec = &member_ordered(spec);
23 let Some(spec) = spec.as_object() else {
24 return Err(FormError::input("Detail specification must be an object"));
25 };
26 let Some(record) = record.as_object() else {
28 return Err(FormError::input("Detail record must be an object"));
29 };
30 if !spec.contains_key("fields") {
31 return Err(FormError::input("Detail specification must declare fields"));
32 }
33 if !options.data.is_null() && !options.data.is_object() {
34 return Err(FormError::input("Detail context must be an object"));
35 }
36 let checked = ListContext {
38 data: if options.data.is_null() {
39 json!({})
40 } else {
41 options.data.clone()
42 },
43 page: None,
44 total: None,
45 };
46 let list = build_display(
47 &Value::Object(spec.clone()),
48 &[Value::Object(record.clone())],
49 options,
50 &checked,
51 "detail",
52 "fields",
53 )?;
54 let columns = list["columns"].as_array().cloned().unwrap_or_default();
55 let cells = list["rows"]
56 .as_array()
57 .and_then(|rows| rows.first())
58 .and_then(|row| row["cells"].as_array())
59 .cloned()
60 .unwrap_or_default();
61 let fields = columns
62 .iter()
63 .zip(cells.iter())
64 .map(|(column, cell)| {
65 json!({
66 "key": column["key"], "label": column["label"], "format": cell["format"],
67 "value": cell["value"], "display": cell["display"], "design": cell["design"]
68 })
69 })
70 .collect::<Vec<_>>();
71 Ok(json!({"fields": fields, "actions": list["actions"], "design": list["design"]}))
72}
73
74pub fn render_detail(
77 spec: &Value,
78 record: &Value,
79 options: &DetailOptions<'_>,
80) -> FormResult<String> {
81 let model = build_detail(spec, record, options)?;
82 let design = &model["design"];
83 let class = join_class(&[
84 "crudui-detail",
85 design["wrapper"]["class"].as_str().unwrap_or(""),
86 ]);
87 let body = model["fields"]
88 .as_array()
89 .map(|fields| {
90 fields
91 .iter()
92 .map(|field| {
93 let cell = json!({"display": field["display"], "design": field["design"], "format": field["format"]});
94 let label = element("dt", &json!({"class":"crudui-detail__label"}), &escape(field["label"].as_str().unwrap_or("")));
95 let value = cell_html(&cell, "dd", &format!("crudui-detail__value crudui-value crudui-value--{}", field["format"]["type"].as_str().unwrap_or("text")));
96 element("div", &json!({"class":"crudui-detail__field"}), &format!("{label}{value}"))
97 })
98 .collect::<String>()
99 })
100 .unwrap_or_default();
101 let mut attrs = json!({"class": class});
102 if let Some(style) = design["wrapper"]["style"]
103 .as_str()
104 .filter(|style| !style.is_empty())
105 {
106 attrs["style"] = style.into();
107 }
108 let mut preloads = String::new();
109 let mut seen = std::collections::HashSet::new();
110 if let Some(fields) = model["fields"].as_array() {
111 for field in fields {
112 let display = &field["display"];
113 if display["kind"] != "image" {
114 continue;
115 }
116 let Some(source) = display["src"].as_str().filter(|source| {
117 !source.is_empty() && !source.to_ascii_lowercase().starts_with("data:")
118 }) else {
119 continue;
120 };
121 if seen.insert(source) {
122 preloads.push_str(&element(
123 "link",
124 &json!({"rel":"preload", "as":"image", "href":source}),
125 "",
126 ));
127 }
128 }
129 }
130 let actions = actions_html(&model["actions"], "crudui-detail");
131 Ok(format!(
132 "{preloads}{actions}{}",
133 element("dl", &attrs, &body)
134 ))
135}
136
137#[cfg(test)]
138mod tests {
139 use super::{build_detail, render_detail, DetailOptions};
140 use serde_json::json;
141
142 #[test]
143 fn detail_reuses_ordered_list_display_model() {
144 let spec = json!({"fields": {"name": {"field": "name", "label": "Name"}, "active": {"field": "active", "label": "Active", "format": {"type": "bool", "true": "Yes", "false": "No"}}}});
145 let model = build_detail(
146 &spec,
147 &json!({"name": "Ada", "active": true}),
148 &DetailOptions::default(),
149 )
150 .unwrap();
151 assert_eq!(model["fields"].as_array().unwrap().len(), 2);
152 assert_eq!(model["fields"][0]["display"], "Ada");
153 assert_eq!(model["fields"][1]["display"]["label"], "Yes");
154 }
155
156 #[test]
157 fn detail_context_must_be_an_object_after_record_checks() {
158 let spec = json!({"fields": {"v": {"field": "v"}}});
159 for data in [json!([]), json!("s"), json!(1)] {
160 let options = DetailOptions {
161 data,
162 ..Default::default()
163 };
164 for result in [
165 build_detail(&spec, &json!({}), &options).map(|_| String::new()),
166 render_detail(&spec, &json!({}), &options),
167 ] {
168 let error = result.unwrap_err();
169 assert_eq!(
170 (
171 error.code.as_str(),
172 error.message.as_str(),
173 error.at.as_str()
174 ),
175 ("INVALID_FORM_INPUT", "Detail context must be an object", "")
176 );
177 }
178 assert_eq!(
179 build_detail(&json!({}), &json!({}), &options)
180 .unwrap_err()
181 .message,
182 "Detail specification must declare fields"
183 );
184 assert_eq!(
185 build_detail(&spec, &json!([]), &options)
186 .unwrap_err()
187 .message,
188 "Detail record must be an object"
189 );
190 }
191 let options = DetailOptions {
192 data: json!(null),
193 ..Default::default()
194 };
195 assert!(render_detail(&spec, &json!({}), &options).is_ok());
196 }
197
198 #[test]
199 fn detail_renders_read_only_definition_list() {
200 let spec = json!({"fields": {"name": {"field": "name", "label": "Name"}}});
201 let html =
202 render_detail(&spec, &json!({"name": "Ada"}), &DetailOptions::default()).unwrap();
203 assert_eq!(
204 html,
205 r#"<dl class="crudui-detail"><div class="crudui-detail__field"><dt class="crudui-detail__label">Name</dt><dd class="crudui-detail__value crudui-value crudui-value--text">Ada</dd></div></dl>"#
206 );
207 }
208}