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