1use std::collections::HashMap;
10
11use serde_json::Value;
12
13use crate::action::{Action, ActionHandler};
14use crate::spec::{Element, Spec};
15
16fn is_navigable_url(s: &str) -> bool {
31 s.starts_with('/') || s.starts_with("http://") || s.starts_with("https://")
32}
33
34fn resolve_action(
35 action: &mut Action,
36 data: &serde_json::Value,
37 resolver: &impl Fn(&str) -> Option<String>,
38) {
39 if action.url.is_some() {
40 return;
41 }
42 let literal: Option<String> = match &action.handler {
46 ActionHandler::Literal(s) => Some(s.clone()),
47 ActionHandler::Binding(d) => crate::data::resolve_path(data, &d.data)
48 .and_then(|v| v.as_str())
49 .map(|s| s.to_string()),
50 };
51 let Some(s) = literal else { return };
52
53 if is_navigable_url(&s) {
54 action.url = Some(s);
55 return;
56 }
57 if let Some(url) = resolver(&s) {
58 action.url = Some(url);
59 }
60}
61
62pub fn resolve_actions(spec: &mut Spec, resolver: impl Fn(&str) -> Option<String>) {
73 let data = spec.data.clone();
74 for el in spec.elements.values_mut() {
75 if let Some(action) = el.action.as_mut() {
76 resolve_action(action, &data, &resolver);
77 }
78 resolve_actions_in_value(&mut el.props, &data, &resolver);
79 }
80}
81
82fn resolve_actions_in_value(
95 value: &mut Value,
96 data: &Value,
97 resolver: &impl Fn(&str) -> Option<String>,
98) {
99 match value {
100 Value::Object(map) => {
101 let has_handler = map.contains_key("handler");
102 let already_resolved = matches!(map.get("url"), Some(Value::String(_)));
103 if has_handler && !already_resolved {
104 if let Some(url) = resolve_props_handler_to_url(map.get("handler"), data, resolver)
105 {
106 map.insert("url".to_string(), Value::String(url));
107 }
108 }
109 for v in map.values_mut() {
110 resolve_actions_in_value(v, data, resolver);
111 }
112 }
113 Value::Array(arr) => {
114 for v in arr.iter_mut() {
115 resolve_actions_in_value(v, data, resolver);
116 }
117 }
118 _ => {}
119 }
120}
121
122fn resolve_props_handler_to_url(
126 handler: Option<&Value>,
127 data: &Value,
128 resolver: &impl Fn(&str) -> Option<String>,
129) -> Option<String> {
130 let literal: String = match handler? {
131 Value::String(s) => s.clone(),
132 Value::Object(map) if map.len() == 1 => {
133 let path = map.get("$data").and_then(|v| v.as_str())?;
134 crate::data::resolve_path(data, path)
135 .and_then(|v| v.as_str())
136 .map(|s| s.to_string())?
137 }
138 _ => return None,
139 };
140 if is_navigable_url(&literal) {
141 Some(literal)
142 } else {
143 resolver(&literal)
144 }
145}
146
147pub fn resolve_actions_strict(
151 spec: &mut Spec,
152 resolver: impl Fn(&str) -> Option<String>,
153) -> Result<(), Vec<String>> {
154 let data = spec.data.clone();
155 let mut missing: Vec<String> = Vec::new();
156 for el in spec.elements.values_mut() {
157 if let Some(action) = el.action.as_mut() {
158 resolve_action(action, &data, &resolver);
159 if action.url.is_none() {
160 missing.push(action.handler.as_str().to_string());
161 }
162 }
163 resolve_actions_in_value(&mut el.props, &data, &resolver);
164 }
165 if missing.is_empty() {
166 Ok(())
167 } else {
168 Err(missing)
169 }
170}
171
172pub fn resolve_errors(spec: &mut Spec, errors: &HashMap<String, Vec<String>>) {
175 for el in spec.elements.values_mut() {
176 attach_errors(el, errors, false);
177 }
178}
179
180pub fn resolve_errors_all(spec: &mut Spec, errors: &HashMap<String, Vec<String>>) {
183 for el in spec.elements.values_mut() {
184 attach_errors(el, errors, true);
185 }
186}
187
188fn attach_errors(el: &mut Element, errors: &HashMap<String, Vec<String>>, all: bool) {
189 let Some(props_obj) = el.props.as_object_mut() else {
190 return;
191 };
192 let key = props_obj
195 .get("name")
196 .or_else(|| props_obj.get("field"))
197 .and_then(|v| v.as_str())
198 .map(String::from);
199 if let Some(k) = key {
200 if let Some(msgs) = errors.get(&k) {
201 if let Some(first) = msgs.first() {
202 props_obj.insert("error".to_string(), Value::String(first.clone()));
203 }
204 }
205 } else if all {
206 if let Ok(errors_value) = serde_json::to_value(errors) {
207 props_obj.insert("errors".to_string(), errors_value);
208 }
209 }
210}
211
212pub fn expand_directives(spec: &mut Spec) {
243 let data = spec.data.clone();
244 let if_removed = remove_if_falsy(spec, &data);
247 let each_expanded = expand_each(spec, &data);
249 rewrite_parent_children(spec, &if_removed, &each_expanded);
251}
252
253fn remove_if_falsy(spec: &mut Spec, data: &serde_json::Value) -> std::collections::HashSet<String> {
257 let mut to_delete: Vec<String> = Vec::new();
258 for (id, el) in spec.elements.iter() {
259 if let Some(predicate) = &el.if_ {
260 if !predicate.evaluate(data) {
262 to_delete.push(id.clone());
263 }
264 }
265 }
266 let removed: std::collections::HashSet<String> = to_delete.iter().cloned().collect();
267 for id in &to_delete {
268 spec.elements.remove(id);
269 }
270 for el in spec.elements.values_mut() {
272 if el.if_.is_some() {
273 el.if_ = None;
274 }
275 }
276 removed
277}
278
279fn expand_each(
283 spec: &mut Spec,
284 data: &serde_json::Value,
285) -> std::collections::HashMap<String, Vec<String>> {
286 let templates: Vec<(String, Element)> = spec
289 .elements
290 .iter()
291 .filter_map(|(id, el)| el.each.as_ref().map(|_| (id.clone(), el.clone())))
292 .collect();
293
294 let template_directives: std::collections::HashMap<String, crate::spec::EachDirective> =
295 templates
296 .iter()
297 .map(|(id, el)| (id.clone(), el.each.clone().unwrap()))
298 .collect();
299
300 let mut expanded: std::collections::HashMap<String, Vec<String>> =
301 std::collections::HashMap::new();
302
303 for (tmpl_id, tmpl_el) in &templates {
304 let each = tmpl_el.each.as_ref().unwrap();
305 let rows: Vec<serde_json::Value> = crate::data::resolve_path(data, &each.path)
306 .and_then(|v| v.as_array())
307 .cloned()
308 .unwrap_or_default();
309 let mut clone_ids: Vec<String> = Vec::with_capacity(rows.len());
310 for (i, row) in rows.iter().enumerate() {
311 let clone_id = format!("{tmpl_id}-{i}");
312 let mut clone = tmpl_el.clone();
313 clone.each = None; clone.if_ = None;
315 inline_resolve_row_paths(&mut clone.props, &each.as_, row);
317 if let Some(action) = clone.action.as_mut() {
321 inline_resolve_row_action(action, &each.as_, row);
322 }
323 for child in clone.children.iter_mut() {
325 if let Some(child_each) = template_directives.get(child) {
326 if child_each.path == each.path && child_each.as_ == each.as_ {
327 *child = format!("{child}-{i}");
328 }
329 }
334 }
335 spec.elements.insert(clone_id.clone(), clone);
336 clone_ids.push(clone_id);
337 }
338 spec.elements.remove(tmpl_id);
339 expanded.insert(tmpl_id.clone(), clone_ids);
340 }
341
342 expanded
343}
344
345fn inline_resolve_row_paths(value: &mut serde_json::Value, as_name: &str, row: &serde_json::Value) {
350 let prefix = format!("/{as_name}/");
351 inline_walk(value, &prefix, row, as_name);
352}
353
354fn inline_resolve_row_action(
361 action: &mut crate::action::Action,
362 as_name: &str,
363 row: &serde_json::Value,
364) {
365 use crate::action::ActionHandler;
366 let prefix = format!("/{as_name}/");
367 if let ActionHandler::Binding(d) = &action.handler {
368 if let Some(rest) = d.data.strip_prefix(&prefix) {
369 let resolved = crate::data::resolve_path(row, &format!("/{rest}"))
370 .and_then(|v| v.as_str())
371 .map(|s| s.to_string());
372 if let Some(s) = resolved {
373 action.handler = ActionHandler::Literal(s);
374 }
375 } else if d.data == format!("/{as_name}") {
377 if let Some(s) = row.as_str() {
379 action.handler = ActionHandler::Literal(s.to_string());
380 }
381 }
382 }
384}
385
386fn inline_walk(
387 value: &mut serde_json::Value,
388 prefix: &str,
389 row: &serde_json::Value,
390 as_name: &str,
391) {
392 match value {
393 serde_json::Value::Object(map) => {
394 if map.len() == 1 {
395 if let Some(serde_json::Value::String(path)) = map.get("$data") {
397 if let Some(rest) = path.strip_prefix(prefix) {
398 let resolved = crate::data::resolve_path(row, &format!("/{rest}"))
399 .cloned()
400 .unwrap_or(serde_json::Value::Null);
401 *value = resolved;
402 return;
403 } else if path == &format!("/{as_name}") {
404 *value = row.clone();
406 return;
407 }
408 }
409 if let Some(serde_json::Value::String(tpl)) = map.get("$template") {
413 let interpolated = interpolate_row_template(tpl, prefix, row, as_name);
414 if !contains_template_marker(&interpolated) {
415 *value = serde_json::Value::String(interpolated);
416 return;
417 } else {
418 map.insert(
419 "$template".to_string(),
420 serde_json::Value::String(interpolated),
421 );
422 return;
423 }
424 }
425 }
426 for v in map.values_mut() {
427 inline_walk(v, prefix, row, as_name);
428 }
429 }
430 serde_json::Value::Array(arr) => {
431 for v in arr.iter_mut() {
432 inline_walk(v, prefix, row, as_name);
433 }
434 }
435 _ => {}
436 }
437}
438
439fn interpolate_row_template(
440 tpl: &str,
441 prefix: &str,
442 row: &serde_json::Value,
443 as_name: &str,
444) -> String {
445 let mut out = String::with_capacity(tpl.len());
446 let mut chars = tpl.chars().peekable();
447 while let Some(c) = chars.next() {
448 if c == '{' {
449 let mut path = String::new();
450 let mut closed = false;
451 for nc in chars.by_ref() {
452 if nc == '}' {
453 closed = true;
454 break;
455 }
456 path.push(nc);
457 }
458 if closed {
459 if let Some(rest) = path.strip_prefix(prefix) {
460 let resolved = crate::data::resolve_path(row, &format!("/{rest}"))
461 .map(value_to_string)
462 .unwrap_or_default();
463 out.push_str(&resolved);
464 } else if path == format!("/{as_name}") {
465 out.push_str(&value_to_string(row));
466 } else {
467 out.push('{');
470 out.push_str(&path);
471 out.push('}');
472 }
473 } else {
474 out.push('{');
475 out.push_str(&path);
476 }
477 } else {
478 out.push(c);
479 }
480 }
481 out
482}
483
484fn contains_template_marker(s: &str) -> bool {
485 let mut chars = s.chars().peekable();
488 while let Some(c) = chars.next() {
489 if c == '{' && matches!(chars.peek(), Some('/')) {
490 return true;
491 }
492 }
493 false
494}
495
496fn value_to_string(v: &serde_json::Value) -> String {
497 match v {
498 serde_json::Value::String(s) => s.clone(),
499 serde_json::Value::Null => String::new(),
500 other => other.to_string(),
501 }
502}
503
504fn rewrite_parent_children(
514 spec: &mut Spec,
515 if_removed: &std::collections::HashSet<String>,
516 each_expanded: &std::collections::HashMap<String, Vec<String>>,
517) {
518 for el in spec.elements.values_mut() {
519 let mut new_children: Vec<String> = Vec::with_capacity(el.children.len());
520 for child in el.children.drain(..) {
521 if if_removed.contains(&child) {
522 continue; }
524 if let Some(clones) = each_expanded.get(&child) {
525 new_children.extend(clones.iter().cloned());
526 } else {
527 new_children.push(child);
528 }
529 }
530 el.children = new_children;
531 }
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537 use crate::action::{Action, HttpMethod};
538 use crate::spec::{Element, Spec};
539
540 fn action(handler: &str) -> Action {
541 Action {
542 handler: ActionHandler::Literal(handler.to_string()),
543 url: None,
544 method: HttpMethod::Post,
545 confirm: None,
546 on_success: None,
547 on_error: None,
548 target: None,
549 }
550 }
551
552 #[test]
553 fn resolve_actions_populates_url_from_resolver() {
554 let mut spec = Spec::builder()
555 .element("btn", Element::new("Button").action(action("users.create")))
556 .build()
557 .unwrap();
558
559 resolve_actions(&mut spec, |h| {
560 if h == "users.create" {
561 Some("/users".to_string())
562 } else {
563 None
564 }
565 });
566
567 let el = spec.elements.get("btn").unwrap();
568 assert_eq!(el.action.as_ref().unwrap().url.as_deref(), Some("/users"));
569 }
570
571 fn action_with_binding(path: &str) -> Action {
574 Action {
575 handler: ActionHandler::Binding(crate::spec::DataRef {
576 data: path.to_string(),
577 }),
578 url: None,
579 method: HttpMethod::Get,
580 confirm: None,
581 on_success: None,
582 on_error: None,
583 target: None,
584 }
585 }
586
587 #[test]
588 fn resolve_actions_resolves_binding_to_literal_path_via_spec_data() {
589 let mut spec = Spec::builder()
590 .data(serde_json::json!({ "row": { "url": "/dashboard/orders/42" } }))
591 .element(
592 "btn",
593 Element::new("Button").action(action_with_binding("/row/url")),
594 )
595 .build()
596 .unwrap();
597
598 resolve_actions(&mut spec, |_| None);
599
600 let el = spec.elements.get("btn").unwrap();
601 assert_eq!(
602 el.action.as_ref().unwrap().url.as_deref(),
603 Some("/dashboard/orders/42"),
604 "binding pointing to a `/path` string in spec.data resolves to action.url"
605 );
606 }
607
608 #[test]
609 fn resolve_actions_resolves_binding_to_named_handler_via_resolver() {
610 let mut spec = Spec::builder()
611 .data(serde_json::json!({ "row": { "handler": "users.show" } }))
612 .element(
613 "btn",
614 Element::new("Button").action(action_with_binding("/row/handler")),
615 )
616 .build()
617 .unwrap();
618
619 resolve_actions(&mut spec, |name| {
620 (name == "users.show").then(|| "/users/show".to_string())
621 });
622
623 let el = spec.elements.get("btn").unwrap();
624 assert_eq!(
625 el.action.as_ref().unwrap().url.as_deref(),
626 Some("/users/show"),
627 "binding resolved to handler name flows through the resolver"
628 );
629 }
630
631 #[test]
632 fn resolve_actions_resolves_binding_to_absolute_url_verbatim() {
633 let mut spec = Spec::builder()
638 .data(serde_json::json!({
639 "dev_link": "http://127.0.0.1:8090/auth/verify?token=abc123"
640 }))
641 .element(
642 "btn",
643 Element::new("Button").action(action_with_binding("/dev_link")),
644 )
645 .build()
646 .unwrap();
647
648 resolve_actions(&mut spec, |_| None);
651
652 let el = spec.elements.get("btn").unwrap();
653 assert_eq!(
654 el.action.as_ref().unwrap().url.as_deref(),
655 Some("http://127.0.0.1:8090/auth/verify?token=abc123"),
656 "a data-bound absolute http(s) URL becomes action.url verbatim"
657 );
658 }
659
660 #[test]
661 fn resolve_actions_binding_missing_data_leaves_url_unset() {
662 let mut spec = Spec::builder()
663 .data(serde_json::json!({}))
664 .element(
665 "btn",
666 Element::new("Button").action(action_with_binding("/missing")),
667 )
668 .build()
669 .unwrap();
670
671 resolve_actions(&mut spec, |_| Some("UNEXPECTED".to_string()));
672
673 let el = spec.elements.get("btn").unwrap();
674 assert!(
675 el.action.as_ref().unwrap().url.is_none(),
676 "missing binding data leaves url unset (renderer emits diagnostic)"
677 );
678 }
679
680 #[test]
681 fn resolve_actions_skips_when_url_already_resolved() {
682 let mut spec = Spec::builder()
683 .data(serde_json::json!({ "row": { "url": "/from-data" } }))
684 .element("btn", {
685 let mut a = action_with_binding("/row/url");
686 a.url = Some("/preset".to_string());
687 Element::new("Button").action(a)
688 })
689 .build()
690 .unwrap();
691
692 resolve_actions(&mut spec, |_| None);
693
694 let el = spec.elements.get("btn").unwrap();
695 assert_eq!(el.action.as_ref().unwrap().url.as_deref(), Some("/preset"));
696 }
697
698 #[test]
699 fn each_inlines_row_action_url_to_literal() {
700 let mut spec = parse_spec(serde_json::json!({
705 "$schema": "ferro-json-ui/v2",
706 "root": "grid",
707 "elements": {
708 "grid": {
709 "type": "Grid",
710 "props": {},
711 "children": ["cell"]
712 },
713 "cell": {
714 "type": "CalendarCell",
715 "$each": {"path": "/cells", "as": "c"},
716 "props": {"day": {"$data": "/c/day"}},
717 "action": {
718 "handler": {"$data": "/c/action_url"},
719 "method": "GET"
720 }
721 }
722 },
723 "data": {
724 "cells": [
725 {"day": 17, "action_url": "/dashboard/calendario?date=2026-05-17"},
726 {"day": 18, "action_url": "/dashboard/calendario?date=2026-05-18"}
727 ]
728 }
729 }));
730
731 expand_directives(&mut spec);
732 resolve_actions(&mut spec, |_| None);
733
734 let cell0 = spec.elements.get("cell-0").expect("expanded cell-0");
735 let cell1 = spec.elements.get("cell-1").expect("expanded cell-1");
736 assert_eq!(
737 cell0.action.as_ref().unwrap().url.as_deref(),
738 Some("/dashboard/calendario?date=2026-05-17"),
739 "$each inlines /c/action_url to a literal; resolve_actions promotes literal to url"
740 );
741 assert_eq!(
742 cell1.action.as_ref().unwrap().url.as_deref(),
743 Some("/dashboard/calendario?date=2026-05-18")
744 );
745 }
746
747 #[test]
748 fn each_leaves_non_row_action_bindings_for_resolve_actions() {
749 let mut spec = parse_spec(serde_json::json!({
752 "$schema": "ferro-json-ui/v2",
753 "root": "grid",
754 "elements": {
755 "grid": {
756 "type": "Grid",
757 "props": {},
758 "children": ["item"]
759 },
760 "item": {
761 "type": "Button",
762 "$each": {"path": "/items", "as": "i"},
763 "props": {"label": {"$data": "/i/label"}},
764 "action": {
765 "handler": {"$data": "/global_link"},
766 "method": "GET"
767 }
768 }
769 },
770 "data": {
771 "items": [{"label": "a"}, {"label": "b"}],
772 "global_link": "/dashboard/shared"
773 }
774 }));
775
776 expand_directives(&mut spec);
777 resolve_actions(&mut spec, |_| None);
778
779 let i0 = spec.elements.get("item-0").unwrap();
780 let i1 = spec.elements.get("item-1").unwrap();
781 assert_eq!(
782 i0.action.as_ref().unwrap().url.as_deref(),
783 Some("/dashboard/shared")
784 );
785 assert_eq!(
786 i1.action.as_ref().unwrap().url.as_deref(),
787 Some("/dashboard/shared")
788 );
789 }
790
791 #[test]
792 fn resolve_actions_passes_through_literal_paths() {
793 let mut spec = Spec::builder()
794 .element("btn", Element::new("Button").action(action("/dashboard")))
795 .build()
796 .unwrap();
797
798 resolve_actions(&mut spec, |_| None);
799
800 let el = spec.elements.get("btn").unwrap();
801 assert_eq!(
802 el.action.as_ref().unwrap().url.as_deref(),
803 Some("/dashboard")
804 );
805 }
806
807 #[test]
808 fn resolve_actions_strict_reports_missing() {
809 let mut spec = Spec::builder()
810 .element(
811 "btn",
812 Element::new("Button").action(action("missing.handler")),
813 )
814 .build()
815 .unwrap();
816
817 let result = resolve_actions_strict(&mut spec, |_| None);
818 assert!(result.is_err());
819 assert_eq!(result.unwrap_err(), vec!["missing.handler".to_string()]);
820 }
821
822 #[test]
823 fn resolve_errors_matches_by_name_prop() {
824 let mut spec = Spec::builder()
825 .element("email", Element::new("Input").prop("name", "email"))
826 .build()
827 .unwrap();
828
829 let mut errors: HashMap<String, Vec<String>> = HashMap::new();
830 errors.insert("email".to_string(), vec!["required".to_string()]);
831
832 resolve_errors(&mut spec, &errors);
833
834 let el = spec.elements.get("email").unwrap();
835 let err_val = el.props.as_object().unwrap().get("error").unwrap();
836 assert_eq!(err_val, &serde_json::json!("required"));
837 }
838
839 #[test]
840 fn resolve_errors_matches_by_field_prop() {
841 let mut spec = Spec::builder()
842 .element("email", Element::new("Input").prop("field", "email"))
843 .build()
844 .unwrap();
845
846 let mut errors: HashMap<String, Vec<String>> = HashMap::new();
847 errors.insert("email".to_string(), vec!["required".to_string()]);
848
849 resolve_errors(&mut spec, &errors);
850
851 let el = spec.elements.get("email").unwrap();
852 let err_val = el.props.as_object().unwrap().get("error").unwrap();
853 assert_eq!(err_val, &serde_json::json!("required"));
854 }
855
856 #[test]
857 fn resolve_errors_all_writes_full_bag_when_no_match() {
858 let mut spec = Spec::builder()
859 .element("card", Element::new("Card").prop("title", "t"))
860 .build()
861 .unwrap();
862
863 let mut errors: HashMap<String, Vec<String>> = HashMap::new();
864 errors.insert("email".to_string(), vec!["required".to_string()]);
865
866 resolve_errors_all(&mut spec, &errors);
867
868 let el = spec.elements.get("card").unwrap();
869 let err_val = el.props.as_object().unwrap().get("errors").unwrap();
870 assert_eq!(err_val["email"], serde_json::json!(["required"]));
871 }
872
873 fn parse_spec(json: serde_json::Value) -> Spec {
878 serde_json::from_value::<Spec>(json).expect("spec parses")
879 }
880
881 #[test]
882 fn expand_if_falsy_deletes_element() {
883 let mut spec = parse_spec(serde_json::json!({
884 "$schema": "ferro-json-ui/v2",
885 "root": "btn",
886 "elements": {
887 "btn": {
888 "type": "Button",
889 "$if": {"path": "/show", "operator": "eq", "value": true},
890 "props": {"label": "Hi"}
891 }
892 },
893 "data": {"show": false}
894 }));
895 expand_directives(&mut spec);
896 assert!(!spec.elements.contains_key("btn"));
897 }
898
899 #[test]
900 fn expand_if_truthy_retains_element() {
901 let mut spec = parse_spec(serde_json::json!({
902 "$schema": "ferro-json-ui/v2",
903 "root": "btn",
904 "elements": {
905 "btn": {
906 "type": "Button",
907 "$if": {"path": "/show", "operator": "eq", "value": true},
908 "props": {"label": "Hi"}
909 }
910 },
911 "data": {"show": true}
912 }));
913 expand_directives(&mut spec);
914 let el = spec.elements.get("btn").expect("btn retained");
915 assert!(
916 el.if_.is_none(),
917 "if_ stripped post-expansion for idempotency"
918 );
919 }
920
921 #[test]
922 fn expand_if_uses_visibility_evaluate() {
923 let mut spec = parse_spec(serde_json::json!({
925 "$schema": "ferro-json-ui/v2",
926 "root": "btn",
927 "elements": {
928 "btn": {
929 "type": "Button",
930 "$if": {"and": [
931 {"path": "/a", "operator": "eq", "value": true},
932 {"path": "/b", "operator": "eq", "value": true}
933 ]},
934 "props": {"label": "Hi"}
935 }
936 },
937 "data": {"a": true, "b": false}
938 }));
939 expand_directives(&mut spec);
940 assert!(!spec.elements.contains_key("btn"));
942 }
943
944 #[test]
945 fn expand_each_produces_n_elements() {
946 let mut spec = parse_spec(serde_json::json!({
947 "$schema": "ferro-json-ui/v2",
948 "root": "order_card",
949 "elements": {
950 "order_card": {
951 "type": "Card",
952 "$each": {"path": "/orders", "as": "order"},
953 "props": {"title": {"$data": "/order/order_number"}}
954 }
955 },
956 "data": {"orders": [
957 {"order_number": "ORD-1"},
958 {"order_number": "ORD-2"},
959 {"order_number": "ORD-3"}
960 ]}
961 }));
962 expand_directives(&mut spec);
963 assert!(spec.elements.contains_key("order_card-0"));
964 assert!(spec.elements.contains_key("order_card-1"));
965 assert!(spec.elements.contains_key("order_card-2"));
966 assert!(!spec.elements.contains_key("order_card"));
967 let c0 = spec.elements.get("order_card-0").unwrap();
968 assert_eq!(c0.props.get("title").unwrap(), &serde_json::json!("ORD-1"));
969 }
970
971 #[test]
972 fn expand_each_auto_suffixes_ids() {
973 let mut spec = parse_spec(serde_json::json!({
974 "$schema": "ferro-json-ui/v2",
975 "root": "order_card",
976 "elements": {
977 "order_card": {
978 "type": "Card",
979 "$each": {"path": "/orders", "as": "order"},
980 "props": {}
981 }
982 },
983 "data": {"orders": [{"x":1},{"x":2}]}
984 }));
985 expand_directives(&mut spec);
986 for id in ["order_card-0", "order_card-1"] {
987 let el = spec.elements.get(id).unwrap();
988 assert!(el.each.is_none(), "{id} should have each stripped");
989 assert!(el.if_.is_none(), "{id} should have if_ stripped");
990 }
991 }
992
993 #[test]
994 fn expand_each_pre_resolves_row_paths() {
995 let mut spec = parse_spec(serde_json::json!({
996 "$schema": "ferro-json-ui/v2",
997 "root": "order_card",
998 "elements": {
999 "order_card": {
1000 "type": "Card",
1001 "$each": {"path": "/orders", "as": "order"},
1002 "props": {"title": {"$data": "/order/order_number"}}
1003 }
1004 },
1005 "data": {"orders": [{"order_number": "ORD-7"}]}
1006 }));
1007 expand_directives(&mut spec);
1008 let c0 = spec.elements.get("order_card-0").unwrap();
1009 assert_eq!(
1010 c0.props.get("title").unwrap(),
1011 &serde_json::json!("ORD-7"),
1012 "/order/X must be pre-resolved to a literal value"
1013 );
1014 }
1015
1016 #[test]
1017 fn expand_each_correlates_child_indexes() {
1018 let mut spec = parse_spec(serde_json::json!({
1019 "$schema": "ferro-json-ui/v2",
1020 "root": "root",
1021 "elements": {
1022 "root": {
1023 "type": "Grid",
1024 "props": {},
1025 "children": ["card"]
1026 },
1027 "card": {
1028 "type": "Card",
1029 "$each": {"path": "/orders", "as": "order"},
1030 "props": {},
1031 "children": ["badge"]
1032 },
1033 "badge": {
1034 "type": "Badge",
1035 "$each": {"path": "/orders", "as": "order"},
1036 "props": {"label": {"$data": "/order/status"}}
1037 }
1038 },
1039 "data": {"orders": [{"status": "A"}, {"status": "B"}]}
1040 }));
1041 expand_directives(&mut spec);
1042 let card0 = spec.elements.get("card-0").unwrap();
1043 assert_eq!(card0.children, vec!["badge-0".to_string()]);
1044 let card1 = spec.elements.get("card-1").unwrap();
1045 assert_eq!(card1.children, vec!["badge-1".to_string()]);
1046 let root = spec.elements.get("root").unwrap();
1047 assert_eq!(
1048 root.children,
1049 vec!["card-0".to_string(), "card-1".to_string()]
1050 );
1051 }
1052
1053 #[test]
1054 fn expand_parent_children_rewritten_for_each() {
1055 let mut spec = parse_spec(serde_json::json!({
1056 "$schema": "ferro-json-ui/v2",
1057 "root": "root",
1058 "elements": {
1059 "root": {
1060 "type": "Grid",
1061 "props": {},
1062 "children": ["card"]
1063 },
1064 "card": {
1065 "type": "Card",
1066 "$each": {"path": "/orders", "as": "order"},
1067 "props": {}
1068 }
1069 },
1070 "data": {"orders": [{"x":1},{"x":2},{"x":3}]}
1071 }));
1072 expand_directives(&mut spec);
1073 let root = spec.elements.get("root").unwrap();
1074 assert_eq!(
1075 root.children,
1076 vec![
1077 "card-0".to_string(),
1078 "card-1".to_string(),
1079 "card-2".to_string()
1080 ]
1081 );
1082 }
1083
1084 #[test]
1085 fn expand_parent_children_pruned_for_if() {
1086 let mut spec = parse_spec(serde_json::json!({
1087 "$schema": "ferro-json-ui/v2",
1088 "root": "root",
1089 "elements": {
1090 "root": {
1091 "type": "Grid",
1092 "props": {},
1093 "children": ["btn"]
1094 },
1095 "btn": {
1096 "type": "Button",
1097 "$if": {"path": "/flag", "operator": "eq", "value": true},
1098 "props": {"label": "Hi"}
1099 }
1100 },
1101 "data": {"flag": false}
1102 }));
1103 expand_directives(&mut spec);
1104 let root = spec.elements.get("root").unwrap();
1105 assert!(root.children.is_empty(), "pruned $if-false child");
1106 assert!(!spec.elements.contains_key("btn"));
1107 }
1108
1109 #[test]
1110 fn expand_if_first_then_each() {
1111 let mut spec = parse_spec(serde_json::json!({
1113 "$schema": "ferro-json-ui/v2",
1114 "root": "card",
1115 "elements": {
1116 "card": {
1117 "type": "Card",
1118 "$if": {"path": "/show", "operator": "eq", "value": true},
1119 "$each": {"path": "/orders", "as": "order"},
1120 "props": {}
1121 }
1122 },
1123 "data": {"show": false, "orders": [{"x":1},{"x":2}]}
1124 }));
1125 expand_directives(&mut spec);
1126 for id in ["card", "card-0", "card-1"] {
1127 assert!(
1128 !spec.elements.contains_key(id),
1129 "{id} must not exist when $if removed the template"
1130 );
1131 }
1132 }
1133
1134 #[test]
1135 fn expand_each_empty_array_produces_zero_clones() {
1136 let mut spec = parse_spec(serde_json::json!({
1137 "$schema": "ferro-json-ui/v2",
1138 "root": "root",
1139 "elements": {
1140 "root": {
1141 "type": "Grid",
1142 "props": {},
1143 "children": ["card"]
1144 },
1145 "card": {
1146 "type": "Card",
1147 "$each": {"path": "/orders", "as": "order"},
1148 "props": {}
1149 }
1150 },
1151 "data": {"orders": []}
1152 }));
1153 expand_directives(&mut spec);
1154 assert!(!spec.elements.contains_key("card"));
1155 let root = spec.elements.get("root").unwrap();
1156 assert!(root.children.is_empty());
1157 }
1158
1159 #[test]
1160 fn expand_directives_idempotent() {
1161 let mut spec = parse_spec(serde_json::json!({
1162 "$schema": "ferro-json-ui/v2",
1163 "root": "root",
1164 "elements": {
1165 "root": {
1166 "type": "Grid",
1167 "props": {},
1168 "children": ["card"]
1169 },
1170 "card": {
1171 "type": "Card",
1172 "$each": {"path": "/orders", "as": "order"},
1173 "props": {"title": {"$data": "/order/name"}}
1174 }
1175 },
1176 "data": {"orders": [{"name": "A"}, {"name": "B"}]}
1177 }));
1178 expand_directives(&mut spec);
1179 let snapshot_after_first = serde_json::to_value(&spec.elements).unwrap();
1180 expand_directives(&mut spec);
1181 let snapshot_after_second = serde_json::to_value(&spec.elements).unwrap();
1182 assert_eq!(
1183 snapshot_after_first, snapshot_after_second,
1184 "expand_directives must be idempotent"
1185 );
1186 }
1187}