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