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 schemars::JsonSchema;
7use serde::de::{self, Deserializer};
8use serde::ser::{SerializeMap, Serializer};
9use serde::{Deserialize, Serialize};
10
11use crate::action::Action;
12use crate::visibility::Visibility;
13
14/// Shared size enum for components (Button, Badge, Avatar, Input).
15#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
16#[serde(rename_all = "snake_case")]
17pub enum Size {
18    Xs,
19    Sm,
20    #[default]
21    Default,
22    Lg,
23}
24
25/// Icon placement relative to button label.
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
27#[serde(rename_all = "snake_case")]
28pub enum IconPosition {
29    #[default]
30    Left,
31    Right,
32}
33
34/// Sort direction for table columns.
35#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
36#[serde(rename_all = "snake_case")]
37pub enum SortDirection {
38    #[default]
39    Asc,
40    Desc,
41}
42
43/// Separator orientation.
44#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
45#[serde(rename_all = "snake_case")]
46pub enum Orientation {
47    #[default]
48    Horizontal,
49    Vertical,
50}
51
52/// Button visual variants (aligned to shadcn/ui).
53#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
54#[serde(rename_all = "snake_case")]
55pub enum ButtonVariant {
56    #[default]
57    Default,
58    Secondary,
59    Destructive,
60    Outline,
61    Ghost,
62    Link,
63}
64
65/// Input field types.
66#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67#[serde(rename_all = "snake_case")]
68pub enum InputType {
69    #[default]
70    Text,
71    Email,
72    Password,
73    Number,
74    Textarea,
75    Hidden,
76    Date,
77    Time,
78    Url,
79    Tel,
80    Search,
81}
82
83/// Alert visual variants.
84#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
85#[serde(rename_all = "snake_case")]
86pub enum AlertVariant {
87    #[default]
88    Info,
89    Success,
90    Warning,
91    Error,
92}
93
94/// Badge visual variants (aligned to shadcn/ui).
95#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
96#[serde(rename_all = "snake_case")]
97pub enum BadgeVariant {
98    #[default]
99    Default,
100    Secondary,
101    Destructive,
102    Outline,
103}
104
105/// Text element types for semantic HTML rendering.
106#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
107#[serde(rename_all = "snake_case")]
108pub enum TextElement {
109    #[default]
110    P,
111    H1,
112    H2,
113    H3,
114    Span,
115    Div,
116    Section,
117}
118
119/// Column display format for tables.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
121#[serde(rename_all = "snake_case")]
122pub enum ColumnFormat {
123    Date,
124    DateTime,
125    Currency,
126    Boolean,
127}
128
129/// Table column definition.
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
131pub struct Column {
132    pub key: String,
133    pub label: String,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub format: Option<ColumnFormat>,
136}
137
138/// Select option (value + label pair).
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
140pub struct SelectOption {
141    pub value: String,
142    pub label: String,
143}
144
145/// Props for Card component.
146// JsonSchema skipped: contains Vec<ComponentNode> — Component has custom Serialize/Deserialize
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub struct CardProps {
149    pub title: String,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub description: Option<String>,
152    #[serde(default, skip_serializing_if = "Vec::is_empty")]
153    pub children: Vec<ComponentNode>,
154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155    pub footer: Vec<ComponentNode>,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub max_width: Option<FormMaxWidth>,
158}
159
160/// Props for Table component.
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
162pub struct TableProps {
163    pub columns: Vec<Column>,
164    pub data_path: String,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub row_actions: Option<Vec<Action>>,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub empty_message: Option<String>,
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub sortable: Option<bool>,
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub sort_column: Option<String>,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub sort_direction: Option<SortDirection>,
175}
176
177/// Maximum width constraint for form containers.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
179#[serde(rename_all = "snake_case")]
180pub enum FormMaxWidth {
181    #[default]
182    Default,
183    Narrow,
184    Wide,
185}
186
187/// Props for Form component.
188// JsonSchema skipped: contains Vec<ComponentNode> — Component has custom Serialize/Deserialize
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct FormProps {
191    pub action: Action,
192    pub fields: Vec<ComponentNode>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub method: Option<crate::action::HttpMethod>,
195    /// Form guard type. When set, the runtime JS disables the submit button
196    /// until the guard condition is met. Value: `"number-gt-0"` — at least
197    /// one number input must have value > 0.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub guard: Option<String>,
200    /// Optional max-width constraint for the form container.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub max_width: Option<FormMaxWidth>,
203}
204
205/// HTML button type attribute.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
207#[serde(rename_all = "snake_case")]
208pub enum ButtonType {
209    #[default]
210    Button,
211    Submit,
212}
213
214/// Props for Button component.
215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
216pub struct ButtonProps {
217    pub label: String,
218    #[serde(default)]
219    pub variant: ButtonVariant,
220    #[serde(default)]
221    pub size: Size,
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub disabled: Option<bool>,
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub icon: Option<String>,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub icon_position: Option<IconPosition>,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub button_type: Option<ButtonType>,
230}
231
232/// Props for Input component.
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
234pub struct InputProps {
235    /// Form field name for data binding.
236    pub field: String,
237    pub label: String,
238    #[serde(default)]
239    pub input_type: InputType,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub placeholder: Option<String>,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub required: Option<bool>,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub disabled: Option<bool>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub error: Option<String>,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub description: Option<String>,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub default_value: Option<String>,
252    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub data_path: Option<String>,
255    /// HTML step attribute for number inputs (e.g., "any", "0.01").
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub step: Option<String>,
258    /// HTML datalist id for autocomplete suggestions.
259    /// When set, renders `list="..."` on the input and a companion `<datalist>`
260    /// whose options come from a view data key matching this id.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub list: Option<String>,
263}
264
265/// Props for Select component.
266#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
267pub struct SelectProps {
268    /// Form field name for data binding.
269    pub field: String,
270    pub label: String,
271    pub options: Vec<SelectOption>,
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub placeholder: Option<String>,
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub required: Option<bool>,
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub disabled: Option<bool>,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub error: Option<String>,
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub description: Option<String>,
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub default_value: Option<String>,
284    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub data_path: Option<String>,
287}
288
289/// Props for Alert component.
290#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
291pub struct AlertProps {
292    pub message: String,
293    #[serde(default)]
294    pub variant: AlertVariant,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub title: Option<String>,
297}
298
299/// Props for Badge component.
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
301pub struct BadgeProps {
302    pub label: String,
303    #[serde(default)]
304    pub variant: BadgeVariant,
305}
306
307/// Props for Modal component.
308// JsonSchema skipped: contains Vec<ComponentNode> — Component has custom Serialize/Deserialize
309#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
310pub struct ModalProps {
311    pub id: String,
312    pub title: String,
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub description: Option<String>,
315    #[serde(default, skip_serializing_if = "Vec::is_empty")]
316    pub children: Vec<ComponentNode>,
317    #[serde(default, skip_serializing_if = "Vec::is_empty")]
318    pub footer: Vec<ComponentNode>,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub trigger_label: Option<String>,
321}
322
323/// Props for Text component.
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
325pub struct TextProps {
326    pub content: String,
327    #[serde(default)]
328    pub element: TextElement,
329}
330
331/// Props for Checkbox component.
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
333pub struct CheckboxProps {
334    /// Form field name for data binding.
335    pub field: String,
336    /// HTML value attribute. When set, the checkbox submits this value instead of "1".
337    /// Required for multi-value checkbox groups (same name, different values).
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub value: Option<String>,
340    pub label: String,
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub description: Option<String>,
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub checked: Option<bool>,
345    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub data_path: Option<String>,
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub required: Option<bool>,
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub disabled: Option<bool>,
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub error: Option<String>,
354}
355
356/// Props for Switch component.
357// JsonSchema skipped: contains Option<Action> — Action has custom Serialize/Deserialize
358#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
359pub struct SwitchProps {
360    /// Form field name for data binding.
361    pub field: String,
362    pub label: String,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub description: Option<String>,
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub checked: Option<bool>,
367    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub data_path: Option<String>,
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub required: Option<bool>,
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub disabled: Option<bool>,
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub error: Option<String>,
376    /// Auto-submit action. When set, the switch renders inside a minimal
377    /// form and submits on change.
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub action: Option<Action>,
380}
381
382/// Props for Separator component.
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
384pub struct SeparatorProps {
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub orientation: Option<Orientation>,
387}
388
389/// A single item in a description list.
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
391pub struct DescriptionItem {
392    pub label: String,
393    pub value: String,
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub format: Option<ColumnFormat>,
396}
397
398/// Props for DescriptionList component.
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
400pub struct DescriptionListProps {
401    pub items: Vec<DescriptionItem>,
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub columns: Option<u8>,
404}
405
406/// A single tab within a Tabs component.
407// JsonSchema skipped: contains Vec<ComponentNode> — Component has custom Serialize/Deserialize
408#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
409pub struct Tab {
410    pub value: String,
411    pub label: String,
412    #[serde(default, skip_serializing_if = "Vec::is_empty")]
413    pub children: Vec<ComponentNode>,
414}
415
416/// Props for Tabs component.
417// JsonSchema skipped: contains Vec<Tab> which contains Vec<ComponentNode>
418#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
419pub struct TabsProps {
420    pub default_tab: String,
421    pub tabs: Vec<Tab>,
422}
423
424/// A single item in a breadcrumb trail.
425#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
426pub struct BreadcrumbItem {
427    pub label: String,
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub url: Option<String>,
430}
431
432/// Props for Breadcrumb component.
433#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
434pub struct BreadcrumbProps {
435    pub items: Vec<BreadcrumbItem>,
436}
437
438/// Props for Pagination component.
439#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
440pub struct PaginationProps {
441    pub current_page: u32,
442    pub per_page: u32,
443    pub total: u32,
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub base_url: Option<String>,
446}
447
448/// Props for Progress component.
449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
450pub struct ProgressProps {
451    /// Percentage value (0-100).
452    pub value: u8,
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub max: Option<u8>,
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub label: Option<String>,
457}
458
459/// Props for Image component.
460#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
461pub struct ImageProps {
462    pub src: String,
463    pub alt: String,
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub aspect_ratio: Option<String>,
466    /// Optional label shown in a skeleton placeholder that sits behind the
467    /// image. When the image fails to load (or is still being generated),
468    /// the `<img>` is hidden via `onerror` and the placeholder remains
469    /// visible, keeping the container at its aspect-ratio size.
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub placeholder_label: Option<String>,
472}
473
474/// Props for Avatar component.
475#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
476pub struct AvatarProps {
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub src: Option<String>,
479    pub alt: String,
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub fallback: Option<String>,
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub size: Option<Size>,
484}
485
486/// Props for Skeleton loading placeholder.
487#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
488pub struct SkeletonProps {
489    #[serde(default, skip_serializing_if = "Option::is_none")]
490    pub width: Option<String>,
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub height: Option<String>,
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    pub rounded: Option<bool>,
495}
496
497/// Toast visual variants.
498#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
499#[serde(rename_all = "snake_case")]
500pub enum ToastVariant {
501    #[default]
502    Info,
503    Success,
504    Warning,
505    Error,
506}
507
508/// A single item in a checklist.
509#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
510pub struct ChecklistItem {
511    pub label: String,
512    #[serde(default)]
513    pub checked: bool,
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    pub href: Option<String>,
516}
517
518/// A single item in a notification dropdown.
519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
520pub struct NotificationItem {
521    #[serde(default, skip_serializing_if = "Option::is_none")]
522    pub icon: Option<String>,
523    pub text: String,
524    #[serde(default, skip_serializing_if = "Option::is_none")]
525    pub timestamp: Option<String>,
526    #[serde(default)]
527    pub read: bool,
528    #[serde(default, skip_serializing_if = "Option::is_none")]
529    pub action_url: Option<String>,
530}
531
532/// A single navigation item in the sidebar.
533#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
534pub struct SidebarNavItem {
535    pub label: String,
536    pub href: String,
537    #[serde(default, skip_serializing_if = "Option::is_none")]
538    pub icon: Option<String>,
539    #[serde(default)]
540    pub active: bool,
541}
542
543/// A collapsible group in the sidebar.
544#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
545pub struct SidebarGroup {
546    pub label: String,
547    #[serde(default)]
548    pub collapsed: bool,
549    pub items: Vec<SidebarNavItem>,
550}
551
552/// Props for StatCard component (live-updatable metric card).
553#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
554pub struct StatCardProps {
555    pub label: String,
556    pub value: String,
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub icon: Option<String>,
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    pub subtitle: Option<String>,
561    /// SSE target key for live updates; maps to `data-sse-target` on the value element.
562    #[serde(default, skip_serializing_if = "Option::is_none")]
563    pub sse_target: Option<String>,
564}
565
566/// Props for Checklist component.
567#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
568pub struct ChecklistProps {
569    pub title: String,
570    pub items: Vec<ChecklistItem>,
571    #[serde(default = "default_true")]
572    pub dismissible: bool,
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    pub dismiss_label: Option<String>,
575    /// Server-side state persistence key for this checklist.
576    #[serde(default, skip_serializing_if = "Option::is_none")]
577    pub data_key: Option<String>,
578}
579
580fn default_true() -> bool {
581    true
582}
583
584/// Props for Toast component (declarative notification intent).
585///
586/// The JS runtime reads data attributes from the rendered element to
587/// display the toast. Timeouts and dismissal are handled client-side.
588#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
589pub struct ToastProps {
590    pub message: String,
591    #[serde(default)]
592    pub variant: ToastVariant,
593    /// Seconds before auto-dismiss. Default 5.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub timeout: Option<u32>,
596    #[serde(default = "default_true")]
597    pub dismissible: bool,
598}
599
600/// Props for NotificationDropdown component.
601#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
602pub struct NotificationDropdownProps {
603    pub notifications: Vec<NotificationItem>,
604    #[serde(default, skip_serializing_if = "Option::is_none")]
605    pub empty_text: Option<String>,
606}
607
608/// Props for Sidebar component.
609#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
610pub struct SidebarProps {
611    #[serde(default, skip_serializing_if = "Vec::is_empty")]
612    pub fixed_top: Vec<SidebarNavItem>,
613    #[serde(default, skip_serializing_if = "Vec::is_empty")]
614    pub groups: Vec<SidebarGroup>,
615    #[serde(default, skip_serializing_if = "Vec::is_empty")]
616    pub fixed_bottom: Vec<SidebarNavItem>,
617}
618
619/// Props for Header component.
620#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
621pub struct HeaderProps {
622    pub business_name: String,
623    /// Unread notification count for badge display.
624    #[serde(default, skip_serializing_if = "Option::is_none")]
625    pub notification_count: Option<u32>,
626    #[serde(default, skip_serializing_if = "Option::is_none")]
627    pub user_name: Option<String>,
628    #[serde(default, skip_serializing_if = "Option::is_none")]
629    pub user_avatar: Option<String>,
630    #[serde(default, skip_serializing_if = "Option::is_none")]
631    pub logout_url: Option<String>,
632}
633
634/// Gap size for Grid layout.
635#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
636#[serde(rename_all = "snake_case")]
637pub enum GapSize {
638    None,
639    Sm,
640    #[default]
641    Md,
642    Lg,
643    Xl,
644}
645
646/// Props for Grid component — multi-column layout.
647// JsonSchema skipped: contains Vec<ComponentNode> — Component has custom Serialize/Deserialize
648#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
649pub struct GridProps {
650    /// Number of columns (1-12) at base (mobile) viewport.
651    #[serde(default = "default_grid_columns")]
652    pub columns: u8,
653    /// Number of columns at md breakpoint (768px+). When set, creates a responsive grid.
654    #[serde(default, skip_serializing_if = "Option::is_none")]
655    pub md_columns: Option<u8>,
656    /// Number of columns at lg breakpoint (1024px+). Optional; falls back to md.
657    #[serde(default, skip_serializing_if = "Option::is_none")]
658    pub lg_columns: Option<u8>,
659    /// Gap between grid items.
660    #[serde(default)]
661    pub gap: GapSize,
662    /// Enables horizontal scroll mode. Children get `min-w-[280px]` and the grid
663    /// uses `grid-flow-col` auto-cols layout for Trello-like horizontal scrolling.
664    #[serde(default, skip_serializing_if = "Option::is_none")]
665    pub scrollable: Option<bool>,
666    #[serde(default, skip_serializing_if = "Vec::is_empty")]
667    pub children: Vec<ComponentNode>,
668}
669
670fn default_grid_columns() -> u8 {
671    2
672}
673
674/// Props for Collapsible section — expandable `<details>`/`<summary>`.
675// JsonSchema skipped: contains Vec<ComponentNode> — Component has custom Serialize/Deserialize
676#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
677pub struct CollapsibleProps {
678    pub title: String,
679    #[serde(default)]
680    pub expanded: bool,
681    #[serde(default, skip_serializing_if = "Vec::is_empty")]
682    pub children: Vec<ComponentNode>,
683}
684
685/// Props for EmptyState component — standardized empty view.
686#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
687pub struct EmptyStateProps {
688    pub title: String,
689    #[serde(default, skip_serializing_if = "Option::is_none")]
690    pub description: Option<String>,
691    #[serde(default, skip_serializing_if = "Option::is_none")]
692    pub action: Option<Action>,
693    #[serde(default, skip_serializing_if = "Option::is_none")]
694    pub action_label: Option<String>,
695}
696
697/// Layout variant for form sections.
698#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
699#[serde(rename_all = "snake_case")]
700pub enum FormSectionLayout {
701    #[default]
702    Stacked,
703    TwoColumn,
704}
705
706/// Props for FormSection component — visual grouping within forms.
707// JsonSchema skipped: contains Vec<ComponentNode> — Component has custom Serialize/Deserialize
708#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
709pub struct FormSectionProps {
710    pub title: String,
711    #[serde(default, skip_serializing_if = "Option::is_none")]
712    pub description: Option<String>,
713    #[serde(default, skip_serializing_if = "Vec::is_empty")]
714    pub children: Vec<ComponentNode>,
715    /// Optional layout variant. Defaults to stacked (single column).
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub layout: Option<FormSectionLayout>,
718}
719
720/// Props for PageHeader component -- page title with optional breadcrumb and action buttons.
721// JsonSchema skipped: contains Vec<ComponentNode>
722#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
723pub struct PageHeaderProps {
724    pub title: String,
725    #[serde(default, skip_serializing_if = "Vec::is_empty")]
726    pub breadcrumb: Vec<BreadcrumbItem>,
727    #[serde(default, skip_serializing_if = "Vec::is_empty")]
728    pub actions: Vec<ComponentNode>,
729}
730
731/// Props for ButtonGroup component -- horizontal button row with consistent gap.
732// JsonSchema skipped: contains Vec<ComponentNode>
733#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
734pub struct ButtonGroupProps {
735    #[serde(default, skip_serializing_if = "Vec::is_empty")]
736    pub buttons: Vec<ComponentNode>,
737}
738
739/// A single action item in a dropdown menu.
740#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
741pub struct DropdownMenuAction {
742    pub label: String,
743    pub action: Action,
744    #[serde(default)]
745    pub destructive: bool,
746}
747
748/// Props for DropdownMenu component — trigger button with absolutely-positioned action panel.
749#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
750pub struct DropdownMenuProps {
751    pub menu_id: String,
752    pub trigger_label: String,
753    pub items: Vec<DropdownMenuAction>,
754    #[serde(default, skip_serializing_if = "Option::is_none")]
755    pub trigger_variant: Option<ButtonVariant>,
756}
757
758/// Props for the DataTable component — Stripe-style alternating rows with DropdownMenu per row,
759/// mobile card fallback, and empty state.
760#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
761pub struct DataTableProps {
762    pub columns: Vec<Column>,
763    pub data_path: String,
764    #[serde(default, skip_serializing_if = "Option::is_none")]
765    pub row_actions: Option<Vec<DropdownMenuAction>>,
766    #[serde(default, skip_serializing_if = "Option::is_none")]
767    pub empty_message: Option<String>,
768    #[serde(default, skip_serializing_if = "Option::is_none")]
769    pub row_key: Option<String>,
770}
771
772/// Props for a single column in a KanbanBoard.
773// JsonSchema skipped: contains Vec<ComponentNode>
774#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
775pub struct KanbanColumnProps {
776    pub id: String,
777    pub title: String,
778    pub count: u32,
779    #[serde(default, skip_serializing_if = "Vec::is_empty")]
780    pub children: Vec<ComponentNode>,
781}
782
783/// Props for KanbanBoard component — horizontal scrollable columns on desktop, tab-based on mobile.
784// JsonSchema skipped: contains Vec<ComponentNode> (via KanbanColumnProps)
785#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
786pub struct KanbanBoardProps {
787    pub columns: Vec<KanbanColumnProps>,
788    #[serde(default, skip_serializing_if = "Option::is_none")]
789    pub mobile_default_column: Option<String>,
790}
791
792/// Props for a calendar day cell.
793///
794/// Renders a single day in a month grid with today highlight,
795/// out-of-month muting, and event count indicator.
796#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
797pub struct CalendarCellProps {
798    pub day: u8,
799    #[serde(default)]
800    pub is_today: bool,
801    #[serde(default)]
802    pub is_current_month: bool,
803    #[serde(default)]
804    pub event_count: u32,
805    /// Optional per-event Tailwind color classes (e.g. "bg-blue-500").
806    /// When non-empty, colored dots are rendered instead of plain primary dots.
807    #[serde(default, skip_serializing_if = "Vec::is_empty")]
808    pub dot_colors: Vec<String>,
809}
810
811/// Visual variant for action cards.
812#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
813#[serde(rename_all = "snake_case")]
814pub enum ActionCardVariant {
815    #[default]
816    Default,
817    Setup,
818    Danger,
819}
820
821/// Props for a horizontal action card with variant-colored left border.
822///
823/// Renders icon + title + description + chevron in a clickable row.
824/// When `href` is set, the card wraps in an `<a>` element with `aria-label`.
825#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
826pub struct ActionCardProps {
827    pub title: String,
828    pub description: String,
829    #[serde(default, skip_serializing_if = "Option::is_none")]
830    pub icon: Option<String>,
831    #[serde(default)]
832    pub variant: ActionCardVariant,
833    /// Optional navigation URL. When set, the card renders as an `<a>` element.
834    #[serde(default, skip_serializing_if = "Option::is_none")]
835    pub href: Option<String>,
836}
837
838/// Props for a touch-friendly product tile with quantity controls.
839///
840/// Renders product name, price, and +/- buttons that drive a hidden input
841/// via JS. Used for POS-style product selection during order creation.
842#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
843pub struct ProductTileProps {
844    pub product_id: String,
845    pub name: String,
846    pub price: String,
847    pub field: String,
848    #[serde(default, skip_serializing_if = "Option::is_none")]
849    pub default_quantity: Option<u32>,
850}
851
852/// Props for a plugin component.
853///
854/// The `plugin_type` field holds the original `"type"` value from JSON,
855/// and `props` holds the remaining fields. Used for custom interactive
856/// components registered via the plugin system.
857// JsonSchema skipped: custom Serialize/Deserialize impl
858#[derive(Debug, Clone, PartialEq)]
859pub struct PluginProps {
860    /// The plugin component type name (e.g., "Map").
861    pub plugin_type: String,
862    /// Raw props passed to the plugin's render function.
863    pub props: serde_json::Value,
864}
865
866impl Serialize for PluginProps {
867    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
868        // Flatten plugin_type back as "type" and merge in the props object.
869        let obj = self.props.as_object();
870        let extra_len = obj.map_or(0, |m| m.len());
871        let mut map = serializer.serialize_map(Some(1 + extra_len))?;
872        map.serialize_entry("type", &self.plugin_type)?;
873        if let Some(obj) = obj {
874            for (k, v) in obj {
875                if k != "type" {
876                    map.serialize_entry(k, v)?;
877                }
878            }
879        }
880        map.end()
881    }
882}
883
884impl<'de> Deserialize<'de> for PluginProps {
885    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
886        let mut value = serde_json::Value::deserialize(deserializer)?;
887        let plugin_type = value
888            .get("type")
889            .and_then(|v| v.as_str())
890            .map(|s| s.to_string())
891            .ok_or_else(|| de::Error::missing_field("type"))?;
892        // Remove "type" from the props to avoid redundancy.
893        if let Some(obj) = value.as_object_mut() {
894            obj.remove("type");
895        }
896        Ok(PluginProps {
897            plugin_type,
898            props: value,
899        })
900    }
901}
902
903/// Component catalog enum. Built-in types are deserialized by name,
904/// unknown types fall through to the `Plugin` variant.
905///
906/// Serializes built-in variants with `"type": "Card"` etc. The Plugin
907/// variant serializes with the plugin's own type name.
908// JsonSchema skipped: custom Serialize/Deserialize impl
909#[derive(Debug, Clone, PartialEq)]
910pub enum Component {
911    Card(CardProps),
912    Table(TableProps),
913    Form(FormProps),
914    Button(ButtonProps),
915    Input(InputProps),
916    Select(SelectProps),
917    Alert(AlertProps),
918    Badge(BadgeProps),
919    Modal(ModalProps),
920    Text(TextProps),
921    Checkbox(CheckboxProps),
922    Switch(SwitchProps),
923    Separator(SeparatorProps),
924    DescriptionList(DescriptionListProps),
925    Tabs(TabsProps),
926    Breadcrumb(BreadcrumbProps),
927    Pagination(PaginationProps),
928    Progress(ProgressProps),
929    Avatar(AvatarProps),
930    Skeleton(SkeletonProps),
931    StatCard(StatCardProps),
932    Checklist(ChecklistProps),
933    Toast(ToastProps),
934    NotificationDropdown(NotificationDropdownProps),
935    Sidebar(SidebarProps),
936    Header(HeaderProps),
937    Grid(GridProps),
938    Collapsible(CollapsibleProps),
939    EmptyState(EmptyStateProps),
940    FormSection(FormSectionProps),
941    PageHeader(PageHeaderProps),
942    ButtonGroup(ButtonGroupProps),
943    DropdownMenu(DropdownMenuProps),
944    KanbanBoard(KanbanBoardProps),
945    CalendarCell(CalendarCellProps),
946    ActionCard(ActionCardProps),
947    ProductTile(ProductTileProps),
948    DataTable(DataTableProps),
949    Image(ImageProps),
950    Plugin(PluginProps),
951}
952
953// ── Custom Serialize for Component ───────────────────────────────────────
954
955/// Helper: serialize a built-in variant by serializing its props, then
956/// injecting `"type": "<name>"` into the resulting JSON object.
957fn serialize_tagged<S: Serializer, T: Serialize>(
958    serializer: S,
959    type_name: &str,
960    props: &T,
961) -> Result<S::Ok, S::Error> {
962    let mut value = serde_json::to_value(props).map_err(serde::ser::Error::custom)?;
963    if let Some(obj) = value.as_object_mut() {
964        obj.insert(
965            "type".to_string(),
966            serde_json::Value::String(type_name.to_string()),
967        );
968    }
969    value.serialize(serializer)
970}
971
972impl Serialize for Component {
973    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
974        match self {
975            Component::Card(p) => serialize_tagged(serializer, "Card", p),
976            Component::Table(p) => serialize_tagged(serializer, "Table", p),
977            Component::Form(p) => serialize_tagged(serializer, "Form", p),
978            Component::Button(p) => serialize_tagged(serializer, "Button", p),
979            Component::Input(p) => serialize_tagged(serializer, "Input", p),
980            Component::Select(p) => serialize_tagged(serializer, "Select", p),
981            Component::Alert(p) => serialize_tagged(serializer, "Alert", p),
982            Component::Badge(p) => serialize_tagged(serializer, "Badge", p),
983            Component::Modal(p) => serialize_tagged(serializer, "Modal", p),
984            Component::Text(p) => serialize_tagged(serializer, "Text", p),
985            Component::Checkbox(p) => serialize_tagged(serializer, "Checkbox", p),
986            Component::Switch(p) => serialize_tagged(serializer, "Switch", p),
987            Component::Separator(p) => serialize_tagged(serializer, "Separator", p),
988            Component::DescriptionList(p) => serialize_tagged(serializer, "DescriptionList", p),
989            Component::Tabs(p) => serialize_tagged(serializer, "Tabs", p),
990            Component::Breadcrumb(p) => serialize_tagged(serializer, "Breadcrumb", p),
991            Component::Pagination(p) => serialize_tagged(serializer, "Pagination", p),
992            Component::Progress(p) => serialize_tagged(serializer, "Progress", p),
993            Component::Avatar(p) => serialize_tagged(serializer, "Avatar", p),
994            Component::Skeleton(p) => serialize_tagged(serializer, "Skeleton", p),
995            Component::StatCard(p) => serialize_tagged(serializer, "StatCard", p),
996            Component::Checklist(p) => serialize_tagged(serializer, "Checklist", p),
997            Component::Toast(p) => serialize_tagged(serializer, "Toast", p),
998            Component::NotificationDropdown(p) => {
999                serialize_tagged(serializer, "NotificationDropdown", p)
1000            }
1001            Component::Sidebar(p) => serialize_tagged(serializer, "Sidebar", p),
1002            Component::Header(p) => serialize_tagged(serializer, "Header", p),
1003            Component::Grid(p) => serialize_tagged(serializer, "Grid", p),
1004            Component::Collapsible(p) => serialize_tagged(serializer, "Collapsible", p),
1005            Component::EmptyState(p) => serialize_tagged(serializer, "EmptyState", p),
1006            Component::FormSection(p) => serialize_tagged(serializer, "FormSection", p),
1007            Component::PageHeader(p) => serialize_tagged(serializer, "PageHeader", p),
1008            Component::ButtonGroup(p) => serialize_tagged(serializer, "ButtonGroup", p),
1009            Component::DropdownMenu(p) => serialize_tagged(serializer, "DropdownMenu", p),
1010            Component::KanbanBoard(p) => serialize_tagged(serializer, "KanbanBoard", p),
1011            Component::CalendarCell(p) => serialize_tagged(serializer, "CalendarCell", p),
1012            Component::ActionCard(p) => serialize_tagged(serializer, "ActionCard", p),
1013            Component::ProductTile(p) => serialize_tagged(serializer, "ProductTile", p),
1014            Component::DataTable(p) => serialize_tagged(serializer, "DataTable", p),
1015            Component::Image(p) => serialize_tagged(serializer, "Image", p),
1016            Component::Plugin(p) => p.serialize(serializer),
1017        }
1018    }
1019}
1020
1021// ── Custom Deserialize for Component ─────────────────────────────────────
1022
1023impl<'de> Deserialize<'de> for Component {
1024    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1025        let value = serde_json::Value::deserialize(deserializer)?;
1026        let type_str = value
1027            .get("type")
1028            .and_then(|v| v.as_str())
1029            .ok_or_else(|| de::Error::missing_field("type"))?;
1030
1031        match type_str {
1032            "Card" => serde_json::from_value::<CardProps>(value)
1033                .map(Component::Card)
1034                .map_err(de::Error::custom),
1035            "Table" => serde_json::from_value::<TableProps>(value)
1036                .map(Component::Table)
1037                .map_err(de::Error::custom),
1038            "Form" => serde_json::from_value::<FormProps>(value)
1039                .map(Component::Form)
1040                .map_err(de::Error::custom),
1041            "Button" => serde_json::from_value::<ButtonProps>(value)
1042                .map(Component::Button)
1043                .map_err(de::Error::custom),
1044            "Input" => serde_json::from_value::<InputProps>(value)
1045                .map(Component::Input)
1046                .map_err(de::Error::custom),
1047            "Select" => serde_json::from_value::<SelectProps>(value)
1048                .map(Component::Select)
1049                .map_err(de::Error::custom),
1050            "Alert" => serde_json::from_value::<AlertProps>(value)
1051                .map(Component::Alert)
1052                .map_err(de::Error::custom),
1053            "Badge" => serde_json::from_value::<BadgeProps>(value)
1054                .map(Component::Badge)
1055                .map_err(de::Error::custom),
1056            "Modal" => serde_json::from_value::<ModalProps>(value)
1057                .map(Component::Modal)
1058                .map_err(de::Error::custom),
1059            "Text" => serde_json::from_value::<TextProps>(value)
1060                .map(Component::Text)
1061                .map_err(de::Error::custom),
1062            "Checkbox" => serde_json::from_value::<CheckboxProps>(value)
1063                .map(Component::Checkbox)
1064                .map_err(de::Error::custom),
1065            "Switch" => serde_json::from_value::<SwitchProps>(value)
1066                .map(Component::Switch)
1067                .map_err(de::Error::custom),
1068            "Separator" => serde_json::from_value::<SeparatorProps>(value)
1069                .map(Component::Separator)
1070                .map_err(de::Error::custom),
1071            "DescriptionList" => serde_json::from_value::<DescriptionListProps>(value)
1072                .map(Component::DescriptionList)
1073                .map_err(de::Error::custom),
1074            "Tabs" => serde_json::from_value::<TabsProps>(value)
1075                .map(Component::Tabs)
1076                .map_err(de::Error::custom),
1077            "Breadcrumb" => serde_json::from_value::<BreadcrumbProps>(value)
1078                .map(Component::Breadcrumb)
1079                .map_err(de::Error::custom),
1080            "Pagination" => serde_json::from_value::<PaginationProps>(value)
1081                .map(Component::Pagination)
1082                .map_err(de::Error::custom),
1083            "Progress" => serde_json::from_value::<ProgressProps>(value)
1084                .map(Component::Progress)
1085                .map_err(de::Error::custom),
1086            "Avatar" => serde_json::from_value::<AvatarProps>(value)
1087                .map(Component::Avatar)
1088                .map_err(de::Error::custom),
1089            "Skeleton" => serde_json::from_value::<SkeletonProps>(value)
1090                .map(Component::Skeleton)
1091                .map_err(de::Error::custom),
1092            "StatCard" => serde_json::from_value::<StatCardProps>(value)
1093                .map(Component::StatCard)
1094                .map_err(de::Error::custom),
1095            "Checklist" => serde_json::from_value::<ChecklistProps>(value)
1096                .map(Component::Checklist)
1097                .map_err(de::Error::custom),
1098            "Toast" => serde_json::from_value::<ToastProps>(value)
1099                .map(Component::Toast)
1100                .map_err(de::Error::custom),
1101            "NotificationDropdown" => serde_json::from_value::<NotificationDropdownProps>(value)
1102                .map(Component::NotificationDropdown)
1103                .map_err(de::Error::custom),
1104            "Sidebar" => serde_json::from_value::<SidebarProps>(value)
1105                .map(Component::Sidebar)
1106                .map_err(de::Error::custom),
1107            "Header" => serde_json::from_value::<HeaderProps>(value)
1108                .map(Component::Header)
1109                .map_err(de::Error::custom),
1110            "Grid" => serde_json::from_value::<GridProps>(value)
1111                .map(Component::Grid)
1112                .map_err(de::Error::custom),
1113            "Collapsible" => serde_json::from_value::<CollapsibleProps>(value)
1114                .map(Component::Collapsible)
1115                .map_err(de::Error::custom),
1116            "EmptyState" => serde_json::from_value::<EmptyStateProps>(value)
1117                .map(Component::EmptyState)
1118                .map_err(de::Error::custom),
1119            "FormSection" => serde_json::from_value::<FormSectionProps>(value)
1120                .map(Component::FormSection)
1121                .map_err(de::Error::custom),
1122            "PageHeader" => serde_json::from_value::<PageHeaderProps>(value)
1123                .map(Component::PageHeader)
1124                .map_err(de::Error::custom),
1125            "ButtonGroup" => serde_json::from_value::<ButtonGroupProps>(value)
1126                .map(Component::ButtonGroup)
1127                .map_err(de::Error::custom),
1128            "DropdownMenu" => serde_json::from_value::<DropdownMenuProps>(value)
1129                .map(Component::DropdownMenu)
1130                .map_err(de::Error::custom),
1131            "KanbanBoard" => serde_json::from_value::<KanbanBoardProps>(value)
1132                .map(Component::KanbanBoard)
1133                .map_err(de::Error::custom),
1134            "CalendarCell" => serde_json::from_value::<CalendarCellProps>(value)
1135                .map(Component::CalendarCell)
1136                .map_err(de::Error::custom),
1137            "ActionCard" => serde_json::from_value::<ActionCardProps>(value)
1138                .map(Component::ActionCard)
1139                .map_err(de::Error::custom),
1140            "ProductTile" => serde_json::from_value::<ProductTileProps>(value)
1141                .map(Component::ProductTile)
1142                .map_err(de::Error::custom),
1143            "DataTable" => serde_json::from_value::<DataTableProps>(value)
1144                .map(Component::DataTable)
1145                .map_err(de::Error::custom),
1146            "Image" => serde_json::from_value::<ImageProps>(value)
1147                .map(Component::Image)
1148                .map_err(de::Error::custom),
1149            _ => {
1150                // Unknown type: treat as a plugin component.
1151                let plugin_type = type_str.to_string();
1152                let mut props = value;
1153                if let Some(obj) = props.as_object_mut() {
1154                    obj.remove("type");
1155                }
1156                Ok(Component::Plugin(PluginProps { plugin_type, props }))
1157            }
1158        }
1159    }
1160}
1161
1162/// A component node wrapping a component with shared fields.
1163///
1164/// Every component in a view tree is wrapped in a `ComponentNode` that
1165/// provides a unique key, optional action binding, and optional visibility
1166/// rules. The component itself is flattened into the node's JSON.
1167// JsonSchema skipped: contains Component via flatten — Component has custom Serialize/Deserialize
1168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1169pub struct ComponentNode {
1170    pub key: String,
1171    #[serde(flatten)]
1172    pub component: Component,
1173    #[serde(default, skip_serializing_if = "Option::is_none")]
1174    pub action: Option<Action>,
1175    #[serde(default, skip_serializing_if = "Option::is_none")]
1176    pub visibility: Option<Visibility>,
1177}
1178
1179impl ComponentNode {
1180    /// Create a Card component node.
1181    pub fn card(key: impl Into<String>, props: CardProps) -> Self {
1182        Self {
1183            key: key.into(),
1184            component: Component::Card(props),
1185            action: None,
1186            visibility: None,
1187        }
1188    }
1189
1190    /// Create a Table component node.
1191    pub fn table(key: impl Into<String>, props: TableProps) -> Self {
1192        Self {
1193            key: key.into(),
1194            component: Component::Table(props),
1195            action: None,
1196            visibility: None,
1197        }
1198    }
1199
1200    /// Create a Form component node.
1201    pub fn form(key: impl Into<String>, props: FormProps) -> Self {
1202        Self {
1203            key: key.into(),
1204            component: Component::Form(props),
1205            action: None,
1206            visibility: None,
1207        }
1208    }
1209
1210    /// Create a Button component node.
1211    pub fn button(key: impl Into<String>, props: ButtonProps) -> Self {
1212        Self {
1213            key: key.into(),
1214            component: Component::Button(props),
1215            action: None,
1216            visibility: None,
1217        }
1218    }
1219
1220    /// Create an Input component node.
1221    pub fn input(key: impl Into<String>, props: InputProps) -> Self {
1222        Self {
1223            key: key.into(),
1224            component: Component::Input(props),
1225            action: None,
1226            visibility: None,
1227        }
1228    }
1229
1230    /// Create a Select component node.
1231    pub fn select(key: impl Into<String>, props: SelectProps) -> Self {
1232        Self {
1233            key: key.into(),
1234            component: Component::Select(props),
1235            action: None,
1236            visibility: None,
1237        }
1238    }
1239
1240    /// Create an Alert component node.
1241    pub fn alert(key: impl Into<String>, props: AlertProps) -> Self {
1242        Self {
1243            key: key.into(),
1244            component: Component::Alert(props),
1245            action: None,
1246            visibility: None,
1247        }
1248    }
1249
1250    /// Create a Badge component node.
1251    pub fn badge(key: impl Into<String>, props: BadgeProps) -> Self {
1252        Self {
1253            key: key.into(),
1254            component: Component::Badge(props),
1255            action: None,
1256            visibility: None,
1257        }
1258    }
1259
1260    /// Create a Modal component node.
1261    pub fn modal(key: impl Into<String>, props: ModalProps) -> Self {
1262        Self {
1263            key: key.into(),
1264            component: Component::Modal(props),
1265            action: None,
1266            visibility: None,
1267        }
1268    }
1269
1270    /// Create a Text component node.
1271    pub fn text(key: impl Into<String>, props: TextProps) -> Self {
1272        Self {
1273            key: key.into(),
1274            component: Component::Text(props),
1275            action: None,
1276            visibility: None,
1277        }
1278    }
1279
1280    /// Create a Checkbox component node.
1281    pub fn checkbox(key: impl Into<String>, props: CheckboxProps) -> Self {
1282        Self {
1283            key: key.into(),
1284            component: Component::Checkbox(props),
1285            action: None,
1286            visibility: None,
1287        }
1288    }
1289
1290    /// Create a Switch component node.
1291    pub fn switch(key: impl Into<String>, props: SwitchProps) -> Self {
1292        Self {
1293            key: key.into(),
1294            component: Component::Switch(props),
1295            action: None,
1296            visibility: None,
1297        }
1298    }
1299
1300    /// Create a Separator component node.
1301    pub fn separator(key: impl Into<String>, props: SeparatorProps) -> Self {
1302        Self {
1303            key: key.into(),
1304            component: Component::Separator(props),
1305            action: None,
1306            visibility: None,
1307        }
1308    }
1309
1310    /// Create a DescriptionList component node.
1311    pub fn description_list(key: impl Into<String>, props: DescriptionListProps) -> Self {
1312        Self {
1313            key: key.into(),
1314            component: Component::DescriptionList(props),
1315            action: None,
1316            visibility: None,
1317        }
1318    }
1319
1320    /// Create a Tabs component node.
1321    pub fn tabs(key: impl Into<String>, props: TabsProps) -> Self {
1322        Self {
1323            key: key.into(),
1324            component: Component::Tabs(props),
1325            action: None,
1326            visibility: None,
1327        }
1328    }
1329
1330    /// Create a Breadcrumb component node.
1331    pub fn breadcrumb(key: impl Into<String>, props: BreadcrumbProps) -> Self {
1332        Self {
1333            key: key.into(),
1334            component: Component::Breadcrumb(props),
1335            action: None,
1336            visibility: None,
1337        }
1338    }
1339
1340    /// Create a Pagination component node.
1341    pub fn pagination(key: impl Into<String>, props: PaginationProps) -> Self {
1342        Self {
1343            key: key.into(),
1344            component: Component::Pagination(props),
1345            action: None,
1346            visibility: None,
1347        }
1348    }
1349
1350    /// Create a Progress component node.
1351    pub fn progress(key: impl Into<String>, props: ProgressProps) -> Self {
1352        Self {
1353            key: key.into(),
1354            component: Component::Progress(props),
1355            action: None,
1356            visibility: None,
1357        }
1358    }
1359
1360    /// Create an Avatar component node.
1361    pub fn avatar(key: impl Into<String>, props: AvatarProps) -> Self {
1362        Self {
1363            key: key.into(),
1364            component: Component::Avatar(props),
1365            action: None,
1366            visibility: None,
1367        }
1368    }
1369
1370    /// Create a Skeleton component node.
1371    pub fn skeleton(key: impl Into<String>, props: SkeletonProps) -> Self {
1372        Self {
1373            key: key.into(),
1374            component: Component::Skeleton(props),
1375            action: None,
1376            visibility: None,
1377        }
1378    }
1379
1380    /// Create a StatCard component node.
1381    pub fn stat_card(key: impl Into<String>, props: StatCardProps) -> Self {
1382        Self {
1383            key: key.into(),
1384            component: Component::StatCard(props),
1385            action: None,
1386            visibility: None,
1387        }
1388    }
1389
1390    /// Create a Checklist component node.
1391    pub fn checklist(key: impl Into<String>, props: ChecklistProps) -> Self {
1392        Self {
1393            key: key.into(),
1394            component: Component::Checklist(props),
1395            action: None,
1396            visibility: None,
1397        }
1398    }
1399
1400    /// Create a Toast component node.
1401    pub fn toast(key: impl Into<String>, props: ToastProps) -> Self {
1402        Self {
1403            key: key.into(),
1404            component: Component::Toast(props),
1405            action: None,
1406            visibility: None,
1407        }
1408    }
1409
1410    /// Create a NotificationDropdown component node.
1411    pub fn notification_dropdown(key: impl Into<String>, props: NotificationDropdownProps) -> Self {
1412        Self {
1413            key: key.into(),
1414            component: Component::NotificationDropdown(props),
1415            action: None,
1416            visibility: None,
1417        }
1418    }
1419
1420    /// Create a Sidebar component node.
1421    pub fn sidebar(key: impl Into<String>, props: SidebarProps) -> Self {
1422        Self {
1423            key: key.into(),
1424            component: Component::Sidebar(props),
1425            action: None,
1426            visibility: None,
1427        }
1428    }
1429
1430    /// Create a Header component node.
1431    pub fn header(key: impl Into<String>, props: HeaderProps) -> Self {
1432        Self {
1433            key: key.into(),
1434            component: Component::Header(props),
1435            action: None,
1436            visibility: None,
1437        }
1438    }
1439
1440    /// Create a Grid component node.
1441    pub fn grid(key: impl Into<String>, props: GridProps) -> Self {
1442        Self {
1443            key: key.into(),
1444            component: Component::Grid(props),
1445            action: None,
1446            visibility: None,
1447        }
1448    }
1449
1450    /// Create a Collapsible component node.
1451    pub fn collapsible(key: impl Into<String>, props: CollapsibleProps) -> Self {
1452        Self {
1453            key: key.into(),
1454            component: Component::Collapsible(props),
1455            action: None,
1456            visibility: None,
1457        }
1458    }
1459
1460    /// Create an EmptyState component node.
1461    pub fn empty_state(key: impl Into<String>, props: EmptyStateProps) -> Self {
1462        Self {
1463            key: key.into(),
1464            component: Component::EmptyState(props),
1465            action: None,
1466            visibility: None,
1467        }
1468    }
1469
1470    /// Create a FormSection component node.
1471    pub fn form_section(key: impl Into<String>, props: FormSectionProps) -> Self {
1472        Self {
1473            key: key.into(),
1474            component: Component::FormSection(props),
1475            action: None,
1476            visibility: None,
1477        }
1478    }
1479
1480    /// Create a DropdownMenu component node.
1481    pub fn dropdown_menu(key: impl Into<String>, props: DropdownMenuProps) -> Self {
1482        Self {
1483            key: key.into(),
1484            component: Component::DropdownMenu(props),
1485            action: None,
1486            visibility: None,
1487        }
1488    }
1489
1490    /// Create a KanbanBoard component node.
1491    pub fn kanban_board(key: impl Into<String>, props: KanbanBoardProps) -> Self {
1492        Self {
1493            key: key.into(),
1494            component: Component::KanbanBoard(props),
1495            action: None,
1496            visibility: None,
1497        }
1498    }
1499
1500    /// Create a CalendarCell component node.
1501    pub fn calendar_cell(key: impl Into<String>, props: CalendarCellProps) -> Self {
1502        Self {
1503            key: key.into(),
1504            component: Component::CalendarCell(props),
1505            action: None,
1506            visibility: None,
1507        }
1508    }
1509
1510    /// Create an ActionCard component node.
1511    pub fn action_card(key: impl Into<String>, props: ActionCardProps) -> Self {
1512        Self {
1513            key: key.into(),
1514            component: Component::ActionCard(props),
1515            action: None,
1516            visibility: None,
1517        }
1518    }
1519
1520    /// Create a ProductTile component node.
1521    pub fn product_tile(key: impl Into<String>, props: ProductTileProps) -> Self {
1522        Self {
1523            key: key.into(),
1524            component: Component::ProductTile(props),
1525            action: None,
1526            visibility: None,
1527        }
1528    }
1529
1530    /// Create a DataTable component node.
1531    pub fn data_table(key: impl Into<String>, props: DataTableProps) -> Self {
1532        Self {
1533            key: key.into(),
1534            component: Component::DataTable(props),
1535            action: None,
1536            visibility: None,
1537        }
1538    }
1539
1540    /// Create an Image component node.
1541    pub fn image(key: impl Into<String>, props: ImageProps) -> Self {
1542        Self {
1543            key: key.into(),
1544            component: Component::Image(props),
1545            action: None,
1546            visibility: None,
1547        }
1548    }
1549
1550    /// Create a Plugin component node.
1551    ///
1552    /// Use `plugin_component` to avoid ambiguity with any `plugin` module.
1553    pub fn plugin_component(key: impl Into<String>, props: PluginProps) -> Self {
1554        Self {
1555            key: key.into(),
1556            component: Component::Plugin(props),
1557            action: None,
1558            visibility: None,
1559        }
1560    }
1561}
1562
1563#[cfg(test)]
1564mod tests {
1565    use super::*;
1566    use crate::action::HttpMethod;
1567    use crate::visibility::{VisibilityCondition, VisibilityOperator};
1568
1569    #[test]
1570    fn card_component_tagged_serialization() {
1571        let card = Component::Card(CardProps {
1572            title: "Test Card".to_string(),
1573            description: Some("A description".to_string()),
1574            children: vec![],
1575            footer: vec![],
1576            max_width: None,
1577        });
1578        let json = serde_json::to_value(&card).unwrap();
1579        assert_eq!(json["type"], "Card");
1580        assert_eq!(json["title"], "Test Card");
1581        assert_eq!(json["description"], "A description");
1582    }
1583
1584    #[test]
1585    fn button_variant_defaults_to_default() {
1586        let json = r#"{"type": "Button", "label": "Click me"}"#;
1587        let component: Component = serde_json::from_str(json).unwrap();
1588        match component {
1589            Component::Button(props) => {
1590                assert_eq!(props.variant, ButtonVariant::Default);
1591                assert_eq!(props.label, "Click me");
1592            }
1593            _ => panic!("expected Button"),
1594        }
1595    }
1596
1597    #[test]
1598    fn input_type_defaults_to_text() {
1599        let json = r#"{"type": "Input", "field": "email", "label": "Email"}"#;
1600        let component: Component = serde_json::from_str(json).unwrap();
1601        match component {
1602            Component::Input(props) => {
1603                assert_eq!(props.input_type, InputType::Text);
1604                assert_eq!(props.field, "email");
1605            }
1606            _ => panic!("expected Input"),
1607        }
1608    }
1609
1610    #[test]
1611    fn alert_variant_defaults_to_info() {
1612        let json = r#"{"type": "Alert", "message": "Hello"}"#;
1613        let component: Component = serde_json::from_str(json).unwrap();
1614        match component {
1615            Component::Alert(props) => assert_eq!(props.variant, AlertVariant::Info),
1616            _ => panic!("expected Alert"),
1617        }
1618    }
1619
1620    #[test]
1621    fn badge_variant_defaults_to_default() {
1622        let json = r#"{"type": "Badge", "label": "New"}"#;
1623        let component: Component = serde_json::from_str(json).unwrap();
1624        match component {
1625            Component::Badge(props) => assert_eq!(props.variant, BadgeVariant::Default),
1626            _ => panic!("expected Badge"),
1627        }
1628    }
1629
1630    #[test]
1631    fn text_element_defaults_to_p() {
1632        let json = r#"{"type": "Text", "content": "Hello world"}"#;
1633        let component: Component = serde_json::from_str(json).unwrap();
1634        match component {
1635            Component::Text(props) => {
1636                assert_eq!(props.element, TextElement::P);
1637                assert_eq!(props.content, "Hello world");
1638            }
1639            _ => panic!("expected Text"),
1640        }
1641    }
1642
1643    #[test]
1644    fn table_component_round_trips() {
1645        let table = Component::Table(TableProps {
1646            columns: vec![
1647                Column {
1648                    key: "name".to_string(),
1649                    label: "Name".to_string(),
1650                    format: None,
1651                },
1652                Column {
1653                    key: "created_at".to_string(),
1654                    label: "Created".to_string(),
1655                    format: Some(ColumnFormat::Date),
1656                },
1657            ],
1658            data_path: "/data/users".to_string(),
1659            row_actions: None,
1660            empty_message: Some("No users found".to_string()),
1661            sortable: None,
1662            sort_column: None,
1663            sort_direction: None,
1664        });
1665        let json = serde_json::to_string(&table).unwrap();
1666        let parsed: Component = serde_json::from_str(&json).unwrap();
1667        assert_eq!(parsed, table);
1668    }
1669
1670    #[test]
1671    fn select_component_round_trips() {
1672        let select = Component::Select(SelectProps {
1673            field: "role".to_string(),
1674            label: "Role".to_string(),
1675            options: vec![
1676                SelectOption {
1677                    value: "admin".to_string(),
1678                    label: "Administrator".to_string(),
1679                },
1680                SelectOption {
1681                    value: "user".to_string(),
1682                    label: "User".to_string(),
1683                },
1684            ],
1685            placeholder: Some("Select a role".to_string()),
1686            required: Some(true),
1687            disabled: None,
1688            error: None,
1689            description: None,
1690            default_value: None,
1691            data_path: None,
1692        });
1693        let json = serde_json::to_string(&select).unwrap();
1694        let parsed: Component = serde_json::from_str(&json).unwrap();
1695        assert_eq!(parsed, select);
1696    }
1697
1698    #[test]
1699    fn modal_component_round_trips() {
1700        let modal = Component::Modal(ModalProps {
1701            id: "modal-confirm".to_string(),
1702            title: "Confirm".to_string(),
1703            description: None,
1704            children: vec![ComponentNode {
1705                key: "msg".to_string(),
1706                component: Component::Text(TextProps {
1707                    content: "Are you sure?".to_string(),
1708                    element: TextElement::P,
1709                }),
1710                action: None,
1711                visibility: None,
1712            }],
1713            footer: vec![],
1714            trigger_label: Some("Open".to_string()),
1715        });
1716        let json = serde_json::to_string(&modal).unwrap();
1717        let parsed: Component = serde_json::from_str(&json).unwrap();
1718        assert_eq!(parsed, modal);
1719    }
1720
1721    #[test]
1722    fn form_component_round_trips() {
1723        let form = Component::Form(FormProps {
1724            action: Action {
1725                handler: "users.store".to_string(),
1726                url: None,
1727                method: HttpMethod::Post,
1728                confirm: None,
1729                on_success: None,
1730                on_error: None,
1731                target: None,
1732            },
1733            fields: vec![ComponentNode {
1734                key: "email-input".to_string(),
1735                component: Component::Input(InputProps {
1736                    field: "email".to_string(),
1737                    label: "Email".to_string(),
1738                    input_type: InputType::Email,
1739                    placeholder: Some("user@example.com".to_string()),
1740                    required: Some(true),
1741                    disabled: None,
1742                    error: None,
1743                    description: None,
1744                    default_value: None,
1745                    data_path: None,
1746                    step: None,
1747                    list: None,
1748                }),
1749                action: None,
1750                visibility: None,
1751            }],
1752            method: None,
1753            guard: None,
1754            max_width: None,
1755        });
1756        let json = serde_json::to_string(&form).unwrap();
1757        let parsed: Component = serde_json::from_str(&json).unwrap();
1758        assert_eq!(parsed, form);
1759    }
1760
1761    #[test]
1762    fn component_node_with_action_and_visibility() {
1763        let node = ComponentNode {
1764            key: "create-btn".to_string(),
1765            component: Component::Button(ButtonProps {
1766                label: "Create User".to_string(),
1767                variant: ButtonVariant::Default,
1768                size: Size::Default,
1769                disabled: None,
1770                icon: None,
1771                icon_position: None,
1772                button_type: None,
1773            }),
1774            action: Some(Action {
1775                handler: "users.create".to_string(),
1776                url: None,
1777                method: HttpMethod::Post,
1778                confirm: None,
1779                on_success: None,
1780                on_error: None,
1781                target: None,
1782            }),
1783            visibility: Some(Visibility::Condition(VisibilityCondition {
1784                path: "/auth/user/role".to_string(),
1785                operator: VisibilityOperator::Eq,
1786                value: Some(serde_json::Value::String("admin".to_string())),
1787            })),
1788        };
1789        let json = serde_json::to_string(&node).unwrap();
1790        let parsed: ComponentNode = serde_json::from_str(&json).unwrap();
1791        assert_eq!(parsed, node);
1792
1793        // Verify flattened structure includes type
1794        let value = serde_json::to_value(&node).unwrap();
1795        assert_eq!(value["type"], "Button");
1796        assert_eq!(value["key"], "create-btn");
1797        assert!(value.get("action").is_some());
1798        assert!(value.get("visibility").is_some());
1799    }
1800
1801    #[test]
1802    fn all_component_variants_serialize() {
1803        let components: Vec<Component> = vec![
1804            Component::Card(CardProps {
1805                title: "t".to_string(),
1806                description: None,
1807                children: vec![],
1808                footer: vec![],
1809                max_width: None,
1810            }),
1811            Component::Table(TableProps {
1812                columns: vec![],
1813                data_path: "/d".to_string(),
1814                row_actions: None,
1815                empty_message: None,
1816                sortable: None,
1817                sort_column: None,
1818                sort_direction: None,
1819            }),
1820            Component::Form(FormProps {
1821                action: Action {
1822                    handler: "h.m".to_string(),
1823                    url: None,
1824                    method: HttpMethod::Post,
1825                    confirm: None,
1826                    on_success: None,
1827                    on_error: None,
1828                    target: None,
1829                },
1830                fields: vec![],
1831                method: None,
1832                guard: None,
1833                max_width: None,
1834            }),
1835            Component::Button(ButtonProps {
1836                label: "b".to_string(),
1837                variant: ButtonVariant::Default,
1838                size: Size::Default,
1839                disabled: None,
1840                icon: None,
1841                icon_position: None,
1842                button_type: None,
1843            }),
1844            Component::Input(InputProps {
1845                field: "f".to_string(),
1846                label: "l".to_string(),
1847                input_type: InputType::Text,
1848                placeholder: None,
1849                required: None,
1850                disabled: None,
1851                error: None,
1852                description: None,
1853                default_value: None,
1854                data_path: None,
1855                step: None,
1856                list: None,
1857            }),
1858            Component::Select(SelectProps {
1859                field: "f".to_string(),
1860                label: "l".to_string(),
1861                options: vec![],
1862                placeholder: None,
1863                required: None,
1864                disabled: None,
1865                error: None,
1866                description: None,
1867                default_value: None,
1868                data_path: None,
1869            }),
1870            Component::Alert(AlertProps {
1871                message: "m".to_string(),
1872                variant: AlertVariant::Info,
1873                title: None,
1874            }),
1875            Component::Badge(BadgeProps {
1876                label: "b".to_string(),
1877                variant: BadgeVariant::Default,
1878            }),
1879            Component::Modal(ModalProps {
1880                id: "modal-t".to_string(),
1881                title: "t".to_string(),
1882                description: None,
1883                children: vec![],
1884                footer: vec![],
1885                trigger_label: None,
1886            }),
1887            Component::Text(TextProps {
1888                content: "c".to_string(),
1889                element: TextElement::P,
1890            }),
1891            Component::Checkbox(CheckboxProps {
1892                field: "f".to_string(),
1893                value: None,
1894                label: "l".to_string(),
1895                description: None,
1896                checked: None,
1897                data_path: None,
1898                required: None,
1899                disabled: None,
1900                error: None,
1901            }),
1902            Component::Switch(SwitchProps {
1903                field: "f".to_string(),
1904                label: "l".to_string(),
1905                description: None,
1906                checked: None,
1907                data_path: None,
1908                required: None,
1909                disabled: None,
1910                error: None,
1911                action: None,
1912            }),
1913            Component::Separator(SeparatorProps { orientation: None }),
1914            Component::DescriptionList(DescriptionListProps {
1915                items: vec![DescriptionItem {
1916                    label: "k".to_string(),
1917                    value: "v".to_string(),
1918                    format: None,
1919                }],
1920                columns: None,
1921            }),
1922            Component::Tabs(TabsProps {
1923                default_tab: "t1".to_string(),
1924                tabs: vec![Tab {
1925                    value: "t1".to_string(),
1926                    label: "Tab 1".to_string(),
1927                    children: vec![],
1928                }],
1929            }),
1930            Component::Breadcrumb(BreadcrumbProps {
1931                items: vec![BreadcrumbItem {
1932                    label: "Home".to_string(),
1933                    url: Some("/".to_string()),
1934                }],
1935            }),
1936            Component::Pagination(PaginationProps {
1937                current_page: 1,
1938                per_page: 10,
1939                total: 100,
1940                base_url: None,
1941            }),
1942            Component::Progress(ProgressProps {
1943                value: 50,
1944                max: None,
1945                label: None,
1946            }),
1947            Component::Avatar(AvatarProps {
1948                src: None,
1949                alt: "User".to_string(),
1950                fallback: Some("U".to_string()),
1951                size: None,
1952            }),
1953            Component::Skeleton(SkeletonProps {
1954                width: None,
1955                height: None,
1956                rounded: None,
1957            }),
1958            Component::StatCard(StatCardProps {
1959                label: "Revenue".to_string(),
1960                value: "$1,234".to_string(),
1961                icon: None,
1962                subtitle: None,
1963                sse_target: None,
1964            }),
1965            Component::Checklist(ChecklistProps {
1966                title: "Tasks".to_string(),
1967                items: vec![],
1968                dismissible: true,
1969                dismiss_label: None,
1970                data_key: None,
1971            }),
1972            Component::Toast(ToastProps {
1973                message: "Saved!".to_string(),
1974                variant: ToastVariant::Success,
1975                timeout: None,
1976                dismissible: true,
1977            }),
1978            Component::NotificationDropdown(NotificationDropdownProps {
1979                notifications: vec![],
1980                empty_text: None,
1981            }),
1982            Component::Sidebar(SidebarProps {
1983                fixed_top: vec![],
1984                groups: vec![],
1985                fixed_bottom: vec![],
1986            }),
1987            Component::Header(HeaderProps {
1988                business_name: "Acme".to_string(),
1989                notification_count: None,
1990                user_name: None,
1991                user_avatar: None,
1992                logout_url: None,
1993            }),
1994            Component::Image(ImageProps {
1995                src: "/img/screenshot.png".to_string(),
1996                alt: "Page screenshot".to_string(),
1997                aspect_ratio: None,
1998                placeholder_label: None,
1999            }),
2000        ];
2001        assert_eq!(components.len(), 27, "should have 27 component variants");
2002        let expected_types = [
2003            "Card",
2004            "Table",
2005            "Form",
2006            "Button",
2007            "Input",
2008            "Select",
2009            "Alert",
2010            "Badge",
2011            "Modal",
2012            "Text",
2013            "Checkbox",
2014            "Switch",
2015            "Separator",
2016            "DescriptionList",
2017            "Tabs",
2018            "Breadcrumb",
2019            "Pagination",
2020            "Progress",
2021            "Avatar",
2022            "Skeleton",
2023            "StatCard",
2024            "Checklist",
2025            "Toast",
2026            "NotificationDropdown",
2027            "Sidebar",
2028            "Header",
2029            "Image",
2030        ];
2031        for (component, expected_type) in components.iter().zip(expected_types.iter()) {
2032            let json = serde_json::to_value(component).unwrap();
2033            assert_eq!(
2034                json["type"], *expected_type,
2035                "component should serialize with type={expected_type}"
2036            );
2037            let roundtripped: Component = serde_json::from_value(json).unwrap();
2038            assert_eq!(&roundtripped, component);
2039        }
2040    }
2041
2042    #[test]
2043    fn size_enum_serialization() {
2044        let cases = [
2045            (Size::Xs, "xs"),
2046            (Size::Sm, "sm"),
2047            (Size::Default, "default"),
2048            (Size::Lg, "lg"),
2049        ];
2050        for (size, expected) in &cases {
2051            let json = serde_json::to_value(size).unwrap();
2052            assert_eq!(json, *expected);
2053            let parsed: Size = serde_json::from_value(json).unwrap();
2054            assert_eq!(&parsed, size);
2055        }
2056    }
2057
2058    #[test]
2059    fn icon_position_serialization() {
2060        let cases = [(IconPosition::Left, "left"), (IconPosition::Right, "right")];
2061        for (pos, expected) in &cases {
2062            let json = serde_json::to_value(pos).unwrap();
2063            assert_eq!(json, *expected);
2064            let parsed: IconPosition = serde_json::from_value(json).unwrap();
2065            assert_eq!(&parsed, pos);
2066        }
2067    }
2068
2069    #[test]
2070    fn sort_direction_serialization() {
2071        let cases = [(SortDirection::Asc, "asc"), (SortDirection::Desc, "desc")];
2072        for (dir, expected) in &cases {
2073            let json = serde_json::to_value(dir).unwrap();
2074            assert_eq!(json, *expected);
2075            let parsed: SortDirection = serde_json::from_value(json).unwrap();
2076            assert_eq!(&parsed, dir);
2077        }
2078    }
2079
2080    #[test]
2081    fn button_with_size_and_icon() {
2082        let button = Component::Button(ButtonProps {
2083            label: "Save".to_string(),
2084            variant: ButtonVariant::Default,
2085            size: Size::Lg,
2086            disabled: None,
2087            icon: Some("save".to_string()),
2088            icon_position: Some(IconPosition::Left),
2089            button_type: None,
2090        });
2091        let json = serde_json::to_value(&button).unwrap();
2092        assert_eq!(json["size"], "lg");
2093        assert_eq!(json["icon"], "save");
2094        assert_eq!(json["icon_position"], "left");
2095        let parsed: Component = serde_json::from_value(json).unwrap();
2096        assert_eq!(parsed, button);
2097    }
2098
2099    #[test]
2100    fn card_with_footer() {
2101        let card = Component::Card(CardProps {
2102            title: "Actions".to_string(),
2103            description: None,
2104            children: vec![],
2105            max_width: None,
2106            footer: vec![ComponentNode {
2107                key: "cancel".to_string(),
2108                component: Component::Button(ButtonProps {
2109                    label: "Cancel".to_string(),
2110                    variant: ButtonVariant::Outline,
2111                    size: Size::Default,
2112                    disabled: None,
2113                    icon: None,
2114                    icon_position: None,
2115                    button_type: None,
2116                }),
2117                action: None,
2118                visibility: None,
2119            }],
2120        });
2121        let json = serde_json::to_value(&card).unwrap();
2122        assert!(json["footer"].is_array());
2123        assert_eq!(json["footer"][0]["label"], "Cancel");
2124        let parsed: Component = serde_json::from_value(json).unwrap();
2125        assert_eq!(parsed, card);
2126    }
2127
2128    #[test]
2129    fn input_with_error_and_description() {
2130        let input = Component::Input(InputProps {
2131            field: "email".to_string(),
2132            label: "Email".to_string(),
2133            input_type: InputType::Email,
2134            placeholder: None,
2135            required: Some(true),
2136            disabled: Some(false),
2137            error: Some("Invalid email".to_string()),
2138            description: Some("Your work email".to_string()),
2139            default_value: Some("user@example.com".to_string()),
2140            data_path: None,
2141            step: None,
2142            list: None,
2143        });
2144        let json = serde_json::to_value(&input).unwrap();
2145        assert_eq!(json["error"], "Invalid email");
2146        assert_eq!(json["description"], "Your work email");
2147        assert_eq!(json["default_value"], "user@example.com");
2148        assert_eq!(json["disabled"], false);
2149        let parsed: Component = serde_json::from_value(json).unwrap();
2150        assert_eq!(parsed, input);
2151    }
2152
2153    #[test]
2154    fn select_with_default_value() {
2155        let select = Component::Select(SelectProps {
2156            field: "role".to_string(),
2157            label: "Role".to_string(),
2158            options: vec![SelectOption {
2159                value: "admin".to_string(),
2160                label: "Admin".to_string(),
2161            }],
2162            placeholder: None,
2163            required: None,
2164            disabled: Some(true),
2165            error: Some("Required field".to_string()),
2166            description: Some("User role".to_string()),
2167            default_value: Some("admin".to_string()),
2168            data_path: None,
2169        });
2170        let json = serde_json::to_value(&select).unwrap();
2171        assert_eq!(json["default_value"], "admin");
2172        assert_eq!(json["error"], "Required field");
2173        assert_eq!(json["description"], "User role");
2174        assert_eq!(json["disabled"], true);
2175        let parsed: Component = serde_json::from_value(json).unwrap();
2176        assert_eq!(parsed, select);
2177    }
2178
2179    #[test]
2180    fn alert_with_title() {
2181        let alert = Component::Alert(AlertProps {
2182            message: "Something happened".to_string(),
2183            variant: AlertVariant::Warning,
2184            title: Some("Warning".to_string()),
2185        });
2186        let json = serde_json::to_value(&alert).unwrap();
2187        assert_eq!(json["title"], "Warning");
2188        assert_eq!(json["message"], "Something happened");
2189        let parsed: Component = serde_json::from_value(json).unwrap();
2190        assert_eq!(parsed, alert);
2191    }
2192
2193    #[test]
2194    fn modal_with_footer_and_description() {
2195        let modal = Component::Modal(ModalProps {
2196            id: "modal-delete-item".to_string(),
2197            title: "Delete Item".to_string(),
2198            description: Some("This action cannot be undone.".to_string()),
2199            children: vec![],
2200            footer: vec![ComponentNode {
2201                key: "confirm".to_string(),
2202                component: Component::Button(ButtonProps {
2203                    label: "Delete".to_string(),
2204                    variant: ButtonVariant::Destructive,
2205                    size: Size::Default,
2206                    disabled: None,
2207                    icon: None,
2208                    icon_position: None,
2209                    button_type: None,
2210                }),
2211                action: None,
2212                visibility: None,
2213            }],
2214            trigger_label: Some("Delete".to_string()),
2215        });
2216        let json = serde_json::to_value(&modal).unwrap();
2217        assert_eq!(json["description"], "This action cannot be undone.");
2218        assert!(json["footer"].is_array());
2219        assert_eq!(json["footer"][0]["label"], "Delete");
2220        let parsed: Component = serde_json::from_value(json).unwrap();
2221        assert_eq!(parsed, modal);
2222    }
2223
2224    #[test]
2225    fn table_with_sort_props() {
2226        let table = Component::Table(TableProps {
2227            columns: vec![Column {
2228                key: "name".to_string(),
2229                label: "Name".to_string(),
2230                format: None,
2231            }],
2232            data_path: "/data/users".to_string(),
2233            row_actions: None,
2234            empty_message: None,
2235            sortable: Some(true),
2236            sort_column: Some("name".to_string()),
2237            sort_direction: Some(SortDirection::Desc),
2238        });
2239        let json = serde_json::to_value(&table).unwrap();
2240        assert_eq!(json["sortable"], true);
2241        assert_eq!(json["sort_column"], "name");
2242        assert_eq!(json["sort_direction"], "desc");
2243        let parsed: Component = serde_json::from_value(json).unwrap();
2244        assert_eq!(parsed, table);
2245    }
2246
2247    #[test]
2248    fn aligned_button_variants_serialize() {
2249        let cases = [
2250            (ButtonVariant::Default, "default"),
2251            (ButtonVariant::Secondary, "secondary"),
2252            (ButtonVariant::Destructive, "destructive"),
2253            (ButtonVariant::Outline, "outline"),
2254            (ButtonVariant::Ghost, "ghost"),
2255            (ButtonVariant::Link, "link"),
2256        ];
2257        for (variant, expected) in &cases {
2258            let json = serde_json::to_value(variant).unwrap();
2259            assert_eq!(
2260                json, *expected,
2261                "ButtonVariant::{variant:?} should serialize as {expected}"
2262            );
2263            let parsed: ButtonVariant = serde_json::from_value(json).unwrap();
2264            assert_eq!(&parsed, variant);
2265        }
2266    }
2267
2268    #[test]
2269    fn aligned_badge_variants_serialize() {
2270        let cases = [
2271            (BadgeVariant::Default, "default"),
2272            (BadgeVariant::Secondary, "secondary"),
2273            (BadgeVariant::Destructive, "destructive"),
2274            (BadgeVariant::Outline, "outline"),
2275        ];
2276        for (variant, expected) in &cases {
2277            let json = serde_json::to_value(variant).unwrap();
2278            assert_eq!(
2279                json, *expected,
2280                "BadgeVariant::{variant:?} should serialize as {expected}"
2281            );
2282            let parsed: BadgeVariant = serde_json::from_value(json).unwrap();
2283            assert_eq!(&parsed, variant);
2284        }
2285    }
2286
2287    #[test]
2288    fn checkbox_round_trips() {
2289        let checkbox = Component::Checkbox(CheckboxProps {
2290            field: "terms".to_string(),
2291            value: None,
2292            label: "Accept Terms".to_string(),
2293            description: Some("You must accept the terms".to_string()),
2294            checked: Some(true),
2295            data_path: None,
2296            required: Some(true),
2297            disabled: Some(false),
2298            error: None,
2299        });
2300        let json = serde_json::to_value(&checkbox).unwrap();
2301        assert_eq!(json["type"], "Checkbox");
2302        assert_eq!(json["field"], "terms");
2303        assert_eq!(json["checked"], true);
2304        assert_eq!(json["description"], "You must accept the terms");
2305        let parsed: Component = serde_json::from_value(json).unwrap();
2306        assert_eq!(parsed, checkbox);
2307    }
2308
2309    #[test]
2310    fn switch_round_trips() {
2311        let switch = Component::Switch(SwitchProps {
2312            field: "notifications".to_string(),
2313            label: "Enable Notifications".to_string(),
2314            description: Some("Receive email notifications".to_string()),
2315            checked: Some(false),
2316            data_path: None,
2317            required: None,
2318            disabled: Some(false),
2319            error: None,
2320            action: None,
2321        });
2322        let json = serde_json::to_value(&switch).unwrap();
2323        assert_eq!(json["type"], "Switch");
2324        assert_eq!(json["field"], "notifications");
2325        assert_eq!(json["checked"], false);
2326        let parsed: Component = serde_json::from_value(json).unwrap();
2327        assert_eq!(parsed, switch);
2328    }
2329
2330    #[test]
2331    fn separator_defaults_to_horizontal() {
2332        let json = r#"{"type": "Separator"}"#;
2333        let component: Component = serde_json::from_str(json).unwrap();
2334        match component {
2335            Component::Separator(props) => {
2336                assert_eq!(props.orientation, None);
2337                // When orientation is None, frontend defaults to horizontal.
2338                // Explicit horizontal also round-trips correctly:
2339                let explicit = Component::Separator(SeparatorProps {
2340                    orientation: Some(Orientation::Horizontal),
2341                });
2342                let v = serde_json::to_value(&explicit).unwrap();
2343                assert_eq!(v["orientation"], "horizontal");
2344                let parsed: Component = serde_json::from_value(v).unwrap();
2345                assert_eq!(parsed, explicit);
2346            }
2347            _ => panic!("expected Separator"),
2348        }
2349    }
2350
2351    #[test]
2352    fn description_list_with_format() {
2353        let dl = Component::DescriptionList(DescriptionListProps {
2354            items: vec![
2355                DescriptionItem {
2356                    label: "Created".to_string(),
2357                    value: "2026-01-15".to_string(),
2358                    format: Some(ColumnFormat::Date),
2359                },
2360                DescriptionItem {
2361                    label: "Name".to_string(),
2362                    value: "Alice".to_string(),
2363                    format: None,
2364                },
2365            ],
2366            columns: Some(2),
2367        });
2368        let json = serde_json::to_value(&dl).unwrap();
2369        assert_eq!(json["type"], "DescriptionList");
2370        assert_eq!(json["columns"], 2);
2371        assert_eq!(json["items"][0]["format"], "date");
2372        assert!(json["items"][1].get("format").is_none());
2373        let parsed: Component = serde_json::from_value(json).unwrap();
2374        assert_eq!(parsed, dl);
2375    }
2376
2377    #[test]
2378    fn checkbox_with_error() {
2379        let checkbox = Component::Checkbox(CheckboxProps {
2380            field: "agree".to_string(),
2381            value: None,
2382            label: "I agree".to_string(),
2383            description: None,
2384            checked: None,
2385            data_path: None,
2386            required: Some(true),
2387            disabled: None,
2388            error: Some("You must agree".to_string()),
2389        });
2390        let json = serde_json::to_value(&checkbox).unwrap();
2391        assert_eq!(json["error"], "You must agree");
2392        assert!(json.get("description").is_none());
2393        assert!(json.get("checked").is_none());
2394        let parsed: Component = serde_json::from_value(json).unwrap();
2395        assert_eq!(parsed, checkbox);
2396    }
2397
2398    #[test]
2399    fn tabs_round_trips() {
2400        let tabs = Component::Tabs(TabsProps {
2401            default_tab: "general".to_string(),
2402            tabs: vec![
2403                Tab {
2404                    value: "general".to_string(),
2405                    label: "General".to_string(),
2406                    children: vec![ComponentNode {
2407                        key: "name-input".to_string(),
2408                        component: Component::Input(InputProps {
2409                            field: "name".to_string(),
2410                            label: "Name".to_string(),
2411                            input_type: InputType::Text,
2412                            placeholder: None,
2413                            required: None,
2414                            disabled: None,
2415                            error: None,
2416                            description: None,
2417                            default_value: None,
2418                            data_path: None,
2419                            step: None,
2420                            list: None,
2421                        }),
2422                        action: None,
2423                        visibility: None,
2424                    }],
2425                },
2426                Tab {
2427                    value: "security".to_string(),
2428                    label: "Security".to_string(),
2429                    children: vec![ComponentNode {
2430                        key: "password-input".to_string(),
2431                        component: Component::Input(InputProps {
2432                            field: "password".to_string(),
2433                            label: "Password".to_string(),
2434                            input_type: InputType::Password,
2435                            placeholder: None,
2436                            required: None,
2437                            disabled: None,
2438                            error: None,
2439                            description: None,
2440                            default_value: None,
2441                            data_path: None,
2442                            step: None,
2443                            list: None,
2444                        }),
2445                        action: None,
2446                        visibility: None,
2447                    }],
2448                },
2449            ],
2450        });
2451        let json = serde_json::to_string(&tabs).unwrap();
2452        let parsed: Component = serde_json::from_str(&json).unwrap();
2453        assert_eq!(parsed, tabs);
2454    }
2455
2456    #[test]
2457    fn breadcrumb_round_trips() {
2458        let breadcrumb = Component::Breadcrumb(BreadcrumbProps {
2459            items: vec![
2460                BreadcrumbItem {
2461                    label: "Home".to_string(),
2462                    url: Some("/".to_string()),
2463                },
2464                BreadcrumbItem {
2465                    label: "Users".to_string(),
2466                    url: Some("/users".to_string()),
2467                },
2468                BreadcrumbItem {
2469                    label: "Edit User".to_string(),
2470                    url: None,
2471                },
2472            ],
2473        });
2474        let json = serde_json::to_string(&breadcrumb).unwrap();
2475        let parsed: Component = serde_json::from_str(&json).unwrap();
2476        assert_eq!(parsed, breadcrumb);
2477
2478        // Verify last item has no URL serialized
2479        let value = serde_json::to_value(&breadcrumb).unwrap();
2480        assert!(value["items"][2].get("url").is_none());
2481    }
2482
2483    #[test]
2484    fn pagination_round_trips() {
2485        let pagination = Component::Pagination(PaginationProps {
2486            current_page: 3,
2487            per_page: 25,
2488            total: 150,
2489            base_url: None,
2490        });
2491        let json = serde_json::to_string(&pagination).unwrap();
2492        let parsed: Component = serde_json::from_str(&json).unwrap();
2493        assert_eq!(parsed, pagination);
2494    }
2495
2496    #[test]
2497    fn progress_round_trips() {
2498        let progress = Component::Progress(ProgressProps {
2499            value: 75,
2500            max: Some(100),
2501            label: Some("Uploading...".to_string()),
2502        });
2503        let json = serde_json::to_string(&progress).unwrap();
2504        let parsed: Component = serde_json::from_str(&json).unwrap();
2505        assert_eq!(parsed, progress);
2506
2507        let value = serde_json::to_value(&progress).unwrap();
2508        assert_eq!(value["value"], 75);
2509        assert_eq!(value["max"], 100);
2510        assert_eq!(value["label"], "Uploading...");
2511    }
2512
2513    #[test]
2514    fn avatar_with_fallback() {
2515        let avatar = Component::Avatar(AvatarProps {
2516            src: None,
2517            alt: "John Doe".to_string(),
2518            fallback: Some("JD".to_string()),
2519            size: Some(Size::Lg),
2520        });
2521        let json = serde_json::to_string(&avatar).unwrap();
2522        let parsed: Component = serde_json::from_str(&json).unwrap();
2523        assert_eq!(parsed, avatar);
2524
2525        let value = serde_json::to_value(&avatar).unwrap();
2526        assert!(value.get("src").is_none());
2527        assert_eq!(value["fallback"], "JD");
2528        assert_eq!(value["size"], "lg");
2529    }
2530
2531    #[test]
2532    fn skeleton_round_trips() {
2533        let skeleton = Component::Skeleton(SkeletonProps {
2534            width: Some("100%".to_string()),
2535            height: Some("40px".to_string()),
2536            rounded: Some(true),
2537        });
2538        let json = serde_json::to_string(&skeleton).unwrap();
2539        let parsed: Component = serde_json::from_str(&json).unwrap();
2540        assert_eq!(parsed, skeleton);
2541
2542        let value = serde_json::to_value(&skeleton).unwrap();
2543        assert_eq!(value["width"], "100%");
2544        assert_eq!(value["height"], "40px");
2545        assert_eq!(value["rounded"], true);
2546    }
2547
2548    #[test]
2549    fn tabs_deserializes_from_json() {
2550        let json = r#"{
2551            "type": "Tabs",
2552            "default_tab": "general",
2553            "tabs": [
2554                {
2555                    "value": "general",
2556                    "label": "General",
2557                    "children": [
2558                        {
2559                            "key": "name-input",
2560                            "type": "Input",
2561                            "field": "name",
2562                            "label": "Name"
2563                        }
2564                    ]
2565                },
2566                {
2567                    "value": "security",
2568                    "label": "Security"
2569                }
2570            ]
2571        }"#;
2572        let component: Component = serde_json::from_str(json).unwrap();
2573        match component {
2574            Component::Tabs(props) => {
2575                assert_eq!(props.default_tab, "general");
2576                assert_eq!(props.tabs.len(), 2);
2577                assert_eq!(props.tabs[0].value, "general");
2578                assert_eq!(props.tabs[0].children.len(), 1);
2579                assert_eq!(props.tabs[1].value, "security");
2580                assert!(props.tabs[1].children.is_empty());
2581            }
2582            _ => panic!("expected Tabs"),
2583        }
2584    }
2585
2586    #[test]
2587    fn input_data_path_round_trips() {
2588        let input = Component::Input(InputProps {
2589            field: "name".to_string(),
2590            label: "Name".to_string(),
2591            input_type: InputType::Text,
2592            placeholder: None,
2593            required: None,
2594            disabled: None,
2595            error: None,
2596            description: None,
2597            default_value: None,
2598            data_path: Some("/data/user/name".to_string()),
2599            step: None,
2600            list: None,
2601        });
2602        let json = serde_json::to_value(&input).unwrap();
2603        assert_eq!(json["data_path"], "/data/user/name");
2604        let parsed: Component = serde_json::from_value(json).unwrap();
2605        assert_eq!(parsed, input);
2606    }
2607
2608    #[test]
2609    fn select_data_path_round_trips() {
2610        let select = Component::Select(SelectProps {
2611            field: "role".to_string(),
2612            label: "Role".to_string(),
2613            options: vec![SelectOption {
2614                value: "admin".to_string(),
2615                label: "Admin".to_string(),
2616            }],
2617            placeholder: None,
2618            required: None,
2619            disabled: None,
2620            error: None,
2621            description: None,
2622            default_value: None,
2623            data_path: Some("/data/user/role".to_string()),
2624        });
2625        let json = serde_json::to_value(&select).unwrap();
2626        assert_eq!(json["data_path"], "/data/user/role");
2627        let parsed: Component = serde_json::from_value(json).unwrap();
2628        assert_eq!(parsed, select);
2629    }
2630
2631    #[test]
2632    fn checkbox_data_path_round_trips() {
2633        let checkbox = Component::Checkbox(CheckboxProps {
2634            field: "terms".to_string(),
2635            value: None,
2636            label: "Accept Terms".to_string(),
2637            description: None,
2638            checked: None,
2639            data_path: Some("/data/user/accepted_terms".to_string()),
2640            required: None,
2641            disabled: None,
2642            error: None,
2643        });
2644        let json = serde_json::to_value(&checkbox).unwrap();
2645        assert_eq!(json["data_path"], "/data/user/accepted_terms");
2646        let parsed: Component = serde_json::from_value(json).unwrap();
2647        assert_eq!(parsed, checkbox);
2648    }
2649
2650    #[test]
2651    fn switch_data_path_round_trips() {
2652        let switch = Component::Switch(SwitchProps {
2653            field: "notifications".to_string(),
2654            label: "Enable Notifications".to_string(),
2655            description: None,
2656            checked: None,
2657            data_path: Some("/data/user/notifications_enabled".to_string()),
2658            required: None,
2659            disabled: None,
2660            error: None,
2661            action: None,
2662        });
2663        let json = serde_json::to_value(&switch).unwrap();
2664        assert_eq!(json["data_path"], "/data/user/notifications_enabled");
2665        let parsed: Component = serde_json::from_value(json).unwrap();
2666        assert_eq!(parsed, switch);
2667    }
2668
2669    // ─── Plugin variant tests ────────────────────────────────────────
2670
2671    #[test]
2672    fn unknown_type_deserializes_as_plugin() {
2673        let json = r#"{"type": "Map", "center": [40.7, -74.0], "zoom": 12}"#;
2674        let component: Component = serde_json::from_str(json).unwrap();
2675        match component {
2676            Component::Plugin(props) => {
2677                assert_eq!(props.plugin_type, "Map");
2678                assert_eq!(props.props["center"][0], 40.7);
2679                assert_eq!(props.props["center"][1], -74.0);
2680                assert_eq!(props.props["zoom"], 12);
2681                // "type" should be removed from props
2682                assert!(props.props.get("type").is_none());
2683            }
2684            _ => panic!("expected Plugin"),
2685        }
2686    }
2687
2688    #[test]
2689    fn plugin_round_trips() {
2690        let plugin = Component::Plugin(PluginProps {
2691            plugin_type: "Chart".to_string(),
2692            props: serde_json::json!({"data": [1, 2, 3], "style": "bar"}),
2693        });
2694        let json = serde_json::to_value(&plugin).unwrap();
2695        assert_eq!(json["type"], "Chart");
2696        assert_eq!(json["data"], serde_json::json!([1, 2, 3]));
2697        assert_eq!(json["style"], "bar");
2698
2699        let parsed: Component = serde_json::from_value(json).unwrap();
2700        assert_eq!(parsed, plugin);
2701    }
2702
2703    #[test]
2704    fn plugin_serializes_with_type_field() {
2705        let plugin = Component::Plugin(PluginProps {
2706            plugin_type: "Map".to_string(),
2707            props: serde_json::json!({"lat": 51.5, "lng": -0.1}),
2708        });
2709        let json = serde_json::to_value(&plugin).unwrap();
2710        assert_eq!(json["type"], "Map");
2711        assert_eq!(json["lat"], 51.5);
2712        assert_eq!(json["lng"], -0.1);
2713    }
2714
2715    #[test]
2716    fn plugin_with_empty_props() {
2717        let json = r#"{"type": "CustomWidget"}"#;
2718        let component: Component = serde_json::from_str(json).unwrap();
2719        match component {
2720            Component::Plugin(props) => {
2721                assert_eq!(props.plugin_type, "CustomWidget");
2722                assert!(props.props.as_object().unwrap().is_empty());
2723            }
2724            _ => panic!("expected Plugin"),
2725        }
2726    }
2727
2728    #[test]
2729    fn plugin_in_component_node() {
2730        let node = ComponentNode {
2731            key: "map-1".to_string(),
2732            component: Component::Plugin(PluginProps {
2733                plugin_type: "Map".to_string(),
2734                props: serde_json::json!({"center": [0.0, 0.0]}),
2735            }),
2736            action: None,
2737            visibility: None,
2738        };
2739        let json = serde_json::to_string(&node).unwrap();
2740        let parsed: ComponentNode = serde_json::from_str(&json).unwrap();
2741        assert_eq!(parsed, node);
2742
2743        let value = serde_json::to_value(&node).unwrap();
2744        assert_eq!(value["type"], "Map");
2745        assert_eq!(value["key"], "map-1");
2746    }
2747
2748    #[test]
2749    fn known_types_not_treated_as_plugin() {
2750        // All known type names must still deserialize to their specific variants.
2751        let known_types = [
2752            "Card",
2753            "Table",
2754            "Form",
2755            "Button",
2756            "Input",
2757            "Select",
2758            "Alert",
2759            "Badge",
2760            "Modal",
2761            "Text",
2762            "Checkbox",
2763            "Switch",
2764            "Separator",
2765            "DescriptionList",
2766            "Tabs",
2767            "Breadcrumb",
2768            "Pagination",
2769            "Progress",
2770            "Avatar",
2771            "Skeleton",
2772        ];
2773        for type_name in &known_types {
2774            // Construct minimal valid JSON for each type (using the
2775            // all_component_variants_serialize test data format).
2776            let json_str = match *type_name {
2777                "Card" => r#"{"type":"Card","title":"t"}"#,
2778                "Table" => r#"{"type":"Table","columns":[],"data_path":"/d"}"#,
2779                "Form" => r#"{"type":"Form","action":{"handler":"h","method":"POST"},"fields":[]}"#,
2780                "Button" => r#"{"type":"Button","label":"b"}"#,
2781                "Input" => r#"{"type":"Input","field":"f","label":"l"}"#,
2782                "Select" => r#"{"type":"Select","field":"f","label":"l","options":[]}"#,
2783                "Alert" => r#"{"type":"Alert","message":"m"}"#,
2784                "Badge" => r#"{"type":"Badge","label":"b"}"#,
2785                "Modal" => r#"{"type":"Modal","id":"modal-t","title":"t"}"#,
2786                "Text" => r#"{"type":"Text","content":"c"}"#,
2787                "Checkbox" => r#"{"type":"Checkbox","field":"f","label":"l"}"#,
2788                "Switch" => r#"{"type":"Switch","field":"f","label":"l"}"#,
2789                "Separator" => r#"{"type":"Separator"}"#,
2790                "DescriptionList" => r#"{"type":"DescriptionList","items":[]}"#,
2791                "Tabs" => r#"{"type":"Tabs","default_tab":"t","tabs":[]}"#,
2792                "Breadcrumb" => r#"{"type":"Breadcrumb","items":[]}"#,
2793                "Pagination" => r#"{"type":"Pagination","current_page":1,"per_page":10,"total":0}"#,
2794                "Progress" => r#"{"type":"Progress","value":0}"#,
2795                "Avatar" => r#"{"type":"Avatar","alt":"a"}"#,
2796                "Skeleton" => r#"{"type":"Skeleton"}"#,
2797                _ => unreachable!(),
2798            };
2799            let component: Component = serde_json::from_str(json_str).unwrap();
2800            assert!(
2801                !matches!(component, Component::Plugin(_)),
2802                "type {type_name} should not deserialize as Plugin"
2803            );
2804        }
2805    }
2806
2807    // ── Serde round-trip tests for 6 new components ──────────────────────
2808
2809    #[test]
2810    fn test_stat_card_serde_round_trip() {
2811        let component = Component::StatCard(StatCardProps {
2812            label: "Orders".into(),
2813            value: "42".into(),
2814            icon: Some("package".into()),
2815            subtitle: Some("today".into()),
2816            sse_target: Some("orders_today".into()),
2817        });
2818        let json = serde_json::to_string(&component).unwrap();
2819        assert!(json.contains("\"type\":\"StatCard\""));
2820        assert!(json.contains("\"sse_target\":\"orders_today\""));
2821        let deserialized: Component = serde_json::from_str(&json).unwrap();
2822        assert_eq!(component, deserialized);
2823    }
2824
2825    #[test]
2826    fn test_checklist_serde_round_trip() {
2827        let component = Component::Checklist(ChecklistProps {
2828            title: "Getting Started".into(),
2829            items: vec![
2830                ChecklistItem {
2831                    label: "Install dependencies".into(),
2832                    checked: true,
2833                    href: None,
2834                },
2835                ChecklistItem {
2836                    label: "Read the docs".into(),
2837                    checked: false,
2838                    href: Some("/docs".into()),
2839                },
2840            ],
2841            dismissible: true,
2842            dismiss_label: Some("Dismiss".into()),
2843            data_key: Some("onboarding".into()),
2844        });
2845        let json = serde_json::to_string(&component).unwrap();
2846        assert!(json.contains("\"type\":\"Checklist\""));
2847        assert!(json.contains("\"data_key\":\"onboarding\""));
2848        let deserialized: Component = serde_json::from_str(&json).unwrap();
2849        assert_eq!(component, deserialized);
2850    }
2851
2852    #[test]
2853    fn test_toast_serde_round_trip() {
2854        let component = Component::Toast(ToastProps {
2855            message: "Operation completed".into(),
2856            variant: ToastVariant::Success,
2857            timeout: Some(10),
2858            dismissible: true,
2859        });
2860        let json = serde_json::to_string(&component).unwrap();
2861        assert!(json.contains("\"type\":\"Toast\""));
2862        assert!(json.contains("\"timeout\":10"));
2863        let deserialized: Component = serde_json::from_str(&json).unwrap();
2864        assert_eq!(component, deserialized);
2865    }
2866
2867    #[test]
2868    fn test_notification_dropdown_serde_round_trip() {
2869        let component = Component::NotificationDropdown(NotificationDropdownProps {
2870            notifications: vec![
2871                NotificationItem {
2872                    icon: Some("bell".into()),
2873                    text: "New message".into(),
2874                    timestamp: Some("2m ago".into()),
2875                    read: false,
2876                    action_url: Some("/messages/1".into()),
2877                },
2878                NotificationItem {
2879                    icon: None,
2880                    text: "Old notification".into(),
2881                    timestamp: None,
2882                    read: true,
2883                    action_url: None,
2884                },
2885            ],
2886            empty_text: Some("No notifications".into()),
2887        });
2888        let json = serde_json::to_string(&component).unwrap();
2889        assert!(json.contains("\"type\":\"NotificationDropdown\""));
2890        assert!(json.contains("\"empty_text\":\"No notifications\""));
2891        let deserialized: Component = serde_json::from_str(&json).unwrap();
2892        assert_eq!(component, deserialized);
2893    }
2894
2895    #[test]
2896    fn test_sidebar_serde_round_trip() {
2897        let component = Component::Sidebar(SidebarProps {
2898            fixed_top: vec![SidebarNavItem {
2899                label: "Dashboard".into(),
2900                href: "/dashboard".into(),
2901                icon: Some("home".into()),
2902                active: true,
2903            }],
2904            groups: vec![SidebarGroup {
2905                label: "Management".into(),
2906                collapsed: false,
2907                items: vec![SidebarNavItem {
2908                    label: "Users".into(),
2909                    href: "/users".into(),
2910                    icon: None,
2911                    active: false,
2912                }],
2913            }],
2914            fixed_bottom: vec![SidebarNavItem {
2915                label: "Settings".into(),
2916                href: "/settings".into(),
2917                icon: Some("gear".into()),
2918                active: false,
2919            }],
2920        });
2921        let json = serde_json::to_string(&component).unwrap();
2922        assert!(json.contains("\"type\":\"Sidebar\""));
2923        assert!(json.contains("\"fixed_top\""));
2924        let deserialized: Component = serde_json::from_str(&json).unwrap();
2925        assert_eq!(component, deserialized);
2926    }
2927
2928    #[test]
2929    fn test_header_serde_round_trip() {
2930        let component = Component::Header(HeaderProps {
2931            business_name: "Acme Corp".into(),
2932            notification_count: Some(5),
2933            user_name: Some("Jane Doe".into()),
2934            user_avatar: Some("/avatar.jpg".into()),
2935            logout_url: Some("/logout".into()),
2936        });
2937        let json = serde_json::to_string(&component).unwrap();
2938        assert!(json.contains("\"type\":\"Header\""));
2939        assert!(json.contains("\"business_name\":\"Acme Corp\""));
2940        assert!(json.contains("\"notification_count\":5"));
2941        let deserialized: Component = serde_json::from_str(&json).unwrap();
2942        assert_eq!(component, deserialized);
2943    }
2944
2945    // ── Convenience constructor tests ─────────────────────────────────────
2946
2947    #[test]
2948    fn test_stat_card_constructor() {
2949        let props = StatCardProps {
2950            label: "Revenue".into(),
2951            value: "$1,000".into(),
2952            icon: None,
2953            subtitle: None,
2954            sse_target: None,
2955        };
2956        let node = ComponentNode::stat_card("revenue-card", props.clone());
2957        assert_eq!(node.key, "revenue-card");
2958        assert!(node.action.is_none());
2959        assert!(node.visibility.is_none());
2960        assert_eq!(node.component, Component::StatCard(props));
2961    }
2962
2963    #[test]
2964    fn test_checklist_constructor() {
2965        let props = ChecklistProps {
2966            title: "Tasks".into(),
2967            items: vec![],
2968            dismissible: true,
2969            dismiss_label: None,
2970            data_key: None,
2971        };
2972        let node = ComponentNode::checklist("task-list", props.clone());
2973        assert_eq!(node.key, "task-list");
2974        assert!(node.action.is_none());
2975        assert!(node.visibility.is_none());
2976        assert_eq!(node.component, Component::Checklist(props));
2977    }
2978
2979    #[test]
2980    fn test_toast_constructor() {
2981        let props = ToastProps {
2982            message: "Done!".into(),
2983            variant: ToastVariant::Success,
2984            timeout: None,
2985            dismissible: true,
2986        };
2987        let node = ComponentNode::toast("success-toast", props.clone());
2988        assert_eq!(node.key, "success-toast");
2989        assert!(node.action.is_none());
2990        assert!(node.visibility.is_none());
2991        assert_eq!(node.component, Component::Toast(props));
2992    }
2993
2994    #[test]
2995    fn test_notification_dropdown_constructor() {
2996        let props = NotificationDropdownProps {
2997            notifications: vec![],
2998            empty_text: Some("All caught up!".into()),
2999        };
3000        let node = ComponentNode::notification_dropdown("notifs", props.clone());
3001        assert_eq!(node.key, "notifs");
3002        assert!(node.action.is_none());
3003        assert!(node.visibility.is_none());
3004        assert_eq!(node.component, Component::NotificationDropdown(props));
3005    }
3006
3007    #[test]
3008    fn test_sidebar_constructor() {
3009        let props = SidebarProps {
3010            fixed_top: vec![],
3011            groups: vec![],
3012            fixed_bottom: vec![],
3013        };
3014        let node = ComponentNode::sidebar("main-nav", props.clone());
3015        assert_eq!(node.key, "main-nav");
3016        assert!(node.action.is_none());
3017        assert!(node.visibility.is_none());
3018        assert_eq!(node.component, Component::Sidebar(props));
3019    }
3020
3021    #[test]
3022    fn test_header_constructor() {
3023        let props = HeaderProps {
3024            business_name: "MyApp".into(),
3025            notification_count: None,
3026            user_name: None,
3027            user_avatar: None,
3028            logout_url: None,
3029        };
3030        let node = ComponentNode::header("page-header", props.clone());
3031        assert_eq!(node.key, "page-header");
3032        assert!(node.action.is_none());
3033        assert!(node.visibility.is_none());
3034        assert_eq!(node.component, Component::Header(props));
3035    }
3036
3037    // ── Sub-type serde tests ─────────────────────────────────────────────
3038
3039    #[test]
3040    fn test_checklist_item_round_trip() {
3041        let checked_item = ChecklistItem {
3042            label: "Completed task".into(),
3043            checked: true,
3044            href: Some("/task/1".into()),
3045        };
3046        let json = serde_json::to_string(&checked_item).unwrap();
3047        let parsed: ChecklistItem = serde_json::from_str(&json).unwrap();
3048        assert_eq!(parsed, checked_item);
3049
3050        let unchecked_item = ChecklistItem {
3051            label: "Pending task".into(),
3052            checked: false,
3053            href: None,
3054        };
3055        let json = serde_json::to_string(&unchecked_item).unwrap();
3056        let parsed: ChecklistItem = serde_json::from_str(&json).unwrap();
3057        assert_eq!(parsed, unchecked_item);
3058        // href is None — should be omitted
3059        assert!(!json.contains("href"));
3060    }
3061
3062    #[test]
3063    fn test_sidebar_group_round_trip() {
3064        let expanded = SidebarGroup {
3065            label: "Main".into(),
3066            collapsed: false,
3067            items: vec![
3068                SidebarNavItem {
3069                    label: "Home".into(),
3070                    href: "/".into(),
3071                    icon: Some("home".into()),
3072                    active: true,
3073                },
3074                SidebarNavItem {
3075                    label: "About".into(),
3076                    href: "/about".into(),
3077                    icon: None,
3078                    active: false,
3079                },
3080            ],
3081        };
3082        let json = serde_json::to_string(&expanded).unwrap();
3083        let parsed: SidebarGroup = serde_json::from_str(&json).unwrap();
3084        assert_eq!(parsed, expanded);
3085        assert_eq!(parsed.items.len(), 2);
3086
3087        let collapsed = SidebarGroup {
3088            label: "Advanced".into(),
3089            collapsed: true,
3090            items: vec![],
3091        };
3092        let json = serde_json::to_string(&collapsed).unwrap();
3093        let parsed: SidebarGroup = serde_json::from_str(&json).unwrap();
3094        assert_eq!(parsed, collapsed);
3095        assert!(parsed.collapsed);
3096    }
3097
3098    #[test]
3099    fn test_notification_item_round_trip() {
3100        let unread = NotificationItem {
3101            icon: Some("mail".into()),
3102            text: "You have a new message".into(),
3103            timestamp: Some("5m ago".into()),
3104            read: false,
3105            action_url: Some("/messages/42".into()),
3106        };
3107        let json = serde_json::to_string(&unread).unwrap();
3108        let parsed: NotificationItem = serde_json::from_str(&json).unwrap();
3109        assert_eq!(parsed, unread);
3110        assert!(!parsed.read);
3111
3112        let read_notif = NotificationItem {
3113            icon: None,
3114            text: "Welcome to the platform".into(),
3115            timestamp: None,
3116            read: true,
3117            action_url: None,
3118        };
3119        let json = serde_json::to_string(&read_notif).unwrap();
3120        let parsed: NotificationItem = serde_json::from_str(&json).unwrap();
3121        assert_eq!(parsed, read_notif);
3122        assert!(parsed.read);
3123        // Optional fields with None should be omitted
3124        assert!(!json.contains("\"icon\""));
3125        assert!(!json.contains("\"action_url\""));
3126    }
3127
3128    // ── Edge case tests ──────────────────────────────────────────────────
3129
3130    #[test]
3131    fn test_stat_card_all_optionals_none() {
3132        let component = Component::StatCard(StatCardProps {
3133            label: "Count".into(),
3134            value: "0".into(),
3135            icon: None,
3136            subtitle: None,
3137            sse_target: None,
3138        });
3139        let json = serde_json::to_string(&component).unwrap();
3140        assert!(json.contains("\"type\":\"StatCard\""));
3141        assert!(!json.contains("\"icon\""));
3142        assert!(!json.contains("\"subtitle\""));
3143        assert!(!json.contains("\"sse_target\""));
3144        let deserialized: Component = serde_json::from_str(&json).unwrap();
3145        assert_eq!(component, deserialized);
3146    }
3147
3148    #[test]
3149    fn test_checklist_empty_items() {
3150        let component = Component::Checklist(ChecklistProps {
3151            title: "Empty List".into(),
3152            items: vec![],
3153            dismissible: true,
3154            dismiss_label: None,
3155            data_key: None,
3156        });
3157        let json = serde_json::to_string(&component).unwrap();
3158        assert!(json.contains("\"type\":\"Checklist\""));
3159        let deserialized: Component = serde_json::from_str(&json).unwrap();
3160        assert_eq!(component, deserialized);
3161        match &deserialized {
3162            Component::Checklist(props) => assert!(props.items.is_empty()),
3163            _ => panic!("expected Checklist"),
3164        }
3165    }
3166
3167    #[test]
3168    fn test_sidebar_empty_groups_and_fixed() {
3169        let component = Component::Sidebar(SidebarProps {
3170            fixed_top: vec![],
3171            groups: vec![],
3172            fixed_bottom: vec![],
3173        });
3174        let json = serde_json::to_string(&component).unwrap();
3175        assert!(json.contains("\"type\":\"Sidebar\""));
3176        // Empty vecs should be omitted (skip_serializing_if = "Vec::is_empty")
3177        assert!(!json.contains("\"fixed_top\""));
3178        assert!(!json.contains("\"groups\""));
3179        assert!(!json.contains("\"fixed_bottom\""));
3180        let deserialized: Component = serde_json::from_str(&json).unwrap();
3181        assert_eq!(component, deserialized);
3182    }
3183
3184    #[test]
3185    fn test_notification_dropdown_empty_uses_empty_text() {
3186        let component = Component::NotificationDropdown(NotificationDropdownProps {
3187            notifications: vec![],
3188            empty_text: Some("Nothing here!".into()),
3189        });
3190        let json = serde_json::to_string(&component).unwrap();
3191        assert!(json.contains("\"type\":\"NotificationDropdown\""));
3192        assert!(json.contains("\"empty_text\":\"Nothing here!\""));
3193        let deserialized: Component = serde_json::from_str(&json).unwrap();
3194        assert_eq!(component, deserialized);
3195    }
3196
3197    // ── Optional field skip_serializing tests ────────────────────────────
3198
3199    #[test]
3200    fn test_stat_card_omits_sse_target_when_none() {
3201        let component = Component::StatCard(StatCardProps {
3202            label: "Revenue".into(),
3203            value: "$500".into(),
3204            icon: None,
3205            subtitle: None,
3206            sse_target: None,
3207        });
3208        let json = serde_json::to_string(&component).unwrap();
3209        assert!(
3210            !json.contains("sse_target"),
3211            "sse_target must be omitted when None"
3212        );
3213    }
3214
3215    // ── Grid tests ────────────────────────────────────────────────────
3216
3217    #[test]
3218    fn grid_round_trips() {
3219        let grid = Component::Grid(GridProps {
3220            columns: 3,
3221            md_columns: None,
3222            lg_columns: None,
3223            gap: GapSize::Lg,
3224            scrollable: None,
3225            children: vec![ComponentNode::text(
3226                "t",
3227                TextProps {
3228                    content: "cell".into(),
3229                    element: TextElement::P,
3230                },
3231            )],
3232        });
3233        let json = serde_json::to_value(&grid).unwrap();
3234        assert_eq!(json["type"], "Grid");
3235        assert_eq!(json["columns"], 3);
3236        assert_eq!(json["gap"], "lg");
3237        let parsed: Component = serde_json::from_value(json).unwrap();
3238        assert_eq!(parsed, grid);
3239    }
3240
3241    #[test]
3242    fn grid_defaults() {
3243        let json = serde_json::json!({"type": "Grid"});
3244        let parsed: Component = serde_json::from_value(json).unwrap();
3245        match parsed {
3246            Component::Grid(props) => {
3247                assert_eq!(props.columns, 2);
3248                assert_eq!(props.gap, GapSize::Md);
3249                assert!(props.children.is_empty());
3250            }
3251            _ => panic!("expected Grid"),
3252        }
3253    }
3254
3255    // ── Collapsible tests ────────────────────────────────────────────
3256
3257    #[test]
3258    fn collapsible_round_trips() {
3259        let c = Component::Collapsible(CollapsibleProps {
3260            title: "Details".into(),
3261            expanded: true,
3262            children: vec![],
3263        });
3264        let json = serde_json::to_value(&c).unwrap();
3265        assert_eq!(json["type"], "Collapsible");
3266        assert_eq!(json["title"], "Details");
3267        assert_eq!(json["expanded"], true);
3268        let parsed: Component = serde_json::from_value(json).unwrap();
3269        assert_eq!(parsed, c);
3270    }
3271
3272    // ── EmptyState tests ─────────────────────────────────────────────
3273
3274    #[test]
3275    fn empty_state_round_trips() {
3276        let es = Component::EmptyState(EmptyStateProps {
3277            title: "No items".into(),
3278            description: Some("Create one".into()),
3279            action: Some(Action::get("items.create")),
3280            action_label: Some("New item".into()),
3281        });
3282        let json = serde_json::to_value(&es).unwrap();
3283        assert_eq!(json["type"], "EmptyState");
3284        assert_eq!(json["title"], "No items");
3285        let parsed: Component = serde_json::from_value(json).unwrap();
3286        assert_eq!(parsed, es);
3287    }
3288
3289    #[test]
3290    fn empty_state_minimal() {
3291        let json = serde_json::json!({"type": "EmptyState", "title": "Nothing"});
3292        let parsed: Component = serde_json::from_value(json).unwrap();
3293        match parsed {
3294            Component::EmptyState(props) => {
3295                assert_eq!(props.title, "Nothing");
3296                assert!(props.description.is_none());
3297                assert!(props.action.is_none());
3298                assert!(props.action_label.is_none());
3299            }
3300            _ => panic!("expected EmptyState"),
3301        }
3302    }
3303
3304    // ── FormSection tests ────────────────────────────────────────────
3305
3306    #[test]
3307    fn form_section_round_trips() {
3308        let fs = Component::FormSection(FormSectionProps {
3309            title: "Contact".into(),
3310            description: Some("Your details".into()),
3311            children: vec![],
3312            layout: None,
3313        });
3314        let json = serde_json::to_value(&fs).unwrap();
3315        assert_eq!(json["type"], "FormSection");
3316        assert_eq!(json["title"], "Contact");
3317        let parsed: Component = serde_json::from_value(json).unwrap();
3318        assert_eq!(parsed, fs);
3319    }
3320
3321    // ── Switch action tests ──────────────────────────────────────────
3322
3323    #[test]
3324    fn switch_with_action_round_trips() {
3325        let sw = Component::Switch(SwitchProps {
3326            field: "active".into(),
3327            label: "Active".into(),
3328            description: None,
3329            checked: Some(true),
3330            data_path: None,
3331            required: None,
3332            disabled: None,
3333            error: None,
3334            action: Some(Action::new("settings.toggle")),
3335        });
3336        let json = serde_json::to_value(&sw).unwrap();
3337        assert!(json["action"].is_object());
3338        assert_eq!(json["action"]["handler"], "settings.toggle");
3339        let parsed: Component = serde_json::from_value(json).unwrap();
3340        assert_eq!(parsed, sw);
3341    }
3342
3343    #[test]
3344    fn switch_without_action_omits_field() {
3345        let sw = Component::Switch(SwitchProps {
3346            field: "f".into(),
3347            label: "l".into(),
3348            description: None,
3349            checked: None,
3350            data_path: None,
3351            required: None,
3352            disabled: None,
3353            error: None,
3354            action: None,
3355        });
3356        let json = serde_json::to_string(&sw).unwrap();
3357        assert!(!json.contains("\"action\""));
3358    }
3359
3360    #[test]
3361    fn test_toast_omits_timeout_when_none() {
3362        let component = Component::Toast(ToastProps {
3363            message: "Hello".into(),
3364            variant: ToastVariant::Info,
3365            timeout: None,
3366            dismissible: false,
3367        });
3368        let json = serde_json::to_string(&component).unwrap();
3369        assert!(
3370            !json.contains("\"timeout\""),
3371            "timeout must be omitted when None"
3372        );
3373    }
3374
3375    #[test]
3376    fn page_header_round_trip_title_only() {
3377        let component = Component::PageHeader(PageHeaderProps {
3378            title: "Test Title".to_string(),
3379            breadcrumb: vec![],
3380            actions: vec![],
3381        });
3382        let json = serde_json::to_value(&component).unwrap();
3383        assert_eq!(json["type"], "PageHeader");
3384        assert_eq!(json["title"], "Test Title");
3385        // Empty vecs are omitted.
3386        assert!(json.get("breadcrumb").is_none());
3387        assert!(json.get("actions").is_none());
3388        let parsed: Component = serde_json::from_value(json).unwrap();
3389        assert_eq!(parsed, component);
3390    }
3391
3392    #[test]
3393    fn page_header_round_trip_with_breadcrumb_and_actions() {
3394        let component = Component::PageHeader(PageHeaderProps {
3395            title: "Users".to_string(),
3396            breadcrumb: vec![
3397                BreadcrumbItem {
3398                    label: "Home".to_string(),
3399                    url: Some("/".to_string()),
3400                },
3401                BreadcrumbItem {
3402                    label: "Users".to_string(),
3403                    url: None,
3404                },
3405            ],
3406            actions: vec![ComponentNode {
3407                key: "add-btn".to_string(),
3408                component: Component::Button(ButtonProps {
3409                    label: "Add User".to_string(),
3410                    variant: ButtonVariant::Default,
3411                    size: Size::Default,
3412                    disabled: None,
3413                    icon: None,
3414                    icon_position: None,
3415                    button_type: None,
3416                }),
3417                action: None,
3418                visibility: None,
3419            }],
3420        });
3421        let json = serde_json::to_string(&component).unwrap();
3422        let parsed: Component = serde_json::from_str(&json).unwrap();
3423        assert_eq!(parsed, component);
3424        // Verify type field present.
3425        let value = serde_json::to_value(&component).unwrap();
3426        assert_eq!(value["type"], "PageHeader");
3427        assert_eq!(value["title"], "Users");
3428        assert!(value["breadcrumb"].is_array());
3429        assert!(value["actions"].is_array());
3430    }
3431
3432    #[test]
3433    fn page_header_deserialize_from_json() {
3434        let json = r#"{"type":"PageHeader","title":"Test"}"#;
3435        let component: Component = serde_json::from_str(json).unwrap();
3436        match component {
3437            Component::PageHeader(props) => {
3438                assert_eq!(props.title, "Test");
3439                assert!(props.breadcrumb.is_empty());
3440                assert!(props.actions.is_empty());
3441            }
3442            _ => panic!("expected PageHeader"),
3443        }
3444    }
3445
3446    #[test]
3447    fn button_group_round_trip_empty() {
3448        let component = Component::ButtonGroup(ButtonGroupProps { buttons: vec![] });
3449        let json = serde_json::to_value(&component).unwrap();
3450        assert_eq!(json["type"], "ButtonGroup");
3451        // Empty vec omitted.
3452        assert!(json.get("buttons").is_none());
3453        let parsed: Component = serde_json::from_value(json).unwrap();
3454        assert_eq!(parsed, component);
3455    }
3456
3457    #[test]
3458    fn button_group_round_trip_with_buttons() {
3459        let component = Component::ButtonGroup(ButtonGroupProps {
3460            buttons: vec![
3461                ComponentNode {
3462                    key: "save".to_string(),
3463                    component: Component::Button(ButtonProps {
3464                        label: "Save".to_string(),
3465                        variant: ButtonVariant::Default,
3466                        size: Size::Default,
3467                        disabled: None,
3468                        icon: None,
3469                        icon_position: None,
3470                        button_type: None,
3471                    }),
3472                    action: None,
3473                    visibility: None,
3474                },
3475                ComponentNode {
3476                    key: "cancel".to_string(),
3477                    component: Component::Button(ButtonProps {
3478                        label: "Cancel".to_string(),
3479                        variant: ButtonVariant::Outline,
3480                        size: Size::Default,
3481                        disabled: None,
3482                        icon: None,
3483                        icon_position: None,
3484                        button_type: None,
3485                    }),
3486                    action: None,
3487                    visibility: None,
3488                },
3489            ],
3490        });
3491        let json = serde_json::to_string(&component).unwrap();
3492        let parsed: Component = serde_json::from_str(&json).unwrap();
3493        assert_eq!(parsed, component);
3494        let value = serde_json::to_value(&component).unwrap();
3495        assert_eq!(value["type"], "ButtonGroup");
3496        assert!(value["buttons"].is_array());
3497        assert_eq!(value["buttons"].as_array().unwrap().len(), 2);
3498    }
3499
3500    #[test]
3501    fn button_group_deserialize_from_json() {
3502        let json = r#"{"type":"ButtonGroup","buttons":[]}"#;
3503        let component: Component = serde_json::from_str(json).unwrap();
3504        match component {
3505            Component::ButtonGroup(props) => {
3506                assert!(props.buttons.is_empty());
3507            }
3508            _ => panic!("expected ButtonGroup"),
3509        }
3510    }
3511
3512    #[test]
3513    fn image_round_trips() {
3514        let json = r#"{"type": "Image", "src": "/img/s.png", "alt": "Screenshot"}"#;
3515        let component: Component = serde_json::from_str(json).unwrap();
3516        match component {
3517            Component::Image(props) => {
3518                assert_eq!(props.src, "/img/s.png");
3519                assert_eq!(props.alt, "Screenshot");
3520                assert!(props.aspect_ratio.is_none());
3521            }
3522            _ => panic!("expected Image"),
3523        }
3524    }
3525
3526    #[test]
3527    fn all_known_types_round_trip() {
3528        let known_types: &[(&str, &str)] = &[
3529            ("Alert", r#"{"type":"Alert","message":"m"}"#),
3530            ("Avatar", r#"{"type":"Avatar","alt":"a"}"#),
3531            ("Badge", r#"{"type":"Badge","label":"b"}"#),
3532            ("Breadcrumb", r#"{"type":"Breadcrumb","items":[]}"#),
3533            ("Button", r#"{"type":"Button","label":"b"}"#),
3534            ("CalendarCell", r#"{"type":"CalendarCell","day":1}"#),
3535            ("Checkbox", r#"{"type":"Checkbox","field":"f","label":"l"}"#),
3536            ("Image", r#"{"type":"Image","src":"/img/s.png","alt":"a"}"#),
3537            ("Input", r#"{"type":"Input","field":"f","label":"l"}"#),
3538            (
3539                "Pagination",
3540                r#"{"type":"Pagination","current_page":1,"per_page":10,"total":0}"#,
3541            ),
3542            ("Progress", r#"{"type":"Progress","value":50}"#),
3543            (
3544                "Select",
3545                r#"{"type":"Select","field":"f","label":"l","options":[]}"#,
3546            ),
3547            ("Separator", r#"{"type":"Separator"}"#),
3548            ("Skeleton", r#"{"type":"Skeleton"}"#),
3549            ("Text", r#"{"type":"Text","content":"c"}"#),
3550        ];
3551        for (type_name, json_str) in known_types {
3552            let component: Component = serde_json::from_str(json_str)
3553                .unwrap_or_else(|e| panic!("failed to parse {type_name}: {e}"));
3554            let serialized = serde_json::to_value(&component).unwrap();
3555            assert_eq!(
3556                serialized["type"], *type_name,
3557                "type mismatch for {type_name}"
3558            );
3559            let reparsed: Component = serde_json::from_value(serialized)
3560                .unwrap_or_else(|e| panic!("failed to reparse {type_name}: {e}"));
3561            assert_eq!(
3562                serde_json::to_value(&reparsed).unwrap()["type"],
3563                *type_name,
3564                "round-trip type mismatch for {type_name}"
3565            );
3566        }
3567    }
3568}