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::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        // Leaf components with no children or actions to resolve.
135        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
164/// Walk the entire component tree and resolve all action handler names to URLs.
165///
166/// The resolver callback maps a handler name (e.g. `"users.store"`) to an
167/// optional URL (e.g. `Some("/users")`). Actions whose handler cannot be
168/// resolved are left with `url: None`.
169pub 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
175/// Walk the entire component tree and resolve all actions, returning an error
176/// for any handler that cannot be resolved.
177///
178/// Returns `Ok(())` if all handlers resolve successfully, or `Err(Vec<String>)`
179/// containing the names of all unresolvable handlers.
180pub 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    // First resolve everything.
189    resolve_actions(view, collecting_resolver);
190
191    // Then collect unresolved handlers by walking the tree again.
192    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
203/// Collect handler names from actions that have no resolved URL.
204fn collect_unresolved_action(action: &Action, unresolved: &mut Vec<String>) {
205    if action.url.is_none() {
206        unresolved.push(action.handler.clone());
207    }
208}
209
210/// Recursively collect unresolved actions from a component node.
211fn 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
342// ---------------------------------------------------------------------------
343// Validation error resolution
344// ---------------------------------------------------------------------------
345
346/// Walk the component tree and set the first validation error message on each
347/// matching form field component (Input, Select, Checkbox, Switch).
348///
349/// Only fields whose `error` is currently `None` are updated; explicit errors
350/// set by the caller take priority.
351pub 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
357/// Walk the component tree and set all validation error messages (joined with
358/// `". "`) on each matching form field component.
359///
360/// Same precedence rule: existing errors are not overwritten.
361pub 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
367/// Set the error string on a form field if the errors map contains its field name.
368fn 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; // explicit error takes priority
376    }
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
388/// Recursively resolve validation errors within a component node.
389fn 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        // Leaf components with no form field semantics.
462        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    /// Helper to build a simple action.
502    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    /// Helper resolver that maps known handlers to URLs.
515    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        // The known handler should still be resolved.
864        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    // -----------------------------------------------------------------------
892    // resolve_errors tests
893    // -----------------------------------------------------------------------
894
895    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    // ── DetailForm resolver tests (Phase 147, Wave 0 RED) ─────────────────
1178
1179    #[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        // Resolver knows "users.update" → "/users/1" (add arm if missing in test_resolver).
1199        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        // DetailField.input holds an Input; validation error against field name "name"
1255        // should attach to the nested InputProps.error after resolve_errors runs.
1256        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}