Skip to main content

ferro_json_ui/
component.rs

1//! Component catalog for JSON-UI.
2//!
3//! Defines the available UI components with typed props. Each component
4//! uses serde's tagged enum representation so JSON includes `"type": "Card"`.
5
6use serde::de::{self, Deserializer};
7use serde::ser::{SerializeMap, Serializer};
8use serde::{Deserialize, Serialize};
9
10use crate::action::Action;
11use crate::visibility::Visibility;
12
13/// Shared size enum for components (Button, Badge, Avatar, Input).
14#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum Size {
17    Xs,
18    Sm,
19    #[default]
20    Default,
21    Lg,
22}
23
24/// Icon placement relative to button label.
25#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum IconPosition {
28    #[default]
29    Left,
30    Right,
31}
32
33/// Sort direction for table columns.
34#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum SortDirection {
37    #[default]
38    Asc,
39    Desc,
40}
41
42/// Separator orientation.
43#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum Orientation {
46    #[default]
47    Horizontal,
48    Vertical,
49}
50
51/// Button visual variants (aligned to shadcn/ui).
52#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum ButtonVariant {
55    #[default]
56    Default,
57    Secondary,
58    Destructive,
59    Outline,
60    Ghost,
61    Link,
62}
63
64/// Input field types.
65#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum InputType {
68    #[default]
69    Text,
70    Email,
71    Password,
72    Number,
73    Textarea,
74    Hidden,
75    Date,
76    Time,
77    Url,
78    Tel,
79    Search,
80}
81
82/// Alert visual variants.
83#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum AlertVariant {
86    #[default]
87    Info,
88    Success,
89    Warning,
90    Error,
91}
92
93/// Badge visual variants (aligned to shadcn/ui).
94#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum BadgeVariant {
97    #[default]
98    Default,
99    Secondary,
100    Destructive,
101    Outline,
102}
103
104/// Text element types for semantic HTML rendering.
105#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum TextElement {
108    #[default]
109    P,
110    H1,
111    H2,
112    H3,
113    Span,
114    Div,
115    Section,
116}
117
118/// Column display format for tables.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum ColumnFormat {
122    Date,
123    DateTime,
124    Currency,
125    Boolean,
126}
127
128/// Table column definition.
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct Column {
131    pub key: String,
132    pub label: String,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub format: Option<ColumnFormat>,
135}
136
137/// Select option (value + label pair).
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct SelectOption {
140    pub value: String,
141    pub label: String,
142}
143
144/// Props for Card component.
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146pub struct CardProps {
147    pub title: String,
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub description: Option<String>,
150    #[serde(default, skip_serializing_if = "Vec::is_empty")]
151    pub children: Vec<ComponentNode>,
152    #[serde(default, skip_serializing_if = "Vec::is_empty")]
153    pub footer: Vec<ComponentNode>,
154}
155
156/// Props for Table component.
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158pub struct TableProps {
159    pub columns: Vec<Column>,
160    pub data_path: String,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub row_actions: Option<Vec<Action>>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub empty_message: Option<String>,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub sortable: Option<bool>,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub sort_column: Option<String>,
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub sort_direction: Option<SortDirection>,
171}
172
173/// Props for Form component.
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct FormProps {
176    pub action: Action,
177    pub fields: Vec<ComponentNode>,
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub method: Option<crate::action::HttpMethod>,
180}
181
182/// Props for Button component.
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184pub struct ButtonProps {
185    pub label: String,
186    #[serde(default)]
187    pub variant: ButtonVariant,
188    #[serde(default)]
189    pub size: Size,
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub disabled: Option<bool>,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub icon: Option<String>,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub icon_position: Option<IconPosition>,
196}
197
198/// Props for Input component.
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
200pub struct InputProps {
201    /// Form field name for data binding.
202    pub field: String,
203    pub label: String,
204    #[serde(default)]
205    pub input_type: InputType,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub placeholder: Option<String>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub required: Option<bool>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub disabled: Option<bool>,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub error: Option<String>,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub description: Option<String>,
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub default_value: Option<String>,
218    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub data_path: Option<String>,
221    /// HTML step attribute for number inputs (e.g., "any", "0.01").
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub step: Option<String>,
224}
225
226/// Props for Select component.
227#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
228pub struct SelectProps {
229    /// Form field name for data binding.
230    pub field: String,
231    pub label: String,
232    pub options: Vec<SelectOption>,
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub placeholder: Option<String>,
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub required: Option<bool>,
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub disabled: Option<bool>,
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub error: Option<String>,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub description: Option<String>,
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub default_value: Option<String>,
245    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub data_path: Option<String>,
248}
249
250/// Props for Alert component.
251#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
252pub struct AlertProps {
253    pub message: String,
254    #[serde(default)]
255    pub variant: AlertVariant,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub title: Option<String>,
258}
259
260/// Props for Badge component.
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub struct BadgeProps {
263    pub label: String,
264    #[serde(default)]
265    pub variant: BadgeVariant,
266}
267
268/// Props for Modal component.
269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
270pub struct ModalProps {
271    pub title: String,
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub description: Option<String>,
274    #[serde(default, skip_serializing_if = "Vec::is_empty")]
275    pub children: Vec<ComponentNode>,
276    #[serde(default, skip_serializing_if = "Vec::is_empty")]
277    pub footer: Vec<ComponentNode>,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub trigger_label: Option<String>,
280}
281
282/// Props for Text component.
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284pub struct TextProps {
285    pub content: String,
286    #[serde(default)]
287    pub element: TextElement,
288}
289
290/// Props for Checkbox component.
291#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
292pub struct CheckboxProps {
293    /// Form field name for data binding.
294    pub field: String,
295    pub label: String,
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub description: Option<String>,
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub checked: Option<bool>,
300    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub data_path: Option<String>,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub required: Option<bool>,
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub disabled: Option<bool>,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub error: Option<String>,
309}
310
311/// Props for Switch component.
312#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
313pub struct SwitchProps {
314    /// Form field name for data binding.
315    pub field: String,
316    pub label: String,
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub description: Option<String>,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub checked: Option<bool>,
321    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub data_path: Option<String>,
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub required: Option<bool>,
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub disabled: Option<bool>,
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub error: Option<String>,
330}
331
332/// Props for Separator component.
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334pub struct SeparatorProps {
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub orientation: Option<Orientation>,
337}
338
339/// A single item in a description list.
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341pub struct DescriptionItem {
342    pub label: String,
343    pub value: String,
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub format: Option<ColumnFormat>,
346}
347
348/// Props for DescriptionList component.
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
350pub struct DescriptionListProps {
351    pub items: Vec<DescriptionItem>,
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub columns: Option<u8>,
354}
355
356/// A single tab within a Tabs component.
357#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
358pub struct Tab {
359    pub value: String,
360    pub label: String,
361    #[serde(default, skip_serializing_if = "Vec::is_empty")]
362    pub children: Vec<ComponentNode>,
363}
364
365/// Props for Tabs component.
366#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
367pub struct TabsProps {
368    pub default_tab: String,
369    pub tabs: Vec<Tab>,
370}
371
372/// A single item in a breadcrumb trail.
373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
374pub struct BreadcrumbItem {
375    pub label: String,
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub url: Option<String>,
378}
379
380/// Props for Breadcrumb component.
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382pub struct BreadcrumbProps {
383    pub items: Vec<BreadcrumbItem>,
384}
385
386/// Props for Pagination component.
387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
388pub struct PaginationProps {
389    pub current_page: u32,
390    pub per_page: u32,
391    pub total: u32,
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub base_url: Option<String>,
394}
395
396/// Props for Progress component.
397#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
398pub struct ProgressProps {
399    /// Percentage value (0-100).
400    pub value: u8,
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub max: Option<u8>,
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub label: Option<String>,
405}
406
407/// Props for Avatar component.
408#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
409pub struct AvatarProps {
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub src: Option<String>,
412    pub alt: String,
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub fallback: Option<String>,
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub size: Option<Size>,
417}
418
419/// Props for Skeleton loading placeholder.
420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421pub struct SkeletonProps {
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub width: Option<String>,
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub height: Option<String>,
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub rounded: Option<bool>,
428}
429
430/// Props for a plugin component.
431///
432/// The `plugin_type` field holds the original `"type"` value from JSON,
433/// and `props` holds the remaining fields. Used for custom interactive
434/// components registered via the plugin system.
435#[derive(Debug, Clone, PartialEq)]
436pub struct PluginProps {
437    /// The plugin component type name (e.g., "Map").
438    pub plugin_type: String,
439    /// Raw props passed to the plugin's render function.
440    pub props: serde_json::Value,
441}
442
443impl Serialize for PluginProps {
444    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
445        // Flatten plugin_type back as "type" and merge in the props object.
446        let obj = self.props.as_object();
447        let extra_len = obj.map_or(0, |m| m.len());
448        let mut map = serializer.serialize_map(Some(1 + extra_len))?;
449        map.serialize_entry("type", &self.plugin_type)?;
450        if let Some(obj) = obj {
451            for (k, v) in obj {
452                if k != "type" {
453                    map.serialize_entry(k, v)?;
454                }
455            }
456        }
457        map.end()
458    }
459}
460
461impl<'de> Deserialize<'de> for PluginProps {
462    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
463        let mut value = serde_json::Value::deserialize(deserializer)?;
464        let plugin_type = value
465            .get("type")
466            .and_then(|v| v.as_str())
467            .map(|s| s.to_string())
468            .ok_or_else(|| de::Error::missing_field("type"))?;
469        // Remove "type" from the props to avoid redundancy.
470        if let Some(obj) = value.as_object_mut() {
471            obj.remove("type");
472        }
473        Ok(PluginProps {
474            plugin_type,
475            props: value,
476        })
477    }
478}
479
480/// Component catalog enum. Built-in types are deserialized by name,
481/// unknown types fall through to the `Plugin` variant.
482///
483/// Serializes built-in variants with `"type": "Card"` etc. The Plugin
484/// variant serializes with the plugin's own type name.
485#[derive(Debug, Clone, PartialEq)]
486pub enum Component {
487    Card(CardProps),
488    Table(TableProps),
489    Form(FormProps),
490    Button(ButtonProps),
491    Input(InputProps),
492    Select(SelectProps),
493    Alert(AlertProps),
494    Badge(BadgeProps),
495    Modal(ModalProps),
496    Text(TextProps),
497    Checkbox(CheckboxProps),
498    Switch(SwitchProps),
499    Separator(SeparatorProps),
500    DescriptionList(DescriptionListProps),
501    Tabs(TabsProps),
502    Breadcrumb(BreadcrumbProps),
503    Pagination(PaginationProps),
504    Progress(ProgressProps),
505    Avatar(AvatarProps),
506    Skeleton(SkeletonProps),
507    Plugin(PluginProps),
508}
509
510// ── Custom Serialize for Component ───────────────────────────────────────
511
512/// Helper: serialize a built-in variant by serializing its props, then
513/// injecting `"type": "<name>"` into the resulting JSON object.
514fn serialize_tagged<S: Serializer, T: Serialize>(
515    serializer: S,
516    type_name: &str,
517    props: &T,
518) -> Result<S::Ok, S::Error> {
519    let mut value = serde_json::to_value(props).map_err(serde::ser::Error::custom)?;
520    if let Some(obj) = value.as_object_mut() {
521        obj.insert(
522            "type".to_string(),
523            serde_json::Value::String(type_name.to_string()),
524        );
525    }
526    value.serialize(serializer)
527}
528
529impl Serialize for Component {
530    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
531        match self {
532            Component::Card(p) => serialize_tagged(serializer, "Card", p),
533            Component::Table(p) => serialize_tagged(serializer, "Table", p),
534            Component::Form(p) => serialize_tagged(serializer, "Form", p),
535            Component::Button(p) => serialize_tagged(serializer, "Button", p),
536            Component::Input(p) => serialize_tagged(serializer, "Input", p),
537            Component::Select(p) => serialize_tagged(serializer, "Select", p),
538            Component::Alert(p) => serialize_tagged(serializer, "Alert", p),
539            Component::Badge(p) => serialize_tagged(serializer, "Badge", p),
540            Component::Modal(p) => serialize_tagged(serializer, "Modal", p),
541            Component::Text(p) => serialize_tagged(serializer, "Text", p),
542            Component::Checkbox(p) => serialize_tagged(serializer, "Checkbox", p),
543            Component::Switch(p) => serialize_tagged(serializer, "Switch", p),
544            Component::Separator(p) => serialize_tagged(serializer, "Separator", p),
545            Component::DescriptionList(p) => serialize_tagged(serializer, "DescriptionList", p),
546            Component::Tabs(p) => serialize_tagged(serializer, "Tabs", p),
547            Component::Breadcrumb(p) => serialize_tagged(serializer, "Breadcrumb", p),
548            Component::Pagination(p) => serialize_tagged(serializer, "Pagination", p),
549            Component::Progress(p) => serialize_tagged(serializer, "Progress", p),
550            Component::Avatar(p) => serialize_tagged(serializer, "Avatar", p),
551            Component::Skeleton(p) => serialize_tagged(serializer, "Skeleton", p),
552            Component::Plugin(p) => p.serialize(serializer),
553        }
554    }
555}
556
557// ── Custom Deserialize for Component ─────────────────────────────────────
558
559impl<'de> Deserialize<'de> for Component {
560    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
561        let value = serde_json::Value::deserialize(deserializer)?;
562        let type_str = value
563            .get("type")
564            .and_then(|v| v.as_str())
565            .ok_or_else(|| de::Error::missing_field("type"))?;
566
567        match type_str {
568            "Card" => serde_json::from_value::<CardProps>(value)
569                .map(Component::Card)
570                .map_err(de::Error::custom),
571            "Table" => serde_json::from_value::<TableProps>(value)
572                .map(Component::Table)
573                .map_err(de::Error::custom),
574            "Form" => serde_json::from_value::<FormProps>(value)
575                .map(Component::Form)
576                .map_err(de::Error::custom),
577            "Button" => serde_json::from_value::<ButtonProps>(value)
578                .map(Component::Button)
579                .map_err(de::Error::custom),
580            "Input" => serde_json::from_value::<InputProps>(value)
581                .map(Component::Input)
582                .map_err(de::Error::custom),
583            "Select" => serde_json::from_value::<SelectProps>(value)
584                .map(Component::Select)
585                .map_err(de::Error::custom),
586            "Alert" => serde_json::from_value::<AlertProps>(value)
587                .map(Component::Alert)
588                .map_err(de::Error::custom),
589            "Badge" => serde_json::from_value::<BadgeProps>(value)
590                .map(Component::Badge)
591                .map_err(de::Error::custom),
592            "Modal" => serde_json::from_value::<ModalProps>(value)
593                .map(Component::Modal)
594                .map_err(de::Error::custom),
595            "Text" => serde_json::from_value::<TextProps>(value)
596                .map(Component::Text)
597                .map_err(de::Error::custom),
598            "Checkbox" => serde_json::from_value::<CheckboxProps>(value)
599                .map(Component::Checkbox)
600                .map_err(de::Error::custom),
601            "Switch" => serde_json::from_value::<SwitchProps>(value)
602                .map(Component::Switch)
603                .map_err(de::Error::custom),
604            "Separator" => serde_json::from_value::<SeparatorProps>(value)
605                .map(Component::Separator)
606                .map_err(de::Error::custom),
607            "DescriptionList" => serde_json::from_value::<DescriptionListProps>(value)
608                .map(Component::DescriptionList)
609                .map_err(de::Error::custom),
610            "Tabs" => serde_json::from_value::<TabsProps>(value)
611                .map(Component::Tabs)
612                .map_err(de::Error::custom),
613            "Breadcrumb" => serde_json::from_value::<BreadcrumbProps>(value)
614                .map(Component::Breadcrumb)
615                .map_err(de::Error::custom),
616            "Pagination" => serde_json::from_value::<PaginationProps>(value)
617                .map(Component::Pagination)
618                .map_err(de::Error::custom),
619            "Progress" => serde_json::from_value::<ProgressProps>(value)
620                .map(Component::Progress)
621                .map_err(de::Error::custom),
622            "Avatar" => serde_json::from_value::<AvatarProps>(value)
623                .map(Component::Avatar)
624                .map_err(de::Error::custom),
625            "Skeleton" => serde_json::from_value::<SkeletonProps>(value)
626                .map(Component::Skeleton)
627                .map_err(de::Error::custom),
628            _ => {
629                // Unknown type: treat as a plugin component.
630                let plugin_type = type_str.to_string();
631                let mut props = value;
632                if let Some(obj) = props.as_object_mut() {
633                    obj.remove("type");
634                }
635                Ok(Component::Plugin(PluginProps { plugin_type, props }))
636            }
637        }
638    }
639}
640
641/// A component node wrapping a component with shared fields.
642///
643/// Every component in a view tree is wrapped in a `ComponentNode` that
644/// provides a unique key, optional action binding, and optional visibility
645/// rules. The component itself is flattened into the node's JSON.
646#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
647pub struct ComponentNode {
648    pub key: String,
649    #[serde(flatten)]
650    pub component: Component,
651    #[serde(default, skip_serializing_if = "Option::is_none")]
652    pub action: Option<Action>,
653    #[serde(default, skip_serializing_if = "Option::is_none")]
654    pub visibility: Option<Visibility>,
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660    use crate::action::HttpMethod;
661    use crate::visibility::{VisibilityCondition, VisibilityOperator};
662
663    #[test]
664    fn card_component_tagged_serialization() {
665        let card = Component::Card(CardProps {
666            title: "Test Card".to_string(),
667            description: Some("A description".to_string()),
668            children: vec![],
669            footer: vec![],
670        });
671        let json = serde_json::to_value(&card).unwrap();
672        assert_eq!(json["type"], "Card");
673        assert_eq!(json["title"], "Test Card");
674        assert_eq!(json["description"], "A description");
675    }
676
677    #[test]
678    fn button_variant_defaults_to_default() {
679        let json = r#"{"type": "Button", "label": "Click me"}"#;
680        let component: Component = serde_json::from_str(json).unwrap();
681        match component {
682            Component::Button(props) => {
683                assert_eq!(props.variant, ButtonVariant::Default);
684                assert_eq!(props.label, "Click me");
685            }
686            _ => panic!("expected Button"),
687        }
688    }
689
690    #[test]
691    fn input_type_defaults_to_text() {
692        let json = r#"{"type": "Input", "field": "email", "label": "Email"}"#;
693        let component: Component = serde_json::from_str(json).unwrap();
694        match component {
695            Component::Input(props) => {
696                assert_eq!(props.input_type, InputType::Text);
697                assert_eq!(props.field, "email");
698            }
699            _ => panic!("expected Input"),
700        }
701    }
702
703    #[test]
704    fn alert_variant_defaults_to_info() {
705        let json = r#"{"type": "Alert", "message": "Hello"}"#;
706        let component: Component = serde_json::from_str(json).unwrap();
707        match component {
708            Component::Alert(props) => assert_eq!(props.variant, AlertVariant::Info),
709            _ => panic!("expected Alert"),
710        }
711    }
712
713    #[test]
714    fn badge_variant_defaults_to_default() {
715        let json = r#"{"type": "Badge", "label": "New"}"#;
716        let component: Component = serde_json::from_str(json).unwrap();
717        match component {
718            Component::Badge(props) => assert_eq!(props.variant, BadgeVariant::Default),
719            _ => panic!("expected Badge"),
720        }
721    }
722
723    #[test]
724    fn text_element_defaults_to_p() {
725        let json = r#"{"type": "Text", "content": "Hello world"}"#;
726        let component: Component = serde_json::from_str(json).unwrap();
727        match component {
728            Component::Text(props) => {
729                assert_eq!(props.element, TextElement::P);
730                assert_eq!(props.content, "Hello world");
731            }
732            _ => panic!("expected Text"),
733        }
734    }
735
736    #[test]
737    fn table_component_round_trips() {
738        let table = Component::Table(TableProps {
739            columns: vec![
740                Column {
741                    key: "name".to_string(),
742                    label: "Name".to_string(),
743                    format: None,
744                },
745                Column {
746                    key: "created_at".to_string(),
747                    label: "Created".to_string(),
748                    format: Some(ColumnFormat::Date),
749                },
750            ],
751            data_path: "/data/users".to_string(),
752            row_actions: None,
753            empty_message: Some("No users found".to_string()),
754            sortable: None,
755            sort_column: None,
756            sort_direction: None,
757        });
758        let json = serde_json::to_string(&table).unwrap();
759        let parsed: Component = serde_json::from_str(&json).unwrap();
760        assert_eq!(parsed, table);
761    }
762
763    #[test]
764    fn select_component_round_trips() {
765        let select = Component::Select(SelectProps {
766            field: "role".to_string(),
767            label: "Role".to_string(),
768            options: vec![
769                SelectOption {
770                    value: "admin".to_string(),
771                    label: "Administrator".to_string(),
772                },
773                SelectOption {
774                    value: "user".to_string(),
775                    label: "User".to_string(),
776                },
777            ],
778            placeholder: Some("Select a role".to_string()),
779            required: Some(true),
780            disabled: None,
781            error: None,
782            description: None,
783            default_value: None,
784            data_path: None,
785        });
786        let json = serde_json::to_string(&select).unwrap();
787        let parsed: Component = serde_json::from_str(&json).unwrap();
788        assert_eq!(parsed, select);
789    }
790
791    #[test]
792    fn modal_component_round_trips() {
793        let modal = Component::Modal(ModalProps {
794            title: "Confirm".to_string(),
795            description: None,
796            children: vec![ComponentNode {
797                key: "msg".to_string(),
798                component: Component::Text(TextProps {
799                    content: "Are you sure?".to_string(),
800                    element: TextElement::P,
801                }),
802                action: None,
803                visibility: None,
804            }],
805            footer: vec![],
806            trigger_label: Some("Open".to_string()),
807        });
808        let json = serde_json::to_string(&modal).unwrap();
809        let parsed: Component = serde_json::from_str(&json).unwrap();
810        assert_eq!(parsed, modal);
811    }
812
813    #[test]
814    fn form_component_round_trips() {
815        let form = Component::Form(FormProps {
816            action: Action {
817                handler: "users.store".to_string(),
818                url: None,
819                method: HttpMethod::Post,
820                confirm: None,
821                on_success: None,
822                on_error: None,
823            },
824            fields: vec![ComponentNode {
825                key: "email-input".to_string(),
826                component: Component::Input(InputProps {
827                    field: "email".to_string(),
828                    label: "Email".to_string(),
829                    input_type: InputType::Email,
830                    placeholder: Some("user@example.com".to_string()),
831                    required: Some(true),
832                    disabled: None,
833                    error: None,
834                    description: None,
835                    default_value: None,
836                    data_path: None,
837                    step: None,
838                }),
839                action: None,
840                visibility: None,
841            }],
842            method: None,
843        });
844        let json = serde_json::to_string(&form).unwrap();
845        let parsed: Component = serde_json::from_str(&json).unwrap();
846        assert_eq!(parsed, form);
847    }
848
849    #[test]
850    fn component_node_with_action_and_visibility() {
851        let node = ComponentNode {
852            key: "create-btn".to_string(),
853            component: Component::Button(ButtonProps {
854                label: "Create User".to_string(),
855                variant: ButtonVariant::Default,
856                size: Size::Default,
857                disabled: None,
858                icon: None,
859                icon_position: None,
860            }),
861            action: Some(Action {
862                handler: "users.create".to_string(),
863                url: None,
864                method: HttpMethod::Post,
865                confirm: None,
866                on_success: None,
867                on_error: None,
868            }),
869            visibility: Some(Visibility::Condition(VisibilityCondition {
870                path: "/auth/user/role".to_string(),
871                operator: VisibilityOperator::Eq,
872                value: Some(serde_json::Value::String("admin".to_string())),
873            })),
874        };
875        let json = serde_json::to_string(&node).unwrap();
876        let parsed: ComponentNode = serde_json::from_str(&json).unwrap();
877        assert_eq!(parsed, node);
878
879        // Verify flattened structure includes type
880        let value = serde_json::to_value(&node).unwrap();
881        assert_eq!(value["type"], "Button");
882        assert_eq!(value["key"], "create-btn");
883        assert!(value.get("action").is_some());
884        assert!(value.get("visibility").is_some());
885    }
886
887    #[test]
888    fn all_component_variants_serialize() {
889        let components: Vec<Component> = vec![
890            Component::Card(CardProps {
891                title: "t".to_string(),
892                description: None,
893                children: vec![],
894                footer: vec![],
895            }),
896            Component::Table(TableProps {
897                columns: vec![],
898                data_path: "/d".to_string(),
899                row_actions: None,
900                empty_message: None,
901                sortable: None,
902                sort_column: None,
903                sort_direction: None,
904            }),
905            Component::Form(FormProps {
906                action: Action {
907                    handler: "h.m".to_string(),
908                    url: None,
909                    method: HttpMethod::Post,
910                    confirm: None,
911                    on_success: None,
912                    on_error: None,
913                },
914                fields: vec![],
915                method: None,
916            }),
917            Component::Button(ButtonProps {
918                label: "b".to_string(),
919                variant: ButtonVariant::Default,
920                size: Size::Default,
921                disabled: None,
922                icon: None,
923                icon_position: None,
924            }),
925            Component::Input(InputProps {
926                field: "f".to_string(),
927                label: "l".to_string(),
928                input_type: InputType::Text,
929                placeholder: None,
930                required: None,
931                disabled: None,
932                error: None,
933                description: None,
934                default_value: None,
935                data_path: None,
936                step: None,
937            }),
938            Component::Select(SelectProps {
939                field: "f".to_string(),
940                label: "l".to_string(),
941                options: vec![],
942                placeholder: None,
943                required: None,
944                disabled: None,
945                error: None,
946                description: None,
947                default_value: None,
948                data_path: None,
949            }),
950            Component::Alert(AlertProps {
951                message: "m".to_string(),
952                variant: AlertVariant::Info,
953                title: None,
954            }),
955            Component::Badge(BadgeProps {
956                label: "b".to_string(),
957                variant: BadgeVariant::Default,
958            }),
959            Component::Modal(ModalProps {
960                title: "t".to_string(),
961                description: None,
962                children: vec![],
963                footer: vec![],
964                trigger_label: None,
965            }),
966            Component::Text(TextProps {
967                content: "c".to_string(),
968                element: TextElement::P,
969            }),
970            Component::Checkbox(CheckboxProps {
971                field: "f".to_string(),
972                label: "l".to_string(),
973                description: None,
974                checked: None,
975                data_path: None,
976                required: None,
977                disabled: None,
978                error: None,
979            }),
980            Component::Switch(SwitchProps {
981                field: "f".to_string(),
982                label: "l".to_string(),
983                description: None,
984                checked: None,
985                data_path: None,
986                required: None,
987                disabled: None,
988                error: None,
989            }),
990            Component::Separator(SeparatorProps { orientation: None }),
991            Component::DescriptionList(DescriptionListProps {
992                items: vec![DescriptionItem {
993                    label: "k".to_string(),
994                    value: "v".to_string(),
995                    format: None,
996                }],
997                columns: None,
998            }),
999            Component::Tabs(TabsProps {
1000                default_tab: "t1".to_string(),
1001                tabs: vec![Tab {
1002                    value: "t1".to_string(),
1003                    label: "Tab 1".to_string(),
1004                    children: vec![],
1005                }],
1006            }),
1007            Component::Breadcrumb(BreadcrumbProps {
1008                items: vec![BreadcrumbItem {
1009                    label: "Home".to_string(),
1010                    url: Some("/".to_string()),
1011                }],
1012            }),
1013            Component::Pagination(PaginationProps {
1014                current_page: 1,
1015                per_page: 10,
1016                total: 100,
1017                base_url: None,
1018            }),
1019            Component::Progress(ProgressProps {
1020                value: 50,
1021                max: None,
1022                label: None,
1023            }),
1024            Component::Avatar(AvatarProps {
1025                src: None,
1026                alt: "User".to_string(),
1027                fallback: Some("U".to_string()),
1028                size: None,
1029            }),
1030            Component::Skeleton(SkeletonProps {
1031                width: None,
1032                height: None,
1033                rounded: None,
1034            }),
1035        ];
1036        assert_eq!(components.len(), 20, "should have 20 component variants");
1037        let expected_types = [
1038            "Card",
1039            "Table",
1040            "Form",
1041            "Button",
1042            "Input",
1043            "Select",
1044            "Alert",
1045            "Badge",
1046            "Modal",
1047            "Text",
1048            "Checkbox",
1049            "Switch",
1050            "Separator",
1051            "DescriptionList",
1052            "Tabs",
1053            "Breadcrumb",
1054            "Pagination",
1055            "Progress",
1056            "Avatar",
1057            "Skeleton",
1058        ];
1059        for (component, expected_type) in components.iter().zip(expected_types.iter()) {
1060            let json = serde_json::to_value(component).unwrap();
1061            assert_eq!(
1062                json["type"], *expected_type,
1063                "component should serialize with type={expected_type}"
1064            );
1065            let roundtripped: Component = serde_json::from_value(json).unwrap();
1066            assert_eq!(&roundtripped, component);
1067        }
1068    }
1069
1070    #[test]
1071    fn size_enum_serialization() {
1072        let cases = [
1073            (Size::Xs, "xs"),
1074            (Size::Sm, "sm"),
1075            (Size::Default, "default"),
1076            (Size::Lg, "lg"),
1077        ];
1078        for (size, expected) in &cases {
1079            let json = serde_json::to_value(size).unwrap();
1080            assert_eq!(json, *expected);
1081            let parsed: Size = serde_json::from_value(json).unwrap();
1082            assert_eq!(&parsed, size);
1083        }
1084    }
1085
1086    #[test]
1087    fn icon_position_serialization() {
1088        let cases = [(IconPosition::Left, "left"), (IconPosition::Right, "right")];
1089        for (pos, expected) in &cases {
1090            let json = serde_json::to_value(pos).unwrap();
1091            assert_eq!(json, *expected);
1092            let parsed: IconPosition = serde_json::from_value(json).unwrap();
1093            assert_eq!(&parsed, pos);
1094        }
1095    }
1096
1097    #[test]
1098    fn sort_direction_serialization() {
1099        let cases = [(SortDirection::Asc, "asc"), (SortDirection::Desc, "desc")];
1100        for (dir, expected) in &cases {
1101            let json = serde_json::to_value(dir).unwrap();
1102            assert_eq!(json, *expected);
1103            let parsed: SortDirection = serde_json::from_value(json).unwrap();
1104            assert_eq!(&parsed, dir);
1105        }
1106    }
1107
1108    #[test]
1109    fn button_with_size_and_icon() {
1110        let button = Component::Button(ButtonProps {
1111            label: "Save".to_string(),
1112            variant: ButtonVariant::Default,
1113            size: Size::Lg,
1114            disabled: None,
1115            icon: Some("save".to_string()),
1116            icon_position: Some(IconPosition::Left),
1117        });
1118        let json = serde_json::to_value(&button).unwrap();
1119        assert_eq!(json["size"], "lg");
1120        assert_eq!(json["icon"], "save");
1121        assert_eq!(json["icon_position"], "left");
1122        let parsed: Component = serde_json::from_value(json).unwrap();
1123        assert_eq!(parsed, button);
1124    }
1125
1126    #[test]
1127    fn card_with_footer() {
1128        let card = Component::Card(CardProps {
1129            title: "Actions".to_string(),
1130            description: None,
1131            children: vec![],
1132            footer: vec![ComponentNode {
1133                key: "cancel".to_string(),
1134                component: Component::Button(ButtonProps {
1135                    label: "Cancel".to_string(),
1136                    variant: ButtonVariant::Outline,
1137                    size: Size::Default,
1138                    disabled: None,
1139                    icon: None,
1140                    icon_position: None,
1141                }),
1142                action: None,
1143                visibility: None,
1144            }],
1145        });
1146        let json = serde_json::to_value(&card).unwrap();
1147        assert!(json["footer"].is_array());
1148        assert_eq!(json["footer"][0]["label"], "Cancel");
1149        let parsed: Component = serde_json::from_value(json).unwrap();
1150        assert_eq!(parsed, card);
1151    }
1152
1153    #[test]
1154    fn input_with_error_and_description() {
1155        let input = Component::Input(InputProps {
1156            field: "email".to_string(),
1157            label: "Email".to_string(),
1158            input_type: InputType::Email,
1159            placeholder: None,
1160            required: Some(true),
1161            disabled: Some(false),
1162            error: Some("Invalid email".to_string()),
1163            description: Some("Your work email".to_string()),
1164            default_value: Some("user@example.com".to_string()),
1165            data_path: None,
1166            step: None,
1167        });
1168        let json = serde_json::to_value(&input).unwrap();
1169        assert_eq!(json["error"], "Invalid email");
1170        assert_eq!(json["description"], "Your work email");
1171        assert_eq!(json["default_value"], "user@example.com");
1172        assert_eq!(json["disabled"], false);
1173        let parsed: Component = serde_json::from_value(json).unwrap();
1174        assert_eq!(parsed, input);
1175    }
1176
1177    #[test]
1178    fn select_with_default_value() {
1179        let select = Component::Select(SelectProps {
1180            field: "role".to_string(),
1181            label: "Role".to_string(),
1182            options: vec![SelectOption {
1183                value: "admin".to_string(),
1184                label: "Admin".to_string(),
1185            }],
1186            placeholder: None,
1187            required: None,
1188            disabled: Some(true),
1189            error: Some("Required field".to_string()),
1190            description: Some("User role".to_string()),
1191            default_value: Some("admin".to_string()),
1192            data_path: None,
1193        });
1194        let json = serde_json::to_value(&select).unwrap();
1195        assert_eq!(json["default_value"], "admin");
1196        assert_eq!(json["error"], "Required field");
1197        assert_eq!(json["description"], "User role");
1198        assert_eq!(json["disabled"], true);
1199        let parsed: Component = serde_json::from_value(json).unwrap();
1200        assert_eq!(parsed, select);
1201    }
1202
1203    #[test]
1204    fn alert_with_title() {
1205        let alert = Component::Alert(AlertProps {
1206            message: "Something happened".to_string(),
1207            variant: AlertVariant::Warning,
1208            title: Some("Warning".to_string()),
1209        });
1210        let json = serde_json::to_value(&alert).unwrap();
1211        assert_eq!(json["title"], "Warning");
1212        assert_eq!(json["message"], "Something happened");
1213        let parsed: Component = serde_json::from_value(json).unwrap();
1214        assert_eq!(parsed, alert);
1215    }
1216
1217    #[test]
1218    fn modal_with_footer_and_description() {
1219        let modal = Component::Modal(ModalProps {
1220            title: "Delete Item".to_string(),
1221            description: Some("This action cannot be undone.".to_string()),
1222            children: vec![],
1223            footer: vec![ComponentNode {
1224                key: "confirm".to_string(),
1225                component: Component::Button(ButtonProps {
1226                    label: "Delete".to_string(),
1227                    variant: ButtonVariant::Destructive,
1228                    size: Size::Default,
1229                    disabled: None,
1230                    icon: None,
1231                    icon_position: None,
1232                }),
1233                action: None,
1234                visibility: None,
1235            }],
1236            trigger_label: Some("Delete".to_string()),
1237        });
1238        let json = serde_json::to_value(&modal).unwrap();
1239        assert_eq!(json["description"], "This action cannot be undone.");
1240        assert!(json["footer"].is_array());
1241        assert_eq!(json["footer"][0]["label"], "Delete");
1242        let parsed: Component = serde_json::from_value(json).unwrap();
1243        assert_eq!(parsed, modal);
1244    }
1245
1246    #[test]
1247    fn table_with_sort_props() {
1248        let table = Component::Table(TableProps {
1249            columns: vec![Column {
1250                key: "name".to_string(),
1251                label: "Name".to_string(),
1252                format: None,
1253            }],
1254            data_path: "/data/users".to_string(),
1255            row_actions: None,
1256            empty_message: None,
1257            sortable: Some(true),
1258            sort_column: Some("name".to_string()),
1259            sort_direction: Some(SortDirection::Desc),
1260        });
1261        let json = serde_json::to_value(&table).unwrap();
1262        assert_eq!(json["sortable"], true);
1263        assert_eq!(json["sort_column"], "name");
1264        assert_eq!(json["sort_direction"], "desc");
1265        let parsed: Component = serde_json::from_value(json).unwrap();
1266        assert_eq!(parsed, table);
1267    }
1268
1269    #[test]
1270    fn aligned_button_variants_serialize() {
1271        let cases = [
1272            (ButtonVariant::Default, "default"),
1273            (ButtonVariant::Secondary, "secondary"),
1274            (ButtonVariant::Destructive, "destructive"),
1275            (ButtonVariant::Outline, "outline"),
1276            (ButtonVariant::Ghost, "ghost"),
1277            (ButtonVariant::Link, "link"),
1278        ];
1279        for (variant, expected) in &cases {
1280            let json = serde_json::to_value(variant).unwrap();
1281            assert_eq!(
1282                json, *expected,
1283                "ButtonVariant::{variant:?} should serialize as {expected}"
1284            );
1285            let parsed: ButtonVariant = serde_json::from_value(json).unwrap();
1286            assert_eq!(&parsed, variant);
1287        }
1288    }
1289
1290    #[test]
1291    fn aligned_badge_variants_serialize() {
1292        let cases = [
1293            (BadgeVariant::Default, "default"),
1294            (BadgeVariant::Secondary, "secondary"),
1295            (BadgeVariant::Destructive, "destructive"),
1296            (BadgeVariant::Outline, "outline"),
1297        ];
1298        for (variant, expected) in &cases {
1299            let json = serde_json::to_value(variant).unwrap();
1300            assert_eq!(
1301                json, *expected,
1302                "BadgeVariant::{variant:?} should serialize as {expected}"
1303            );
1304            let parsed: BadgeVariant = serde_json::from_value(json).unwrap();
1305            assert_eq!(&parsed, variant);
1306        }
1307    }
1308
1309    #[test]
1310    fn checkbox_round_trips() {
1311        let checkbox = Component::Checkbox(CheckboxProps {
1312            field: "terms".to_string(),
1313            label: "Accept Terms".to_string(),
1314            description: Some("You must accept the terms".to_string()),
1315            checked: Some(true),
1316            data_path: None,
1317            required: Some(true),
1318            disabled: Some(false),
1319            error: None,
1320        });
1321        let json = serde_json::to_value(&checkbox).unwrap();
1322        assert_eq!(json["type"], "Checkbox");
1323        assert_eq!(json["field"], "terms");
1324        assert_eq!(json["checked"], true);
1325        assert_eq!(json["description"], "You must accept the terms");
1326        let parsed: Component = serde_json::from_value(json).unwrap();
1327        assert_eq!(parsed, checkbox);
1328    }
1329
1330    #[test]
1331    fn switch_round_trips() {
1332        let switch = Component::Switch(SwitchProps {
1333            field: "notifications".to_string(),
1334            label: "Enable Notifications".to_string(),
1335            description: Some("Receive email notifications".to_string()),
1336            checked: Some(false),
1337            data_path: None,
1338            required: None,
1339            disabled: Some(false),
1340            error: None,
1341        });
1342        let json = serde_json::to_value(&switch).unwrap();
1343        assert_eq!(json["type"], "Switch");
1344        assert_eq!(json["field"], "notifications");
1345        assert_eq!(json["checked"], false);
1346        let parsed: Component = serde_json::from_value(json).unwrap();
1347        assert_eq!(parsed, switch);
1348    }
1349
1350    #[test]
1351    fn separator_defaults_to_horizontal() {
1352        let json = r#"{"type": "Separator"}"#;
1353        let component: Component = serde_json::from_str(json).unwrap();
1354        match component {
1355            Component::Separator(props) => {
1356                assert_eq!(props.orientation, None);
1357                // When orientation is None, frontend defaults to horizontal.
1358                // Explicit horizontal also round-trips correctly:
1359                let explicit = Component::Separator(SeparatorProps {
1360                    orientation: Some(Orientation::Horizontal),
1361                });
1362                let v = serde_json::to_value(&explicit).unwrap();
1363                assert_eq!(v["orientation"], "horizontal");
1364                let parsed: Component = serde_json::from_value(v).unwrap();
1365                assert_eq!(parsed, explicit);
1366            }
1367            _ => panic!("expected Separator"),
1368        }
1369    }
1370
1371    #[test]
1372    fn description_list_with_format() {
1373        let dl = Component::DescriptionList(DescriptionListProps {
1374            items: vec![
1375                DescriptionItem {
1376                    label: "Created".to_string(),
1377                    value: "2026-01-15".to_string(),
1378                    format: Some(ColumnFormat::Date),
1379                },
1380                DescriptionItem {
1381                    label: "Name".to_string(),
1382                    value: "Alice".to_string(),
1383                    format: None,
1384                },
1385            ],
1386            columns: Some(2),
1387        });
1388        let json = serde_json::to_value(&dl).unwrap();
1389        assert_eq!(json["type"], "DescriptionList");
1390        assert_eq!(json["columns"], 2);
1391        assert_eq!(json["items"][0]["format"], "date");
1392        assert!(json["items"][1].get("format").is_none());
1393        let parsed: Component = serde_json::from_value(json).unwrap();
1394        assert_eq!(parsed, dl);
1395    }
1396
1397    #[test]
1398    fn checkbox_with_error() {
1399        let checkbox = Component::Checkbox(CheckboxProps {
1400            field: "agree".to_string(),
1401            label: "I agree".to_string(),
1402            description: None,
1403            checked: None,
1404            data_path: None,
1405            required: Some(true),
1406            disabled: None,
1407            error: Some("You must agree".to_string()),
1408        });
1409        let json = serde_json::to_value(&checkbox).unwrap();
1410        assert_eq!(json["error"], "You must agree");
1411        assert!(json.get("description").is_none());
1412        assert!(json.get("checked").is_none());
1413        let parsed: Component = serde_json::from_value(json).unwrap();
1414        assert_eq!(parsed, checkbox);
1415    }
1416
1417    #[test]
1418    fn tabs_round_trips() {
1419        let tabs = Component::Tabs(TabsProps {
1420            default_tab: "general".to_string(),
1421            tabs: vec![
1422                Tab {
1423                    value: "general".to_string(),
1424                    label: "General".to_string(),
1425                    children: vec![ComponentNode {
1426                        key: "name-input".to_string(),
1427                        component: Component::Input(InputProps {
1428                            field: "name".to_string(),
1429                            label: "Name".to_string(),
1430                            input_type: InputType::Text,
1431                            placeholder: None,
1432                            required: None,
1433                            disabled: None,
1434                            error: None,
1435                            description: None,
1436                            default_value: None,
1437                            data_path: None,
1438                            step: None,
1439                        }),
1440                        action: None,
1441                        visibility: None,
1442                    }],
1443                },
1444                Tab {
1445                    value: "security".to_string(),
1446                    label: "Security".to_string(),
1447                    children: vec![ComponentNode {
1448                        key: "password-input".to_string(),
1449                        component: Component::Input(InputProps {
1450                            field: "password".to_string(),
1451                            label: "Password".to_string(),
1452                            input_type: InputType::Password,
1453                            placeholder: None,
1454                            required: None,
1455                            disabled: None,
1456                            error: None,
1457                            description: None,
1458                            default_value: None,
1459                            data_path: None,
1460                            step: None,
1461                        }),
1462                        action: None,
1463                        visibility: None,
1464                    }],
1465                },
1466            ],
1467        });
1468        let json = serde_json::to_string(&tabs).unwrap();
1469        let parsed: Component = serde_json::from_str(&json).unwrap();
1470        assert_eq!(parsed, tabs);
1471    }
1472
1473    #[test]
1474    fn breadcrumb_round_trips() {
1475        let breadcrumb = Component::Breadcrumb(BreadcrumbProps {
1476            items: vec![
1477                BreadcrumbItem {
1478                    label: "Home".to_string(),
1479                    url: Some("/".to_string()),
1480                },
1481                BreadcrumbItem {
1482                    label: "Users".to_string(),
1483                    url: Some("/users".to_string()),
1484                },
1485                BreadcrumbItem {
1486                    label: "Edit User".to_string(),
1487                    url: None,
1488                },
1489            ],
1490        });
1491        let json = serde_json::to_string(&breadcrumb).unwrap();
1492        let parsed: Component = serde_json::from_str(&json).unwrap();
1493        assert_eq!(parsed, breadcrumb);
1494
1495        // Verify last item has no URL serialized
1496        let value = serde_json::to_value(&breadcrumb).unwrap();
1497        assert!(value["items"][2].get("url").is_none());
1498    }
1499
1500    #[test]
1501    fn pagination_round_trips() {
1502        let pagination = Component::Pagination(PaginationProps {
1503            current_page: 3,
1504            per_page: 25,
1505            total: 150,
1506            base_url: None,
1507        });
1508        let json = serde_json::to_string(&pagination).unwrap();
1509        let parsed: Component = serde_json::from_str(&json).unwrap();
1510        assert_eq!(parsed, pagination);
1511    }
1512
1513    #[test]
1514    fn progress_round_trips() {
1515        let progress = Component::Progress(ProgressProps {
1516            value: 75,
1517            max: Some(100),
1518            label: Some("Uploading...".to_string()),
1519        });
1520        let json = serde_json::to_string(&progress).unwrap();
1521        let parsed: Component = serde_json::from_str(&json).unwrap();
1522        assert_eq!(parsed, progress);
1523
1524        let value = serde_json::to_value(&progress).unwrap();
1525        assert_eq!(value["value"], 75);
1526        assert_eq!(value["max"], 100);
1527        assert_eq!(value["label"], "Uploading...");
1528    }
1529
1530    #[test]
1531    fn avatar_with_fallback() {
1532        let avatar = Component::Avatar(AvatarProps {
1533            src: None,
1534            alt: "John Doe".to_string(),
1535            fallback: Some("JD".to_string()),
1536            size: Some(Size::Lg),
1537        });
1538        let json = serde_json::to_string(&avatar).unwrap();
1539        let parsed: Component = serde_json::from_str(&json).unwrap();
1540        assert_eq!(parsed, avatar);
1541
1542        let value = serde_json::to_value(&avatar).unwrap();
1543        assert!(value.get("src").is_none());
1544        assert_eq!(value["fallback"], "JD");
1545        assert_eq!(value["size"], "lg");
1546    }
1547
1548    #[test]
1549    fn skeleton_round_trips() {
1550        let skeleton = Component::Skeleton(SkeletonProps {
1551            width: Some("100%".to_string()),
1552            height: Some("40px".to_string()),
1553            rounded: Some(true),
1554        });
1555        let json = serde_json::to_string(&skeleton).unwrap();
1556        let parsed: Component = serde_json::from_str(&json).unwrap();
1557        assert_eq!(parsed, skeleton);
1558
1559        let value = serde_json::to_value(&skeleton).unwrap();
1560        assert_eq!(value["width"], "100%");
1561        assert_eq!(value["height"], "40px");
1562        assert_eq!(value["rounded"], true);
1563    }
1564
1565    #[test]
1566    fn tabs_deserializes_from_json() {
1567        let json = r#"{
1568            "type": "Tabs",
1569            "default_tab": "general",
1570            "tabs": [
1571                {
1572                    "value": "general",
1573                    "label": "General",
1574                    "children": [
1575                        {
1576                            "key": "name-input",
1577                            "type": "Input",
1578                            "field": "name",
1579                            "label": "Name"
1580                        }
1581                    ]
1582                },
1583                {
1584                    "value": "security",
1585                    "label": "Security"
1586                }
1587            ]
1588        }"#;
1589        let component: Component = serde_json::from_str(json).unwrap();
1590        match component {
1591            Component::Tabs(props) => {
1592                assert_eq!(props.default_tab, "general");
1593                assert_eq!(props.tabs.len(), 2);
1594                assert_eq!(props.tabs[0].value, "general");
1595                assert_eq!(props.tabs[0].children.len(), 1);
1596                assert_eq!(props.tabs[1].value, "security");
1597                assert!(props.tabs[1].children.is_empty());
1598            }
1599            _ => panic!("expected Tabs"),
1600        }
1601    }
1602
1603    #[test]
1604    fn input_data_path_round_trips() {
1605        let input = Component::Input(InputProps {
1606            field: "name".to_string(),
1607            label: "Name".to_string(),
1608            input_type: InputType::Text,
1609            placeholder: None,
1610            required: None,
1611            disabled: None,
1612            error: None,
1613            description: None,
1614            default_value: None,
1615            data_path: Some("/data/user/name".to_string()),
1616            step: None,
1617        });
1618        let json = serde_json::to_value(&input).unwrap();
1619        assert_eq!(json["data_path"], "/data/user/name");
1620        let parsed: Component = serde_json::from_value(json).unwrap();
1621        assert_eq!(parsed, input);
1622    }
1623
1624    #[test]
1625    fn select_data_path_round_trips() {
1626        let select = Component::Select(SelectProps {
1627            field: "role".to_string(),
1628            label: "Role".to_string(),
1629            options: vec![SelectOption {
1630                value: "admin".to_string(),
1631                label: "Admin".to_string(),
1632            }],
1633            placeholder: None,
1634            required: None,
1635            disabled: None,
1636            error: None,
1637            description: None,
1638            default_value: None,
1639            data_path: Some("/data/user/role".to_string()),
1640        });
1641        let json = serde_json::to_value(&select).unwrap();
1642        assert_eq!(json["data_path"], "/data/user/role");
1643        let parsed: Component = serde_json::from_value(json).unwrap();
1644        assert_eq!(parsed, select);
1645    }
1646
1647    #[test]
1648    fn checkbox_data_path_round_trips() {
1649        let checkbox = Component::Checkbox(CheckboxProps {
1650            field: "terms".to_string(),
1651            label: "Accept Terms".to_string(),
1652            description: None,
1653            checked: None,
1654            data_path: Some("/data/user/accepted_terms".to_string()),
1655            required: None,
1656            disabled: None,
1657            error: None,
1658        });
1659        let json = serde_json::to_value(&checkbox).unwrap();
1660        assert_eq!(json["data_path"], "/data/user/accepted_terms");
1661        let parsed: Component = serde_json::from_value(json).unwrap();
1662        assert_eq!(parsed, checkbox);
1663    }
1664
1665    #[test]
1666    fn switch_data_path_round_trips() {
1667        let switch = Component::Switch(SwitchProps {
1668            field: "notifications".to_string(),
1669            label: "Enable Notifications".to_string(),
1670            description: None,
1671            checked: None,
1672            data_path: Some("/data/user/notifications_enabled".to_string()),
1673            required: None,
1674            disabled: None,
1675            error: None,
1676        });
1677        let json = serde_json::to_value(&switch).unwrap();
1678        assert_eq!(json["data_path"], "/data/user/notifications_enabled");
1679        let parsed: Component = serde_json::from_value(json).unwrap();
1680        assert_eq!(parsed, switch);
1681    }
1682
1683    // ─── Plugin variant tests ────────────────────────────────────────
1684
1685    #[test]
1686    fn unknown_type_deserializes_as_plugin() {
1687        let json = r#"{"type": "Map", "center": [40.7, -74.0], "zoom": 12}"#;
1688        let component: Component = serde_json::from_str(json).unwrap();
1689        match component {
1690            Component::Plugin(props) => {
1691                assert_eq!(props.plugin_type, "Map");
1692                assert_eq!(props.props["center"][0], 40.7);
1693                assert_eq!(props.props["center"][1], -74.0);
1694                assert_eq!(props.props["zoom"], 12);
1695                // "type" should be removed from props
1696                assert!(props.props.get("type").is_none());
1697            }
1698            _ => panic!("expected Plugin"),
1699        }
1700    }
1701
1702    #[test]
1703    fn plugin_round_trips() {
1704        let plugin = Component::Plugin(PluginProps {
1705            plugin_type: "Chart".to_string(),
1706            props: serde_json::json!({"data": [1, 2, 3], "style": "bar"}),
1707        });
1708        let json = serde_json::to_value(&plugin).unwrap();
1709        assert_eq!(json["type"], "Chart");
1710        assert_eq!(json["data"], serde_json::json!([1, 2, 3]));
1711        assert_eq!(json["style"], "bar");
1712
1713        let parsed: Component = serde_json::from_value(json).unwrap();
1714        assert_eq!(parsed, plugin);
1715    }
1716
1717    #[test]
1718    fn plugin_serializes_with_type_field() {
1719        let plugin = Component::Plugin(PluginProps {
1720            plugin_type: "Map".to_string(),
1721            props: serde_json::json!({"lat": 51.5, "lng": -0.1}),
1722        });
1723        let json = serde_json::to_value(&plugin).unwrap();
1724        assert_eq!(json["type"], "Map");
1725        assert_eq!(json["lat"], 51.5);
1726        assert_eq!(json["lng"], -0.1);
1727    }
1728
1729    #[test]
1730    fn plugin_with_empty_props() {
1731        let json = r#"{"type": "CustomWidget"}"#;
1732        let component: Component = serde_json::from_str(json).unwrap();
1733        match component {
1734            Component::Plugin(props) => {
1735                assert_eq!(props.plugin_type, "CustomWidget");
1736                assert!(props.props.as_object().unwrap().is_empty());
1737            }
1738            _ => panic!("expected Plugin"),
1739        }
1740    }
1741
1742    #[test]
1743    fn plugin_in_component_node() {
1744        let node = ComponentNode {
1745            key: "map-1".to_string(),
1746            component: Component::Plugin(PluginProps {
1747                plugin_type: "Map".to_string(),
1748                props: serde_json::json!({"center": [0.0, 0.0]}),
1749            }),
1750            action: None,
1751            visibility: None,
1752        };
1753        let json = serde_json::to_string(&node).unwrap();
1754        let parsed: ComponentNode = serde_json::from_str(&json).unwrap();
1755        assert_eq!(parsed, node);
1756
1757        let value = serde_json::to_value(&node).unwrap();
1758        assert_eq!(value["type"], "Map");
1759        assert_eq!(value["key"], "map-1");
1760    }
1761
1762    #[test]
1763    fn known_types_not_treated_as_plugin() {
1764        // All known type names must still deserialize to their specific variants.
1765        let known_types = [
1766            "Card",
1767            "Table",
1768            "Form",
1769            "Button",
1770            "Input",
1771            "Select",
1772            "Alert",
1773            "Badge",
1774            "Modal",
1775            "Text",
1776            "Checkbox",
1777            "Switch",
1778            "Separator",
1779            "DescriptionList",
1780            "Tabs",
1781            "Breadcrumb",
1782            "Pagination",
1783            "Progress",
1784            "Avatar",
1785            "Skeleton",
1786        ];
1787        for type_name in &known_types {
1788            // Construct minimal valid JSON for each type (using the
1789            // all_component_variants_serialize test data format).
1790            let json_str = match *type_name {
1791                "Card" => r#"{"type":"Card","title":"t"}"#,
1792                "Table" => r#"{"type":"Table","columns":[],"data_path":"/d"}"#,
1793                "Form" => r#"{"type":"Form","action":{"handler":"h","method":"POST"},"fields":[]}"#,
1794                "Button" => r#"{"type":"Button","label":"b"}"#,
1795                "Input" => r#"{"type":"Input","field":"f","label":"l"}"#,
1796                "Select" => r#"{"type":"Select","field":"f","label":"l","options":[]}"#,
1797                "Alert" => r#"{"type":"Alert","message":"m"}"#,
1798                "Badge" => r#"{"type":"Badge","label":"b"}"#,
1799                "Modal" => r#"{"type":"Modal","title":"t"}"#,
1800                "Text" => r#"{"type":"Text","content":"c"}"#,
1801                "Checkbox" => r#"{"type":"Checkbox","field":"f","label":"l"}"#,
1802                "Switch" => r#"{"type":"Switch","field":"f","label":"l"}"#,
1803                "Separator" => r#"{"type":"Separator"}"#,
1804                "DescriptionList" => r#"{"type":"DescriptionList","items":[]}"#,
1805                "Tabs" => r#"{"type":"Tabs","default_tab":"t","tabs":[]}"#,
1806                "Breadcrumb" => r#"{"type":"Breadcrumb","items":[]}"#,
1807                "Pagination" => r#"{"type":"Pagination","current_page":1,"per_page":10,"total":0}"#,
1808                "Progress" => r#"{"type":"Progress","value":0}"#,
1809                "Avatar" => r#"{"type":"Avatar","alt":"a"}"#,
1810                "Skeleton" => r#"{"type":"Skeleton"}"#,
1811                _ => unreachable!(),
1812            };
1813            let component: Component = serde_json::from_str(json_str).unwrap();
1814            assert!(
1815                !matches!(component, Component::Plugin(_)),
1816                "type {type_name} should not deserialize as Plugin"
1817            );
1818        }
1819    }
1820}