Skip to main content

polyspec_crudui_generator/
binding.rs

1use crate::choice_list::{
2    check_choice_appearance, choice_pairs, is_choice_list, with_group_choices, CHOICE_LIST_EXPECTED,
3};
4use crate::design::resolve_design;
5use crate::messages::{form_messages, format_count, Messages};
6use crate::template::member_ordered_template;
7use crate::util::*;
8use crate::widget::{declared_attributes, evaluate_widget, widget_kind, WidgetContext};
9use polyspec_crudui_validator::validate::group_source::is_group_source;
10use crate::{FieldTemplate, FormError, FormResult, FormTemplate};
11use serde::{Deserialize, Serialize};
12use serde_json::{json, Map, Value};
13
14/// Per-instance input names, content language and presentation.
15///
16/// Every option is kept as decoded JSON so that a value of the wrong type is rejected
17/// by binding, not by decoding. Null means the default.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(default, rename_all = "camelCase")]
20pub struct BindOptions {
21    /// Stable DOM identifier prefix, `crudui` by default.
22    pub id_prefix: Value,
23    /// Content and interface language: `ko` (default), `en`, `ja` or `zh`.
24    pub language: Value,
25    /// Input prefix overriding the template prefix.
26    pub key_prefix: Value,
27    /// Unsupported field handling: `throw` (default) or `marker`.
28    pub unsupported: Value,
29    /// Row paths `{collection}.{key}` rendered collapsed, a list of strings; null renders every row expanded.
30    pub collapsed: Value,
31}
32impl Default for BindOptions {
33    fn default() -> Self {
34        Self {
35            id_prefix: "crudui".into(),
36            language: "ko".into(),
37            key_prefix: Value::Null,
38            unsupported: "throw".into(),
39            collapsed: Value::Null,
40        }
41    }
42}
43
44/// A present string option, or `None` for null.
45fn string_option<'a>(value: &'a Value, error: &str) -> FormResult<Option<&'a str>> {
46    match value {
47        Value::Null => Ok(None),
48        Value::String(text) => Ok(Some(text)),
49        _ => Err(FormError::input(error)),
50    }
51}
52
53struct Binding<'a> {
54    data: &'a Value,
55    /// The row paths rendered collapsed.
56    collapsed: Vec<&'a str>,
57    id_prefix: &'a str,
58    key_prefix: Option<&'a str>,
59    unsupported: &'a str,
60    language: &'a str,
61    messages: &'static Messages,
62}
63
64/// Enclosing repeated rows of a node.
65#[derive(Clone, Default)]
66struct Scope {
67    /// Repeated path segment positions, independent of their key encoding.
68    row_segments: Vec<usize>,
69    /// One-based positions of the enclosing rows.
70    row_numbers: Vec<usize>,
71    /// Number of enclosing rows with a sticky header.
72    sticky_depth: usize,
73    /// Whether the enclosing groups lay their field nodes out inline.
74    inline: bool,
75}
76
77/// The `design.layout` a group declares; compilation has checked the value.
78fn declared_layout(spec: &Value) -> Option<&str> {
79    spec["design"]["layout"].as_str()
80}
81
82/// The scope of a group's children: a declared layout replaces the inherited one, and a line
83/// ends it.
84fn layout_scope(scope: &Scope, layout: Option<&str>) -> Scope {
85    let mut next = scope.clone();
86    if let Some(layout) = layout {
87        next.inline = layout == "inline";
88    }
89    next
90}
91
92/// Evaluate the node grammar using a compiled template and independent record data.
93/// The template specification is read in member order; the data keeps its own order.
94pub fn bind_form(
95    template: &FormTemplate,
96    data: &Value,
97    options: &BindOptions,
98) -> FormResult<Vec<Value>> {
99    bind_ordered(&member_ordered_template(template), data, options)
100}
101
102/// Evaluate the template's form buttons for a record.
103///
104/// Each button is an ordered JSON object `type, tag, text, attrs`; `attrs` keeps the
105/// output order type, class, style, name, value, href, onclick. Text is the declared
106/// content or the interface text of the button type in `options.language`; only the
107/// language option applies.
108pub fn bind_buttons(
109    template: &FormTemplate,
110    data: &Value,
111    options: &BindOptions,
112) -> FormResult<Vec<Value>> {
113    let language = bindable_language(template, data, options)?;
114    let messages = form_messages(language)?;
115    let members = member_ordered_template(template);
116    let declared = &members.buttons;
117    for button in declared {
118        let kind = button.get("type").and_then(Value::as_str).unwrap_or_default();
119        if !matches!(kind, "submit" | "reset" | "button" | "link") {
120            return Err(FormError::input(format!("Invalid button type: {kind}")));
121        }
122    }
123    Ok(crate::buttons::button_models(
124        declared,
125        data,
126        language,
127        messages,
128    ))
129}
130
131/// Markup of buttons evaluated by [`bind_buttons`], identical to the form footer buttons.
132///
133/// Every button must be an object with tag `a` or `button`, string text and `attrs`
134/// whose names are type, class, style, name, value, href or onclick and whose values are
135/// strings; anything else is an input error.
136pub fn form_buttons_html(buttons: &Value) -> FormResult<String> {
137    let list = buttons
138        .as_array()
139        .ok_or_else(|| FormError::input("Form buttons must be a list"))?;
140    crate::buttons::buttons_markup(list)
141}
142
143/// The language of a binding, once the record is an object and the template a form template.
144fn bindable_language<'a>(
145    template: &FormTemplate,
146    data: &Value,
147    options: &'a BindOptions,
148) -> FormResult<&'a str> {
149    if !data.is_object() {
150        return Err(FormError::input("Form data must be an object"));
151    }
152    if template.kind != "crudui/form-template" {
153        return Err(FormError::input("Unsupported form template"));
154    }
155    Ok(string_option(&options.language, "Language must be a string")?.unwrap_or("ko"))
156}
157
158/// `bind_form` over a template already in specification member order.
159pub(crate) fn bind_ordered(
160    template: &FormTemplate,
161    data: &Value,
162    options: &BindOptions,
163) -> FormResult<Vec<Value>> {
164    let language = bindable_language(template, data, options)?;
165    let key_prefix = string_option(&options.key_prefix, "keyPrefix must be a string")?;
166    let id_prefix = string_option(&options.id_prefix, "idPrefix must be a string")?;
167    let unsupported = match &options.unsupported {
168        Value::Null => "throw",
169        Value::String(mode) if mode == "throw" || mode == "marker" => mode.as_str(),
170        _ => return Err(FormError::input("unsupported must be throw or marker")),
171    };
172    let collapsed = match &options.collapsed {
173        Value::Null => Vec::new(),
174        Value::Array(rows) => rows
175            .iter()
176            .map(|row| {
177                row.as_str()
178                    .ok_or_else(|| FormError::input("collapsed must be a list of row paths"))
179            })
180            .collect::<FormResult<Vec<_>>>()?,
181        _ => return Err(FormError::input("collapsed must be a list of row paths")),
182    };
183    let messages = form_messages(language)?;
184    let state = Binding {
185        data,
186        collapsed,
187        id_prefix: id_prefix.unwrap_or("crudui"),
188        key_prefix: key_prefix.or(template.key_prefix.as_deref()),
189        unsupported,
190        language,
191        messages,
192    };
193    template
194        .fields
195        .iter()
196        .map(|field| state.field(field, &field.name, &Scope::default()))
197        .collect()
198}
199
200/// Evaluated controls and limits for a repeated field.
201struct Multiple {
202    /// Rows come only from the data: missing data is zero rows and no row control renders.
203    only: bool,
204    min: Option<f64>,
205    max: Option<f64>,
206    copy: bool,
207    sortable: bool,
208    title: Option<String>,
209    controls: &'static str,
210    sticky: bool,
211}
212
213fn multiple(spec: &Value) -> Option<Multiple> {
214    match spec.get("multiple") {
215        Some(Value::Bool(true)) => Some(Multiple {
216            only: false,
217            min: None,
218            max: None,
219            copy: false,
220            sortable: false,
221            title: None,
222            controls: "header",
223            sticky: false,
224        }),
225        Some(Value::String(keyword)) if keyword == "only" => Some(Multiple {
226            only: true,
227            min: None,
228            max: None,
229            copy: false,
230            sortable: false,
231            title: None,
232            controls: "header",
233            sticky: false,
234        }),
235        Some(Value::Object(m)) => Some(Multiple {
236            only: m.get("only").is_some_and(|v| *v == true),
237            min: m.get("min").and_then(Value::as_f64),
238            max: m.get("max").and_then(Value::as_f64),
239            copy: m.get("copy").is_some_and(|v| *v == true),
240            sortable: m.get("sortable").is_some_and(|v| *v == true),
241            title: m.get("title").and_then(Value::as_str).map(str::to_owned),
242            controls: match m.get("controls").and_then(Value::as_str) {
243                Some("footer") => "footer",
244                Some("outline") => "outline",
245                _ => "header",
246            },
247            sticky: m.get("header").is_some_and(|v| v == "sticky"),
248        }),
249        _ => None,
250    }
251}
252
253/// Language codes, frame and title of a language field.
254struct Lang<'a> {
255    codes: Vec<Value>,
256    frame: bool,
257    title: Option<&'a Value>,
258    group_class: &'a str,
259}
260
261fn lang(spec: &Value) -> Option<Lang<'_>> {
262    let defaults = || ["ko", "en", "ja", "zh"].map(Value::from).to_vec();
263    match spec.get("lang") {
264        Some(Value::Bool(true)) => Some(Lang {
265            codes: defaults(),
266            frame: true,
267            title: None,
268            group_class: "",
269        }),
270        Some(Value::Object(l)) => Some(Lang {
271            codes: l
272                .get("only")
273                .and_then(Value::as_array)
274                .filter(|codes| !codes.is_empty())
275                .cloned()
276                .unwrap_or_else(defaults),
277            frame: l.get("frame").is_none_or(|v| *v != false),
278            title: l.get("title"),
279            group_class: l.get("group_class").and_then(Value::as_str).unwrap_or(""),
280        }),
281        _ => None,
282    }
283}
284
285/// Row keys of a keyed collection. Missing data has one initial row, or none in a
286/// data-only collection.
287fn rows(value: Option<&Value>, path: &str, only: bool) -> FormResult<Vec<String>> {
288    match value {
289        None if only => Ok(Vec::new()),
290        None => Ok(vec!["__0000000000000__".into()]),
291        Some(Value::Object(m)) => Ok(m.keys().cloned().collect()),
292        Some(_) => Err(FormError::input(format!(
293            "Repeated data must be a keyed object: {path}"
294        ))),
295    }
296}
297
298/// Present group data, including a repeated group row, must be an object.
299fn check_group(value: Option<&Value>, path: &str) -> FormResult<()> {
300    match value {
301        Some(value) if !value.is_object() => Err(FormError::input(format!(
302            "Group data must be an object: {path}"
303        ))),
304        _ => Ok(()),
305    }
306}
307
308fn text_at<'a>(value: &'a Value, node: &str, key: &str) -> &'a str {
309    value[node][key].as_str().unwrap_or("")
310}
311
312fn node_root(kind: &str, path: &str, design: &Value, spec: &Value) -> Map<String, Value> {
313    let mut node = Map::new();
314    put_string(&mut node, "kind", kind);
315    put_string(&mut node, "path", path);
316    put_string(&mut node, "className", text_at(design, "wrapper", "class"));
317    put_nonempty(
318        &mut node,
319        "style",
320        style(text_at(design, "wrapper", "style")),
321    );
322    if let Some(attributes) = declared_attributes(spec, true) {
323        node.insert("attributes".into(), attributes.into());
324    }
325    node.insert("hidden".into(), (design["show"] != true).into());
326    node
327}
328
329/// Header with the given parts, or `None` when every part is empty.
330fn node_header(parts: Vec<(&str, Option<String>)>, design: &Value) -> Option<Value> {
331    let present = parts
332        .into_iter()
333        .filter_map(|(key, value)| value.filter(|v| !v.is_empty()).map(|v| (key, v)))
334        .collect::<Vec<_>>();
335    if present.is_empty() {
336        return None;
337    }
338    let mut header = Map::new();
339    put_string(&mut header, "className", text_at(design, "label", "class"));
340    put_nonempty(
341        &mut header,
342        "style",
343        style(text_at(design, "label", "style")),
344    );
345    for (key, value) in present {
346        put_string(&mut header, key, value);
347    }
348    Some(header.into())
349}
350
351fn node_body(class: &str, inline: &str, id: Option<String>) -> Value {
352    let mut body = Map::new();
353    put_string(&mut body, "className", class);
354    put_nonempty(&mut body, "style", style(inline));
355    if let Some(id) = id {
356        put_nonempty(&mut body, "id", id);
357    }
358    body.into()
359}
360
361fn row_controls(settings: &Multiple, messages: &Messages, index: usize, count: usize) -> Value {
362    let full = settings.max.is_some_and(|max| count as f64 >= max);
363    let mut actions = Vec::new();
364    if settings.sortable {
365        actions.push(action("move-up", messages.move_up, index == 0));
366        actions.push(action("move-down", messages.move_down, index + 1 == count));
367    }
368    actions.push(action("add-row", messages.add_row, full));
369    if settings.copy {
370        actions.push(action("copy-row", messages.copy_row, full));
371    }
372    let minimum = settings.min.is_some_and(|min| count as f64 <= min);
373    actions.push(action("remove-row", messages.remove_row, minimum));
374    json!({"placement": settings.controls, "label": messages.row_controls, "actions": actions})
375}
376
377fn action(name: &str, label: &str, disabled: bool) -> Value {
378    json!({"name": name, "label": label, "disabled": disabled})
379}
380
381fn label_target(widget: &Value) -> Option<String> {
382    if widget["unsupported"] == true {
383        return None;
384    }
385    widget["extra"]["file"]["id"]
386        .as_str()
387        .or_else(|| widget["attrs"]["id"].as_str())
388        .map(str::to_owned)
389}
390
391impl Binding<'_> {
392    fn widget(
393        &self,
394        declared: &Value,
395        path: &str,
396        design: &Value,
397        row_segments: &[usize],
398    ) -> FormResult<Value> {
399        // A group source lists the rows of its group in the data as its choice list.
400        let grouped = is_group_source(&declared["items"])
401            .then(|| with_group_choices(declared, self.data, path))
402            .transpose()?;
403        let spec = grouped.as_ref().unwrap_or(declared);
404        let field_type = spec["type"].as_str().unwrap_or("").to_lowercase();
405        if is_choice_list(&spec["items"]) {
406            // Only the choices of a choice or multichoice field declare their appearance, and
407            // only the choice list of a select field has groups.
408            let appearance = matches!(widget_kind(&field_type), Some("choice" | "multichoice"));
409            let groups = widget_kind(&field_type) == Some("select");
410            if choice_pairs(&spec["items"], appearance, groups).is_none() {
411                return Err(FormError::input(format!(
412                    "Invalid items at {path}: expected {CHOICE_LIST_EXPECTED}"
413                )));
414            }
415            if appearance {
416                check_choice_appearance(&spec["items"], path)?;
417            }
418        }
419        let id = control_id(self.id_prefix, path);
420        let context = WidgetContext {
421            spec,
422            value: value_at(self.data, path),
423            path,
424            design,
425            key_prefix: self.key_prefix,
426            id: &id,
427            language: self.language,
428            row_segments,
429        };
430        let kind = spec["type"].as_str().unwrap_or("");
431        match evaluate_widget(kind, &context) {
432            Some(widget) => Ok(widget),
433            None if self.unsupported == "marker" => Ok(json!({"unsupported": true, "type": kind})),
434            None => Err(FormError {
435                code: "UNSUPPORTED_FIELD_TYPE".into(),
436                message: format!("Unsupported field type \"{kind}\" at \"{path}\""),
437                at: path.into(),
438                trace: Vec::new(),
439            }),
440        }
441    }
442
443    fn children(
444        &self,
445        fields: &[FieldTemplate],
446        path: &str,
447        scope: &Scope,
448    ) -> FormResult<Vec<Value>> {
449        fields
450            .iter()
451            .map(|field| self.field(field, &format!("{path}.{}", field.name), scope))
452            .collect()
453    }
454
455    /// Build the node for one composed field.
456    fn field(&self, field: &FieldTemplate, path: &str, scope: &Scope) -> FormResult<Value> {
457        let spec = Value::Object(field.spec.clone());
458        let design = resolve_design(spec.get("design"), self.data, path, &scope.row_segments);
459        let language = self.language;
460        let label = spec
461            .get("label")
462            .filter(|v| polyspec_crudui_validator::expr::is_truthy(v))
463            .map(|v| translate(Some(v), language))
464            .filter(|v| !v.is_empty());
465        let description = Some(translate(spec.get("description"), language));
466        if let Some(settings) = multiple(&spec) {
467            return self.collection(
468                field,
469                &spec,
470                path,
471                &design,
472                label,
473                description,
474                &settings,
475                scope,
476            );
477        }
478        if spec["type"] == "group" {
479            check_group(value_at(self.data, path), path)?;
480            let layout = declared_layout(&spec);
481            let mut node = node_root("group", path, &design, &spec);
482            // A line group is one row in an inline layout, and its children take no inline layout.
483            if layout == Some("line") {
484                let class_name = join_class(&[
485                    if scope.inline {
486                        "crudui-node--inline"
487                    } else {
488                        ""
489                    },
490                    "crudui-node--line",
491                    text_at(&design, "wrapper", "class"),
492                ]);
493                put_string(&mut node, "className", class_name);
494            }
495            if let Some(header) = node_header(
496                vec![("label", label), ("description", description)],
497                &design,
498            ) {
499                node.insert("header".into(), header);
500            }
501            node.insert(
502                "body".into(),
503                node_body(
504                    text_at(&design, "group", "class"),
505                    text_at(&design, "group", "style"),
506                    None,
507                ),
508            );
509            node.insert(
510                "children".into(),
511                self.children(&field.children, path, &layout_scope(scope, layout))?
512                    .into(),
513            );
514            return Ok(node.into());
515        }
516        if let Some(settings) = lang(&spec) {
517            return self.lang(&spec, path, &design, label, description, &settings, scope);
518        }
519        self.leaf(&spec, path, &design, label, description, scope)
520    }
521
522    fn leaf(
523        &self,
524        spec: &Value,
525        path: &str,
526        design: &Value,
527        label: Option<String>,
528        description: Option<String>,
529        scope: &Scope,
530    ) -> FormResult<Value> {
531        let kind = spec["type"].as_str().unwrap_or("");
532        let mut node = node_root("field", path, design, spec);
533        // A field node of an inline layout is one row of a label column and a control column.
534        if scope.inline {
535            let class_name =
536                join_class(&["crudui-node--inline", text_at(design, "wrapper", "class")]);
537            put_string(&mut node, "className", class_name);
538        }
539        if kind == "checkbox" || kind == "switcher" {
540            let id = control_id(self.id_prefix, path);
541            // An inline layout writes the label in the label column of the header instead of the caption.
542            let header_label = label.clone().filter(|_| scope.inline);
543            let parts = match &header_label {
544                Some(label) => vec![
545                    ("label", Some(label.clone())),
546                    ("labelFor", Some(id.clone())),
547                    ("description", description),
548                ],
549                None => vec![("description", description)],
550            };
551            if let Some(header) = node_header(parts, design) {
552                node.insert("header".into(), header);
553            }
554            node.insert("body".into(), node_body("", "", None));
555            let on = |v: &Value| v.as_f64() == Some(1.0) || *v == true || *v == "1";
556            let checked = match value_at(self.data, path) {
557                Some(value) => on(value),
558                None => spec.get("default").is_some_and(on),
559            };
560            // A switcher is a checkbox input announced and drawn as a switch.
561            let switcher = kind == "switcher";
562            let mut checkbox = json!({
563                "id": id,
564                "name": bracket(path, self.key_prefix),
565                "className": join_class(&[
566                    "valid-target",
567                    if switcher { "crudui-input crudui-input--switch" } else { "" },
568                    text_at(design, "main", "class"),
569                ]),
570                "checked": checked,
571            });
572            if switcher {
573                checkbox["role"] = "switch".into();
574            }
575            if header_label.is_none() {
576                checkbox["caption"] = label.unwrap_or_default().into();
577            }
578            if let Some(attributes) = declared_attributes(spec, false) {
579                checkbox["attributes"] = attributes.into();
580            }
581            node.insert("checkbox".into(), checkbox);
582            return Ok(node.into());
583        }
584        let widget = self.widget(spec, path, design, &scope.row_segments)?;
585        if kind != "hidden" {
586            let label_for = label.as_ref().and_then(|_| label_target(&widget));
587            let parts = vec![
588                ("label", label),
589                ("labelFor", label_for),
590                ("description", description),
591            ];
592            if let Some(header) = node_header(parts, design) {
593                node.insert("header".into(), header);
594            }
595        }
596        node.insert("body".into(), node_body("", "", None));
597        node.insert("widget".into(), widget);
598        Ok(node.into())
599    }
600
601    #[allow(clippy::too_many_arguments, reason = "the binding step takes the shared inputs")]
602    fn collection(
603        &self,
604        field: &FieldTemplate,
605        spec: &Value,
606        path: &str,
607        design: &Value,
608        label: Option<String>,
609        description: Option<String>,
610        settings: &Multiple,
611        scope: &Scope,
612    ) -> FormResult<Value> {
613        let keys = rows(value_at(self.data, path), path, settings.only)?;
614        let item = if spec["type"] == "group" {
615            "group"
616        } else {
617            "field"
618        };
619        let full = settings.max.is_some_and(|max| keys.len() as f64 >= max);
620        let rows_scope = if item == "group" {
621            layout_scope(scope, declared_layout(spec))
622        } else {
623            scope.clone()
624        };
625        let children = keys
626            .iter()
627            .enumerate()
628            .map(|(index, key)| {
629                self.row(
630                    field,
631                    spec,
632                    path,
633                    key,
634                    index,
635                    keys.len(),
636                    &label,
637                    settings,
638                    &rows_scope,
639                )
640            })
641            .collect::<FormResult<Vec<_>>>()?;
642        let count = Some(format_count(self.messages.count, keys.len()));
643        let mut node = node_root("collection", path, design, spec);
644        if let Some(header) = node_header(
645            vec![
646                ("label", label),
647                ("description", description),
648                ("count", count),
649            ],
650            design,
651        ) {
652            node.insert("header".into(), header);
653        }
654        node.insert("body".into(), node_body("", "", None));
655        put_string(&mut node, "item", item);
656        if keys.is_empty() && !settings.only {
657            node.insert(
658                "controls".into(),
659                json!({
660                    "placement": "footer",
661                    "label": self.messages.collection_controls,
662                    "actions": [action("add-row", self.messages.add_row, full)],
663                }),
664            );
665        }
666        node.insert("children".into(), children.into());
667        Ok(node.into())
668    }
669
670    #[allow(clippy::too_many_arguments, reason = "the binding step takes the shared inputs")]
671    fn row(
672        &self,
673        field: &FieldTemplate,
674        spec: &Value,
675        collection: &str,
676        key: &str,
677        index: usize,
678        count: usize,
679        label: &Option<String>,
680        settings: &Multiple,
681        scope: &Scope,
682    ) -> FormResult<Value> {
683        let messages = self.messages;
684        let row_path = format!("{collection}.{key}");
685        let mut inner = scope.clone();
686        inner.row_segments.push(segments(collection).len());
687        let design = resolve_design(
688            spec.get("design"),
689            self.data,
690            &row_path,
691            &inner.row_segments,
692        );
693        inner.row_numbers.push(index + 1);
694        inner.sticky_depth += usize::from(settings.sticky);
695        let mut row = Map::new();
696        put_string(&mut row, "kind", "row");
697        put_string(&mut row, "key", key);
698        put_string(&mut row, "className", "");
699        row.insert("hidden".into(), false.into());
700        // Rows of a data-only collection have no row controls.
701        if !settings.only {
702            row.insert(
703                "controls".into(),
704                row_controls(settings, messages, index, count),
705            );
706        }
707        if settings.sticky {
708            row.insert("sticky".into(), true.into());
709            row.insert("stickyDepth".into(), scope.sticky_depth.into());
710        }
711        let mut header = Map::new();
712        put_string(&mut header, "className", "");
713        if let Some(label) = label {
714            put_string(&mut header, "label", label.as_str());
715        }
716        let number = inner
717            .row_numbers
718            .iter()
719            .map(usize::to_string)
720            .collect::<Vec<_>>();
721        put_string(&mut header, "number", number.join("."));
722        if spec["type"] != "group" {
723            row.insert("header".into(), header.into());
724            row.insert("body".into(), node_body("", "", None));
725            row.insert(
726                "widget".into(),
727                self.widget(spec, &row_path, &design, &inner.row_segments)?,
728            );
729            return Ok(row.into());
730        }
731        check_group(value_at(self.data, &row_path), &row_path)?;
732        let children = self.children(&field.children, &row_path, &inner)?;
733        let nested = children
734            .iter()
735            .filter(|child| child["kind"] == "collection")
736            .collect::<Vec<_>>();
737        let summary = if nested.is_empty() {
738            messages.collapsed.to_owned()
739        } else {
740            let total = nested
741                .iter()
742                .map(|child| child["children"].as_array().map_or(0, Vec::len))
743                .sum();
744            format_count(messages.children, total)
745        };
746        if let Some(title) = &settings.title {
747            let title = match value_at(self.data, &format!("{row_path}.{title}")) {
748                None | Some(Value::Null) => messages.untitled.to_owned(),
749                Some(Value::String(text)) if text.is_empty() => messages.untitled.to_owned(),
750                Some(value) => js_string(value),
751            };
752            put_string(&mut header, "title", title);
753        }
754        put_string(&mut header, "summary", summary);
755        row.insert("header".into(), header.into());
756        let body_id = format!("{}:body", control_id(self.id_prefix, &row_path));
757        row.insert(
758            "body".into(),
759            node_body(
760                text_at(&design, "group", "class"),
761                text_at(&design, "group", "style"),
762                Some(body_id),
763            ),
764        );
765        row.insert("collapsible".into(), true.into());
766        row.insert(
767            "expanded".into(),
768            (!self.collapsed.contains(&row_path.as_str())).into(),
769        );
770        put_string(&mut row, "toggleLabel", messages.toggle_row);
771        row.insert("children".into(), children.into());
772        Ok(row.into())
773    }
774
775    #[allow(clippy::too_many_arguments, reason = "the binding step takes the shared inputs")]
776    fn lang(
777        &self,
778        spec: &Value,
779        path: &str,
780        design: &Value,
781        label: Option<String>,
782        description: Option<String>,
783        settings: &Lang<'_>,
784        scope: &Scope,
785    ) -> FormResult<Value> {
786        let title = Some(translate(settings.title, self.language));
787        let mut node = node_root("lang", path, design, spec);
788        if let Some(header) = node_header(
789            vec![
790                ("label", label),
791                ("description", description),
792                ("title", title),
793            ],
794            design,
795        ) {
796            node.insert("header".into(), header);
797        }
798        // A framed language group is a node modifier; the stylesheet draws the frame around its body.
799        let frame = if settings.frame {
800            "crudui-node--framed"
801        } else {
802            ""
803        };
804        let class_name = join_class(&[frame, text_at(design, "wrapper", "class")]);
805        put_string(&mut node, "className", class_name);
806        node.insert(
807            "body".into(),
808            node_body(&join_class(&[settings.group_class]), "", None),
809        );
810        let children = settings
811            .codes
812            .iter()
813            .map(|code| {
814                let lang_path = format!("{path}.{}", js_string(code));
815                let lang_design = resolve_design(
816                    spec.get("design"),
817                    self.data,
818                    &lang_path,
819                    &scope.row_segments,
820                );
821                Ok(json!({
822                    "kind": "lang-item",
823                    "lang": code,
824                    "className": "",
825                    "hidden": false,
826                    "header": {"className": "", "label": code},
827                    "body": node_body("", "", None),
828                    "widget": self.widget(spec, &lang_path, &lang_design, &scope.row_segments)?,
829                }))
830            })
831            .collect::<FormResult<Vec<_>>>()?;
832        node.insert("children".into(), children.into());
833        Ok(node.into())
834    }
835}