Skip to main content

polyspec_crudui_generator/
detail.rs

1//! Read-only detail models and HTML rendering.
2
3use 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
11/// Detail options use the same composition, language and data inputs as lists; the list-only
12/// `layout`, `page` and `total` are ignored.
13pub type DetailOptions<'a> = ListOptions<'a>;
14
15/// Build one read-only detail model from a supplied record.
16pub fn build_detail(
17    spec: &Value,
18    record: &Value,
19    options: &DetailOptions<'_>,
20) -> FormResult<Value> {
21    // The specification is read in member order; the record keeps its own order.
22    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    // Argument shapes in argument order, then the declaration, then options.
27    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    // Page and total are list-only options: a detail neither checks nor uses them.
37    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
74/// Render one read-only detail: image preload links, the actions when the detail has any, and
75/// the definition list.
76pub 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}