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