Skip to main content

ferro_json_ui/
view.rs

1//! Top-level view container for JSON-UI.
2//!
3//! A `JsonUiView` is the root structure that defines a complete page.
4//! It contains the schema version, optional layout and title, and the
5//! component tree. Views can be built programmatically or parsed from JSON.
6
7use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::component::ComponentNode;
12
13/// Schema version identifier for JSON-UI views.
14pub const SCHEMA_VERSION: &str = "ferro-json-ui/v1";
15
16/// Top-level JSON-UI view container.
17///
18/// Every JSON-UI response is a `JsonUiView` containing a component tree.
19/// The `$schema` field identifies the schema version for compatibility.
20///
21/// # Example
22///
23/// ```rust
24/// use ferro_json_ui::JsonUiView;
25///
26/// let view = JsonUiView::new()
27///     .title("Dashboard")
28///     .layout("app");
29///
30/// let json = view.to_json().unwrap();
31/// assert!(json.contains("ferro-json-ui/v1"));
32/// ```
33// JsonSchema skipped: contains Vec<ComponentNode> — Component has custom Serialize/Deserialize
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct JsonUiView {
36    #[serde(rename = "$schema")]
37    pub schema: String,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub layout: Option<String>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub title: Option<String>,
42    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
43    pub data: serde_json::Value,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub errors: Option<HashMap<String, Vec<String>>>,
46    pub components: Vec<ComponentNode>,
47}
48
49impl JsonUiView {
50    /// Create a new view with the current schema version and empty components.
51    pub fn new() -> Self {
52        Self {
53            schema: SCHEMA_VERSION.to_string(),
54            layout: None,
55            title: None,
56            data: serde_json::Value::Null,
57            errors: None,
58            components: vec![],
59        }
60    }
61
62    /// Set the view title.
63    pub fn title(mut self, title: impl Into<String>) -> Self {
64        self.title = Some(title.into());
65        self
66    }
67
68    /// Set the view data payload.
69    pub fn data(mut self, data: serde_json::Value) -> Self {
70        self.data = data;
71        self
72    }
73
74    /// Set the validation errors map.
75    pub fn errors(mut self, errors: HashMap<String, Vec<String>>) -> Self {
76        self.errors = Some(errors);
77        self
78    }
79
80    /// Set the layout name.
81    pub fn layout(mut self, layout: impl Into<String>) -> Self {
82        self.layout = Some(layout.into());
83        self
84    }
85
86    /// Add a single component to the view.
87    pub fn component(mut self, node: ComponentNode) -> Self {
88        self.components.push(node);
89        self
90    }
91
92    /// Set all components at once, replacing any existing.
93    pub fn components(mut self, nodes: Vec<ComponentNode>) -> Self {
94        self.components = nodes;
95        self
96    }
97
98    /// Parse a view from a JSON string.
99    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
100        serde_json::from_str(json)
101    }
102
103    /// Serialize the view to a compact JSON string.
104    pub fn to_json(&self) -> Result<String, serde_json::Error> {
105        serde_json::to_string(self)
106    }
107
108    /// Serialize the view to a pretty-printed JSON string.
109    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
110        serde_json::to_string_pretty(self)
111    }
112}
113
114impl Default for JsonUiView {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::action::{Action, HttpMethod};
124    use crate::component::*;
125    use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
126
127    #[test]
128    fn schema_field_serializes_as_dollar_schema() {
129        let view = JsonUiView::new();
130        let json = serde_json::to_value(&view).unwrap();
131        assert_eq!(json["$schema"], "ferro-json-ui/v1");
132        assert!(json.get("schema").is_none());
133    }
134
135    #[test]
136    fn builder_produces_valid_json() {
137        let view = JsonUiView::new()
138            .title("Users")
139            .layout("app")
140            .component(ComponentNode {
141                key: "header".to_string(),
142                component: Component::Card(CardProps {
143                    title: "User Management".to_string(),
144                    description: None,
145                    children: vec![],
146                    footer: vec![],
147                    max_width: None,
148                }),
149                action: None,
150                visibility: None,
151            });
152
153        let json = view.to_json().unwrap();
154        assert!(json.contains("\"$schema\":\"ferro-json-ui/v1\""));
155        assert!(json.contains("\"title\":\"Users\""));
156        assert!(json.contains("\"layout\":\"app\""));
157        assert!(json.contains("\"type\":\"Card\""));
158    }
159
160    #[test]
161    fn round_trip_build_to_json_from_json() {
162        let original = JsonUiView::new()
163            .title("Dashboard")
164            .layout("app")
165            .component(ComponentNode {
166                key: "alert".to_string(),
167                component: Component::Alert(AlertProps {
168                    message: "Welcome".to_string(),
169                    variant: AlertVariant::Success,
170                    title: None,
171                }),
172                action: None,
173                visibility: None,
174            })
175            .component(ComponentNode {
176                key: "content".to_string(),
177                component: Component::Text(TextProps {
178                    content: "Hello world".to_string(),
179                    element: TextElement::H1,
180                }),
181                action: None,
182                visibility: None,
183            });
184
185        let json = original.to_json().unwrap();
186        let parsed = JsonUiView::from_json(&json).unwrap();
187        assert_eq!(original, parsed);
188    }
189
190    #[test]
191    fn from_json_full_example() {
192        // Based on the research doc example
193        let json = r#"{
194            "$schema": "ferro-json-ui/v1",
195            "layout": "app",
196            "title": "Users",
197            "components": [
198                {
199                    "key": "header",
200                    "type": "Card",
201                    "title": "User Management",
202                    "children": [
203                        {
204                            "key": "create-btn",
205                            "type": "Button",
206                            "label": "Create User",
207                            "variant": "default",
208                            "action": {
209                                "handler": "users.create",
210                                "method": "POST"
211                            }
212                        }
213                    ]
214                },
215                {
216                    "key": "users-table",
217                    "type": "Table",
218                    "columns": [
219                        {"key": "name", "label": "Name"},
220                        {"key": "email", "label": "Email"},
221                        {"key": "created_at", "label": "Created", "format": "date"}
222                    ],
223                    "data_path": "/data/users",
224                    "visibility": {
225                        "path": "/data/users",
226                        "operator": "not_empty"
227                    }
228                }
229            ]
230        }"#;
231        let view = JsonUiView::from_json(json).unwrap();
232        assert_eq!(view.schema, "ferro-json-ui/v1");
233        assert_eq!(view.title.as_deref(), Some("Users"));
234        assert_eq!(view.layout.as_deref(), Some("app"));
235        assert_eq!(view.components.len(), 2);
236
237        // Verify first component is a Card
238        assert_eq!(view.components[0].key, "header");
239        match &view.components[0].component {
240            Component::Card(props) => {
241                assert_eq!(props.title, "User Management");
242                assert_eq!(props.children.len(), 1);
243                // Verify nested button
244                match &props.children[0].component {
245                    Component::Button(bp) => assert_eq!(bp.label, "Create User"),
246                    _ => panic!("expected Button child"),
247                }
248            }
249            _ => panic!("expected Card"),
250        }
251
252        // Verify second component is a Table with visibility
253        assert_eq!(view.components[1].key, "users-table");
254        match &view.components[1].component {
255            Component::Table(props) => {
256                assert_eq!(props.columns.len(), 3);
257                assert_eq!(props.data_path, "/data/users");
258            }
259            _ => panic!("expected Table"),
260        }
261        assert!(view.components[1].visibility.is_some());
262    }
263
264    #[test]
265    fn empty_view_serializes() {
266        let view = JsonUiView::new();
267        let json = view.to_json().unwrap();
268        let parsed = JsonUiView::from_json(&json).unwrap();
269        assert_eq!(parsed.schema, SCHEMA_VERSION);
270        assert!(parsed.title.is_none());
271        assert!(parsed.layout.is_none());
272        assert!(parsed.components.is_empty());
273    }
274
275    #[test]
276    fn to_json_pretty_is_readable() {
277        let view = JsonUiView::new().title("Test");
278        let pretty = view.to_json_pretty().unwrap();
279        assert!(pretty.contains('\n'));
280        assert!(pretty.contains("  "));
281    }
282
283    #[test]
284    fn components_method_replaces_existing() {
285        let view = JsonUiView::new()
286            .component(ComponentNode {
287                key: "first".to_string(),
288                component: Component::Text(TextProps {
289                    content: "first".to_string(),
290                    element: TextElement::P,
291                }),
292                action: None,
293                visibility: None,
294            })
295            .components(vec![ComponentNode {
296                key: "replaced".to_string(),
297                component: Component::Text(TextProps {
298                    content: "replaced".to_string(),
299                    element: TextElement::P,
300                }),
301                action: None,
302                visibility: None,
303            }]);
304        assert_eq!(view.components.len(), 1);
305        assert_eq!(view.components[0].key, "replaced");
306    }
307
308    #[test]
309    fn complex_view_with_action_and_visibility() {
310        let view = JsonUiView::new()
311            .title("Admin Panel")
312            .component(ComponentNode {
313                key: "delete-btn".to_string(),
314                component: Component::Button(ButtonProps {
315                    label: "Delete All".to_string(),
316                    variant: ButtonVariant::Destructive,
317                    size: Size::Default,
318                    disabled: Some(false),
319                    icon: None,
320                    icon_position: None,
321                    button_type: None,
322                }),
323                action: Some(Action {
324                    handler: "admin.delete_all".to_string(),
325                    url: None,
326                    method: HttpMethod::Delete,
327                    confirm: None,
328                    on_success: None,
329                    on_error: None,
330                    target: None,
331                }),
332                visibility: Some(Visibility::Condition(VisibilityCondition {
333                    path: "/auth/user/role".to_string(),
334                    operator: VisibilityOperator::Eq,
335                    value: Some(serde_json::Value::String("admin".to_string())),
336                })),
337            });
338
339        let json = view.to_json().unwrap();
340        let parsed = JsonUiView::from_json(&json).unwrap();
341        assert_eq!(view, parsed);
342    }
343
344    #[test]
345    fn view_with_data_serializes_data_field() {
346        let view = JsonUiView::new()
347            .title("Users")
348            .data(serde_json::json!({"users": [{"name": "Alice"}]}));
349
350        let json = serde_json::to_value(&view).unwrap();
351        assert!(json.get("data").is_some());
352        assert_eq!(json["data"]["users"][0]["name"], "Alice");
353    }
354
355    #[test]
356    fn view_without_data_omits_data_field() {
357        let view = JsonUiView::new().title("Empty");
358        let json = serde_json::to_value(&view).unwrap();
359        // skip_serializing_if is_null means no data key in output
360        assert!(json.get("data").is_none());
361    }
362
363    #[test]
364    fn round_trip_with_data_preserves_nested_structures() {
365        let data = serde_json::json!({
366            "users": [
367                {"id": 1, "name": "Alice", "roles": ["admin", "user"]},
368                {"id": 2, "name": "Bob", "roles": ["user"]}
369            ],
370            "meta": {"total": 2, "page": 1}
371        });
372        let view = JsonUiView::new().title("Users").data(data);
373
374        let json_str = view.to_json().unwrap();
375        let parsed = JsonUiView::from_json(&json_str).unwrap();
376        assert_eq!(view, parsed);
377        assert_eq!(parsed.data["users"][0]["name"], "Alice");
378        assert_eq!(parsed.data["meta"]["total"], 2);
379    }
380
381    #[test]
382    fn builder_data_method_works() {
383        let view = JsonUiView::new().data(serde_json::json!({"key": "value"}));
384        assert_eq!(view.data["key"], "value");
385    }
386
387    #[test]
388    fn view_with_errors_serializes() {
389        let mut errors = std::collections::HashMap::new();
390        errors.insert("email".to_string(), vec!["Required".to_string()]);
391        let view = JsonUiView::new().errors(errors);
392        let json = serde_json::to_value(&view).unwrap();
393        assert!(json.get("errors").is_some());
394        assert_eq!(json["errors"]["email"][0], "Required");
395    }
396
397    #[test]
398    fn view_without_errors_omits_field() {
399        let view = JsonUiView::new().title("Empty");
400        let json = serde_json::to_value(&view).unwrap();
401        assert!(json.get("errors").is_none());
402    }
403
404    #[test]
405    fn errors_builder_method() {
406        let mut errors = std::collections::HashMap::new();
407        errors.insert("name".to_string(), vec!["Too short".to_string()]);
408        let view = JsonUiView::new().errors(errors);
409        assert!(view.errors.is_some());
410        let errs = view.errors.unwrap();
411        assert_eq!(errs["name"], vec!["Too short".to_string()]);
412    }
413
414    // ── JSON Schema generation tests ─────────────────────────────────────
415
416    #[test]
417    fn test_json_schema_for_table_props_generates() {
418        use crate::component::TableProps;
419        let schema = schemars::schema_for!(TableProps);
420        let value = serde_json::to_value(&schema).unwrap();
421        // Schema must be a valid object with title or properties
422        assert!(
423            value.is_object(),
424            "schema should serialize to a JSON object"
425        );
426        // TableProps has required field `data_path`
427        let schema_str = serde_json::to_string(&schema).unwrap();
428        assert!(
429            schema_str.contains("data_path"),
430            "schema should reference data_path field"
431        );
432    }
433
434    #[test]
435    fn test_json_schema_for_stat_card_props_generates() {
436        use crate::component::StatCardProps;
437        let schema = schemars::schema_for!(StatCardProps);
438        let value = serde_json::to_value(&schema).unwrap();
439        assert!(
440            value.is_object(),
441            "schema should serialize to a JSON object"
442        );
443        let schema_str = serde_json::to_string(&schema).unwrap();
444        assert!(
445            schema_str.contains("label"),
446            "schema should reference label field"
447        );
448        assert!(
449            schema_str.contains("value"),
450            "schema should reference value field"
451        );
452    }
453
454    #[test]
455    fn test_json_schema_for_action_generates() {
456        use crate::action::Action;
457        let schema = schemars::schema_for!(Action);
458        let value = serde_json::to_value(&schema).unwrap();
459        assert!(
460            value.is_object(),
461            "schema should serialize to a JSON object"
462        );
463        let schema_str = serde_json::to_string(&schema).unwrap();
464        assert!(
465            schema_str.contains("handler"),
466            "schema should reference handler field"
467        );
468    }
469
470    #[test]
471    fn test_json_schema_for_visibility_generates() {
472        use crate::visibility::Visibility;
473        let schema = schemars::schema_for!(Visibility);
474        let value = serde_json::to_value(&schema).unwrap();
475        assert!(
476            value.is_object(),
477            "schema should serialize to a JSON object"
478        );
479    }
480}