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