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