Skip to main content

ferro_json_ui/
resolve.rs

1//! Resolvers for JSON-UI component trees.
2//!
3//! Walks a `JsonUiView`'s component tree and resolves action handler
4//! references to URLs and validation errors to form field error messages.
5//! Both resolvers keep ferro-json-ui decoupled from the framework.
6
7use std::collections::HashMap;
8
9use crate::action::Action;
10use crate::component::{Component, ComponentNode};
11use crate::view::JsonUiView;
12
13/// Resolve a single action using the callback.
14fn resolve_action(action: &mut Action, resolver: &impl Fn(&str) -> Option<String>) {
15    if action.url.is_none() {
16        // Literal paths (starting with "/") are passed through as-is so
17        // callers can use Action::get("/dashboard/...") without registering
18        // a named route.
19        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
29/// Recursively resolve all actions within a component node.
30fn resolve_component_node(node: &mut ComponentNode, resolver: &impl Fn(&str) -> Option<String>) {
31    // Resolve the node-level action (any component can have one).
32    if let Some(ref mut action) = node.action {
33        resolve_action(action, resolver);
34    }
35
36    // Recurse into component-specific children.
37    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        // Leaf components with no children or actions to resolve.
129        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
158/// Walk the entire component tree and resolve all action handler names to URLs.
159///
160/// The resolver callback maps a handler name (e.g. `"users.store"`) to an
161/// optional URL (e.g. `Some("/users")`). Actions whose handler cannot be
162/// resolved are left with `url: None`.
163pub 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
169/// Walk the entire component tree and resolve all actions, returning an error
170/// for any handler that cannot be resolved.
171///
172/// Returns `Ok(())` if all handlers resolve successfully, or `Err(Vec<String>)`
173/// containing the names of all unresolvable handlers.
174pub 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    // First resolve everything.
183    resolve_actions(view, collecting_resolver);
184
185    // Then collect unresolved handlers by walking the tree again.
186    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
197/// Collect handler names from actions that have no resolved URL.
198fn collect_unresolved_action(action: &Action, unresolved: &mut Vec<String>) {
199    if action.url.is_none() {
200        unresolved.push(action.handler.clone());
201    }
202}
203
204/// Recursively collect unresolved actions from a component node.
205fn 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
330// ---------------------------------------------------------------------------
331// Validation error resolution
332// ---------------------------------------------------------------------------
333
334/// Walk the component tree and set the first validation error message on each
335/// matching form field component (Input, Select, Checkbox, Switch).
336///
337/// Only fields whose `error` is currently `None` are updated; explicit errors
338/// set by the caller take priority.
339pub 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
345/// Walk the component tree and set all validation error messages (joined with
346/// `". "`) on each matching form field component.
347///
348/// Same precedence rule: existing errors are not overwritten.
349pub 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
355/// Set the error string on a form field if the errors map contains its field name.
356fn 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; // explicit error takes priority
364    }
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
376/// Recursively resolve validation errors within a component node.
377fn 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        // Leaf components with no form field semantics.
445        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    /// Helper to build a simple action.
485    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    /// Helper resolver that maps known handlers to URLs.
498    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        // The known handler should still be resolved.
847        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    // -----------------------------------------------------------------------
875    // resolve_errors tests
876    // -----------------------------------------------------------------------
877
878    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}