1use std::collections::HashMap;
8
9use crate::action::Action;
10use crate::component::{Component, ComponentNode};
11use crate::view::JsonUiView;
12
13fn resolve_action(action: &mut Action, resolver: &impl Fn(&str) -> Option<String>) {
15 if action.url.is_none() {
16 if action.handler.starts_with('/') {
20 action.url = Some(action.handler.clone());
21 return;
22 }
23 if let Some(url) = resolver(&action.handler) {
24 action.url = Some(url);
25 }
26 }
27}
28
29fn resolve_component_node(node: &mut ComponentNode, resolver: &impl Fn(&str) -> Option<String>) {
31 if let Some(ref mut action) = node.action {
33 resolve_action(action, resolver);
34 }
35
36 match &mut node.component {
38 Component::Card(props) => {
39 for child in &mut props.children {
40 resolve_component_node(child, resolver);
41 }
42 for child in &mut props.footer {
43 resolve_component_node(child, resolver);
44 }
45 }
46 Component::Form(props) => {
47 resolve_action(&mut props.action, resolver);
48 for field in &mut props.fields {
49 resolve_component_node(field, resolver);
50 }
51 }
52 Component::DetailForm(props) => {
53 resolve_action(&mut props.action, resolver);
54 for field in &mut props.fields {
55 resolve_component_node(&mut field.input, resolver);
56 }
57 }
58 Component::Modal(props) => {
59 for child in &mut props.children {
60 resolve_component_node(child, resolver);
61 }
62 for child in &mut props.footer {
63 resolve_component_node(child, resolver);
64 }
65 }
66 Component::Tabs(props) => {
67 for tab in &mut props.tabs {
68 for child in &mut tab.children {
69 resolve_component_node(child, resolver);
70 }
71 }
72 }
73 Component::Table(props) => {
74 if let Some(ref mut row_actions) = props.row_actions {
75 for action in row_actions {
76 resolve_action(action, resolver);
77 }
78 }
79 }
80 Component::Grid(props) => {
81 for child in &mut props.children {
82 resolve_component_node(child, resolver);
83 }
84 }
85 Component::Collapsible(props) => {
86 for child in &mut props.children {
87 resolve_component_node(child, resolver);
88 }
89 }
90 Component::FormSection(props) => {
91 for child in &mut props.children {
92 resolve_component_node(child, resolver);
93 }
94 }
95 Component::PageHeader(props) => {
96 for child in &mut props.actions {
97 resolve_component_node(child, resolver);
98 }
99 }
100 Component::ButtonGroup(props) => {
101 for child in &mut props.buttons {
102 resolve_component_node(child, resolver);
103 }
104 }
105 Component::DropdownMenu(props) => {
106 for item in &mut props.items {
107 resolve_action(&mut item.action, resolver);
108 }
109 }
110 Component::KanbanBoard(props) => {
111 for col in &mut props.columns {
112 for child in &mut col.children {
113 resolve_component_node(child, resolver);
114 }
115 }
116 }
117 Component::EmptyState(props) => {
118 if let Some(ref mut action) = props.action {
119 resolve_action(action, resolver);
120 }
121 }
122 Component::Switch(props) => {
123 if let Some(ref mut action) = props.action {
124 resolve_action(action, resolver);
125 }
126 }
127 Component::DataTable(props) => {
128 if let Some(ref mut actions) = props.row_actions {
129 for item in actions {
130 resolve_action(&mut item.action, resolver);
131 }
132 }
133 }
134 Component::Button(_)
136 | Component::Input(_)
137 | Component::Select(_)
138 | Component::Alert(_)
139 | Component::Badge(_)
140 | Component::Text(_)
141 | Component::Checkbox(_)
142 | Component::Separator(_)
143 | Component::DescriptionList(_)
144 | Component::Breadcrumb(_)
145 | Component::Pagination(_)
146 | Component::Progress(_)
147 | Component::Avatar(_)
148 | Component::Skeleton(_)
149 | Component::StatCard(_)
150 | Component::Checklist(_)
151 | Component::Toast(_)
152 | Component::NotificationDropdown(_)
153 | Component::Sidebar(_)
154 | Component::Header(_)
155 | Component::CalendarCell(_)
156 | Component::ActionCard(_)
157 | Component::ProductTile(_)
158 | Component::Image(_)
159 | Component::KeyValueEditor(_)
160 | Component::RichTextEditor(_)
161 | Component::Plugin(_) => {}
162 }
163}
164
165pub fn resolve_actions(view: &mut JsonUiView, resolver: impl Fn(&str) -> Option<String>) {
171 for node in &mut view.components {
172 resolve_component_node(node, &resolver);
173 }
174}
175
176pub fn resolve_actions_strict(
182 view: &mut JsonUiView,
183 resolver: impl Fn(&str) -> Option<String>,
184) -> Result<(), Vec<String>> {
185 let mut unresolved: Vec<String> = Vec::new();
186
187 let collecting_resolver = |handler: &str| -> Option<String> { resolver(handler) };
188
189 resolve_actions(view, collecting_resolver);
191
192 for node in &view.components {
194 collect_unresolved_node(node, &mut unresolved);
195 }
196
197 if unresolved.is_empty() {
198 Ok(())
199 } else {
200 Err(unresolved)
201 }
202}
203
204fn collect_unresolved_action(action: &Action, unresolved: &mut Vec<String>) {
206 if action.url.is_none() {
207 unresolved.push(action.handler.clone());
208 }
209}
210
211fn collect_unresolved_node(node: &ComponentNode, unresolved: &mut Vec<String>) {
213 if let Some(ref action) = node.action {
214 collect_unresolved_action(action, unresolved);
215 }
216
217 match &node.component {
218 Component::Card(props) => {
219 for child in &props.children {
220 collect_unresolved_node(child, unresolved);
221 }
222 for child in &props.footer {
223 collect_unresolved_node(child, unresolved);
224 }
225 }
226 Component::Form(props) => {
227 collect_unresolved_action(&props.action, unresolved);
228 for field in &props.fields {
229 collect_unresolved_node(field, unresolved);
230 }
231 }
232 Component::DetailForm(props) => {
233 collect_unresolved_action(&props.action, unresolved);
234 for field in &props.fields {
235 collect_unresolved_node(&field.input, unresolved);
236 }
237 }
238 Component::Modal(props) => {
239 for child in &props.children {
240 collect_unresolved_node(child, unresolved);
241 }
242 for child in &props.footer {
243 collect_unresolved_node(child, unresolved);
244 }
245 }
246 Component::Tabs(props) => {
247 for tab in &props.tabs {
248 for child in &tab.children {
249 collect_unresolved_node(child, unresolved);
250 }
251 }
252 }
253 Component::Table(props) => {
254 if let Some(ref row_actions) = props.row_actions {
255 for action in row_actions {
256 collect_unresolved_action(action, unresolved);
257 }
258 }
259 }
260 Component::Grid(props) => {
261 for child in &props.children {
262 collect_unresolved_node(child, unresolved);
263 }
264 }
265 Component::Collapsible(props) => {
266 for child in &props.children {
267 collect_unresolved_node(child, unresolved);
268 }
269 }
270 Component::FormSection(props) => {
271 for child in &props.children {
272 collect_unresolved_node(child, unresolved);
273 }
274 }
275 Component::PageHeader(props) => {
276 for child in &props.actions {
277 collect_unresolved_node(child, unresolved);
278 }
279 }
280 Component::ButtonGroup(props) => {
281 for child in &props.buttons {
282 collect_unresolved_node(child, unresolved);
283 }
284 }
285 Component::DropdownMenu(props) => {
286 for item in &props.items {
287 collect_unresolved_action(&item.action, unresolved);
288 }
289 }
290 Component::KanbanBoard(props) => {
291 for col in &props.columns {
292 for child in &col.children {
293 collect_unresolved_node(child, unresolved);
294 }
295 }
296 }
297 Component::EmptyState(props) => {
298 if let Some(ref action) = props.action {
299 collect_unresolved_action(action, unresolved);
300 }
301 }
302 Component::Switch(props) => {
303 if let Some(ref action) = props.action {
304 collect_unresolved_action(action, unresolved);
305 }
306 }
307 Component::DataTable(props) => {
308 if let Some(ref actions) = props.row_actions {
309 for item in actions {
310 collect_unresolved_action(&item.action, unresolved);
311 }
312 }
313 }
314 Component::Button(_)
315 | Component::Input(_)
316 | Component::Select(_)
317 | Component::Alert(_)
318 | Component::Badge(_)
319 | Component::Text(_)
320 | Component::Checkbox(_)
321 | Component::Separator(_)
322 | Component::DescriptionList(_)
323 | Component::Breadcrumb(_)
324 | Component::Pagination(_)
325 | Component::Progress(_)
326 | Component::Avatar(_)
327 | Component::Skeleton(_)
328 | Component::StatCard(_)
329 | Component::Checklist(_)
330 | Component::Toast(_)
331 | Component::NotificationDropdown(_)
332 | Component::Sidebar(_)
333 | Component::Header(_)
334 | Component::CalendarCell(_)
335 | Component::ActionCard(_)
336 | Component::ProductTile(_)
337 | Component::Image(_)
338 | Component::KeyValueEditor(_)
339 | Component::RichTextEditor(_)
340 | Component::Plugin(_) => {}
341 }
342}
343
344pub fn resolve_errors(view: &mut JsonUiView, errors: &HashMap<String, Vec<String>>) {
354 for node in &mut view.components {
355 resolve_errors_node(node, errors, false);
356 }
357}
358
359pub fn resolve_errors_all(view: &mut JsonUiView, errors: &HashMap<String, Vec<String>>) {
364 for node in &mut view.components {
365 resolve_errors_node(node, errors, true);
366 }
367}
368
369fn set_field_error(
371 error_slot: &mut Option<String>,
372 field: &str,
373 errors: &HashMap<String, Vec<String>>,
374 all: bool,
375) {
376 if error_slot.is_some() {
377 return; }
379 if let Some(messages) = errors.get(field) {
380 if !messages.is_empty() {
381 if all {
382 *error_slot = Some(messages.join(". "));
383 } else {
384 *error_slot = Some(messages[0].clone());
385 }
386 }
387 }
388}
389
390fn resolve_errors_node(node: &mut ComponentNode, errors: &HashMap<String, Vec<String>>, all: bool) {
392 match &mut node.component {
393 Component::Input(props) => {
394 set_field_error(&mut props.error, &props.field, errors, all);
395 }
396 Component::Select(props) => {
397 set_field_error(&mut props.error, &props.field, errors, all);
398 }
399 Component::Checkbox(props) => {
400 set_field_error(&mut props.error, &props.field, errors, all);
401 }
402 Component::Switch(props) => {
403 set_field_error(&mut props.error, &props.field, errors, all);
404 }
405 Component::Card(props) => {
406 for child in &mut props.children {
407 resolve_errors_node(child, errors, all);
408 }
409 for child in &mut props.footer {
410 resolve_errors_node(child, errors, all);
411 }
412 }
413 Component::Form(props) => {
414 for field in &mut props.fields {
415 resolve_errors_node(field, errors, all);
416 }
417 }
418 Component::DetailForm(props) => {
419 for field in &mut props.fields {
420 resolve_errors_node(&mut field.input, errors, all);
421 }
422 }
423 Component::Modal(props) => {
424 for child in &mut props.children {
425 resolve_errors_node(child, errors, all);
426 }
427 for child in &mut props.footer {
428 resolve_errors_node(child, errors, all);
429 }
430 }
431 Component::Tabs(props) => {
432 for tab in &mut props.tabs {
433 for child in &mut tab.children {
434 resolve_errors_node(child, errors, all);
435 }
436 }
437 }
438 Component::Grid(props) => {
439 for child in &mut props.children {
440 resolve_errors_node(child, errors, all);
441 }
442 }
443 Component::Collapsible(props) => {
444 for child in &mut props.children {
445 resolve_errors_node(child, errors, all);
446 }
447 }
448 Component::FormSection(props) => {
449 for child in &mut props.children {
450 resolve_errors_node(child, errors, all);
451 }
452 }
453 Component::PageHeader(props) => {
454 for child in &mut props.actions {
455 resolve_errors_node(child, errors, all);
456 }
457 }
458 Component::ButtonGroup(props) => {
459 for child in &mut props.buttons {
460 resolve_errors_node(child, errors, all);
461 }
462 }
463 Component::Table(_)
465 | Component::Button(_)
466 | Component::Alert(_)
467 | Component::Badge(_)
468 | Component::Text(_)
469 | Component::Separator(_)
470 | Component::DescriptionList(_)
471 | Component::Breadcrumb(_)
472 | Component::Pagination(_)
473 | Component::Progress(_)
474 | Component::Avatar(_)
475 | Component::Skeleton(_)
476 | Component::StatCard(_)
477 | Component::Checklist(_)
478 | Component::Toast(_)
479 | Component::NotificationDropdown(_)
480 | Component::Sidebar(_)
481 | Component::Header(_)
482 | Component::EmptyState(_)
483 | Component::DropdownMenu(_)
484 | Component::KanbanBoard(_)
485 | Component::CalendarCell(_)
486 | Component::ActionCard(_)
487 | Component::ProductTile(_)
488 | Component::DataTable(_)
489 | Component::Image(_)
490 | Component::Plugin(_) => {}
491 Component::KeyValueEditor(props) => {
492 set_field_error(&mut props.error, &props.field, errors, all);
493 }
494 Component::RichTextEditor(props) => {
495 set_field_error(&mut props.error, &props.name, errors, all);
496 }
497 }
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use crate::action::HttpMethod;
504 use crate::component::*;
505
506 fn make_action(handler: &str) -> Action {
508 Action {
509 handler: handler.to_string(),
510 url: None,
511 method: HttpMethod::Post,
512 confirm: None,
513 on_success: None,
514 on_error: None,
515 target: None,
516 }
517 }
518
519 fn test_resolver(handler: &str) -> Option<String> {
521 match handler {
522 "users.store" => Some("/users".to_string()),
523 "users.show" => Some("/users/{id}".to_string()),
524 "users.destroy" => Some("/users/{id}".to_string()),
525 "users.create" => Some("/users/create".to_string()),
526 "posts.index" => Some("/posts".to_string()),
527 _ => None,
528 }
529 }
530
531 #[test]
532 fn resolve_button_with_action() {
533 let mut view = JsonUiView::new().component(ComponentNode {
534 key: "btn".to_string(),
535 component: Component::Button(ButtonProps {
536 label: "Create".to_string(),
537 variant: ButtonVariant::Default,
538 size: Size::Default,
539 disabled: None,
540 icon: None,
541 icon_position: None,
542 button_type: None,
543 }),
544 action: Some(make_action("users.store")),
545 visibility: None,
546 });
547
548 resolve_actions(&mut view, test_resolver);
549
550 assert_eq!(
551 view.components[0].action.as_ref().unwrap().url,
552 Some("/users".to_string())
553 );
554 }
555
556 #[test]
557 fn resolve_nested_card_children() {
558 let mut view = JsonUiView::new().component(ComponentNode {
559 key: "card".to_string(),
560 component: Component::Card(CardProps {
561 title: "Users".to_string(),
562 description: None,
563 max_width: None,
564 children: vec![ComponentNode {
565 key: "btn".to_string(),
566 component: Component::Button(ButtonProps {
567 label: "Create".to_string(),
568 variant: ButtonVariant::Default,
569 size: Size::Default,
570 disabled: None,
571 icon: None,
572 icon_position: None,
573 button_type: None,
574 }),
575 action: Some(make_action("users.create")),
576 visibility: None,
577 }],
578 footer: vec![ComponentNode {
579 key: "footer-btn".to_string(),
580 component: Component::Button(ButtonProps {
581 label: "Save".to_string(),
582 variant: ButtonVariant::Default,
583 size: Size::Default,
584 disabled: None,
585 icon: None,
586 icon_position: None,
587 button_type: None,
588 }),
589 action: Some(make_action("users.store")),
590 visibility: None,
591 }],
592 }),
593 action: None,
594 visibility: None,
595 });
596
597 resolve_actions(&mut view, test_resolver);
598
599 match &view.components[0].component {
600 Component::Card(props) => {
601 assert_eq!(
602 props.children[0].action.as_ref().unwrap().url,
603 Some("/users/create".to_string())
604 );
605 assert_eq!(
606 props.footer[0].action.as_ref().unwrap().url,
607 Some("/users".to_string())
608 );
609 }
610 _ => panic!("expected Card"),
611 }
612 }
613
614 #[test]
615 fn resolve_form_action() {
616 let mut view = JsonUiView::new().component(ComponentNode {
617 key: "form".to_string(),
618 component: Component::Form(FormProps {
619 action: make_action("users.store"),
620 fields: vec![ComponentNode {
621 key: "name".to_string(),
622 component: Component::Input(InputProps {
623 field: "name".to_string(),
624 label: "Name".to_string(),
625 input_type: InputType::Text,
626 placeholder: None,
627 required: None,
628 disabled: None,
629 error: None,
630 description: None,
631 default_value: None,
632 data_path: None,
633 step: None,
634 list: None,
635 }),
636 action: None,
637 visibility: None,
638 }],
639 method: None,
640 guard: None,
641 max_width: None,
642 }),
643 action: None,
644 visibility: None,
645 });
646
647 resolve_actions(&mut view, test_resolver);
648
649 match &view.components[0].component {
650 Component::Form(props) => {
651 assert_eq!(props.action.url, Some("/users".to_string()));
652 }
653 _ => panic!("expected Form"),
654 }
655 }
656
657 #[test]
658 fn resolve_table_row_actions() {
659 let mut view = JsonUiView::new().component(ComponentNode {
660 key: "table".to_string(),
661 component: Component::Table(TableProps {
662 columns: vec![Column {
663 key: "name".to_string(),
664 label: "Name".to_string(),
665 format: None,
666 }],
667 data_path: "/data/users".to_string(),
668 row_actions: Some(vec![
669 make_action("users.show"),
670 make_action("users.destroy"),
671 ]),
672 empty_message: None,
673 sortable: None,
674 sort_column: None,
675 sort_direction: None,
676 }),
677 action: None,
678 visibility: None,
679 });
680
681 resolve_actions(&mut view, test_resolver);
682
683 match &view.components[0].component {
684 Component::Table(props) => {
685 let row_actions = props.row_actions.as_ref().unwrap();
686 assert_eq!(row_actions[0].url, Some("/users/{id}".to_string()));
687 assert_eq!(row_actions[1].url, Some("/users/{id}".to_string()));
688 }
689 _ => panic!("expected Table"),
690 }
691 }
692
693 #[test]
694 fn resolve_tabs_children() {
695 let mut view = JsonUiView::new().component(ComponentNode {
696 key: "tabs".to_string(),
697 component: Component::Tabs(TabsProps {
698 default_tab: "general".to_string(),
699 tabs: vec![
700 Tab {
701 value: "general".to_string(),
702 label: "General".to_string(),
703 children: vec![ComponentNode {
704 key: "btn1".to_string(),
705 component: Component::Button(ButtonProps {
706 label: "Save".to_string(),
707 variant: ButtonVariant::Default,
708 size: Size::Default,
709 disabled: None,
710 icon: None,
711 icon_position: None,
712 button_type: None,
713 }),
714 action: Some(make_action("users.store")),
715 visibility: None,
716 }],
717 },
718 Tab {
719 value: "posts".to_string(),
720 label: "Posts".to_string(),
721 children: vec![ComponentNode {
722 key: "btn2".to_string(),
723 component: Component::Button(ButtonProps {
724 label: "View Posts".to_string(),
725 variant: ButtonVariant::Default,
726 size: Size::Default,
727 disabled: None,
728 icon: None,
729 icon_position: None,
730 button_type: None,
731 }),
732 action: Some(make_action("posts.index")),
733 visibility: None,
734 }],
735 },
736 ],
737 }),
738 action: None,
739 visibility: None,
740 });
741
742 resolve_actions(&mut view, test_resolver);
743
744 match &view.components[0].component {
745 Component::Tabs(props) => {
746 assert_eq!(
747 props.tabs[0].children[0].action.as_ref().unwrap().url,
748 Some("/users".to_string())
749 );
750 assert_eq!(
751 props.tabs[1].children[0].action.as_ref().unwrap().url,
752 Some("/posts".to_string())
753 );
754 }
755 _ => panic!("expected Tabs"),
756 }
757 }
758
759 #[test]
760 fn resolve_modal_children_and_footer() {
761 let mut view = JsonUiView::new().component(ComponentNode {
762 key: "modal".to_string(),
763 component: Component::Modal(ModalProps {
764 id: "modal-confirm".to_string(),
765 title: "Confirm".to_string(),
766 description: None,
767 children: vec![ComponentNode {
768 key: "info".to_string(),
769 component: Component::Text(TextProps {
770 content: "Are you sure?".to_string(),
771 element: TextElement::P,
772 }),
773 action: None,
774 visibility: None,
775 }],
776 footer: vec![ComponentNode {
777 key: "confirm-btn".to_string(),
778 component: Component::Button(ButtonProps {
779 label: "Delete".to_string(),
780 variant: ButtonVariant::Destructive,
781 size: Size::Default,
782 disabled: None,
783 icon: None,
784 icon_position: None,
785 button_type: None,
786 }),
787 action: Some(make_action("users.destroy")),
788 visibility: None,
789 }],
790 trigger_label: Some("Open".to_string()),
791 }),
792 action: None,
793 visibility: None,
794 });
795
796 resolve_actions(&mut view, test_resolver);
797
798 match &view.components[0].component {
799 Component::Modal(props) => {
800 assert_eq!(
801 props.footer[0].action.as_ref().unwrap().url,
802 Some("/users/{id}".to_string())
803 );
804 }
805 _ => panic!("expected Modal"),
806 }
807 }
808
809 #[test]
810 fn unresolvable_handler_leaves_url_none() {
811 let mut view = JsonUiView::new().component(ComponentNode {
812 key: "btn".to_string(),
813 component: Component::Button(ButtonProps {
814 label: "Unknown".to_string(),
815 variant: ButtonVariant::Default,
816 size: Size::Default,
817 disabled: None,
818 icon: None,
819 icon_position: None,
820 button_type: None,
821 }),
822 action: Some(make_action("nonexistent.handler")),
823 visibility: None,
824 });
825
826 resolve_actions(&mut view, test_resolver);
827
828 assert_eq!(view.components[0].action.as_ref().unwrap().url, None);
829 }
830
831 #[test]
832 fn strict_with_missing_handler_returns_error() {
833 let mut view = JsonUiView::new()
834 .component(ComponentNode {
835 key: "btn1".to_string(),
836 component: Component::Button(ButtonProps {
837 label: "OK".to_string(),
838 variant: ButtonVariant::Default,
839 size: Size::Default,
840 disabled: None,
841 icon: None,
842 icon_position: None,
843 button_type: None,
844 }),
845 action: Some(make_action("users.store")),
846 visibility: None,
847 })
848 .component(ComponentNode {
849 key: "btn2".to_string(),
850 component: Component::Button(ButtonProps {
851 label: "Bad".to_string(),
852 variant: ButtonVariant::Default,
853 size: Size::Default,
854 disabled: None,
855 icon: None,
856 icon_position: None,
857 button_type: None,
858 }),
859 action: Some(make_action("unknown.handler")),
860 visibility: None,
861 });
862
863 let result = resolve_actions_strict(&mut view, test_resolver);
864 assert!(result.is_err());
865 let errors = result.unwrap_err();
866 assert_eq!(errors, vec!["unknown.handler"]);
867
868 assert_eq!(
870 view.components[0].action.as_ref().unwrap().url,
871 Some("/users".to_string())
872 );
873 }
874
875 #[test]
876 fn strict_with_all_resolved_returns_ok() {
877 let mut view = JsonUiView::new().component(ComponentNode {
878 key: "btn".to_string(),
879 component: Component::Button(ButtonProps {
880 label: "Create".to_string(),
881 variant: ButtonVariant::Default,
882 size: Size::Default,
883 disabled: None,
884 icon: None,
885 icon_position: None,
886 button_type: None,
887 }),
888 action: Some(make_action("users.store")),
889 visibility: None,
890 });
891
892 let result = resolve_actions_strict(&mut view, test_resolver);
893 assert!(result.is_ok());
894 }
895
896 fn make_errors(pairs: &[(&str, &[&str])]) -> HashMap<String, Vec<String>> {
901 pairs
902 .iter()
903 .map(|(k, v)| (k.to_string(), v.iter().map(|s| s.to_string()).collect()))
904 .collect()
905 }
906
907 fn make_input_node(key: &str, field: &str) -> ComponentNode {
908 ComponentNode {
909 key: key.to_string(),
910 component: Component::Input(InputProps {
911 field: field.to_string(),
912 label: field.to_string(),
913 input_type: InputType::Text,
914 placeholder: None,
915 required: None,
916 disabled: None,
917 error: None,
918 description: None,
919 default_value: None,
920 data_path: None,
921 step: None,
922 list: None,
923 }),
924 action: None,
925 visibility: None,
926 }
927 }
928
929 #[test]
930 fn resolve_errors_populates_input_error() {
931 let mut view = JsonUiView::new().component(make_input_node("email-input", "email"));
932 let errors = make_errors(&[("email", &["Email is required"])]);
933 resolve_errors(&mut view, &errors);
934
935 match &view.components[0].component {
936 Component::Input(props) => {
937 assert_eq!(props.error, Some("Email is required".to_string()));
938 }
939 _ => panic!("expected Input"),
940 }
941 }
942
943 #[test]
944 fn resolve_errors_populates_select_error() {
945 let mut view = JsonUiView::new().component(ComponentNode {
946 key: "role-select".to_string(),
947 component: Component::Select(SelectProps {
948 field: "role".to_string(),
949 label: "Role".to_string(),
950 options: vec![SelectOption {
951 value: "admin".to_string(),
952 label: "Admin".to_string(),
953 }],
954 placeholder: None,
955 required: None,
956 disabled: None,
957 error: None,
958 description: None,
959 default_value: None,
960 data_path: None,
961 }),
962 action: None,
963 visibility: None,
964 });
965 let errors = make_errors(&[("role", &["Role is required"])]);
966 resolve_errors(&mut view, &errors);
967
968 match &view.components[0].component {
969 Component::Select(props) => {
970 assert_eq!(props.error, Some("Role is required".to_string()));
971 }
972 _ => panic!("expected Select"),
973 }
974 }
975
976 #[test]
977 fn resolve_errors_populates_checkbox_error() {
978 let mut view = JsonUiView::new().component(ComponentNode {
979 key: "terms-checkbox".to_string(),
980 component: Component::Checkbox(CheckboxProps {
981 field: "terms".to_string(),
982 value: None,
983 label: "Accept Terms".to_string(),
984 description: None,
985 checked: None,
986 data_path: None,
987 required: None,
988 disabled: None,
989 error: None,
990 }),
991 action: None,
992 visibility: None,
993 });
994 let errors = make_errors(&[("terms", &["You must accept the terms"])]);
995 resolve_errors(&mut view, &errors);
996
997 match &view.components[0].component {
998 Component::Checkbox(props) => {
999 assert_eq!(props.error, Some("You must accept the terms".to_string()));
1000 }
1001 _ => panic!("expected Checkbox"),
1002 }
1003 }
1004
1005 #[test]
1006 fn resolve_errors_populates_switch_error() {
1007 let mut view = JsonUiView::new().component(ComponentNode {
1008 key: "notif-switch".to_string(),
1009 component: Component::Switch(SwitchProps {
1010 field: "notifications".to_string(),
1011 label: "Notifications".to_string(),
1012 description: None,
1013 checked: None,
1014 data_path: None,
1015 required: None,
1016 disabled: None,
1017 error: None,
1018 action: None,
1019 compact: false,
1020 }),
1021 action: None,
1022 visibility: None,
1023 });
1024 let errors = make_errors(&[("notifications", &["Must enable notifications"])]);
1025 resolve_errors(&mut view, &errors);
1026
1027 match &view.components[0].component {
1028 Component::Switch(props) => {
1029 assert_eq!(props.error, Some("Must enable notifications".to_string()));
1030 }
1031 _ => panic!("expected Switch"),
1032 }
1033 }
1034
1035 #[test]
1036 fn resolve_errors_does_not_overwrite_existing() {
1037 let mut view = JsonUiView::new().component(ComponentNode {
1038 key: "email-input".to_string(),
1039 component: Component::Input(InputProps {
1040 field: "email".to_string(),
1041 label: "Email".to_string(),
1042 input_type: InputType::Email,
1043 placeholder: None,
1044 required: None,
1045 disabled: None,
1046 error: Some("Custom error".to_string()),
1047 description: None,
1048 default_value: None,
1049 data_path: None,
1050 step: None,
1051 list: None,
1052 }),
1053 action: None,
1054 visibility: None,
1055 });
1056 let errors = make_errors(&[("email", &["Validation error"])]);
1057 resolve_errors(&mut view, &errors);
1058
1059 match &view.components[0].component {
1060 Component::Input(props) => {
1061 assert_eq!(props.error, Some("Custom error".to_string()));
1062 }
1063 _ => panic!("expected Input"),
1064 }
1065 }
1066
1067 #[test]
1068 fn resolve_errors_nested_in_form() {
1069 let mut view = JsonUiView::new().component(ComponentNode {
1070 key: "form".to_string(),
1071 component: Component::Form(FormProps {
1072 action: make_action("users.store"),
1073 fields: vec![
1074 make_input_node("name-input", "name"),
1075 make_input_node("email-input", "email"),
1076 ],
1077 method: None,
1078 guard: None,
1079 max_width: None,
1080 }),
1081 action: None,
1082 visibility: None,
1083 });
1084 let errors = make_errors(&[
1085 ("name", &["Name is required"]),
1086 ("email", &["Email is invalid"]),
1087 ]);
1088 resolve_errors(&mut view, &errors);
1089
1090 match &view.components[0].component {
1091 Component::Form(props) => {
1092 match &props.fields[0].component {
1093 Component::Input(p) => {
1094 assert_eq!(p.error, Some("Name is required".to_string()));
1095 }
1096 _ => panic!("expected Input"),
1097 }
1098 match &props.fields[1].component {
1099 Component::Input(p) => {
1100 assert_eq!(p.error, Some("Email is invalid".to_string()));
1101 }
1102 _ => panic!("expected Input"),
1103 }
1104 }
1105 _ => panic!("expected Form"),
1106 }
1107 }
1108
1109 #[test]
1110 fn resolve_errors_nested_in_card() {
1111 let mut view = JsonUiView::new().component(ComponentNode {
1112 key: "card".to_string(),
1113 component: Component::Card(CardProps {
1114 title: "User".to_string(),
1115 description: None,
1116 children: vec![make_input_node("name-input", "name")],
1117 footer: vec![],
1118 max_width: None,
1119 }),
1120 action: None,
1121 visibility: None,
1122 });
1123 let errors = make_errors(&[("name", &["Name is required"])]);
1124 resolve_errors(&mut view, &errors);
1125
1126 match &view.components[0].component {
1127 Component::Card(props) => match &props.children[0].component {
1128 Component::Input(p) => {
1129 assert_eq!(p.error, Some("Name is required".to_string()));
1130 }
1131 _ => panic!("expected Input"),
1132 },
1133 _ => panic!("expected Card"),
1134 }
1135 }
1136
1137 #[test]
1138 fn resolve_errors_no_matching_field() {
1139 let mut view = JsonUiView::new().component(make_input_node("email-input", "email"));
1140 let errors = make_errors(&[("unknown_field", &["Some error"])]);
1141 resolve_errors(&mut view, &errors);
1142
1143 match &view.components[0].component {
1144 Component::Input(props) => {
1145 assert_eq!(props.error, None);
1146 }
1147 _ => panic!("expected Input"),
1148 }
1149 }
1150
1151 #[test]
1152 fn resolve_errors_all_concatenates_messages() {
1153 let mut view = JsonUiView::new().component(make_input_node("email-input", "email"));
1154 let errors = make_errors(&[("email", &["Too short", "Invalid format", "Already taken"])]);
1155 resolve_errors_all(&mut view, &errors);
1156
1157 match &view.components[0].component {
1158 Component::Input(props) => {
1159 assert_eq!(
1160 props.error,
1161 Some("Too short. Invalid format. Already taken".to_string())
1162 );
1163 }
1164 _ => panic!("expected Input"),
1165 }
1166 }
1167
1168 #[test]
1169 fn resolve_errors_empty_errors_map() {
1170 let mut view = JsonUiView::new().component(make_input_node("email-input", "email"));
1171 let errors: HashMap<String, Vec<String>> = HashMap::new();
1172 resolve_errors(&mut view, &errors);
1173
1174 match &view.components[0].component {
1175 Component::Input(props) => {
1176 assert_eq!(props.error, None);
1177 }
1178 _ => panic!("expected Input"),
1179 }
1180 }
1181
1182 #[test]
1185 fn resolve_detail_form_action() {
1186 let mut view = JsonUiView::new().component(ComponentNode {
1187 key: "df".to_string(),
1188 component: Component::DetailForm(DetailFormProps {
1189 mode: EditMode::Edit,
1190 action: make_action("users.update"),
1191 fields: vec![],
1192 edit_url: "/users/1?mode=edit".to_string(),
1193 cancel_url: "/users/1".to_string(),
1194 edit_label: None,
1195 save_label: None,
1196 cancel_label: None,
1197 method: None,
1198 }),
1199 action: None,
1200 visibility: None,
1201 });
1202
1203 resolve_actions(&mut view, |h: &str| match h {
1205 "users.update" => Some("/users/1".to_string()),
1206 _ => test_resolver(h),
1207 });
1208
1209 match &view.components[0].component {
1210 Component::DetailForm(props) => {
1211 assert_eq!(
1212 props.action.url.as_deref(),
1213 Some("/users/1"),
1214 "resolver must populate DetailForm action.url from handler"
1215 );
1216 }
1217 _ => panic!("expected Component::DetailForm"),
1218 }
1219 }
1220
1221 #[test]
1222 fn resolve_does_not_touch_edit_or_cancel_url() {
1223 let mut view = JsonUiView::new().component(ComponentNode {
1224 key: "df".to_string(),
1225 component: Component::DetailForm(DetailFormProps {
1226 mode: EditMode::View,
1227 action: make_action("users.update"),
1228 fields: vec![],
1229 edit_url: "RAW_EDIT".to_string(),
1230 cancel_url: "RAW_CANCEL".to_string(),
1231 edit_label: None,
1232 save_label: None,
1233 cancel_label: None,
1234 method: None,
1235 }),
1236 action: None,
1237 visibility: None,
1238 });
1239
1240 resolve_actions(&mut view, test_resolver);
1241
1242 match &view.components[0].component {
1243 Component::DetailForm(props) => {
1244 assert_eq!(
1245 props.edit_url, "RAW_EDIT",
1246 "edit_url must not be resolved (D-16)"
1247 );
1248 assert_eq!(
1249 props.cancel_url, "RAW_CANCEL",
1250 "cancel_url must not be resolved (D-16)"
1251 );
1252 }
1253 _ => panic!("expected Component::DetailForm"),
1254 }
1255 }
1256
1257 #[test]
1258 fn resolve_errors_propagates_into_detail_form_fields() {
1259 let mut view = JsonUiView::new().component(ComponentNode {
1262 key: "df".to_string(),
1263 component: Component::DetailForm(DetailFormProps {
1264 mode: EditMode::Edit,
1265 action: make_action("users.update"),
1266 fields: vec![DetailField {
1267 label: "Name".to_string(),
1268 value: "Ada".to_string(),
1269 input: ComponentNode::input(
1270 "name",
1271 InputProps {
1272 field: "name".to_string(),
1273 label: "".to_string(),
1274 input_type: InputType::Text,
1275 placeholder: None,
1276 required: None,
1277 disabled: None,
1278 error: None,
1279 description: None,
1280 default_value: None,
1281 data_path: None,
1282 step: None,
1283 list: None,
1284 },
1285 ),
1286 }],
1287 edit_url: "/users/1?mode=edit".to_string(),
1288 cancel_url: "/users/1".to_string(),
1289 edit_label: None,
1290 save_label: None,
1291 cancel_label: None,
1292 method: None,
1293 }),
1294 action: None,
1295 visibility: None,
1296 });
1297
1298 let mut errors = HashMap::new();
1299 errors.insert("name".to_string(), vec!["required".to_string()]);
1300
1301 resolve_errors(&mut view, &errors);
1302
1303 match &view.components[0].component {
1304 Component::DetailForm(props) => match &props.fields[0].input.component {
1305 Component::Input(ip) => assert_eq!(
1306 ip.error.as_deref(),
1307 Some("required"),
1308 "validation error must propagate into nested Input inside DetailForm"
1309 ),
1310 other => panic!("expected Input inside DetailField, got {other:?}"),
1311 },
1312 _ => panic!("expected Component::DetailForm"),
1313 }
1314 }
1315}