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