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#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(default, rename_all = "camelCase")]
20pub struct BindOptions {
21 pub id_prefix: Value,
23 pub language: Value,
25 pub key_prefix: Value,
27 pub unsupported: Value,
29 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
44fn 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 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#[derive(Clone, Default)]
66struct Scope {
67 row_segments: Vec<usize>,
69 row_numbers: Vec<usize>,
71 sticky_depth: usize,
73 inline: bool,
75}
76
77fn declared_layout(spec: &Value) -> Option<&str> {
79 spec["design"]["layout"].as_str()
80}
81
82fn 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
92pub 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
102pub 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
131pub 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
143fn 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
158pub(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
200struct Multiple {
202 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
253struct 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
285fn 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
298fn 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
329fn 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 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 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 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 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 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 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 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 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 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}