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