1use crate::form_render::{render_model, NodeErrors, RenderModel};
2use serde_json::{json, Map, Value};
3
4use crate::util::{join_class, js_string, scalar, style};
5use crate::{Form, FormResult};
6
7pub(crate) fn escape(value: &str) -> String {
8 value
9 .replace('&', "&")
10 .replace('<', "<")
11 .replace('>', ">")
12 .replace('"', """)
13 .replace('\'', "'")
14}
15
16pub(crate) fn raw_text(value: &str) -> String {
17 value
18 .replace('&', "&")
19 .replace('<', "<")
20 .replace('>', ">")
21}
22
23pub(crate) fn raw_attribute(value: &str) -> String {
24 value
25 .replace('&', "&")
26 .replace('"', """)
27 .replace('<', "<")
28}
29
30fn attributes(attrs: &Value, raw: bool) -> String {
31 let Some(attrs) = attrs.as_object() else {
32 return String::new();
33 };
34 attrs
35 .iter()
36 .map(|(key, value)| {
37 let value = scalar(Some(value));
38 let value = if raw {
39 raw_attribute(&value)
40 } else {
41 escape(&value)
42 };
43 let key = if raw {
44 key.as_str()
45 } else {
46 match key.as_str() {
47 "autocomplete" => "autoComplete",
48 "readonly" => "readOnly",
49 "maxlength" => "maxLength",
50 "minlength" => "minLength",
51 "colspan" => "colSpan",
52 "rowspan" => "rowSpan",
53 _ => key.as_str(),
54 }
55 };
56 format!(" {key}=\"{value}\"")
57 })
58 .collect()
59}
60
61pub(crate) fn sanitize_url(url: &str) -> String {
62 let pattern=regex::RegexBuilder::new(r"^[\x00-\x1f ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*:")
63 .case_insensitive(true).unicode(false).build().expect("URL scheme pattern");
64 if pattern.is_match(url) {
65 "javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')".into()
66 } else {
67 url.into()
68 }
69}
70
71pub(crate) fn element(tag: &str, attrs: &Value, content: &str) -> String {
72 let mut attrs = attrs.as_object().cloned().unwrap_or_default();
73 if let Some(value) = attrs.get("style").and_then(Value::as_str) {
74 let value = crate::css::render_style(value);
75 if value.is_empty() {
76 attrs.shift_remove("style");
77 } else {
78 attrs.insert("style".into(), value.into());
79 }
80 }
81 for key in ["href", "src"] {
82 if let Some(value) = attrs.get(key).and_then(Value::as_str) {
83 if key == "src" && value.is_empty() {
84 attrs.shift_remove(key);
85 } else {
86 attrs.insert(key.into(), sanitize_url(value).into());
87 }
88 }
89 }
90 for key in ["readonly", "disabled", "required", "multiple", "autofocus"] {
91 if attrs.contains_key(key) {
92 attrs.insert(key.into(), "".into());
93 }
94 }
95 if tag == "input" {
96 for key in ["name", "checked", "value"] {
97 if let Some(value) = attrs.shift_remove(key) {
98 attrs.insert(key.into(), value);
99 }
100 }
101 } else if matches!(tag, "select" | "textarea") {
102 attrs.shift_remove("value");
103 }
104 let attributes = attributes(&attrs.into(), false);
105 if matches!(tag, "input" | "img" | "br" | "hr" | "link") {
106 format!("<{tag}{attributes}/>")
107 } else {
108 let leading_newline = if tag == "textarea" && content.starts_with('\n') {
109 "\n"
110 } else {
111 ""
112 };
113 format!("<{tag}{attributes}>{leading_newline}{content}</{tag}>")
114 }
115}
116
117pub(crate) fn raw_element(tag: &str, attrs: &Value, content: &str) -> String {
118 let attributes = attributes(attrs, true);
119 if matches!(tag, "input" | "img" | "br" | "hr" | "link") {
120 format!("<{tag}{attributes}>")
121 } else {
122 format!("<{tag}{attributes}>{content}</{tag}>")
123 }
124}
125
126fn has_events(attrs: &Value) -> bool {
127 attrs.as_object().into_iter().flatten().any(|(key, _)| {
128 key.strip_prefix("on")
129 .and_then(|rest| rest.bytes().next())
130 .is_some_and(|byte| byte.is_ascii_lowercase())
131 })
132}
133
134fn str_at<'a>(value: &'a Value, key: &str) -> &'a str {
135 value[key].as_str().unwrap_or("")
136}
137
138pub(crate) fn appearance_attrs(class: String, inline: &str) -> Value {
139 let mut attrs = Map::new();
140 if !class.is_empty() {
141 attrs.insert("class".into(), class.into());
142 }
143 let inline = style(inline);
144 if !inline.is_empty() {
145 attrs.insert("style".into(), inline.into());
146 }
147 attrs.into()
148}
149
150fn affix(affix: &Value, raw: bool) -> String {
151 if !affix.is_object() {
152 return String::new();
153 }
154 let element = if raw { raw_element } else { element };
155 let text = if raw { raw_text } else { escape };
156 element(
157 "span",
158 &appearance_attrs(str_at(affix, "class").into(), str_at(affix, "style")),
159 &text(str_at(affix, "text")),
160 )
161}
162
163fn control(widget: &Value, search: bool) -> String {
164 let tag = widget["tag"].as_str().unwrap_or("input");
165 let raw = search || widget["layout"] == "condition" || has_events(&widget["attrs"]);
166 let text = if raw { raw_text } else { escape };
167 let render = if raw { raw_element } else { element };
168 let content = if tag == "select" {
169 let option = |option: &Value| {
170 let mut attrs = json!({"value":option["value"]});
171 if option["selected"] == true {
172 attrs["selected"] = if search { "selected" } else { "" }.into();
173 }
174 render("option", &attrs, &text(str_at(option, "label")))
175 };
176 let mut content = String::new();
178 let options = widget["options"].as_array().map_or(&[][..], Vec::as_slice);
179 let mut start = 0;
180 while start < options.len() {
181 let group = options[start].get("group");
182 let end = start
183 + options[start..]
184 .iter()
185 .take_while(|o| {
186 o.get("group").map(|g| &g["index"]) == group.map(|g| &g["index"])
187 })
188 .count();
189 let run: String = options[start..end].iter().map(option).collect();
190 content += &match group {
191 Some(group) => render("optgroup", &json!({"label":group["label"]}), &run),
192 None => run,
193 };
194 start = end;
195 }
196 content
197 } else {
198 text(str_at(widget, "text"))
199 };
200 let attrs = if raw {
201 widget["attrs"].clone()
202 } else {
203 style_last(&widget["attrs"])
204 };
205 render(tag, &attrs, &content)
206}
207
208fn style_last(attrs: &Value) -> Value {
210 let mut attrs = attrs.clone();
211 if let Some(style) = attrs
212 .as_object_mut()
213 .and_then(|attrs| attrs.shift_remove("style"))
214 {
215 attrs["style"] = style;
216 }
217 attrs
218}
219
220fn script(text: &str) -> String {
221 element("script", &json!({"nonce":""}), text)
222}
223
224fn widget(model: &Value) -> String {
225 if model["unsupported"] == true {
226 return element(
227 "div",
228 &json!({"class":"crudui-widget crudui-widget--unsupported","data-unsupported-type":model["type"]}),
229 "",
230 );
231 }
232 match str_at(model, "layout") {
233 "widget" => element(
234 "div",
235 &json!({"class":"crudui-widget"}),
236 &(affix(&model["prepend"], has_events(&model["attrs"]))
237 + &control(model, false)
238 + &affix(&model["append"], has_events(&model["attrs"]))),
239 ),
240 "bare" => control(model, false),
241 "range" => {
242 let raw = has_events(&model["attrs"]);
243 let mut attrs = json!({"class":"crudui-widget__output"});
244 if let Some(id) = model["attrs"].get("id") {
245 attrs["for"] = id.clone();
246 }
247 let label = str_at(model, "text");
248 let output = if raw {
249 raw_element("output", &attrs, &raw_text(label))
250 } else {
251 element("output", &attrs, &escape(label))
252 };
253 element(
254 "div",
255 &json!({"class":"crudui-widget crudui-widget--range"}),
256 &(affix(&model["prepend"], raw)
257 + &control(model, false)
258 + &output
259 + &affix(&model["append"], raw)),
260 )
261 }
262 "host-script" => control(model, false) + &script(str_at(model, "script")),
263 "choices" => {
264 let radio = model["kind"] == "choice";
265 let content = model["options"]
266 .as_array()
267 .into_iter()
268 .flatten()
269 .map(|option| {
270 let mut attrs = model["extra"]["input"]
271 .as_object()
272 .cloned()
273 .unwrap_or_default();
274 attrs.insert(
275 "type".into(),
276 if radio { "radio" } else { "checkbox" }.into(),
277 );
278 attrs.insert("value".into(), option["value"].clone());
279 attrs.insert("autocomplete".into(), "off".into());
280 attrs.insert("class".into(), "valid-target crudui-choices__input".into());
281 if option["id"].is_string() {
282 attrs.insert("id".into(), option["id"].clone());
283 }
284 if radio {
285 attrs.insert(
286 "data-is-default".into(),
287 if option["isDefault"] == true { "1" } else { "" }.into(),
288 );
289 }
290 if option["selected"] == true {
291 attrs.insert("checked".into(), "".into());
292 }
293 if let Some(declared) = model["extra"]["option"].as_object() {
294 attrs.extend(declared.clone());
295 }
296 if let Some(declared) = option["attributes"].as_object() {
298 attrs.extend(declared.clone());
299 }
300 let mut label = json!({"for":option["id"],"class":join_class(&[
301 str_at(model, "itemLabelClass"),
302 str_at(option, "className"),
303 ])});
304 if let Some(inline) = option.get("style") {
305 label["style"] = inline.clone();
306 }
307 let raw = has_events(&model["extra"]["input"]);
308 let input = if raw { raw_element } else { element };
309 let text = if raw { raw_text } else { escape };
310 input("input", &attrs.into(), "")
311 + &input(
312 "label",
313 &label,
314 &input("span", &json!({}), &text(str_at(option, "label"))),
315 )
316 })
317 .collect::<String>();
318 element("div", &model["attrs"], &content)
319 }
320 "file" => {
321 let raw = has_events(&model["extra"]["file"]);
322 let input = if raw { raw_element } else { element };
323 let mut content = affix(&model["prepend"], raw);
324 if model["extra"]["display"].is_object() {
325 let display = if raw {
326 json!({"class":model["extra"]["display"]["class"],"readonly":"","type":"text","value":""})
327 } else {
328 model["extra"]["display"].clone()
329 };
330 content += &input("input", &display, "");
331 }
332 content += &input("input", &model["extra"]["file"], "");
333 if model["extra"]["display"].is_object() {
334 content += &element(
335 "button",
336 &json!({"class":"crudui-widget__button","type":"button"}),
337 " ",
338 );
339 }
340 element("div", &json!({"class":"crudui-widget"}), &content)
341 }
342 "display" => element(
343 "div",
344 &style_last(&model["attrs"]),
345 str_at(model, "rawHtml"),
346 ),
347 "search" => {
348 let mut content = String::new();
349 if !str_at(model, "styleChrome").is_empty() {
350 content += &element("style", &json!({"nonce":""}), str_at(model, "styleChrome"));
351 }
352 content += &script(str_at(model, "script"));
353 content
354 + &element(
355 "div",
356 &json!({"class":"crudui-widget crudui-widget--search"}),
357 &(affix(&model["prepend"], true)
358 + &control(model, true)
359 + &affix(&model["append"], true)),
360 )
361 }
362 "button" => {
363 if has_events(&model["attrs"]) {
364 raw_element("button", &model["attrs"], &raw_text(str_at(model, "text")))
365 } else {
366 element(
367 "button",
368 &style_last(&model["attrs"]),
369 &escape(str_at(model, "text")),
370 )
371 }
372 }
373 "condition" => condition_widget(model),
374 _ => String::new(),
375 }
376}
377
378fn condition_widget(model: &Value) -> String {
382 let raw = true;
383 let editor = &model["condition"];
384 let mut content = affix(&model["prepend"], raw);
385 if let Some(error) = editor.get("error") {
386 content += &raw_element(
387 "p",
388 &json!({"class":"crudui-condition__error","role":"alert"}),
389 &raw_text(error.as_str().unwrap_or("")),
390 );
391 }
392 content += &control(model, false);
393 if editor.get("error").is_none() {
394 content += &raw_element(
395 "div",
396 &json!({"class":"crudui-condition","data-condition-for":editor["for"]}),
397 &condition_node(&editor["root"], editor),
398 );
399 }
400 raw_element(
401 "div",
402 &json!({"class":"crudui-widget crudui-widget--condition"}),
403 &(content + &affix(&model["append"], raw)),
404 )
405}
406
407fn condition_button(action: &str, label: &Value) -> String {
408 raw_element(
409 "button",
410 &json!({"type":"button","class":"crudui-action crudui-action--text","data-condition-action":action}),
411 &raw_text(label.as_str().unwrap_or("")),
412 )
413}
414
415fn condition_select(attrs: &Value, choices: &[&str], selected: &str) -> String {
416 let options: String = choices
417 .iter()
418 .map(|choice| {
419 let mut option = json!({"value":choice});
420 if *choice == selected {
421 option["selected"] = "".into();
422 }
423 raw_element("option", &option, &raw_text(choice))
424 })
425 .collect();
426 raw_element("select", attrs, &options)
427}
428
429fn condition_node(node: &Value, editor: &Value) -> String {
431 let labels = &editor["labels"];
432 let path = str_at(node, "path");
433 let remove = if path.is_empty() {
434 String::new()
435 } else {
436 condition_button("remove", &labels["remove"])
437 };
438 match str_at(node, "kind") {
439 "group" => {
440 let items: String = node["items"]
441 .as_array()
442 .map_or(&[][..], Vec::as_slice)
443 .iter()
444 .map(|item| {
445 raw_element(
446 "li",
447 &json!({"class":"crudui-condition__item"}),
448 &condition_node(item, editor),
449 )
450 })
451 .collect();
452 let judge = if editor["judge"] == true {
453 condition_button("add-judge", &labels["addJudge"])
454 } else {
455 String::new()
456 };
457 raw_element(
458 "fieldset",
459 &json!({"class":"crudui-condition__group","data-condition-path":path}),
460 &(condition_select(
461 &json!({"class":"crudui-condition__mode","data-condition-part":"mode","aria-label":labels["mode"]}),
462 &["all", "any"],
463 str_at(node, "mode"),
464 ) + &raw_element("ol", &json!({"class":"crudui-condition__list"}), &items)
465 + &condition_button("add-comparison", &labels["addComparison"])
466 + &condition_button("add-group", &labels["addGroup"])
467 + &judge
468 + &remove),
469 )
470 }
471 "judge" => raw_element(
472 "div",
473 &json!({"class":"crudui-condition__judge","data-condition-path":path}),
474 &(raw_element(
475 "input",
476 &json!({"class":"crudui-condition__text","data-condition-part":"judge","value":node["text"]}),
477 "",
478 ) + &remove),
479 ),
480 _ => {
481 let field = str_at(node, "field");
482 let control = match editor["fields"].as_array() {
483 Some(names) => {
484 let mut choices: Vec<&str> = names.iter().filter_map(Value::as_str).collect();
485 if !choices.contains(&field) {
486 choices.insert(0, field);
487 }
488 condition_select(
489 &json!({"class":"crudui-condition__field","data-condition-part":"field"}),
490 &choices,
491 field,
492 )
493 }
494 None => raw_element(
495 "input",
496 &json!({"class":"crudui-condition__field","data-condition-part":"field","value":field}),
497 "",
498 ),
499 };
500 raw_element(
501 "div",
502 &json!({"class":"crudui-condition__comparison","data-condition-path":path}),
503 &(control
504 + &condition_select(
505 &json!({"class":"crudui-condition__op","data-condition-part":"op"}),
506 &crate::condition_editor::CONDITION_OPS,
507 str_at(node, "op"),
508 )
509 + &raw_element(
510 "input",
511 &json!({"class":"crudui-condition__value","data-condition-part":"value","value":node["value"]}),
512 "",
513 )
514 + &remove),
515 )
516 }
517 }
518}
519
520fn classes(parts: &[&str]) -> String {
521 parts
522 .iter()
523 .filter(|part| !part.is_empty())
524 .copied()
525 .collect::<Vec<_>>()
526 .join(" ")
527}
528
529fn part_text(value: &Value) -> String {
531 escape(&js_string(value))
532}
533
534fn controls(controls: &Value) -> String {
536 let buttons = controls["actions"]
537 .as_array()
538 .into_iter()
539 .flatten()
540 .map(|action| {
541 let mut attrs = json!({"type":"button","class":"crudui-action","data-crudui-action":action["name"],"aria-label":action["label"]});
542 if action["disabled"] == true {
543 attrs["aria-disabled"] = "true".into();
544 }
545 element("button", &attrs, "")
546 })
547 .collect::<String>();
548 element(
549 "div",
550 &json!({"class":"crudui-controls","role":"group","aria-label":controls["label"]}),
551 &buttons,
552 )
553}
554
555fn header(node: &Value) -> String {
556 let header = &node["header"];
557 let mut parts = String::new();
558 if node["collapsible"] == true {
559 let mut attrs = json!({"type":"button","class":"crudui-action","data-crudui-action":"toggle-row",
560 "aria-expanded": (node["expanded"] == true).to_string()});
561 if let Some(id) = node["body"]["id"].as_str() {
562 attrs["aria-controls"] = id.into();
563 }
564 if let Some(label) = node["toggleLabel"].as_str() {
565 attrs["aria-label"] = label.into();
566 }
567 parts += &element("button", &attrs, "");
568 }
569 if let Some(label) = header.get("label") {
570 parts += &match header.get("labelFor").and_then(Value::as_str) {
571 Some(target) if !target.is_empty() => element(
572 "label",
573 &json!({"class":"crudui-node__label","for":target}),
574 &part_text(label),
575 ),
576 _ => element(
577 "span",
578 &json!({"class":"crudui-node__label"}),
579 &part_text(label),
580 ),
581 };
582 }
583 if let Some(description) = header.get("description") {
584 parts += &element(
585 "p",
586 &json!({"class":"crudui-node__description"}),
587 &part_text(description),
588 );
589 }
590 for key in ["number", "title"] {
591 if let Some(value) = header.get(key) {
592 parts += &element(
593 "span",
594 &json!({"class":format!("crudui-node__{key}")}),
595 &part_text(value),
596 );
597 }
598 }
599 if let Some(summary) = header.get("summary") {
600 let mut attrs = json!({"class":"crudui-node__summary"});
601 if node["expanded"] == true {
602 attrs["hidden"] = "".into();
603 }
604 parts += &element("span", &attrs, &part_text(summary));
605 }
606 if let Some(count) = header.get("count") {
607 parts += &element(
608 "span",
609 &json!({"class":"crudui-node__count"}),
610 &part_text(count),
611 );
612 }
613 if node["controls"]["placement"] == "header" {
614 parts += &controls(&node["controls"]);
615 }
616 if parts.is_empty() {
617 return String::new();
618 }
619 element(
620 "div",
621 &json!({"class":classes(&["crudui-node__header", str_at(header, "className")]),"style":str_at(header, "style")}),
622 &parts,
623 )
624}
625
626fn body(node: &Value, errors: &NodeErrors) -> String {
627 let body = &node["body"];
628 let mut attrs = json!({"class":classes(&["crudui-node__body", str_at(body, "className")]),"style":str_at(body, "style")});
629 if let Some(id) = body["id"].as_str().filter(|id| !id.is_empty()) {
630 attrs["id"] = id.into();
631 }
632 if node["collapsible"] == true && node["expanded"] != true {
633 attrs["hidden"] = "".into();
634 }
635 let content = if node["checkbox"].is_object() {
636 let checkbox = &node["checkbox"];
637 let mut input = json!({"class":checkbox["className"],"id":checkbox["id"],"name":checkbox["name"],"type":"checkbox"});
638 if let Some(role) = checkbox.get("role") {
639 input["role"] = role.clone();
640 }
641 input["value"] = "1".into();
642 if checkbox["checked"] == true {
643 input["checked"] = "".into();
644 }
645 if let (Some(input), Some(declared)) =
646 (input.as_object_mut(), checkbox["attributes"].as_object())
647 {
648 input.extend(declared.clone());
649 }
650 let caption = match checkbox.get("caption") {
652 Some(caption) => element(
653 "label",
654 &json!({"for":checkbox["id"]}),
655 &escape(caption.as_str().unwrap_or("")),
656 ),
657 None => String::new(),
658 };
659 element("input", &input, "") + &caption
660 } else if node.get("widget").is_some() {
661 widget(&node["widget"])
662 } else {
663 nodes(&node["children"], errors)
664 };
665 element("div", &attrs, &content)
666}
667
668fn errors_html(block: &str, messages: Option<&Vec<String>>) -> String {
670 let Some(messages) = messages.filter(|messages| !messages.is_empty()) else {
671 return String::new();
672 };
673 let paragraphs: String = messages
674 .iter()
675 .map(|text| {
676 element(
677 "p",
678 &json!({"class": format!("{block}__error")}),
679 &escape(text),
680 )
681 })
682 .collect();
683 element(
684 "div",
685 &json!({"class": format!("{block}__errors")}),
686 ¶graphs,
687 )
688}
689
690fn node(node: &Value, errors: &NodeErrors) -> String {
692 let kind = str_at(node, "kind");
693 let modifier = format!("crudui-node--{kind}");
694 let sticky = if node["sticky"] == true {
695 "crudui-node--sticky"
696 } else {
697 ""
698 };
699 let depth = if node["sticky"] == true {
701 format!(
702 "--crudui-sticky-depth: {}",
703 node["stickyDepth"].as_u64().unwrap_or(0)
704 )
705 } else {
706 String::new()
707 };
708 let style = [str_at(node, "style"), depth.as_str()]
709 .into_iter()
710 .filter(|s| !s.is_empty())
711 .collect::<Vec<_>>()
712 .join("; ");
713 let mut attrs = json!({"class":classes(&["crudui-node", &modifier, sticky, str_at(node, "className")]),"style":style});
714 if kind != "row" && kind != "lang-item" {
715 if let Some(path) = node.get("path") {
716 attrs["data-field-path"] = js_string(path).into();
717 }
718 }
719 if let Some(key) = node.get("key") {
720 attrs["data-crudui-row-key"] = js_string(key).into();
721 }
722 if let Some(lang) = node.get("lang") {
723 attrs["data-lang"] = js_string(lang).into();
724 }
725 if node["hidden"] == true {
726 attrs["hidden"] = "".into();
727 }
728 if let (Some(attrs), Some(declared)) = (attrs.as_object_mut(), node["attributes"].as_object()) {
729 attrs.extend(declared.clone());
730 }
731 let footer = if node["controls"]["placement"] == "footer" {
732 element(
733 "div",
734 &json!({"class":"crudui-node__footer"}),
735 &controls(&node["controls"]),
736 )
737 } else {
738 String::new()
739 };
740 let header_html = header(node);
741 let header_slot = if node["sticky"] == true && !header_html.is_empty() {
742 element(
743 "div",
744 &json!({"class":"crudui-node__header-container"}),
745 &header_html,
746 )
747 } else {
748 header_html
749 };
750 let errors_slot = errors_html("crudui-node", errors.get(&(node as *const Value)));
751 element(
752 "div",
753 &attrs,
754 &(header_slot + &body(node, errors) + &errors_slot + &footer),
755 )
756}
757
758fn nodes(nodes: &Value, errors: &NodeErrors) -> String {
759 nodes
760 .as_array()
761 .into_iter()
762 .flatten()
763 .map(|child| node(child, errors))
764 .collect()
765}
766
767pub(crate) fn render_fields(fields: &[Value], model: &RenderModel, description: &str) -> String {
770 let description = if description.is_empty() {
771 String::new()
772 } else {
773 element(
774 "p",
775 &json!({"class":"crudui-form__description"}),
776 &escape(description),
777 )
778 };
779 element(
780 "div",
781 &json!({"class":"crudui-form"}),
782 &(description
783 + &errors_html("crudui-form", Some(&model.form_errors))
784 + &element(
785 "div",
786 &json!({"class":"crudui-form__body"}),
787 &fields
788 .iter()
789 .map(|field| node(field, &model.node_errors))
790 .collect::<String>(),
791 )),
792 )
793}
794
795pub(crate) fn render_form_html(
797 fields: &[Value],
798 template: &crate::FormTemplate,
799 data: &Value,
800 language: &str,
801 model: &RenderModel,
802) -> FormResult<String> {
803 let messages = crate::messages::form_messages(language)?;
804 let description = crate::util::translate(template.description.as_ref(), language);
805 let body = render_fields(fields, model, &description);
806 let footer = element(
807 "div",
808 &json!({"class":"crudui-form__footer"}),
809 &element(
810 "div",
811 &json!({"class":"crudui-controls","role":"group","aria-label":messages.form_actions}),
812 &crate::buttons::form_buttons_html(&template.buttons, data, language, messages),
813 ),
814 );
815 let block = format!(
816 "{}{footer}</div>",
817 body.strip_suffix("</div>")
818 .expect("form markup ends with its closing tag")
819 );
820 let Some(attrs) = &model.form else {
821 return Ok(block);
822 };
823 let hidden: String = model
824 .hidden
825 .iter()
826 .map(|(name, value)| {
827 element(
828 "input",
829 &json!({"type":"hidden","name":name,"value":value}),
830 "",
831 )
832 })
833 .collect();
834 Ok(element(
835 "form",
836 &Value::Object(attrs.clone()),
837 &(hidden + &block),
838 ))
839}
840
841pub fn render_form(form: &Form, options: Option<&Value>) -> FormResult<String> {
845 let model = render_model(form.fields(), form.template().action.as_ref(), options)?;
846 render_form_html(
847 form.fields(),
848 form.template(),
849 &form.get_data(),
850 form.language(),
851 &model,
852 )
853}