use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::component::ComponentNode;
pub const SCHEMA_VERSION: &str = "ferro-json-ui/v1";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JsonUiView {
#[serde(rename = "$schema")]
pub schema: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layout: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub data: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub errors: Option<HashMap<String, Vec<String>>>,
pub components: Vec<ComponentNode>,
}
impl JsonUiView {
pub fn new() -> Self {
Self {
schema: SCHEMA_VERSION.to_string(),
layout: None,
title: None,
data: serde_json::Value::Null,
errors: None,
components: vec![],
}
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn data(mut self, data: serde_json::Value) -> Self {
self.data = data;
self
}
pub fn errors(mut self, errors: HashMap<String, Vec<String>>) -> Self {
self.errors = Some(errors);
self
}
pub fn layout(mut self, layout: impl Into<String>) -> Self {
self.layout = Some(layout.into());
self
}
pub fn component(mut self, node: ComponentNode) -> Self {
self.components.push(node);
self
}
pub fn components(mut self, nodes: Vec<ComponentNode>) -> Self {
self.components = nodes;
self
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
impl Default for JsonUiView {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::action::{Action, HttpMethod};
use crate::component::*;
use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
#[test]
fn schema_field_serializes_as_dollar_schema() {
let view = JsonUiView::new();
let json = serde_json::to_value(&view).unwrap();
assert_eq!(json["$schema"], "ferro-json-ui/v1");
assert!(json.get("schema").is_none());
}
#[test]
fn builder_produces_valid_json() {
let view = JsonUiView::new()
.title("Users")
.layout("app")
.component(ComponentNode {
key: "header".to_string(),
component: Component::Card(CardProps {
title: "User Management".to_string(),
description: None,
children: vec![],
footer: vec![],
max_width: None,
}),
action: None,
visibility: None,
});
let json = view.to_json().unwrap();
assert!(json.contains("\"$schema\":\"ferro-json-ui/v1\""));
assert!(json.contains("\"title\":\"Users\""));
assert!(json.contains("\"layout\":\"app\""));
assert!(json.contains("\"type\":\"Card\""));
}
#[test]
fn round_trip_build_to_json_from_json() {
let original = JsonUiView::new()
.title("Dashboard")
.layout("app")
.component(ComponentNode {
key: "alert".to_string(),
component: Component::Alert(AlertProps {
message: "Welcome".to_string(),
variant: AlertVariant::Success,
title: None,
}),
action: None,
visibility: None,
})
.component(ComponentNode {
key: "content".to_string(),
component: Component::Text(TextProps {
content: "Hello world".to_string(),
element: TextElement::H1,
}),
action: None,
visibility: None,
});
let json = original.to_json().unwrap();
let parsed = JsonUiView::from_json(&json).unwrap();
assert_eq!(original, parsed);
}
#[test]
fn from_json_full_example() {
let json = r#"{
"$schema": "ferro-json-ui/v1",
"layout": "app",
"title": "Users",
"components": [
{
"key": "header",
"type": "Card",
"title": "User Management",
"children": [
{
"key": "create-btn",
"type": "Button",
"label": "Create User",
"variant": "default",
"action": {
"handler": "users.create",
"method": "POST"
}
}
]
},
{
"key": "users-table",
"type": "Table",
"columns": [
{"key": "name", "label": "Name"},
{"key": "email", "label": "Email"},
{"key": "created_at", "label": "Created", "format": "date"}
],
"data_path": "/data/users",
"visibility": {
"path": "/data/users",
"operator": "not_empty"
}
}
]
}"#;
let view = JsonUiView::from_json(json).unwrap();
assert_eq!(view.schema, "ferro-json-ui/v1");
assert_eq!(view.title.as_deref(), Some("Users"));
assert_eq!(view.layout.as_deref(), Some("app"));
assert_eq!(view.components.len(), 2);
assert_eq!(view.components[0].key, "header");
match &view.components[0].component {
Component::Card(props) => {
assert_eq!(props.title, "User Management");
assert_eq!(props.children.len(), 1);
match &props.children[0].component {
Component::Button(bp) => assert_eq!(bp.label, "Create User"),
_ => panic!("expected Button child"),
}
}
_ => panic!("expected Card"),
}
assert_eq!(view.components[1].key, "users-table");
match &view.components[1].component {
Component::Table(props) => {
assert_eq!(props.columns.len(), 3);
assert_eq!(props.data_path, "/data/users");
}
_ => panic!("expected Table"),
}
assert!(view.components[1].visibility.is_some());
}
#[test]
fn empty_view_serializes() {
let view = JsonUiView::new();
let json = view.to_json().unwrap();
let parsed = JsonUiView::from_json(&json).unwrap();
assert_eq!(parsed.schema, SCHEMA_VERSION);
assert!(parsed.title.is_none());
assert!(parsed.layout.is_none());
assert!(parsed.components.is_empty());
}
#[test]
fn to_json_pretty_is_readable() {
let view = JsonUiView::new().title("Test");
let pretty = view.to_json_pretty().unwrap();
assert!(pretty.contains('\n'));
assert!(pretty.contains(" "));
}
#[test]
fn components_method_replaces_existing() {
let view = JsonUiView::new()
.component(ComponentNode {
key: "first".to_string(),
component: Component::Text(TextProps {
content: "first".to_string(),
element: TextElement::P,
}),
action: None,
visibility: None,
})
.components(vec![ComponentNode {
key: "replaced".to_string(),
component: Component::Text(TextProps {
content: "replaced".to_string(),
element: TextElement::P,
}),
action: None,
visibility: None,
}]);
assert_eq!(view.components.len(), 1);
assert_eq!(view.components[0].key, "replaced");
}
#[test]
fn complex_view_with_action_and_visibility() {
let view = JsonUiView::new()
.title("Admin Panel")
.component(ComponentNode {
key: "delete-btn".to_string(),
component: Component::Button(ButtonProps {
label: "Delete All".to_string(),
variant: ButtonVariant::Destructive,
size: Size::Default,
disabled: Some(false),
icon: None,
icon_position: None,
button_type: None,
}),
action: Some(Action {
handler: "admin.delete_all".to_string(),
url: None,
method: HttpMethod::Delete,
confirm: None,
on_success: None,
on_error: None,
target: None,
}),
visibility: Some(Visibility::Condition(VisibilityCondition {
path: "/auth/user/role".to_string(),
operator: VisibilityOperator::Eq,
value: Some(serde_json::Value::String("admin".to_string())),
})),
});
let json = view.to_json().unwrap();
let parsed = JsonUiView::from_json(&json).unwrap();
assert_eq!(view, parsed);
}
#[test]
fn view_with_data_serializes_data_field() {
let view = JsonUiView::new()
.title("Users")
.data(serde_json::json!({"users": [{"name": "Alice"}]}));
let json = serde_json::to_value(&view).unwrap();
assert!(json.get("data").is_some());
assert_eq!(json["data"]["users"][0]["name"], "Alice");
}
#[test]
fn view_without_data_omits_data_field() {
let view = JsonUiView::new().title("Empty");
let json = serde_json::to_value(&view).unwrap();
assert!(json.get("data").is_none());
}
#[test]
fn round_trip_with_data_preserves_nested_structures() {
let data = serde_json::json!({
"users": [
{"id": 1, "name": "Alice", "roles": ["admin", "user"]},
{"id": 2, "name": "Bob", "roles": ["user"]}
],
"meta": {"total": 2, "page": 1}
});
let view = JsonUiView::new().title("Users").data(data);
let json_str = view.to_json().unwrap();
let parsed = JsonUiView::from_json(&json_str).unwrap();
assert_eq!(view, parsed);
assert_eq!(parsed.data["users"][0]["name"], "Alice");
assert_eq!(parsed.data["meta"]["total"], 2);
}
#[test]
fn builder_data_method_works() {
let view = JsonUiView::new().data(serde_json::json!({"key": "value"}));
assert_eq!(view.data["key"], "value");
}
#[test]
fn view_with_errors_serializes() {
let mut errors = std::collections::HashMap::new();
errors.insert("email".to_string(), vec!["Required".to_string()]);
let view = JsonUiView::new().errors(errors);
let json = serde_json::to_value(&view).unwrap();
assert!(json.get("errors").is_some());
assert_eq!(json["errors"]["email"][0], "Required");
}
#[test]
fn view_without_errors_omits_field() {
let view = JsonUiView::new().title("Empty");
let json = serde_json::to_value(&view).unwrap();
assert!(json.get("errors").is_none());
}
#[test]
fn errors_builder_method() {
let mut errors = std::collections::HashMap::new();
errors.insert("name".to_string(), vec!["Too short".to_string()]);
let view = JsonUiView::new().errors(errors);
assert!(view.errors.is_some());
let errs = view.errors.unwrap();
assert_eq!(errs["name"], vec!["Too short".to_string()]);
}
#[test]
fn test_json_schema_for_table_props_generates() {
use crate::component::TableProps;
let schema = schemars::schema_for!(TableProps);
let value = serde_json::to_value(&schema).unwrap();
assert!(
value.is_object(),
"schema should serialize to a JSON object"
);
let schema_str = serde_json::to_string(&schema).unwrap();
assert!(
schema_str.contains("data_path"),
"schema should reference data_path field"
);
}
#[test]
fn test_json_schema_for_stat_card_props_generates() {
use crate::component::StatCardProps;
let schema = schemars::schema_for!(StatCardProps);
let value = serde_json::to_value(&schema).unwrap();
assert!(
value.is_object(),
"schema should serialize to a JSON object"
);
let schema_str = serde_json::to_string(&schema).unwrap();
assert!(
schema_str.contains("label"),
"schema should reference label field"
);
assert!(
schema_str.contains("value"),
"schema should reference value field"
);
}
#[test]
fn test_json_schema_for_action_generates() {
use crate::action::Action;
let schema = schemars::schema_for!(Action);
let value = serde_json::to_value(&schema).unwrap();
assert!(
value.is_object(),
"schema should serialize to a JSON object"
);
let schema_str = serde_json::to_string(&schema).unwrap();
assert!(
schema_str.contains("handler"),
"schema should reference handler field"
);
}
#[test]
fn test_json_schema_for_visibility_generates() {
use crate::visibility::Visibility;
let schema = schemars::schema_for!(Visibility);
let value = serde_json::to_value(&schema).unwrap();
assert!(
value.is_object(),
"schema should serialize to a JSON object"
);
}
}