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