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::Plugin(_) => {}
154    }
155}
156
157/// Walk the entire component tree and resolve all action handler names to URLs.
158///
159/// The resolver callback maps a handler name (e.g. `"users.store"`) to an
160/// optional URL (e.g. `Some("/users")`). Actions whose handler cannot be
161/// resolved are left with `url: None`.
162pub 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
168/// Walk the entire component tree and resolve all actions, returning an error
169/// for any handler that cannot be resolved.
170///
171/// Returns `Ok(())` if all handlers resolve successfully, or `Err(Vec<String>)`
172/// containing the names of all unresolvable handlers.
173pub 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    // First resolve everything.
182    resolve_actions(view, collecting_resolver);
183
184    // Then collect unresolved handlers by walking the tree again.
185    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
196/// Collect handler names from actions that have no resolved URL.
197fn collect_unresolved_action(action: &Action, unresolved: &mut Vec<String>) {
198    if action.url.is_none() {
199        unresolved.push(action.handler.clone());
200    }
201}
202
203/// Recursively collect unresolved actions from a component node.
204fn 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
328// ---------------------------------------------------------------------------
329// Validation error resolution
330// ---------------------------------------------------------------------------
331
332/// Walk the component tree and set the first validation error message on each
333/// matching form field component (Input, Select, Checkbox, Switch).
334///
335/// Only fields whose `error` is currently `None` are updated; explicit errors
336/// set by the caller take priority.
337pub 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
343/// Walk the component tree and set all validation error messages (joined with
344/// `". "`) on each matching form field component.
345///
346/// Same precedence rule: existing errors are not overwritten.
347pub 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
353/// Set the error string on a form field if the errors map contains its field name.
354fn 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; // explicit error takes priority
362    }
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
374/// Recursively resolve validation errors within a component node.
375fn 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        // Leaf components with no form field semantics.
443        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    /// Helper to build a simple action.
480    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    /// Helper resolver that maps known handlers to URLs.
493    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        // The known handler should still be resolved.
842        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    // -----------------------------------------------------------------------
870    // resolve_errors tests
871    // -----------------------------------------------------------------------
872
873    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}