1use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::component::ComponentNode;
12
13pub const SCHEMA_VERSION: &str = "ferro-json-ui/v1";
15
16#[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 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 pub fn title(mut self, title: impl Into<String>) -> Self {
64 self.title = Some(title.into());
65 self
66 }
67
68 pub fn data(mut self, data: serde_json::Value) -> Self {
70 self.data = data;
71 self
72 }
73
74 pub fn errors(mut self, errors: HashMap<String, Vec<String>>) -> Self {
76 self.errors = Some(errors);
77 self
78 }
79
80 pub fn layout(mut self, layout: impl Into<String>) -> Self {
82 self.layout = Some(layout.into());
83 self
84 }
85
86 pub fn component(mut self, node: ComponentNode) -> Self {
88 self.components.push(node);
89 self
90 }
91
92 pub fn components(mut self, nodes: Vec<ComponentNode>) -> Self {
94 self.components = nodes;
95 self
96 }
97
98 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
100 serde_json::from_str(json)
101 }
102
103 pub fn to_json(&self) -> Result<String, serde_json::Error> {
105 serde_json::to_string(self)
106 }
107
108 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 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 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 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 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 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 #[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 assert!(
423 value.is_object(),
424 "schema should serialize to a JSON object"
425 );
426 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}