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::{Deserialize, Serialize};
8
9use crate::action::Action;
10
11/// Shared size enum for components (Button, Badge, Avatar, Input).
12#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
13#[serde(rename_all = "snake_case")]
14pub enum Size {
15    Xs,
16    Sm,
17    #[default]
18    Default,
19    Lg,
20}
21
22/// Icon placement relative to button label.
23#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
24#[serde(rename_all = "snake_case")]
25pub enum IconPosition {
26    #[default]
27    Left,
28    Right,
29}
30
31/// Sort direction for table columns.
32#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
33#[serde(rename_all = "snake_case")]
34pub enum SortDirection {
35    #[default]
36    Asc,
37    Desc,
38}
39
40/// Separator orientation.
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
42#[serde(rename_all = "snake_case")]
43pub enum Orientation {
44    #[default]
45    Horizontal,
46    Vertical,
47}
48
49/// Button visual variants (aligned to shadcn/ui).
50#[derive(
51    Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, strum::AsRefStr,
52)]
53#[serde(rename_all = "snake_case")]
54#[strum(serialize_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    File,
82}
83
84/// Alert visual variants.
85#[derive(
86    Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, strum::AsRefStr,
87)]
88#[serde(rename_all = "snake_case")]
89#[strum(serialize_all = "snake_case")]
90pub enum AlertVariant {
91    #[default]
92    Info,
93    Success,
94    Warning,
95    Error,
96}
97
98/// Badge visual variants (aligned to shadcn/ui).
99#[derive(
100    Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, strum::AsRefStr,
101)]
102#[serde(rename_all = "snake_case")]
103#[strum(serialize_all = "snake_case")]
104pub enum BadgeVariant {
105    #[default]
106    Default,
107    Secondary,
108    Destructive,
109    Outline,
110}
111
112/// Text element types for semantic HTML rendering.
113#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
114#[serde(rename_all = "snake_case")]
115pub enum TextElement {
116    #[default]
117    P,
118    H1,
119    H2,
120    H3,
121    Span,
122    Div,
123    Section,
124}
125
126/// Column display format for tables.
127///
128/// `Badge` cells expect the row value to be an object `{variant, label}` matching
129/// [`BadgeProps`]. Other variants are display hints layered over plain cell text.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
131#[serde(rename_all = "snake_case")]
132pub enum ColumnFormat {
133    Date,
134    DateTime,
135    Currency,
136    Boolean,
137    Badge,
138    /// Cell value is an image URL string; rendered as an `<img>` thumbnail.
139    Image,
140}
141
142/// Table column definition.
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
144pub struct Column {
145    pub key: String,
146    pub label: String,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub format: Option<ColumnFormat>,
149}
150
151/// Select option (value + label pair).
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
153pub struct SelectOption {
154    pub value: String,
155    pub label: String,
156}
157
158/// Visual variant for Card chrome.
159///
160/// - `Bordered` (default): `border + bg-card + shadow-sm` with `p-4`.
161///   Dashboard cards in dense layouts.
162/// - `Elevated`: `bg-card + shadow-md` (no border) with `p-8`.
163///   Auth pages, error pages, standalone marketing cards.
164#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
165#[serde(rename_all = "snake_case")]
166pub enum CardVariant {
167    #[default]
168    Bordered,
169    Elevated,
170}
171
172/// Props for Card component.
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
174pub struct CardProps {
175    pub title: String,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub description: Option<String>,
178    /// Optional muted secondary line rendered immediately below the title and
179    /// above the description. Pattern: name → role, customer → staff,
180    /// title → category. Visually `text-sm text-text-muted`.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub subtitle: Option<String>,
183    /// Optional small badge text rendered alongside the title. Visually a
184    /// Badge-styled pill inside the Card chrome — for status indicators,
185    /// counters, countdown labels, etc. Independent of the title hierarchy.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub badge: Option<String>,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub max_width: Option<FormMaxWidth>,
190    /// IDs of footer elements (resolved against `Spec.elements`).
191    #[serde(default, skip_serializing_if = "Vec::is_empty")]
192    pub footer: Vec<String>,
193    #[serde(default)]
194    pub variant: CardVariant,
195}
196
197/// Props for Table component.
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
199pub struct TableProps {
200    pub columns: Vec<Column>,
201    pub data_path: String,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub row_actions: Option<Vec<Action>>,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub empty_message: Option<String>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub sortable: Option<bool>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub sort_column: Option<String>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub sort_direction: Option<SortDirection>,
212}
213
214/// Maximum width constraint for form containers.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
216#[serde(rename_all = "snake_case")]
217pub enum FormMaxWidth {
218    #[default]
219    Default,
220    Narrow,
221    Wide,
222}
223
224/// Props for Form component.
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
226pub struct FormProps {
227    pub action: Action,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub method: Option<crate::action::HttpMethod>,
230    /// Form guard type. When set, the runtime JS disables the submit button
231    /// until the guard condition is met. Value: `"number-gt-0"` — at least
232    /// one number input must have value > 0.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub guard: Option<String>,
235    /// Optional max-width constraint for the form container.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub max_width: Option<FormMaxWidth>,
238    /// Optional HTML `id` attribute for the rendered `<form>`. Pair with a
239    /// Button's `form` prop to submit this form from a button placed outside
240    /// it (e.g. in a PageHeader actions slot).
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub id: Option<String>,
243    /// HTML form `enctype` attribute. Set to `"multipart/form-data"` for forms
244    /// carrying a file input. Without this, the browser default encoding
245    /// (`application/x-www-form-urlencoded`) is used and file inputs are sent
246    /// as plain text rather than a multipart body.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub enctype: Option<String>,
249}
250
251/// HTML button type attribute.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
253#[serde(rename_all = "snake_case")]
254pub enum ButtonType {
255    #[default]
256    Button,
257    Submit,
258}
259
260/// Props for Button component.
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
262pub struct ButtonProps {
263    pub label: String,
264    #[serde(default)]
265    pub variant: ButtonVariant,
266    #[serde(default)]
267    pub size: Size,
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub disabled: Option<bool>,
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub icon: Option<String>,
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub icon_position: Option<IconPosition>,
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub button_type: Option<ButtonType>,
276    /// HTML5 `form` attribute. Lets a submit button rendered outside its
277    /// target `<form>` (e.g. in a PageHeader actions slot) still submit
278    /// that form, by matching the form's `id`.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub form: Option<String>,
281}
282
283/// Props for Input component.
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
285pub struct InputProps {
286    /// Form field name for data binding.
287    pub field: String,
288    pub label: String,
289    #[serde(default)]
290    pub input_type: InputType,
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub placeholder: Option<String>,
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub required: Option<bool>,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub disabled: Option<bool>,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub error: Option<String>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub description: Option<String>,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub default_value: Option<String>,
303    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub data_path: Option<String>,
306    /// HTML step attribute for number inputs (e.g., "any", "0.01").
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub step: Option<String>,
309    /// HTML datalist id for autocomplete suggestions.
310    /// When set, renders `list="..."` on the input and a companion `<datalist>`
311    /// whose options come from a view data key matching this id.
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub list: Option<String>,
314    /// HTML `accept` attribute for `input_type = "file"`. Comma-separated MIME
315    /// types or extensions (e.g. `"image/jpeg,image/png,image/webp"`). Browser-
316    /// side filter hint only — server-side MIME validation is the consumer's
317    /// responsibility (the spec layer does not enforce file content type).
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub accept: Option<String>,
320}
321
322/// Props for RichTextEditor leaf element — rendered by the Quill 2.0.3 plugin.
323///
324/// The plugin emits a container div (`<div data-ferro-quill ...>`) and a hidden
325/// input that receives the editor's HTML on every text-change event. The form
326/// handler receives standard `field=<html>` POST data on submit.
327///
328/// # Security
329/// The editor produces user-controlled HTML. Sanitization on submit is the
330/// consumer's responsibility — handle this in the form handler before
331/// persisting (e.g. via `ammonia`).
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
333pub struct RichTextEditorProps {
334    pub field: String,
335    pub label: String,
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub placeholder: Option<String>,
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub default_value: Option<String>,
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub data_path: Option<String>,
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub error: Option<String>,
344}
345
346/// Props for Select component.
347#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
348pub struct SelectProps {
349    /// Form field name for data binding.
350    pub field: String,
351    pub label: String,
352    pub options: Vec<SelectOption>,
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub placeholder: Option<String>,
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub required: Option<bool>,
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub disabled: Option<bool>,
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub error: Option<String>,
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub description: Option<String>,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub default_value: Option<String>,
365    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub data_path: Option<String>,
368}
369
370/// Props for Alert component.
371#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
372pub struct AlertProps {
373    pub message: String,
374    #[serde(default)]
375    pub variant: AlertVariant,
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub title: Option<String>,
378}
379
380/// Props for Badge component.
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
382pub struct BadgeProps {
383    pub label: String,
384    #[serde(default)]
385    pub variant: BadgeVariant,
386}
387
388/// Props for Modal component.
389#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
390pub struct ModalProps {
391    pub id: String,
392    pub title: String,
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub description: Option<String>,
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub trigger_label: Option<String>,
397    /// IDs of footer elements (resolved against `Spec.elements`).
398    #[serde(default, skip_serializing_if = "Vec::is_empty")]
399    pub footer: Vec<String>,
400}
401
402/// Props for Text component.
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
404pub struct TextProps {
405    pub content: String,
406    #[serde(default)]
407    pub element: TextElement,
408}
409
410/// Props for Checkbox component.
411#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
412pub struct CheckboxProps {
413    /// Form field name for data binding.
414    pub field: String,
415    /// HTML value attribute. When set, the checkbox submits this value instead of "1".
416    /// Required for multi-value checkbox groups (same name, different values).
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub value: Option<String>,
419    pub label: String,
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub description: Option<String>,
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub checked: Option<bool>,
424    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub data_path: Option<String>,
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub required: Option<bool>,
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub disabled: Option<bool>,
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub error: Option<String>,
433}
434
435/// Props for CheckboxList component — multi-select checkbox group.
436///
437/// Each checked option submits as `field=value`. Options may be supplied
438/// statically via `options` or resolved at render time from `options_path`.
439/// Pre-selected values are read from `selected_path` (a `Vec<String>`).
440#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
441pub struct CheckboxListProps {
442    /// Shared form field name; each checkbox submits as `field=value`.
443    pub field: String,
444    /// Static options list. When empty and `options_path` is set, options are
445    /// resolved from the data at render time.
446    #[serde(default, skip_serializing_if = "Vec::is_empty")]
447    pub options: Vec<SelectOption>,
448    /// Data path to an array of `{value, label}` objects for data-driven options.
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub options_path: Option<String>,
451    /// Data path to a `Vec<String>` of pre-selected values.
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub selected_path: Option<String>,
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub label: Option<String>,
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub description: Option<String>,
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub disabled: Option<bool>,
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub error: Option<String>,
462}
463
464/// Props for Switch component.
465#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
466pub struct SwitchProps {
467    /// Form field name for data binding.
468    pub field: String,
469    pub label: String,
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub description: Option<String>,
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub checked: Option<bool>,
474    /// Data path for pre-filling from handler data (e.g., "/data/user/name").
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    pub data_path: Option<String>,
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub required: Option<bool>,
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub disabled: Option<bool>,
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub error: Option<String>,
483    /// Auto-submit action. When set, the switch renders inside a minimal
484    /// form and submits on change.
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub action: Option<Action>,
487    /// When true, applies `scale-75 origin-left` CSS to the switch container
488    /// for compact inline display (e.g. per-row settings toggles).
489    #[serde(default, skip_serializing_if = "Option::is_none")]
490    pub compact: Option<bool>,
491}
492
493/// Props for Separator component.
494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
495pub struct SeparatorProps {
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub orientation: Option<Orientation>,
498}
499
500/// A single item in a description list.
501#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
502pub struct DescriptionItem {
503    pub label: String,
504    pub value: String,
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub format: Option<ColumnFormat>,
507}
508
509/// Props for DescriptionList component.
510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
511pub struct DescriptionListProps {
512    #[serde(default, skip_serializing_if = "Vec::is_empty")]
513    pub items: Vec<DescriptionItem>,
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    pub columns: Option<u8>,
516    /// Optional data-path override of `items`. When set, the renderer
517    /// resolves the array at this path and decodes each entry as a
518    /// `DescriptionItem`.
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub data_path: Option<String>,
521}
522
523/// A single tab within a Tabs component.
524#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
525pub struct Tab {
526    pub value: String,
527    pub label: String,
528    /// IDs of elements rendered inside this tab's panel.
529    #[serde(default, skip_serializing_if = "Vec::is_empty")]
530    pub children: Vec<String>,
531}
532
533/// Props for Tabs component.
534#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
535pub struct TabsProps {
536    pub default_tab: String,
537    pub tabs: Vec<Tab>,
538}
539
540/// A single item in a breadcrumb trail.
541#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
542pub struct BreadcrumbItem {
543    pub label: String,
544    #[serde(default, skip_serializing_if = "Option::is_none")]
545    pub url: Option<String>,
546}
547
548/// Props for Breadcrumb component.
549#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
550pub struct BreadcrumbProps {
551    pub items: Vec<BreadcrumbItem>,
552}
553
554/// Props for Pagination component.
555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
556pub struct PaginationProps {
557    pub current_page: u32,
558    pub per_page: u32,
559    pub total: u32,
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub base_url: Option<String>,
562}
563
564/// Props for Progress component.
565#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
566pub struct ProgressProps {
567    /// Percentage value (0-100).
568    pub value: u8,
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub max: Option<u8>,
571    #[serde(default, skip_serializing_if = "Option::is_none")]
572    pub label: Option<String>,
573}
574
575/// Props for Image component.
576#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
577pub struct ImageProps {
578    #[serde(default)]
579    pub src: String,
580    pub alt: String,
581    #[serde(default, skip_serializing_if = "Option::is_none")]
582    pub aspect_ratio: Option<String>,
583    /// Optional label shown in a skeleton placeholder that sits behind the
584    /// image. When the image fails to load (or is still being generated),
585    /// the `<img>` is hidden via `onerror` and the placeholder remains
586    /// visible, keeping the container at its aspect-ratio size.
587    #[serde(default, skip_serializing_if = "Option::is_none")]
588    pub placeholder_label: Option<String>,
589    /// Server-rendered inline SVG string. When set, the SVG is emitted verbatim
590    /// inside a `<div aria-label="{alt}">` wrapper; no `<img>` tag is produced.
591    ///
592    /// # Safety
593    /// Content is NOT sanitized. The SVG string is emitted into the response
594    /// verbatim. Pass only server-constructed SVG (e.g. bar charts, QR codes).
595    /// Do NOT pass untrusted input. `alt` is required and is HTML-escaped.
596    #[serde(default, skip_serializing_if = "Option::is_none")]
597    pub inline_svg: Option<String>,
598    /// Optional data-path override of `src`. When set, the renderer resolves
599    /// the value at this path against handler data and uses it as the
600    /// `<img src>`. Falls back to `src` when missing or non-string.
601    #[serde(default, skip_serializing_if = "Option::is_none")]
602    pub data_path: Option<String>,
603}
604
605impl ImageProps {
606    /// Convenience constructor for inline-SVG images. `src` is set to the
607    /// empty string; the renderer takes the SVG path when `inline_svg` is `Some`.
608    ///
609    /// # Safety
610    /// `svg` is emitted verbatim. See [`ImageProps::inline_svg`] for the trust model.
611    pub fn inline_svg(svg: impl Into<String>, alt: impl Into<String>) -> Self {
612        Self {
613            src: String::new(),
614            alt: alt.into(),
615            aspect_ratio: None,
616            placeholder_label: None,
617            inline_svg: Some(svg.into()),
618            data_path: None,
619        }
620    }
621}
622
623/// Props for Avatar component.
624#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
625pub struct AvatarProps {
626    #[serde(default, skip_serializing_if = "Option::is_none")]
627    pub src: Option<String>,
628    pub alt: String,
629    #[serde(default, skip_serializing_if = "Option::is_none")]
630    pub fallback: Option<String>,
631    #[serde(default, skip_serializing_if = "Option::is_none")]
632    pub size: Option<Size>,
633}
634
635/// Props for Skeleton loading placeholder.
636#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
637pub struct SkeletonProps {
638    #[serde(default, skip_serializing_if = "Option::is_none")]
639    pub width: Option<String>,
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub height: Option<String>,
642    #[serde(default, skip_serializing_if = "Option::is_none")]
643    pub rounded: Option<bool>,
644}
645
646/// Props for the `RawHtml` component — server-injected HTML island.
647///
648/// # Safety
649/// `html` is emitted into the response VERBATIM with NO sanitization. The
650/// component exists to bridge server-rendered HTML fragments (e.g. a status
651/// pill, a link badge) into a v2 spec where a first-class component would
652/// be over-engineering.
653///
654/// Sanitization is the CONSUMER's responsibility — pass only server-
655/// constructed HTML, or run untrusted input through a sanitiser (e.g.
656/// `ammonia`) in the handler before embedding. This mirrors
657/// `RichTextEditorProps` discipline (see component.rs).
658///
659/// For richer widgets (interactive forms, charts, OAuth flows), use the
660/// first-class plugin system (`JsonUiPlugin`) instead — see
661/// `docs/src/json-ui/plugins.md`.
662#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
663pub struct RawHtmlProps {
664    /// Server-constructed HTML emitted verbatim. NOT sanitized.
665    #[serde(default)]
666    pub html: String,
667}
668
669/// Props for the `StreamText` component — SSE token stream renderer.
670///
671/// Connects to `sse_url` via the browser `EventSource` API and appends arriving
672/// tokens as plain text nodes. The SSE endpoint MUST emit `event: done` on
673/// completion to prevent `EventSource` auto-reconnect.
674#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
675pub struct StreamTextProps {
676    /// URL of the server-sent-events endpoint that streams tokens.
677    /// Must emit `event: done` on completion.
678    #[serde(default)]
679    pub sse_url: String,
680    /// Text shown inside the content area before the first token arrives.
681    #[serde(default, skip_serializing_if = "Option::is_none")]
682    pub placeholder: Option<String>,
683    /// Status text shown while the stream is open.
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub loading_text: Option<String>,
686}
687
688/// Toast visual variants.
689#[derive(
690    Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, strum::AsRefStr,
691)]
692#[serde(rename_all = "snake_case")]
693#[strum(serialize_all = "snake_case")]
694pub enum ToastVariant {
695    #[default]
696    Info,
697    Success,
698    Warning,
699    Error,
700}
701
702/// A single item in a checklist.
703#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
704pub struct ChecklistItem {
705    pub label: String,
706    #[serde(default)]
707    pub checked: bool,
708    #[serde(default, skip_serializing_if = "Option::is_none")]
709    pub href: Option<String>,
710}
711
712/// A single item in a notification dropdown.
713#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
714pub struct NotificationItem {
715    #[serde(default, skip_serializing_if = "Option::is_none")]
716    pub icon: Option<String>,
717    pub text: String,
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    pub timestamp: Option<String>,
720    #[serde(default)]
721    pub read: bool,
722    #[serde(default, skip_serializing_if = "Option::is_none")]
723    pub action_url: Option<String>,
724}
725
726/// A single navigation item in the sidebar.
727#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
728pub struct SidebarNavItem {
729    pub label: String,
730    pub href: String,
731    #[serde(default, skip_serializing_if = "Option::is_none")]
732    pub icon: Option<String>,
733    #[serde(default)]
734    pub active: bool,
735    /// When true, the item renders as a muted, non-clickable `<span>`
736    /// instead of an `<a>` — useful for "coming soon" placeholders.
737    #[serde(default, skip_serializing_if = "Option::is_none")]
738    pub disabled: Option<bool>,
739}
740
741/// A collapsible group in the sidebar.
742#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
743pub struct SidebarGroup {
744    pub label: String,
745    #[serde(default)]
746    pub collapsed: bool,
747    pub items: Vec<SidebarNavItem>,
748}
749
750/// Props for StatCard component (live-updatable metric card).
751#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
752pub struct StatCardProps {
753    pub label: String,
754    pub value: String,
755    #[serde(default, skip_serializing_if = "Option::is_none")]
756    pub icon: Option<String>,
757    #[serde(default, skip_serializing_if = "Option::is_none")]
758    pub subtitle: Option<String>,
759    /// SSE target key for live updates; maps to `data-sse-target` on the value element.
760    #[serde(default, skip_serializing_if = "Option::is_none")]
761    pub sse_target: Option<String>,
762    /// Resolves the initial displayed value from handler data at render time.
763    /// Format: `/segment/segment` (same JSON-pointer as `data::resolve_path`).
764    /// Falls back to `value` when missing or non-string. Mirrors
765    /// `ImageProps.data_path` / `DescriptionListProps.data_path`.
766    #[serde(default, skip_serializing_if = "Option::is_none")]
767    pub value_path: Option<String>,
768}
769
770/// Props for Checklist component.
771#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
772pub struct ChecklistProps {
773    pub title: String,
774    pub items: Vec<ChecklistItem>,
775    #[serde(default = "default_true")]
776    pub dismissible: bool,
777    #[serde(default, skip_serializing_if = "Option::is_none")]
778    pub dismiss_label: Option<String>,
779    /// Server-side state persistence key for this checklist.
780    #[serde(default, skip_serializing_if = "Option::is_none")]
781    pub data_key: Option<String>,
782}
783
784fn default_true() -> bool {
785    true
786}
787
788/// Props for Toast component (declarative notification intent).
789///
790/// The JS runtime reads data attributes from the rendered element to
791/// display the toast. Timeouts and dismissal are handled client-side.
792#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
793pub struct ToastProps {
794    pub message: String,
795    #[serde(default)]
796    pub variant: ToastVariant,
797    /// Seconds before auto-dismiss. Default 5.
798    #[serde(default, skip_serializing_if = "Option::is_none")]
799    pub timeout: Option<u32>,
800    #[serde(default = "default_true")]
801    pub dismissible: bool,
802}
803
804/// Props for NotificationDropdown component.
805#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
806pub struct NotificationDropdownProps {
807    pub notifications: Vec<NotificationItem>,
808    #[serde(default, skip_serializing_if = "Option::is_none")]
809    pub empty_text: Option<String>,
810}
811
812/// Props for Sidebar component.
813#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
814pub struct SidebarProps {
815    #[serde(default, skip_serializing_if = "Vec::is_empty")]
816    pub fixed_top: Vec<SidebarNavItem>,
817    #[serde(default, skip_serializing_if = "Vec::is_empty")]
818    pub groups: Vec<SidebarGroup>,
819    #[serde(default, skip_serializing_if = "Vec::is_empty")]
820    pub fixed_bottom: Vec<SidebarNavItem>,
821}
822
823/// Props for Header component.
824#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
825pub struct HeaderProps {
826    pub business_name: String,
827    /// Unread notification count for badge display.
828    #[serde(default, skip_serializing_if = "Option::is_none")]
829    pub notification_count: Option<u32>,
830    #[serde(default, skip_serializing_if = "Option::is_none")]
831    pub user_name: Option<String>,
832    #[serde(default, skip_serializing_if = "Option::is_none")]
833    pub user_avatar: Option<String>,
834    #[serde(default, skip_serializing_if = "Option::is_none")]
835    pub logout_url: Option<String>,
836}
837
838/// Gap size for Grid layout.
839#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
840#[serde(rename_all = "snake_case")]
841pub enum GapSize {
842    None,
843    Sm,
844    #[default]
845    Md,
846    Lg,
847    Xl,
848}
849
850/// Props for Grid component — multi-column layout.
851#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
852pub struct GridProps {
853    /// Number of columns (1-12) at base (mobile) viewport.
854    #[serde(default = "default_grid_columns")]
855    pub columns: u8,
856    /// Number of columns at md breakpoint (768px+). When set, creates a responsive grid.
857    #[serde(default, skip_serializing_if = "Option::is_none")]
858    pub md_columns: Option<u8>,
859    /// Number of columns at lg breakpoint (1024px+). Optional; falls back to md.
860    #[serde(default, skip_serializing_if = "Option::is_none")]
861    pub lg_columns: Option<u8>,
862    /// Gap between grid items.
863    #[serde(default)]
864    pub gap: GapSize,
865    /// Enables horizontal scroll mode. Children get `min-w-[280px]` and the grid
866    /// uses `grid-flow-col` auto-cols layout for Trello-like horizontal scrolling.
867    #[serde(default, skip_serializing_if = "Option::is_none")]
868    pub scrollable: Option<bool>,
869}
870
871fn default_grid_columns() -> u8 {
872    2
873}
874
875/// Props for Collapsible section — expandable `<details>`/`<summary>`.
876#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
877pub struct CollapsibleProps {
878    pub title: String,
879    #[serde(default)]
880    pub expanded: bool,
881}
882
883/// Props for EmptyState component — standardized empty view.
884#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
885pub struct EmptyStateProps {
886    pub title: String,
887    #[serde(default, skip_serializing_if = "Option::is_none")]
888    pub description: Option<String>,
889    #[serde(default, skip_serializing_if = "Option::is_none")]
890    pub action: Option<Action>,
891    #[serde(default, skip_serializing_if = "Option::is_none")]
892    pub action_label: Option<String>,
893}
894
895/// Layout variant for form sections.
896#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
897#[serde(rename_all = "snake_case")]
898pub enum FormSectionLayout {
899    #[default]
900    Stacked,
901    TwoColumn,
902}
903
904/// Props for FormSection component — visual grouping within forms.
905#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
906pub struct FormSectionProps {
907    pub title: String,
908    #[serde(default, skip_serializing_if = "Option::is_none")]
909    pub description: Option<String>,
910    /// Optional layout variant. Defaults to stacked (single column).
911    #[serde(default, skip_serializing_if = "Option::is_none")]
912    pub layout: Option<FormSectionLayout>,
913}
914
915/// Props for PageHeader component -- page title with optional breadcrumb and action buttons.
916#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
917pub struct PageHeaderProps {
918    pub title: String,
919    #[serde(default, skip_serializing_if = "Vec::is_empty")]
920    pub breadcrumb: Vec<BreadcrumbItem>,
921    /// IDs of action button elements rendered to the right of the title.
922    #[serde(
923        default,
924        deserialize_with = "deserialize_actions_lax",
925        skip_serializing_if = "Vec::is_empty"
926    )]
927    pub actions: Vec<String>,
928}
929
930/// Props for ButtonGroup component -- horizontal button row with consistent gap.
931#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
932pub struct ButtonGroupProps {
933    /// Gap between buttons. Defaults to small spacing.
934    #[serde(default)]
935    pub gap: GapSize,
936}
937
938/// Props for DetailPage component -- opinionated resource-detail skeleton.
939///
940/// Renders a PageHeader (title + breadcrumb + actions), an info Card
941/// wrapping the `info` slot IDs (typically a Badge plus a DescriptionList),
942/// and `Element.children` as stacked sections below the card (tabs,
943/// related-resource lists, action panels). Centralizes the visual contract
944/// every dashboard detail page follows so per-page rebuilds cannot drift
945/// from the canonical shape.
946#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
947pub struct DetailPageProps {
948    pub title: String,
949    #[serde(default, skip_serializing_if = "Vec::is_empty")]
950    pub breadcrumb: Vec<BreadcrumbItem>,
951    /// IDs of action button elements rendered to the right of the title.
952    #[serde(
953        default,
954        deserialize_with = "deserialize_actions_lax",
955        skip_serializing_if = "Vec::is_empty"
956    )]
957    pub actions: Vec<String>,
958    /// IDs of elements rendered inside the info Card
959    /// (typically a Badge and a DescriptionList). Omit to skip the card.
960    #[serde(default, skip_serializing_if = "Vec::is_empty")]
961    pub info: Vec<String>,
962}
963
964/// A single action item in a dropdown menu.
965#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
966pub struct DropdownMenuAction {
967    pub label: String,
968    pub action: Action,
969    #[serde(default)]
970    pub destructive: bool,
971    /// When set, this item is only emitted in a DataTable row when the row's
972    /// `visible_if` field is truthy (true / non-zero number / non-empty string /
973    /// non-empty array or object). An absent or falsy field hides the item —
974    /// fail-closed so a typo in the view spec cannot leak an action onto every
975    /// row. Outside DataTable contexts (e.g. standalone `DropdownMenu` element)
976    /// the field is ignored.
977    #[serde(default, skip_serializing_if = "Option::is_none")]
978    pub visible_if: Option<String>,
979}
980
981/// Props for DropdownMenu component — trigger button with absolutely-positioned action panel.
982#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
983pub struct DropdownMenuProps {
984    pub menu_id: String,
985    pub trigger_label: String,
986    pub items: Vec<DropdownMenuAction>,
987    #[serde(default, skip_serializing_if = "Option::is_none")]
988    pub trigger_variant: Option<ButtonVariant>,
989}
990
991/// Props for the DataTable component — Stripe-style alternating rows with DropdownMenu per row,
992/// mobile card fallback, and empty state.
993#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
994pub struct DataTableProps {
995    pub columns: Vec<Column>,
996    pub data_path: String,
997    #[serde(default, skip_serializing_if = "Option::is_none")]
998    pub row_actions: Option<Vec<DropdownMenuAction>>,
999    #[serde(default, skip_serializing_if = "Option::is_none")]
1000    pub empty_message: Option<String>,
1001    #[serde(default, skip_serializing_if = "Option::is_none")]
1002    pub row_key: Option<String>,
1003    /// URL pattern for row click navigation. Use `{row_key}` as placeholder.
1004    #[serde(default, skip_serializing_if = "Option::is_none")]
1005    pub row_href: Option<String>,
1006}
1007
1008/// Props for MediaCardGrid — a responsive card grid backed by a data array.
1009/// Mirrors DataTable's row_key/row_actions/data_path contract but renders
1010/// cards with an optional screenshot image instead of table rows.
1011#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1012pub struct MediaCardGridProps {
1013    pub data_path: String,
1014    /// Key in each row object whose value becomes the card title.
1015    pub title_key: String,
1016    /// Key for the subtitle/URL line below the title.
1017    #[serde(default, skip_serializing_if = "Option::is_none")]
1018    pub description_key: Option<String>,
1019    /// Key for the screenshot image URL. No image rendered when absent or empty.
1020    #[serde(default, skip_serializing_if = "Option::is_none")]
1021    pub image_key: Option<String>,
1022    /// Key for the URL the image links to (opens in new tab).
1023    #[serde(default, skip_serializing_if = "Option::is_none")]
1024    pub image_href_key: Option<String>,
1025    /// CSS aspect-ratio value for the image (default "4/5").
1026    #[serde(default, skip_serializing_if = "Option::is_none")]
1027    pub image_aspect_ratio: Option<String>,
1028    /// CSS object-position for the cropped image: "top" | "center" | "bottom"
1029    /// (or any valid object-position value). Default "center".
1030    #[serde(default, skip_serializing_if = "Option::is_none")]
1031    pub image_position: Option<String>,
1032    /// Key for the footer badge label text.
1033    #[serde(default, skip_serializing_if = "Option::is_none")]
1034    pub badge_key: Option<String>,
1035    /// Key for the badge variant string: "outline" | "destructive" | "default".
1036    #[serde(default, skip_serializing_if = "Option::is_none")]
1037    pub badge_variant_key: Option<String>,
1038    /// Key used for {row_key} substitution in row_action URLs.
1039    #[serde(default, skip_serializing_if = "Option::is_none")]
1040    pub row_key: Option<String>,
1041    #[serde(default, skip_serializing_if = "Option::is_none")]
1042    pub row_actions: Option<Vec<DropdownMenuAction>>,
1043    #[serde(default, skip_serializing_if = "Option::is_none")]
1044    pub empty_message: Option<String>,
1045    /// Number of columns in the grid (default 3).
1046    #[serde(default, skip_serializing_if = "Option::is_none")]
1047    pub columns: Option<u8>,
1048}
1049
1050/// Props for a single column (lane) in a KanbanBoard.
1051///
1052/// A column is structure: its `id` is the lane key matched against each
1053/// item's `group_by` value, and `title` is the lane header. `count` and
1054/// `children` are only honored by static specs that set neither
1055/// `KanbanBoardProps.items_path` nor `group_by`; in the data-bound path the
1056/// renderer computes the count and renders cards from `items_path`.
1057#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1058pub struct KanbanColumnProps {
1059    pub id: String,
1060    pub title: String,
1061    #[serde(default)]
1062    pub count: u32,
1063    /// IDs of elements rendered inside this column (static specs only).
1064    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1065    pub children: Vec<String>,
1066}
1067
1068/// Props for KanbanBoard — horizontal scrollable columns on desktop, tab-based
1069/// on mobile.
1070///
1071/// A kanban is fixed lanes plus items sorted into them by a status field.
1072/// `columns` is structure only (lane `id` + `title`) and is always rendered —
1073/// an empty lane still shows its header and a zero count. Card content is
1074/// data-bound: `items_path` resolves a flat array of entity objects, each
1075/// bucketed into the column whose `id` equals the item's `group_by` value,
1076/// then rendered as a card via the `card_*` / `row_*` bindings. This is the
1077/// same prescribed-card + field-key convention used by `DataTable` and
1078/// `MediaCardGrid`. For fully-custom card structure, template the cards with
1079/// the `$each` directive inside a `KanbanColumn` instead.
1080#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1081pub struct KanbanBoardProps {
1082    /// Lane structure — `id` + `title`. Always rendered.
1083    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1084    pub columns: Vec<KanbanColumnProps>,
1085    /// JSON-Pointer to a flat array of entity objects. Each item is bucketed
1086    /// into the column whose `id` equals the item's `group_by` value.
1087    #[serde(default, skip_serializing_if = "Option::is_none")]
1088    pub items_path: Option<String>,
1089    /// Field on each item that selects its lane: `column.id == item[group_by]`.
1090    #[serde(default, skip_serializing_if = "Option::is_none")]
1091    pub group_by: Option<String>,
1092    /// Item field whose value becomes the card title.
1093    #[serde(default, skip_serializing_if = "Option::is_none")]
1094    pub card_title_key: Option<String>,
1095    /// Item field whose value becomes the card subtitle/description.
1096    #[serde(default, skip_serializing_if = "Option::is_none")]
1097    pub card_description_key: Option<String>,
1098    /// Per-card dropdown actions. `{row_key}` / `{id}` interpolate from the
1099    /// item, matching `DataTable` / `MediaCardGrid`.
1100    #[serde(default, skip_serializing_if = "Option::is_none")]
1101    pub row_actions: Option<Vec<DropdownMenuAction>>,
1102    /// Item field used for `{row_key}` substitution in action URLs
1103    /// (defaults to `id`).
1104    #[serde(default, skip_serializing_if = "Option::is_none")]
1105    pub row_key: Option<String>,
1106    #[serde(default, skip_serializing_if = "Option::is_none")]
1107    pub mobile_default_column: Option<String>,
1108    /// Placeholder text shown inside empty lanes. When `None`, empty lanes
1109    /// render no placeholder (back-compat). Provide a short, neutral message —
1110    /// e.g. "Nessun ordine", "Nothing here".
1111    #[serde(default, skip_serializing_if = "Option::is_none")]
1112    pub empty_label: Option<String>,
1113}
1114
1115/// Props for a calendar day cell.
1116///
1117/// Renders a single day in a month grid with today highlight,
1118/// out-of-month muting, and event count indicator.
1119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1120pub struct CalendarCellProps {
1121    pub day: u8,
1122    #[serde(default)]
1123    pub is_today: bool,
1124    #[serde(default)]
1125    pub is_current_month: bool,
1126    #[serde(default)]
1127    pub event_count: u32,
1128    /// Optional per-event Tailwind color classes (e.g. "bg-blue-500").
1129    /// When non-empty, colored dots are rendered instead of plain primary dots.
1130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1131    pub dot_colors: Vec<String>,
1132}
1133
1134/// Visual variant for action cards.
1135#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1136#[serde(rename_all = "snake_case")]
1137pub enum ActionCardVariant {
1138    #[default]
1139    Default,
1140    Setup,
1141    Danger,
1142}
1143
1144/// Props for a horizontal action card with variant-colored left border.
1145///
1146/// Renders icon + title + description + chevron in a clickable row.
1147/// When `href` is set, the card wraps in an `<a>` element with `aria-label`.
1148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1149pub struct ActionCardProps {
1150    pub title: String,
1151    pub description: String,
1152    #[serde(default, skip_serializing_if = "Option::is_none")]
1153    pub icon: Option<String>,
1154    #[serde(default)]
1155    pub variant: ActionCardVariant,
1156    /// Optional navigation URL. When set, the card renders as an `<a>` element.
1157    #[serde(default, skip_serializing_if = "Option::is_none")]
1158    pub href: Option<String>,
1159}
1160
1161/// Props for a touch-friendly product tile with quantity controls.
1162///
1163/// Renders product name, price, and +/- buttons that drive a hidden input
1164/// via JS. Used for POS-style product selection during order creation.
1165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1166pub struct ProductTileProps {
1167    pub product_id: String,
1168    pub name: String,
1169    pub price: String,
1170    pub field: String,
1171    #[serde(default, skip_serializing_if = "Option::is_none")]
1172    pub default_quantity: Option<u32>,
1173}
1174
1175/// Lax deserializer for PageHeader.actions. Per D-19/F6:
1176/// Accepts: missing field (via #[serde(default)]), null, [], empty string "",
1177/// and array of strings. Rejects: non-empty strings, arrays of non-strings.
1178/// This loosens the wire-format contract for actions only — other Vec<String>
1179/// ID-slot fields (e.g. CardProps.footer) remain strict.
1180fn deserialize_actions_lax<'de, D: serde::Deserializer<'de>>(
1181    d: D,
1182) -> Result<Vec<String>, D::Error> {
1183    use serde::de::Error;
1184    let v = serde_json::Value::deserialize(d)?;
1185    match v {
1186        serde_json::Value::Null => Ok(Vec::new()),
1187        serde_json::Value::String(s) if s.is_empty() => Ok(Vec::new()),
1188        serde_json::Value::Array(arr) => arr
1189            .into_iter()
1190            .map(|item| {
1191                item.as_str()
1192                    .map(String::from)
1193                    .ok_or_else(|| D::Error::custom("PageHeader.actions: expected string in array"))
1194            })
1195            .collect(),
1196        other => Err(D::Error::custom(format!(
1197            "PageHeader.actions: expected null, empty string, or array of strings; got {other:?}"
1198        ))),
1199    }
1200}
1201
1202#[cfg(test)]
1203mod schema_smoke_tests {
1204    //! Runtime `schema_for!` smoke tests per D-32.
1205    //!
1206    //! Each test asserts that the generated JSON Schema for the given Props
1207    //! struct is a non-empty JSON object with a populated `properties` field.
1208    //! This proves the `JsonSchema` derive executes without panic on every
1209    //! surviving Props struct — a compile-time `#[derive(JsonSchema)]` alone
1210    //! does not prove the generated code runs.
1211    //!
1212    //! One `#[test]` per type for clear failure localization.
1213
1214    use super::*;
1215
1216    fn assert_schema_nonempty_object<T: schemars::JsonSchema>(type_label: &str) {
1217        let schema = schemars::schema_for!(T);
1218        let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1219        assert!(
1220            value.is_object(),
1221            "{type_label}: schema must be a JSON object"
1222        );
1223        let props = value
1224            .get("properties")
1225            .and_then(|p| p.as_object())
1226            .map(|o| !o.is_empty())
1227            .unwrap_or(false);
1228        assert!(
1229            props,
1230            "{type_label}: schema must have a non-empty `properties` field"
1231        );
1232    }
1233
1234    #[test]
1235    fn schema_for_card_props_generates() {
1236        assert_schema_nonempty_object::<CardProps>("CardProps");
1237    }
1238
1239    #[test]
1240    fn schema_for_table_props_generates() {
1241        assert_schema_nonempty_object::<TableProps>("TableProps");
1242    }
1243
1244    #[test]
1245    fn schema_for_form_props_generates() {
1246        assert_schema_nonempty_object::<FormProps>("FormProps");
1247    }
1248
1249    #[test]
1250    fn schema_for_button_props_generates() {
1251        assert_schema_nonempty_object::<ButtonProps>("ButtonProps");
1252    }
1253
1254    #[test]
1255    fn schema_for_input_props_generates() {
1256        assert_schema_nonempty_object::<InputProps>("InputProps");
1257    }
1258
1259    #[test]
1260    fn schema_for_select_props_generates() {
1261        assert_schema_nonempty_object::<SelectProps>("SelectProps");
1262    }
1263
1264    #[test]
1265    fn schema_for_alert_props_generates() {
1266        assert_schema_nonempty_object::<AlertProps>("AlertProps");
1267    }
1268
1269    #[test]
1270    fn schema_for_badge_props_generates() {
1271        assert_schema_nonempty_object::<BadgeProps>("BadgeProps");
1272    }
1273
1274    #[test]
1275    fn schema_for_modal_props_generates() {
1276        assert_schema_nonempty_object::<ModalProps>("ModalProps");
1277    }
1278
1279    #[test]
1280    fn schema_for_text_props_generates() {
1281        assert_schema_nonempty_object::<TextProps>("TextProps");
1282    }
1283
1284    #[test]
1285    fn schema_for_checkbox_props_generates() {
1286        assert_schema_nonempty_object::<CheckboxProps>("CheckboxProps");
1287    }
1288
1289    #[test]
1290    fn schema_for_switch_props_generates() {
1291        assert_schema_nonempty_object::<SwitchProps>("SwitchProps");
1292    }
1293
1294    #[test]
1295    fn schema_for_separator_props_generates() {
1296        assert_schema_nonempty_object::<SeparatorProps>("SeparatorProps");
1297    }
1298
1299    #[test]
1300    fn schema_for_description_list_props_generates() {
1301        assert_schema_nonempty_object::<DescriptionListProps>("DescriptionListProps");
1302    }
1303
1304    #[test]
1305    fn schema_for_tab_generates() {
1306        assert_schema_nonempty_object::<Tab>("Tab");
1307    }
1308
1309    #[test]
1310    fn schema_for_tabs_props_generates() {
1311        assert_schema_nonempty_object::<TabsProps>("TabsProps");
1312    }
1313
1314    #[test]
1315    fn schema_for_breadcrumb_props_generates() {
1316        assert_schema_nonempty_object::<BreadcrumbProps>("BreadcrumbProps");
1317    }
1318
1319    #[test]
1320    fn schema_for_pagination_props_generates() {
1321        assert_schema_nonempty_object::<PaginationProps>("PaginationProps");
1322    }
1323
1324    #[test]
1325    fn schema_for_progress_props_generates() {
1326        assert_schema_nonempty_object::<ProgressProps>("ProgressProps");
1327    }
1328
1329    #[test]
1330    fn schema_for_image_props_generates() {
1331        assert_schema_nonempty_object::<ImageProps>("ImageProps");
1332    }
1333
1334    #[test]
1335    fn image_inline_svg_factory_roundtrips_via_serde() {
1336        let p = ImageProps::inline_svg("<svg/>", "alt");
1337        let json = serde_json::to_value(&p).expect("serialization must not fail");
1338        let parsed: ImageProps =
1339            serde_json::from_value(json).expect("deserialization must not fail");
1340        assert_eq!(parsed.inline_svg, Some("<svg/>".to_string()));
1341        assert_eq!(parsed.alt, "alt");
1342        assert_eq!(parsed.src, "");
1343    }
1344
1345    #[test]
1346    fn schema_for_avatar_props_generates() {
1347        assert_schema_nonempty_object::<AvatarProps>("AvatarProps");
1348    }
1349
1350    #[test]
1351    fn schema_for_skeleton_props_generates() {
1352        assert_schema_nonempty_object::<SkeletonProps>("SkeletonProps");
1353    }
1354
1355    #[test]
1356    fn schema_for_stat_card_props_generates() {
1357        assert_schema_nonempty_object::<StatCardProps>("StatCardProps");
1358    }
1359
1360    #[test]
1361    fn schema_for_checklist_props_generates() {
1362        assert_schema_nonempty_object::<ChecklistProps>("ChecklistProps");
1363    }
1364
1365    #[test]
1366    fn schema_for_toast_props_generates() {
1367        assert_schema_nonempty_object::<ToastProps>("ToastProps");
1368    }
1369
1370    #[test]
1371    fn schema_for_notification_dropdown_props_generates() {
1372        assert_schema_nonempty_object::<NotificationDropdownProps>("NotificationDropdownProps");
1373    }
1374
1375    #[test]
1376    fn schema_for_sidebar_props_generates() {
1377        assert_schema_nonempty_object::<SidebarProps>("SidebarProps");
1378    }
1379
1380    #[test]
1381    fn schema_for_header_props_generates() {
1382        assert_schema_nonempty_object::<HeaderProps>("HeaderProps");
1383    }
1384
1385    #[test]
1386    fn schema_for_grid_props_generates() {
1387        assert_schema_nonempty_object::<GridProps>("GridProps");
1388    }
1389
1390    #[test]
1391    fn schema_for_collapsible_props_generates() {
1392        assert_schema_nonempty_object::<CollapsibleProps>("CollapsibleProps");
1393    }
1394
1395    #[test]
1396    fn schema_for_empty_state_props_generates() {
1397        assert_schema_nonempty_object::<EmptyStateProps>("EmptyStateProps");
1398    }
1399
1400    #[test]
1401    fn schema_for_form_section_props_generates() {
1402        assert_schema_nonempty_object::<FormSectionProps>("FormSectionProps");
1403    }
1404
1405    #[test]
1406    fn schema_for_page_header_props_generates() {
1407        assert_schema_nonempty_object::<PageHeaderProps>("PageHeaderProps");
1408    }
1409
1410    #[test]
1411    fn schema_for_button_group_props_generates() {
1412        assert_schema_nonempty_object::<ButtonGroupProps>("ButtonGroupProps");
1413    }
1414
1415    #[test]
1416    fn schema_for_dropdown_menu_action_generates() {
1417        assert_schema_nonempty_object::<DropdownMenuAction>("DropdownMenuAction");
1418    }
1419
1420    #[test]
1421    fn schema_for_dropdown_menu_props_generates() {
1422        assert_schema_nonempty_object::<DropdownMenuProps>("DropdownMenuProps");
1423    }
1424
1425    #[test]
1426    fn schema_for_data_table_props_generates() {
1427        assert_schema_nonempty_object::<DataTableProps>("DataTableProps");
1428    }
1429
1430    #[test]
1431    fn schema_for_kanban_column_props_generates() {
1432        assert_schema_nonempty_object::<KanbanColumnProps>("KanbanColumnProps");
1433    }
1434
1435    #[test]
1436    fn schema_for_kanban_board_props_generates() {
1437        assert_schema_nonempty_object::<KanbanBoardProps>("KanbanBoardProps");
1438    }
1439
1440    #[test]
1441    fn schema_for_calendar_cell_props_generates() {
1442        assert_schema_nonempty_object::<CalendarCellProps>("CalendarCellProps");
1443    }
1444
1445    #[test]
1446    fn schema_for_action_card_props_generates() {
1447        assert_schema_nonempty_object::<ActionCardProps>("ActionCardProps");
1448    }
1449
1450    #[test]
1451    fn schema_for_product_tile_props_generates() {
1452        assert_schema_nonempty_object::<ProductTileProps>("ProductTileProps");
1453    }
1454
1455    #[test]
1456    fn card_props_round_trips_footer() {
1457        let original = CardProps {
1458            title: "Hero".to_string(),
1459            description: None,
1460            subtitle: None,
1461            badge: None,
1462            max_width: None,
1463            footer: vec!["btn1".to_string(), "btn2".to_string()],
1464            variant: CardVariant::Bordered,
1465        };
1466        let json = serde_json::to_string(&original).unwrap();
1467        let parsed: CardProps = serde_json::from_str(&json).unwrap();
1468        assert_eq!(original.footer, parsed.footer);
1469    }
1470
1471    #[test]
1472    fn tab_round_trips_children() {
1473        let original = Tab {
1474            value: "overview".to_string(),
1475            label: "Overview".to_string(),
1476            children: vec!["panel1".to_string()],
1477        };
1478        let json = serde_json::to_string(&original).unwrap();
1479        let parsed: Tab = serde_json::from_str(&json).unwrap();
1480        assert_eq!(original.children, parsed.children);
1481    }
1482
1483    #[test]
1484    fn card_props_omits_empty_footer_in_json() {
1485        let card = CardProps {
1486            title: "Card".to_string(),
1487            description: None,
1488            subtitle: None,
1489            badge: None,
1490            max_width: None,
1491            footer: Vec::new(),
1492            variant: CardVariant::Bordered,
1493        };
1494        let json = serde_json::to_string(&card).unwrap();
1495        assert!(
1496            !json.contains("\"footer\""),
1497            "empty footer must be skipped, got: {json}"
1498        );
1499    }
1500
1501    #[test]
1502    fn card_props_round_trips_badge() {
1503        let original = CardProps {
1504            title: "Hero".to_string(),
1505            description: None,
1506            subtitle: None,
1507            badge: Some("Scade tra 9m".to_string()),
1508            max_width: None,
1509            footer: Vec::new(),
1510            variant: CardVariant::Bordered,
1511        };
1512        let json = serde_json::to_string(&original).unwrap();
1513        let parsed: CardProps = serde_json::from_str(&json).unwrap();
1514        assert_eq!(original.badge, parsed.badge);
1515    }
1516
1517    #[test]
1518    fn card_props_omits_empty_badge_in_json() {
1519        let card = CardProps {
1520            title: "Card".to_string(),
1521            description: None,
1522            subtitle: None,
1523            badge: None,
1524            max_width: None,
1525            footer: Vec::new(),
1526            variant: CardVariant::Bordered,
1527        };
1528        let json = serde_json::to_string(&card).unwrap();
1529        assert!(
1530            !json.contains("\"badge\""),
1531            "empty badge must be skipped, got: {json}"
1532        );
1533    }
1534
1535    #[test]
1536    fn card_props_round_trips_subtitle() {
1537        let original = CardProps {
1538            title: "Hero".to_string(),
1539            description: None,
1540            subtitle: Some("Marco Rossi".to_string()),
1541            badge: None,
1542            max_width: None,
1543            footer: Vec::new(),
1544            variant: CardVariant::Bordered,
1545        };
1546        let json = serde_json::to_string(&original).unwrap();
1547        let parsed: CardProps = serde_json::from_str(&json).unwrap();
1548        assert_eq!(original.subtitle, parsed.subtitle);
1549    }
1550
1551    #[test]
1552    fn card_props_omits_empty_subtitle_in_json() {
1553        let card = CardProps {
1554            title: "Card".to_string(),
1555            description: None,
1556            subtitle: None,
1557            badge: None,
1558            max_width: None,
1559            footer: Vec::new(),
1560            variant: CardVariant::Bordered,
1561        };
1562        let json = serde_json::to_string(&card).unwrap();
1563        assert!(
1564            !json.contains("\"subtitle\""),
1565            "empty subtitle must be skipped, got: {json}"
1566        );
1567    }
1568
1569    #[test]
1570    fn card_props_schema_includes_badge() {
1571        let schema = schemars::schema_for!(CardProps);
1572        let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1573        let props = value
1574            .get("properties")
1575            .and_then(|p| p.as_object())
1576            .expect("schema has a properties object");
1577        assert!(
1578            props.contains_key("badge"),
1579            "CardProps schema must expose a `badge` property; got keys: {:?}",
1580            props.keys().collect::<Vec<_>>()
1581        );
1582        // `badge: Option<String>` — schemars 1.x emits either {"type": ["string","null"]}
1583        // or a {"type":"string"} entry inside a oneOf/anyOf branch. We only assert
1584        // presence + that the rendered schema mentions a string somewhere under
1585        // the badge entry, which is robust to either encoding.
1586        let badge_schema = props.get("badge").expect("badge entry");
1587        let badge_json = badge_schema.to_string();
1588        assert!(
1589            badge_json.contains("\"string\""),
1590            "badge schema entry must mention string type; got: {badge_json}"
1591        );
1592    }
1593
1594    #[test]
1595    fn card_props_schema_includes_subtitle() {
1596        let schema = schemars::schema_for!(CardProps);
1597        let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1598        let props = value
1599            .get("properties")
1600            .and_then(|p| p.as_object())
1601            .expect("schema has a properties object");
1602        assert!(
1603            props.contains_key("subtitle"),
1604            "CardProps schema must expose a `subtitle` property; got keys: {:?}",
1605            props.keys().collect::<Vec<_>>()
1606        );
1607        // Same robustness note as `card_props_schema_includes_badge` —
1608        // `subtitle: Option<String>` may surface as type-union or oneOf depending
1609        // on the schemars version. Assert string is mentioned in the rendered
1610        // entry rather than locking down the exact null encoding.
1611        let subtitle_schema = props.get("subtitle").expect("subtitle entry");
1612        let subtitle_json = subtitle_schema.to_string();
1613        assert!(
1614            subtitle_json.contains("\"string\""),
1615            "subtitle schema entry must mention string type; got: {subtitle_json}"
1616        );
1617    }
1618
1619    #[test]
1620    fn schema_for_checkbox_list_props_generates() {
1621        assert_schema_nonempty_object::<CheckboxListProps>("CheckboxListProps");
1622    }
1623
1624    #[test]
1625    fn checkbox_list_props_serde_roundtrip() {
1626        let json = serde_json::json!({
1627            "field": "services",
1628            "options": [{"value": "a", "label": "Alpha"}, {"value": "b", "label": "Beta"}],
1629            "selected_path": "/preselected"
1630        });
1631        let parsed: CheckboxListProps = serde_json::from_value(json.clone()).expect("decode");
1632        assert_eq!(parsed.field, "services");
1633        assert_eq!(parsed.options.len(), 2);
1634        assert_eq!(parsed.selected_path.as_deref(), Some("/preselected"));
1635        let reserialized = serde_json::to_value(&parsed).expect("encode");
1636        // None/empty fields are omitted by serde.
1637        assert!(reserialized.get("label").is_none());
1638        assert!(reserialized.get("disabled").is_none());
1639    }
1640
1641    #[test]
1642    fn schema_for_rich_text_editor_props_generates() {
1643        assert_schema_nonempty_object::<RichTextEditorProps>("RichTextEditorProps");
1644    }
1645
1646    #[test]
1647    fn rich_text_editor_props_serde_roundtrip() {
1648        let json = serde_json::json!({
1649            "field": "body",
1650            "label": "Body"
1651        });
1652        let parsed: RichTextEditorProps = serde_json::from_value(json).expect("decode");
1653        assert_eq!(parsed.field, "body");
1654        assert_eq!(parsed.label, "Body");
1655        assert!(parsed.placeholder.is_none());
1656        assert!(parsed.default_value.is_none());
1657        assert!(parsed.data_path.is_none());
1658        assert!(parsed.error.is_none());
1659        let reserialized = serde_json::to_value(&parsed).expect("encode");
1660        // Optional None fields are omitted.
1661        assert!(reserialized.get("placeholder").is_none());
1662        assert!(reserialized.get("error").is_none());
1663    }
1664}
1665
1666#[cfg(test)]
1667mod strum_tests {
1668    use super::*;
1669
1670    /// Assert AsRef<str> matches serde JSON wire format for every variant of
1671    /// AlertVariant, BadgeVariant, ButtonVariant, and ToastVariant.
1672    /// Threat T-162-08-01: strum and serde must agree on every snake_case string.
1673    #[test]
1674    fn variant_enums_strum_matches_serde_wire_format() {
1675        fn check<T: AsRef<str> + serde::Serialize>(variants: &[T], label: &str) {
1676            for v in variants {
1677                let json = serde_json::to_string(v).expect("serialize");
1678                let json_stripped = json.trim_matches('"');
1679                assert_eq!(
1680                    v.as_ref(),
1681                    json_stripped,
1682                    "strum AsRefStr drifted from serde for {label} variant"
1683                );
1684            }
1685        }
1686        check(
1687            &[
1688                AlertVariant::Info,
1689                AlertVariant::Success,
1690                AlertVariant::Warning,
1691                AlertVariant::Error,
1692            ],
1693            "AlertVariant",
1694        );
1695        check(
1696            &[
1697                BadgeVariant::Default,
1698                BadgeVariant::Secondary,
1699                BadgeVariant::Destructive,
1700                BadgeVariant::Outline,
1701            ],
1702            "BadgeVariant",
1703        );
1704        check(
1705            &[
1706                ButtonVariant::Default,
1707                ButtonVariant::Secondary,
1708                ButtonVariant::Destructive,
1709                ButtonVariant::Outline,
1710                ButtonVariant::Ghost,
1711                ButtonVariant::Link,
1712            ],
1713            "ButtonVariant",
1714        );
1715        check(
1716            &[
1717                ToastVariant::Info,
1718                ToastVariant::Success,
1719                ToastVariant::Warning,
1720                ToastVariant::Error,
1721            ],
1722            "ToastVariant",
1723        );
1724    }
1725
1726    #[test]
1727    fn alert_variant_as_ref_str_matches_wire_format() {
1728        assert_eq!(AlertVariant::Success.as_ref(), "success");
1729        assert_eq!(AlertVariant::Warning.as_ref(), "warning");
1730        assert_eq!(AlertVariant::Info.as_ref(), "info");
1731        assert_eq!(AlertVariant::Error.as_ref(), "error");
1732    }
1733}
1734
1735#[cfg(test)]
1736mod card_variant_tests {
1737    use super::*;
1738
1739    #[test]
1740    fn card_variant_default_is_bordered() {
1741        assert_eq!(CardVariant::default(), CardVariant::Bordered);
1742    }
1743
1744    #[test]
1745    fn card_variant_serializes_snake_case() {
1746        assert_eq!(
1747            serde_json::to_value(CardVariant::Bordered).unwrap(),
1748            serde_json::json!("bordered")
1749        );
1750        assert_eq!(
1751            serde_json::to_value(CardVariant::Elevated).unwrap(),
1752            serde_json::json!("elevated")
1753        );
1754    }
1755
1756    #[test]
1757    fn card_variant_deserializes_snake_case() {
1758        assert_eq!(
1759            serde_json::from_value::<CardVariant>(serde_json::json!("bordered")).unwrap(),
1760            CardVariant::Bordered
1761        );
1762        assert_eq!(
1763            serde_json::from_value::<CardVariant>(serde_json::json!("elevated")).unwrap(),
1764            CardVariant::Elevated
1765        );
1766    }
1767
1768    #[test]
1769    fn card_props_without_variant_defaults_to_bordered() {
1770        let v = serde_json::json!({"title": "x"});
1771        let p: CardProps = serde_json::from_value(v).unwrap();
1772        assert_eq!(p.variant, CardVariant::Bordered);
1773    }
1774
1775    #[test]
1776    fn card_props_with_elevated_variant() {
1777        let v = serde_json::json!({"title": "x", "variant": "elevated"});
1778        let p: CardProps = serde_json::from_value(v).unwrap();
1779        assert_eq!(p.variant, CardVariant::Elevated);
1780    }
1781
1782    #[test]
1783    fn card_props_roundtrip_preserves_variant() {
1784        let p = CardProps {
1785            title: "x".into(),
1786            description: None,
1787            subtitle: None,
1788            badge: None,
1789            max_width: None,
1790            footer: vec![],
1791            variant: CardVariant::Elevated,
1792        };
1793        let j = serde_json::to_value(&p).unwrap();
1794        let back: CardProps = serde_json::from_value(j).unwrap();
1795        assert_eq!(back.variant, CardVariant::Elevated);
1796    }
1797}
1798
1799#[cfg(test)]
1800mod kanban_board_props_tests {
1801    use super::*;
1802
1803    #[test]
1804    fn kanban_board_props_serde_static_columns() {
1805        let v = serde_json::json!({
1806            "columns": [{"title": "To Do", "id": "todo", "count": 0}]
1807        });
1808        let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
1809        assert_eq!(p.columns.len(), 1);
1810        assert!(p.items_path.is_none());
1811        assert!(p.group_by.is_none());
1812    }
1813
1814    #[test]
1815    fn kanban_board_props_serde_data_bound() {
1816        let v = serde_json::json!({
1817            "columns": [{"title": "Open", "id": "open"}],
1818            "items_path": "/data/order",
1819            "group_by": "status",
1820            "card_title_key": "name"
1821        });
1822        let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
1823        assert_eq!(p.columns.len(), 1);
1824        assert_eq!(p.items_path.as_deref(), Some("/data/order"));
1825        assert_eq!(p.group_by.as_deref(), Some("status"));
1826        assert_eq!(p.card_title_key.as_deref(), Some("name"));
1827    }
1828
1829    #[test]
1830    fn kanban_board_props_serde_neither() {
1831        let v = serde_json::json!({});
1832        let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
1833        assert!(p.columns.is_empty());
1834        assert!(p.items_path.is_none());
1835        assert!(p.group_by.is_none());
1836    }
1837
1838    #[test]
1839    fn kanban_board_props_empty_columns_skipped_on_serialize() {
1840        let p = KanbanBoardProps {
1841            columns: vec![],
1842            items_path: Some("/data/order".into()),
1843            group_by: Some("status".into()),
1844            card_title_key: None,
1845            card_description_key: None,
1846            row_actions: None,
1847            row_key: None,
1848            mobile_default_column: None,
1849            empty_label: None,
1850        };
1851        let j = serde_json::to_value(&p).unwrap();
1852        assert!(
1853            j.get("columns").is_none(),
1854            "empty columns must be skipped, got: {j}"
1855        );
1856        assert_eq!(
1857            j.get("items_path").and_then(|v| v.as_str()),
1858            Some("/data/order")
1859        );
1860    }
1861}
1862
1863#[cfg(test)]
1864mod page_header_actions_tests {
1865    use super::*;
1866
1867    #[test]
1868    fn page_header_actions_missing_field() {
1869        let v = serde_json::json!({"title": "X"});
1870        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
1871        assert!(p.actions.is_empty());
1872    }
1873
1874    #[test]
1875    fn page_header_actions_null() {
1876        let v = serde_json::json!({"title": "X", "actions": null});
1877        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
1878        assert!(p.actions.is_empty());
1879    }
1880
1881    #[test]
1882    fn page_header_actions_empty_string() {
1883        let v = serde_json::json!({"title": "X", "actions": ""});
1884        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
1885        assert!(p.actions.is_empty());
1886    }
1887
1888    #[test]
1889    fn page_header_actions_empty_array() {
1890        let v = serde_json::json!({"title": "X", "actions": []});
1891        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
1892        assert!(p.actions.is_empty());
1893    }
1894
1895    #[test]
1896    fn page_header_actions_non_empty_array() {
1897        let v = serde_json::json!({"title": "X", "actions": ["a", "b"]});
1898        let p: PageHeaderProps = serde_json::from_value(v).unwrap();
1899        assert_eq!(p.actions, vec!["a".to_string(), "b".to_string()]);
1900    }
1901
1902    #[test]
1903    fn page_header_actions_non_empty_string_rejected() {
1904        let v = serde_json::json!({"title": "X", "actions": "not-empty"});
1905        let result: Result<PageHeaderProps, _> = serde_json::from_value(v);
1906        assert!(result.is_err(), "non-empty string must be rejected");
1907    }
1908
1909    #[test]
1910    fn page_header_actions_non_string_array_rejected() {
1911        let v = serde_json::json!({"title": "X", "actions": [1, 2, 3]});
1912        let result: Result<PageHeaderProps, _> = serde_json::from_value(v);
1913        assert!(result.is_err(), "array of non-strings must be rejected");
1914    }
1915}