1use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9use crate::action::Action;
10
11#[derive(
13 Debug,
14 Clone,
15 Copy,
16 Default,
17 PartialEq,
18 Eq,
19 Serialize,
20 Deserialize,
21 JsonSchema,
22 strum::AsRefStr,
23 strum::VariantArray,
24)]
25#[serde(rename_all = "snake_case")]
26#[strum(serialize_all = "snake_case")]
27pub enum Variant {
28 #[default]
29 Primary,
30 Secondary,
31 Outline,
32 Ghost,
33 Destructive,
34}
35
36#[derive(
39 Debug,
40 Clone,
41 Copy,
42 Default,
43 PartialEq,
44 Eq,
45 Serialize,
46 Deserialize,
47 JsonSchema,
48 strum::AsRefStr,
49 strum::VariantArray,
50)]
51#[serde(rename_all = "snake_case")]
52#[strum(serialize_all = "snake_case")]
53pub enum Tone {
54 #[default]
55 Neutral,
56 Success,
57 Warning,
58 Destructive,
59}
60
61#[derive(
63 Debug,
64 Clone,
65 Copy,
66 Default,
67 PartialEq,
68 Eq,
69 Serialize,
70 Deserialize,
71 JsonSchema,
72 strum::AsRefStr,
73 strum::VariantArray,
74)]
75#[serde(rename_all = "snake_case")]
76#[strum(serialize_all = "snake_case")]
77pub enum Size {
78 Sm,
79 #[default]
80 Md,
81 Lg,
82}
83
84#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
86#[serde(rename_all = "snake_case")]
87pub enum IconPosition {
88 #[default]
89 Left,
90 Right,
91}
92
93#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
95#[serde(rename_all = "snake_case")]
96pub enum SortDirection {
97 #[default]
98 Asc,
99 Desc,
100}
101
102#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
104#[serde(rename_all = "snake_case")]
105pub enum Orientation {
106 #[default]
107 Horizontal,
108 Vertical,
109}
110
111#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
113#[serde(rename_all = "snake_case")]
114pub enum InputType {
115 #[default]
116 Text,
117 Email,
118 Password,
119 Number,
120 Textarea,
121 Hidden,
122 Date,
123 Time,
124 Url,
125 Tel,
126 Search,
127 File,
128}
129
130#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
132#[serde(rename_all = "snake_case")]
133pub enum TextElement {
134 #[default]
135 P,
136 H1,
137 H2,
138 H3,
139 Span,
140 Div,
141 Section,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
149#[serde(rename_all = "snake_case")]
150pub enum ColumnFormat {
151 Date,
152 DateTime,
153 Currency,
154 Boolean,
155 Badge,
156 Image,
158 Icon,
163}
164
165#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
167#[serde(rename_all = "snake_case")]
168pub enum ColumnAlign {
169 #[default]
170 Left,
171 Center,
172 Right,
173}
174
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
177pub struct Column {
178 pub key: String,
179 pub label: String,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub format: Option<ColumnFormat>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub align: Option<ColumnAlign>,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
190pub struct SelectOption {
191 pub value: String,
192 pub label: String,
193}
194
195#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
202#[serde(rename_all = "snake_case")]
203pub enum CardAppearance {
204 #[default]
205 Bordered,
206 Elevated,
207}
208
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
211pub struct CardProps {
212 pub title: String,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub description: Option<String>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub subtitle: Option<String>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub badge: Option<String>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub max_width: Option<FormMaxWidth>,
227 #[serde(default, skip_serializing_if = "Vec::is_empty")]
229 pub footer: Vec<String>,
230 #[serde(default)]
231 pub appearance: CardAppearance,
232}
233
234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
236pub struct TableProps {
237 pub columns: Vec<Column>,
238 pub data_path: String,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub row_actions: Option<Vec<Action>>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub empty_message: Option<String>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub sortable: Option<bool>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub sort_column: Option<String>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub sort_direction: Option<SortDirection>,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
253#[serde(rename_all = "snake_case")]
254pub enum FormMaxWidth {
255 #[default]
256 Default,
257 Narrow,
258 Wide,
259}
260
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
263pub struct FormProps {
264 pub action: Action,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub method: Option<crate::action::HttpMethod>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub guard: Option<String>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub max_width: Option<FormMaxWidth>,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub id: Option<String>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub enctype: Option<String>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub fill: Option<bool>,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
301#[serde(rename_all = "snake_case")]
302pub enum ButtonType {
303 #[default]
304 Button,
305 Submit,
306}
307
308#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
310pub struct ButtonProps {
311 pub label: String,
312 #[serde(default)]
313 pub variant: Variant,
314 #[serde(default)]
315 pub size: Size,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub disabled: Option<bool>,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub icon: Option<String>,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub icon_position: Option<IconPosition>,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub button_type: Option<ButtonType>,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub form: Option<String>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub disable_on_submit: Option<bool>,
335}
336
337#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
339pub struct InputProps {
340 pub field: String,
342 pub label: String,
343 #[serde(default)]
344 pub input_type: InputType,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub placeholder: Option<String>,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub required: Option<bool>,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub disabled: Option<bool>,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub error: Option<String>,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub description: Option<String>,
355 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub default_value: Option<String>,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub data_path: Option<String>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub step: Option<String>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub list: Option<String>,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub accept: Option<String>,
374}
375
376#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
387pub struct RichTextEditorProps {
388 pub field: String,
389 pub label: String,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub placeholder: Option<String>,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub default_value: Option<String>,
394 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub data_path: Option<String>,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub error: Option<String>,
398}
399
400#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
402pub struct SelectProps {
403 pub field: String,
405 pub label: String,
406 pub options: Vec<SelectOption>,
407 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub placeholder: Option<String>,
409 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub required: Option<bool>,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub disabled: Option<bool>,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub error: Option<String>,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub description: Option<String>,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub default_value: Option<String>,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub data_path: Option<String>,
422}
423
424#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
426pub struct AlertProps {
427 pub message: String,
428 #[serde(default)]
429 pub tone: Tone,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub title: Option<String>,
432}
433
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
436pub struct BadgeProps {
437 pub label: String,
438 #[serde(default)]
439 pub tone: Tone,
440}
441
442#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
444pub struct ModalProps {
445 pub id: String,
446 pub title: 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 trigger_label: Option<String>,
451 #[serde(default, skip_serializing_if = "Vec::is_empty")]
453 pub footer: Vec<String>,
454}
455
456#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
458pub struct TextProps {
459 pub content: String,
460 #[serde(default)]
461 pub element: TextElement,
462}
463
464#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
466pub struct CheckboxProps {
467 pub field: String,
469 #[serde(default, skip_serializing_if = "Option::is_none")]
472 pub value: Option<String>,
473 pub label: String,
474 #[serde(default, skip_serializing_if = "Option::is_none")]
475 pub description: Option<String>,
476 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub checked: Option<bool>,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub data_path: Option<String>,
481 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub required: Option<bool>,
483 #[serde(default, skip_serializing_if = "Option::is_none")]
484 pub disabled: Option<bool>,
485 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub error: Option<String>,
487}
488
489#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
495pub struct CheckboxListProps {
496 pub field: String,
498 #[serde(default, skip_serializing_if = "Vec::is_empty")]
501 pub options: Vec<SelectOption>,
502 #[serde(default, skip_serializing_if = "Option::is_none")]
504 pub options_path: Option<String>,
505 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub selected_path: Option<String>,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
509 pub label: Option<String>,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub description: Option<String>,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
513 pub disabled: Option<bool>,
514 #[serde(default, skip_serializing_if = "Option::is_none")]
515 pub error: Option<String>,
516}
517
518#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
520pub struct SwitchProps {
521 pub field: String,
523 pub label: String,
524 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub description: Option<String>,
526 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub checked: Option<bool>,
528 #[serde(default, skip_serializing_if = "Option::is_none")]
530 pub data_path: Option<String>,
531 #[serde(default, skip_serializing_if = "Option::is_none")]
532 pub required: Option<bool>,
533 #[serde(default, skip_serializing_if = "Option::is_none")]
534 pub disabled: Option<bool>,
535 #[serde(default, skip_serializing_if = "Option::is_none")]
536 pub error: Option<String>,
537 #[serde(default, skip_serializing_if = "Option::is_none")]
540 pub action: Option<Action>,
541 #[serde(default, skip_serializing_if = "Option::is_none")]
544 pub compact: Option<bool>,
545}
546
547#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
549pub struct SeparatorProps {
550 #[serde(default, skip_serializing_if = "Option::is_none")]
551 pub orientation: Option<Orientation>,
552}
553
554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
556pub struct DescriptionItem {
557 pub label: String,
558 pub value: String,
559 #[serde(default, skip_serializing_if = "Option::is_none")]
560 pub format: Option<ColumnFormat>,
561}
562
563#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
565pub struct DescriptionListProps {
566 #[serde(default, skip_serializing_if = "Vec::is_empty")]
567 pub items: Vec<DescriptionItem>,
568 #[serde(default, skip_serializing_if = "Option::is_none")]
569 pub columns: Option<u8>,
570 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub data_path: Option<String>,
575}
576
577#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
579pub struct Tab {
580 pub value: String,
581 pub label: String,
582 #[serde(default, skip_serializing_if = "Vec::is_empty")]
584 pub children: Vec<String>,
585}
586
587#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
589pub struct TabsProps {
590 pub default_tab: String,
591 pub tabs: Vec<Tab>,
592}
593
594#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
596pub struct BreadcrumbItem {
597 pub label: String,
598 #[serde(default, skip_serializing_if = "Option::is_none")]
599 pub url: Option<String>,
600}
601
602#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
604pub struct BreadcrumbProps {
605 pub items: Vec<BreadcrumbItem>,
606}
607
608#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
610pub struct PaginationProps {
611 pub current_page: u32,
612 pub per_page: u32,
613 pub total: u32,
614 #[serde(default, skip_serializing_if = "Option::is_none")]
615 pub base_url: Option<String>,
616}
617
618#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
620pub struct ProgressProps {
621 pub value: u8,
623 #[serde(default, skip_serializing_if = "Option::is_none")]
624 pub max: Option<u8>,
625 #[serde(default, skip_serializing_if = "Option::is_none")]
626 pub label: Option<String>,
627}
628
629#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
631pub struct ImageProps {
632 #[serde(default)]
633 pub src: String,
634 pub alt: String,
635 #[serde(default, skip_serializing_if = "Option::is_none")]
636 pub aspect_ratio: Option<String>,
637 #[serde(default, skip_serializing_if = "Option::is_none")]
642 pub placeholder_label: Option<String>,
643 #[serde(default, skip_serializing_if = "Option::is_none")]
651 pub inline_svg: Option<String>,
652 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub data_path: Option<String>,
657}
658
659impl ImageProps {
660 pub fn inline_svg(svg: impl Into<String>, alt: impl Into<String>) -> Self {
666 Self {
667 src: String::new(),
668 alt: alt.into(),
669 aspect_ratio: None,
670 placeholder_label: None,
671 inline_svg: Some(svg.into()),
672 data_path: None,
673 }
674 }
675}
676
677#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
679pub struct AvatarProps {
680 #[serde(default, skip_serializing_if = "Option::is_none")]
681 pub src: Option<String>,
682 pub alt: String,
683 #[serde(default, skip_serializing_if = "Option::is_none")]
684 pub fallback: Option<String>,
685 #[serde(default, skip_serializing_if = "Option::is_none")]
686 pub size: Option<Size>,
687}
688
689#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
691pub struct SkeletonProps {
692 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub width: Option<String>,
694 #[serde(default, skip_serializing_if = "Option::is_none")]
695 pub height: Option<String>,
696 #[serde(default, skip_serializing_if = "Option::is_none")]
697 pub rounded: Option<bool>,
698}
699
700#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
717pub struct RawHtmlProps {
718 #[serde(default)]
720 pub html: String,
721}
722
723#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
729pub struct StreamTextProps {
730 #[serde(default)]
733 pub sse_url: String,
734 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub placeholder: Option<String>,
737 #[serde(default, skip_serializing_if = "Option::is_none")]
739 pub loading_text: Option<String>,
740}
741
742#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
744pub struct ChecklistItem {
745 pub label: String,
746 #[serde(default)]
747 pub checked: bool,
748 #[serde(default, skip_serializing_if = "Option::is_none")]
749 pub href: Option<String>,
750}
751
752#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
754pub struct NotificationItem {
755 #[serde(default, skip_serializing_if = "Option::is_none")]
756 pub icon: Option<String>,
757 pub text: String,
758 #[serde(default, skip_serializing_if = "Option::is_none")]
759 pub timestamp: Option<String>,
760 #[serde(default)]
761 pub read: bool,
762 #[serde(default, skip_serializing_if = "Option::is_none")]
763 pub action_url: Option<String>,
764}
765
766#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
768pub struct SidebarNavItem {
769 pub label: String,
770 pub href: String,
771 #[serde(default, skip_serializing_if = "Option::is_none")]
772 pub icon: Option<String>,
773 #[serde(default)]
774 pub active: bool,
775 #[serde(default, skip_serializing_if = "Option::is_none")]
778 pub disabled: Option<bool>,
779}
780
781#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
783pub struct SidebarGroup {
784 pub label: String,
785 #[serde(default)]
786 pub collapsed: bool,
787 pub items: Vec<SidebarNavItem>,
788}
789
790#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
792pub struct StatCardProps {
793 pub label: String,
794 pub value: String,
795 #[serde(default)]
798 pub tone: Tone,
799 #[serde(default, skip_serializing_if = "Option::is_none")]
800 pub icon: Option<String>,
801 #[serde(default, skip_serializing_if = "Option::is_none")]
802 pub subtitle: Option<String>,
803 #[serde(default, skip_serializing_if = "Option::is_none")]
805 pub sse_target: Option<String>,
806 #[serde(default, skip_serializing_if = "Option::is_none")]
811 pub value_path: Option<String>,
812}
813
814#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
816pub struct ChecklistProps {
817 pub title: String,
818 pub items: Vec<ChecklistItem>,
819 #[serde(default = "default_true")]
820 pub dismissible: bool,
821 #[serde(default, skip_serializing_if = "Option::is_none")]
822 pub dismiss_label: Option<String>,
823 #[serde(default, skip_serializing_if = "Option::is_none")]
825 pub data_key: Option<String>,
826}
827
828fn default_true() -> bool {
829 true
830}
831
832#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
837pub struct ToastProps {
838 pub message: String,
839 #[serde(default)]
840 pub tone: Tone,
841 #[serde(default, skip_serializing_if = "Option::is_none")]
844 pub timeout: Option<u32>,
845 #[serde(default = "default_true")]
848 pub dismissible: bool,
849}
850
851#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
853pub struct NotificationDropdownProps {
854 pub notifications: Vec<NotificationItem>,
855 #[serde(default, skip_serializing_if = "Option::is_none")]
856 pub empty_text: Option<String>,
857}
858
859#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
861pub struct SidebarProps {
862 #[serde(default, skip_serializing_if = "Vec::is_empty")]
863 pub fixed_top: Vec<SidebarNavItem>,
864 #[serde(default, skip_serializing_if = "Vec::is_empty")]
865 pub groups: Vec<SidebarGroup>,
866 #[serde(default, skip_serializing_if = "Vec::is_empty")]
867 pub fixed_bottom: Vec<SidebarNavItem>,
868}
869
870#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
872pub struct HeaderProps {
873 pub business_name: String,
874 #[serde(default, skip_serializing_if = "Option::is_none")]
876 pub notification_count: Option<u32>,
877 #[serde(default, skip_serializing_if = "Option::is_none")]
878 pub user_name: Option<String>,
879 #[serde(default, skip_serializing_if = "Option::is_none")]
880 pub user_avatar: Option<String>,
881 #[serde(default, skip_serializing_if = "Option::is_none")]
882 pub logout_url: Option<String>,
883}
884
885#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
887#[serde(rename_all = "snake_case")]
888pub enum GapSize {
889 None,
890 Sm,
891 #[default]
892 Md,
893 Lg,
894 Xl,
895}
896
897#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
899pub struct GridProps {
900 #[serde(default = "default_grid_columns")]
902 pub columns: u8,
903 #[serde(default, skip_serializing_if = "Option::is_none")]
905 pub md_columns: Option<u8>,
906 #[serde(default, skip_serializing_if = "Option::is_none")]
908 pub lg_columns: Option<u8>,
909 #[serde(default)]
911 pub gap: GapSize,
912 #[serde(default, skip_serializing_if = "Option::is_none")]
915 pub scrollable: Option<bool>,
916 #[serde(default, skip_serializing_if = "Vec::is_empty")]
922 pub spans: Vec<u8>,
923 #[serde(default, skip_serializing_if = "Vec::is_empty")]
930 pub row_weights: Vec<u8>,
931 #[serde(default, skip_serializing_if = "Option::is_none")]
938 pub fill: Option<bool>,
939}
940
941fn default_grid_columns() -> u8 {
942 2
943}
944
945#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
947pub struct CollapsibleProps {
948 pub title: String,
949 #[serde(default)]
950 pub expanded: bool,
951}
952
953#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
955pub struct EmptyStateProps {
956 pub title: String,
957 #[serde(default, skip_serializing_if = "Option::is_none")]
958 pub description: Option<String>,
959 #[serde(default, skip_serializing_if = "Option::is_none")]
960 pub action: Option<Action>,
961 #[serde(default, skip_serializing_if = "Option::is_none")]
962 pub action_label: Option<String>,
963}
964
965#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
967#[serde(rename_all = "snake_case")]
968pub enum FormSectionLayout {
969 #[default]
970 Stacked,
971 TwoColumn,
972}
973
974#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
976pub struct FormSectionProps {
977 pub title: String,
978 #[serde(default, skip_serializing_if = "Option::is_none")]
979 pub description: Option<String>,
980 #[serde(default, skip_serializing_if = "Option::is_none")]
982 pub layout: Option<FormSectionLayout>,
983}
984
985#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
987pub struct PageHeaderProps {
988 pub title: String,
989 #[serde(default, skip_serializing_if = "Vec::is_empty")]
990 pub breadcrumb: Vec<BreadcrumbItem>,
991 #[serde(
993 default,
994 deserialize_with = "deserialize_actions_lax",
995 skip_serializing_if = "Vec::is_empty"
996 )]
997 pub actions: Vec<String>,
998}
999
1000#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1002pub struct ButtonGroupProps {
1003 #[serde(default)]
1005 pub gap: GapSize,
1006}
1007
1008#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1019pub struct ActionItem {
1020 pub label: String,
1021 pub action: Action,
1022 #[serde(default)]
1025 pub destructive: bool,
1026 #[serde(default, skip_serializing_if = "Option::is_none")]
1027 pub variant: Option<Variant>,
1028 #[serde(default, skip_serializing_if = "Option::is_none")]
1029 pub icon: Option<String>,
1030 #[serde(default, skip_serializing_if = "Option::is_none")]
1034 pub visible_if: Option<String>,
1035}
1036
1037#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1048pub struct ActionGroupProps {
1049 pub items: Vec<ActionItem>,
1050 pub menu_id: String,
1053 #[serde(default, skip_serializing_if = "Option::is_none")]
1055 pub max_inline: Option<u8>,
1056 #[serde(default, skip_serializing_if = "Option::is_none")]
1058 pub overflow_label: Option<String>,
1059 #[serde(default, skip_serializing_if = "Option::is_none")]
1061 pub row_key: Option<String>,
1062}
1063
1064#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1080pub struct SegmentedControlProps {
1081 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1083 pub items: Vec<SegmentedItem>,
1084 #[serde(default, skip_serializing_if = "Option::is_none")]
1087 pub data_path: Option<String>,
1088 #[serde(default)]
1090 pub size: Size,
1091 #[serde(default, skip_serializing_if = "Option::is_none")]
1094 pub aria_label: Option<String>,
1095}
1096
1097#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1099pub struct SegmentedItem {
1100 pub label: String,
1102 pub href: String,
1104 #[serde(default)]
1106 pub active: bool,
1107 #[serde(default, skip_serializing_if = "Option::is_none")]
1110 pub aria_label: Option<String>,
1111}
1112
1113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1129pub struct SidebarLayoutProps {
1130 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1132 pub items: Vec<SidebarLayoutItem>,
1133 #[serde(default, skip_serializing_if = "Option::is_none")]
1135 pub data_path: Option<String>,
1136 pub active: String,
1139 #[serde(default, skip_serializing_if = "Option::is_none")]
1141 pub aria_label: Option<String>,
1142}
1143
1144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1146pub struct SidebarLayoutItem {
1147 pub slug: String,
1150 pub label: String,
1152 pub url: String,
1155}
1156
1157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1166pub struct DetailPageProps {
1167 pub title: String,
1168 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1169 pub breadcrumb: Vec<BreadcrumbItem>,
1170 #[serde(
1172 default,
1173 deserialize_with = "deserialize_actions_lax",
1174 skip_serializing_if = "Vec::is_empty"
1175 )]
1176 pub actions: Vec<String>,
1177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1180 pub info: Vec<String>,
1181}
1182
1183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1185pub struct DropdownMenuAction {
1186 pub label: String,
1187 pub action: Action,
1188 #[serde(default)]
1189 pub destructive: bool,
1190 #[serde(default, skip_serializing_if = "Option::is_none")]
1197 pub visible_if: Option<String>,
1198}
1199
1200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1203pub struct DataTableProps {
1204 pub columns: Vec<Column>,
1205 pub data_path: String,
1206 #[serde(default, skip_serializing_if = "Option::is_none")]
1207 pub row_actions: Option<Vec<DropdownMenuAction>>,
1208 #[serde(default, skip_serializing_if = "Option::is_none")]
1209 pub empty_message: Option<String>,
1210 #[serde(default, skip_serializing_if = "Option::is_none")]
1211 pub row_key: Option<String>,
1212 #[serde(default, skip_serializing_if = "Option::is_none")]
1214 pub row_href: Option<String>,
1215}
1216
1217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1221pub struct MediaCardGridProps {
1222 pub data_path: String,
1223 pub title_key: String,
1225 #[serde(default, skip_serializing_if = "Option::is_none")]
1227 pub description_key: Option<String>,
1228 #[serde(default, skip_serializing_if = "Option::is_none")]
1230 pub image_key: Option<String>,
1231 #[serde(default, skip_serializing_if = "Option::is_none")]
1233 pub image_href_key: Option<String>,
1234 #[serde(default, skip_serializing_if = "Option::is_none")]
1236 pub image_aspect_ratio: Option<String>,
1237 #[serde(default, skip_serializing_if = "Option::is_none")]
1240 pub image_position: Option<String>,
1241 #[serde(default, skip_serializing_if = "Option::is_none")]
1243 pub badge_key: Option<String>,
1244 #[serde(default, skip_serializing_if = "Option::is_none")]
1246 pub badge_tone_key: Option<String>,
1247 #[serde(default, skip_serializing_if = "Option::is_none")]
1249 pub row_key: Option<String>,
1250 #[serde(default, skip_serializing_if = "Option::is_none")]
1251 pub row_actions: Option<Vec<DropdownMenuAction>>,
1252 #[serde(default, skip_serializing_if = "Option::is_none")]
1253 pub empty_message: Option<String>,
1254 #[serde(default, skip_serializing_if = "Option::is_none")]
1256 pub columns: Option<u8>,
1257}
1258
1259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1267pub struct KanbanColumnProps {
1268 pub id: String,
1269 pub title: String,
1270 #[serde(default)]
1271 pub count: u32,
1272 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1274 pub children: Vec<String>,
1275}
1276
1277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1290pub struct KanbanBoardProps {
1291 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1293 pub columns: Vec<KanbanColumnProps>,
1294 #[serde(default, skip_serializing_if = "Option::is_none")]
1297 pub items_path: Option<String>,
1298 #[serde(default, skip_serializing_if = "Option::is_none")]
1300 pub group_by: Option<String>,
1301 #[serde(default, skip_serializing_if = "Option::is_none")]
1303 pub card_title_key: Option<String>,
1304 #[serde(default, skip_serializing_if = "Option::is_none")]
1306 pub card_description_key: Option<String>,
1307 #[serde(default, skip_serializing_if = "Option::is_none")]
1310 pub row_actions: Option<Vec<DropdownMenuAction>>,
1311 #[serde(default, skip_serializing_if = "Option::is_none")]
1314 pub row_key: Option<String>,
1315 #[serde(default, skip_serializing_if = "Option::is_none")]
1316 pub mobile_default_column: Option<String>,
1317 #[serde(default, skip_serializing_if = "Option::is_none")]
1321 pub empty_label: Option<String>,
1322}
1323
1324#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1329pub struct CalendarCellProps {
1330 pub day: u8,
1331 #[serde(default)]
1332 pub is_today: bool,
1333 #[serde(default)]
1334 pub is_current_month: bool,
1335 #[serde(default)]
1336 pub event_count: u32,
1337 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1340 pub dot_colors: Vec<String>,
1341 #[serde(default)]
1345 pub closed: bool,
1346}
1347
1348#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1353pub struct ActionCardProps {
1354 pub title: String,
1355 pub description: String,
1356 #[serde(default, skip_serializing_if = "Option::is_none")]
1357 pub icon: Option<String>,
1358 #[serde(default)]
1359 pub tone: Tone,
1360 #[serde(default, skip_serializing_if = "Option::is_none")]
1362 pub href: Option<String>,
1363}
1364
1365#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1370pub struct TileProps {
1371 pub item_id: String,
1372 pub name: String,
1373 pub price: String,
1374 pub field: String,
1375 #[serde(default, skip_serializing_if = "Option::is_none")]
1376 pub default_quantity: Option<u32>,
1377 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1388 pub categories: Vec<String>,
1389 #[serde(default, skip_serializing_if = "Option::is_none")]
1392 pub image_url: Option<String>,
1393 #[serde(default, skip_serializing_if = "Option::is_none")]
1397 pub color: Option<Tone>,
1398 #[serde(default, skip_serializing_if = "Option::is_none")]
1400 pub stock_badge: Option<String>,
1401 #[serde(default, skip_serializing_if = "Option::is_none")]
1409 pub price_cents: Option<u64>,
1410}
1411
1412#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1416pub struct TileGridProps {
1417 pub data_path: String,
1419 pub form_id: String,
1428 #[serde(default, skip_serializing_if = "Option::is_none")]
1431 pub categories_path: Option<String>,
1432 #[serde(default, skip_serializing_if = "Option::is_none")]
1434 pub columns: Option<u8>,
1435 #[serde(default, skip_serializing_if = "Option::is_none")]
1437 pub search: Option<bool>,
1438 #[serde(default, skip_serializing_if = "Option::is_none")]
1443 pub search_placeholder: Option<String>,
1444 #[serde(default, skip_serializing_if = "Option::is_none")]
1449 pub all_label: Option<String>,
1450}
1451
1452#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1455pub struct SelectionPanelProps {
1456 pub form_id: String,
1458 #[serde(default, skip_serializing_if = "Option::is_none")]
1460 pub empty_message: Option<String>,
1461 #[serde(default, skip_serializing_if = "Option::is_none")]
1466 pub currency: Option<String>,
1467 #[serde(default, skip_serializing_if = "Option::is_none")]
1471 pub total_label: Option<String>,
1472}
1473
1474#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1478pub struct FilterTabsProps {
1479 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1483 pub items: Vec<String>,
1484 #[serde(default, skip_serializing_if = "Option::is_none")]
1488 pub all_label: Option<String>,
1489}
1490
1491#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1494pub struct QuantityStepperProps {
1495 pub field: String,
1497 #[serde(default, skip_serializing_if = "Option::is_none")]
1499 pub min: Option<u32>,
1500 #[serde(default, skip_serializing_if = "Option::is_none")]
1502 pub max: Option<u32>,
1503 #[serde(default, skip_serializing_if = "Option::is_none")]
1505 pub step: Option<u32>,
1506}
1507
1508#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
1510#[serde(rename_all = "snake_case")]
1511pub enum NumpadMode {
1512 #[default]
1514 Quantity,
1515 Price,
1517}
1518
1519#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1523pub struct NumpadProps {
1524 pub target_field: String,
1526 #[serde(default)]
1528 pub mode: NumpadMode,
1529}
1530
1531fn deserialize_actions_lax<'de, D: serde::Deserializer<'de>>(
1537 d: D,
1538) -> Result<Vec<String>, D::Error> {
1539 use serde::de::Error;
1540 let v = serde_json::Value::deserialize(d)?;
1541 match v {
1542 serde_json::Value::Null => Ok(Vec::new()),
1543 serde_json::Value::String(s) if s.is_empty() => Ok(Vec::new()),
1544 serde_json::Value::Array(arr) => arr
1545 .into_iter()
1546 .map(|item| {
1547 item.as_str()
1548 .map(String::from)
1549 .ok_or_else(|| D::Error::custom("PageHeader.actions: expected string in array"))
1550 })
1551 .collect(),
1552 other => Err(D::Error::custom(format!(
1553 "PageHeader.actions: expected null, empty string, or array of strings; got {other:?}"
1554 ))),
1555 }
1556}
1557
1558#[cfg(test)]
1559mod schema_smoke_tests {
1560 use super::*;
1571
1572 fn assert_schema_nonempty_object<T: schemars::JsonSchema>(type_label: &str) {
1573 let schema = schemars::schema_for!(T);
1574 let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1575 assert!(
1576 value.is_object(),
1577 "{type_label}: schema must be a JSON object"
1578 );
1579 let props = value
1580 .get("properties")
1581 .and_then(|p| p.as_object())
1582 .map(|o| !o.is_empty())
1583 .unwrap_or(false);
1584 assert!(
1585 props,
1586 "{type_label}: schema must have a non-empty `properties` field"
1587 );
1588 }
1589
1590 #[test]
1591 fn schema_for_card_props_generates() {
1592 assert_schema_nonempty_object::<CardProps>("CardProps");
1593 }
1594
1595 #[test]
1596 fn schema_for_table_props_generates() {
1597 assert_schema_nonempty_object::<TableProps>("TableProps");
1598 }
1599
1600 #[test]
1601 fn schema_for_form_props_generates() {
1602 assert_schema_nonempty_object::<FormProps>("FormProps");
1603 }
1604
1605 #[test]
1606 fn schema_for_button_props_generates() {
1607 assert_schema_nonempty_object::<ButtonProps>("ButtonProps");
1608 }
1609
1610 #[test]
1611 fn schema_for_input_props_generates() {
1612 assert_schema_nonempty_object::<InputProps>("InputProps");
1613 }
1614
1615 #[test]
1616 fn schema_for_select_props_generates() {
1617 assert_schema_nonempty_object::<SelectProps>("SelectProps");
1618 }
1619
1620 #[test]
1621 fn schema_for_alert_props_generates() {
1622 assert_schema_nonempty_object::<AlertProps>("AlertProps");
1623 }
1624
1625 #[test]
1626 fn schema_for_badge_props_generates() {
1627 assert_schema_nonempty_object::<BadgeProps>("BadgeProps");
1628 }
1629
1630 #[test]
1631 fn schema_for_modal_props_generates() {
1632 assert_schema_nonempty_object::<ModalProps>("ModalProps");
1633 }
1634
1635 #[test]
1636 fn schema_for_text_props_generates() {
1637 assert_schema_nonempty_object::<TextProps>("TextProps");
1638 }
1639
1640 #[test]
1641 fn schema_for_checkbox_props_generates() {
1642 assert_schema_nonempty_object::<CheckboxProps>("CheckboxProps");
1643 }
1644
1645 #[test]
1646 fn schema_for_switch_props_generates() {
1647 assert_schema_nonempty_object::<SwitchProps>("SwitchProps");
1648 }
1649
1650 #[test]
1651 fn schema_for_separator_props_generates() {
1652 assert_schema_nonempty_object::<SeparatorProps>("SeparatorProps");
1653 }
1654
1655 #[test]
1656 fn schema_for_description_list_props_generates() {
1657 assert_schema_nonempty_object::<DescriptionListProps>("DescriptionListProps");
1658 }
1659
1660 #[test]
1661 fn schema_for_tab_generates() {
1662 assert_schema_nonempty_object::<Tab>("Tab");
1663 }
1664
1665 #[test]
1666 fn schema_for_tabs_props_generates() {
1667 assert_schema_nonempty_object::<TabsProps>("TabsProps");
1668 }
1669
1670 #[test]
1671 fn schema_for_breadcrumb_props_generates() {
1672 assert_schema_nonempty_object::<BreadcrumbProps>("BreadcrumbProps");
1673 }
1674
1675 #[test]
1676 fn schema_for_pagination_props_generates() {
1677 assert_schema_nonempty_object::<PaginationProps>("PaginationProps");
1678 }
1679
1680 #[test]
1681 fn schema_for_progress_props_generates() {
1682 assert_schema_nonempty_object::<ProgressProps>("ProgressProps");
1683 }
1684
1685 #[test]
1686 fn schema_for_image_props_generates() {
1687 assert_schema_nonempty_object::<ImageProps>("ImageProps");
1688 }
1689
1690 #[test]
1691 fn image_inline_svg_factory_roundtrips_via_serde() {
1692 let p = ImageProps::inline_svg("<svg/>", "alt");
1693 let json = serde_json::to_value(&p).expect("serialization must not fail");
1694 let parsed: ImageProps =
1695 serde_json::from_value(json).expect("deserialization must not fail");
1696 assert_eq!(parsed.inline_svg, Some("<svg/>".to_string()));
1697 assert_eq!(parsed.alt, "alt");
1698 assert_eq!(parsed.src, "");
1699 }
1700
1701 #[test]
1702 fn schema_for_avatar_props_generates() {
1703 assert_schema_nonempty_object::<AvatarProps>("AvatarProps");
1704 }
1705
1706 #[test]
1707 fn schema_for_skeleton_props_generates() {
1708 assert_schema_nonempty_object::<SkeletonProps>("SkeletonProps");
1709 }
1710
1711 #[test]
1712 fn schema_for_stat_card_props_generates() {
1713 assert_schema_nonempty_object::<StatCardProps>("StatCardProps");
1714 }
1715
1716 #[test]
1717 fn schema_for_checklist_props_generates() {
1718 assert_schema_nonempty_object::<ChecklistProps>("ChecklistProps");
1719 }
1720
1721 #[test]
1722 fn schema_for_toast_props_generates() {
1723 assert_schema_nonempty_object::<ToastProps>("ToastProps");
1724 }
1725
1726 #[test]
1727 fn schema_for_notification_dropdown_props_generates() {
1728 assert_schema_nonempty_object::<NotificationDropdownProps>("NotificationDropdownProps");
1729 }
1730
1731 #[test]
1732 fn schema_for_sidebar_props_generates() {
1733 assert_schema_nonempty_object::<SidebarProps>("SidebarProps");
1734 }
1735
1736 #[test]
1737 fn schema_for_header_props_generates() {
1738 assert_schema_nonempty_object::<HeaderProps>("HeaderProps");
1739 }
1740
1741 #[test]
1742 fn schema_for_grid_props_generates() {
1743 assert_schema_nonempty_object::<GridProps>("GridProps");
1744 }
1745
1746 #[test]
1747 fn schema_for_collapsible_props_generates() {
1748 assert_schema_nonempty_object::<CollapsibleProps>("CollapsibleProps");
1749 }
1750
1751 #[test]
1752 fn schema_for_empty_state_props_generates() {
1753 assert_schema_nonempty_object::<EmptyStateProps>("EmptyStateProps");
1754 }
1755
1756 #[test]
1757 fn schema_for_form_section_props_generates() {
1758 assert_schema_nonempty_object::<FormSectionProps>("FormSectionProps");
1759 }
1760
1761 #[test]
1762 fn schema_for_page_header_props_generates() {
1763 assert_schema_nonempty_object::<PageHeaderProps>("PageHeaderProps");
1764 }
1765
1766 #[test]
1767 fn schema_for_button_group_props_generates() {
1768 assert_schema_nonempty_object::<ButtonGroupProps>("ButtonGroupProps");
1769 }
1770
1771 #[test]
1772 fn schema_for_action_item_generates() {
1773 assert_schema_nonempty_object::<ActionItem>("ActionItem");
1774 }
1775
1776 #[test]
1777 fn schema_for_action_group_props_generates() {
1778 assert_schema_nonempty_object::<ActionGroupProps>("ActionGroupProps");
1779 }
1780
1781 #[test]
1782 fn schema_for_dropdown_menu_action_generates() {
1783 assert_schema_nonempty_object::<DropdownMenuAction>("DropdownMenuAction");
1784 }
1785
1786 #[test]
1787 fn schema_for_data_table_props_generates() {
1788 assert_schema_nonempty_object::<DataTableProps>("DataTableProps");
1789 }
1790
1791 #[test]
1792 fn schema_for_kanban_column_props_generates() {
1793 assert_schema_nonempty_object::<KanbanColumnProps>("KanbanColumnProps");
1794 }
1795
1796 #[test]
1797 fn schema_for_kanban_board_props_generates() {
1798 assert_schema_nonempty_object::<KanbanBoardProps>("KanbanBoardProps");
1799 }
1800
1801 #[test]
1802 fn schema_for_calendar_cell_props_generates() {
1803 assert_schema_nonempty_object::<CalendarCellProps>("CalendarCellProps");
1804 }
1805
1806 #[test]
1807 fn schema_for_action_card_props_generates() {
1808 assert_schema_nonempty_object::<ActionCardProps>("ActionCardProps");
1809 }
1810
1811 #[test]
1812 fn schema_for_tile_props_generates() {
1813 assert_schema_nonempty_object::<TileProps>("TileProps");
1814 }
1815
1816 #[test]
1817 fn card_props_round_trips_footer() {
1818 let original = CardProps {
1819 title: "Hero".to_string(),
1820 description: None,
1821 subtitle: None,
1822 badge: None,
1823 max_width: None,
1824 footer: vec!["btn1".to_string(), "btn2".to_string()],
1825 appearance: CardAppearance::Bordered,
1826 };
1827 let json = serde_json::to_string(&original).unwrap();
1828 let parsed: CardProps = serde_json::from_str(&json).unwrap();
1829 assert_eq!(original.footer, parsed.footer);
1830 }
1831
1832 #[test]
1833 fn tab_round_trips_children() {
1834 let original = Tab {
1835 value: "overview".to_string(),
1836 label: "Overview".to_string(),
1837 children: vec!["panel1".to_string()],
1838 };
1839 let json = serde_json::to_string(&original).unwrap();
1840 let parsed: Tab = serde_json::from_str(&json).unwrap();
1841 assert_eq!(original.children, parsed.children);
1842 }
1843
1844 #[test]
1845 fn card_props_omits_empty_footer_in_json() {
1846 let card = CardProps {
1847 title: "Card".to_string(),
1848 description: None,
1849 subtitle: None,
1850 badge: None,
1851 max_width: None,
1852 footer: Vec::new(),
1853 appearance: CardAppearance::Bordered,
1854 };
1855 let json = serde_json::to_string(&card).unwrap();
1856 assert!(
1857 !json.contains("\"footer\""),
1858 "empty footer must be skipped, got: {json}"
1859 );
1860 }
1861
1862 #[test]
1863 fn card_props_round_trips_badge() {
1864 let original = CardProps {
1865 title: "Hero".to_string(),
1866 description: None,
1867 subtitle: None,
1868 badge: Some("Scade tra 9m".to_string()),
1869 max_width: None,
1870 footer: Vec::new(),
1871 appearance: CardAppearance::Bordered,
1872 };
1873 let json = serde_json::to_string(&original).unwrap();
1874 let parsed: CardProps = serde_json::from_str(&json).unwrap();
1875 assert_eq!(original.badge, parsed.badge);
1876 }
1877
1878 #[test]
1879 fn card_props_omits_empty_badge_in_json() {
1880 let card = CardProps {
1881 title: "Card".to_string(),
1882 description: None,
1883 subtitle: None,
1884 badge: None,
1885 max_width: None,
1886 footer: Vec::new(),
1887 appearance: CardAppearance::Bordered,
1888 };
1889 let json = serde_json::to_string(&card).unwrap();
1890 assert!(
1891 !json.contains("\"badge\""),
1892 "empty badge must be skipped, got: {json}"
1893 );
1894 }
1895
1896 #[test]
1897 fn card_props_round_trips_subtitle() {
1898 let original = CardProps {
1899 title: "Hero".to_string(),
1900 description: None,
1901 subtitle: Some("Marco Rossi".to_string()),
1902 badge: None,
1903 max_width: None,
1904 footer: Vec::new(),
1905 appearance: CardAppearance::Bordered,
1906 };
1907 let json = serde_json::to_string(&original).unwrap();
1908 let parsed: CardProps = serde_json::from_str(&json).unwrap();
1909 assert_eq!(original.subtitle, parsed.subtitle);
1910 }
1911
1912 #[test]
1913 fn card_props_omits_empty_subtitle_in_json() {
1914 let card = CardProps {
1915 title: "Card".to_string(),
1916 description: None,
1917 subtitle: None,
1918 badge: None,
1919 max_width: None,
1920 footer: Vec::new(),
1921 appearance: CardAppearance::Bordered,
1922 };
1923 let json = serde_json::to_string(&card).unwrap();
1924 assert!(
1925 !json.contains("\"subtitle\""),
1926 "empty subtitle must be skipped, got: {json}"
1927 );
1928 }
1929
1930 #[test]
1931 fn card_props_schema_includes_badge() {
1932 let schema = schemars::schema_for!(CardProps);
1933 let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1934 let props = value
1935 .get("properties")
1936 .and_then(|p| p.as_object())
1937 .expect("schema has a properties object");
1938 assert!(
1939 props.contains_key("badge"),
1940 "CardProps schema must expose a `badge` property; got keys: {:?}",
1941 props.keys().collect::<Vec<_>>()
1942 );
1943 let badge_schema = props.get("badge").expect("badge entry");
1948 let badge_json = badge_schema.to_string();
1949 assert!(
1950 badge_json.contains("\"string\""),
1951 "badge schema entry must mention string type; got: {badge_json}"
1952 );
1953 }
1954
1955 #[test]
1956 fn card_props_schema_includes_subtitle() {
1957 let schema = schemars::schema_for!(CardProps);
1958 let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1959 let props = value
1960 .get("properties")
1961 .and_then(|p| p.as_object())
1962 .expect("schema has a properties object");
1963 assert!(
1964 props.contains_key("subtitle"),
1965 "CardProps schema must expose a `subtitle` property; got keys: {:?}",
1966 props.keys().collect::<Vec<_>>()
1967 );
1968 let subtitle_schema = props.get("subtitle").expect("subtitle entry");
1973 let subtitle_json = subtitle_schema.to_string();
1974 assert!(
1975 subtitle_json.contains("\"string\""),
1976 "subtitle schema entry must mention string type; got: {subtitle_json}"
1977 );
1978 }
1979
1980 #[test]
1981 fn schema_for_checkbox_list_props_generates() {
1982 assert_schema_nonempty_object::<CheckboxListProps>("CheckboxListProps");
1983 }
1984
1985 #[test]
1986 fn checkbox_list_props_serde_roundtrip() {
1987 let json = serde_json::json!({
1988 "field": "services",
1989 "options": [{"value": "a", "label": "Alpha"}, {"value": "b", "label": "Beta"}],
1990 "selected_path": "/preselected"
1991 });
1992 let parsed: CheckboxListProps = serde_json::from_value(json.clone()).expect("decode");
1993 assert_eq!(parsed.field, "services");
1994 assert_eq!(parsed.options.len(), 2);
1995 assert_eq!(parsed.selected_path.as_deref(), Some("/preselected"));
1996 let reserialized = serde_json::to_value(&parsed).expect("encode");
1997 assert!(reserialized.get("label").is_none());
1999 assert!(reserialized.get("disabled").is_none());
2000 }
2001
2002 #[test]
2003 fn schema_for_rich_text_editor_props_generates() {
2004 assert_schema_nonempty_object::<RichTextEditorProps>("RichTextEditorProps");
2005 }
2006
2007 #[test]
2008 fn rich_text_editor_props_serde_roundtrip() {
2009 let json = serde_json::json!({
2010 "field": "body",
2011 "label": "Body"
2012 });
2013 let parsed: RichTextEditorProps = serde_json::from_value(json).expect("decode");
2014 assert_eq!(parsed.field, "body");
2015 assert_eq!(parsed.label, "Body");
2016 assert!(parsed.placeholder.is_none());
2017 assert!(parsed.default_value.is_none());
2018 assert!(parsed.data_path.is_none());
2019 assert!(parsed.error.is_none());
2020 let reserialized = serde_json::to_value(&parsed).expect("encode");
2021 assert!(reserialized.get("placeholder").is_none());
2023 assert!(reserialized.get("error").is_none());
2024 }
2025
2026 #[test]
2027 fn schema_for_tile_grid_props_generates() {
2028 assert_schema_nonempty_object::<TileGridProps>("TileGridProps");
2029 }
2030
2031 #[test]
2032 fn schema_for_selection_panel_props_generates() {
2033 assert_schema_nonempty_object::<SelectionPanelProps>("SelectionPanelProps");
2034 }
2035
2036 #[test]
2037 fn schema_for_filter_tabs_props_generates() {
2038 assert_schema_nonempty_object::<FilterTabsProps>("FilterTabsProps");
2039 }
2040
2041 #[test]
2042 fn schema_for_quantity_stepper_props_generates() {
2043 assert_schema_nonempty_object::<QuantityStepperProps>("QuantityStepperProps");
2044 }
2045
2046 #[test]
2047 fn schema_for_numpad_props_generates() {
2048 assert_schema_nonempty_object::<NumpadProps>("NumpadProps");
2049 }
2050}
2051
2052#[cfg(test)]
2053mod strum_tests {
2054 use super::*;
2055
2056 use strum::VariantArray;
2057
2058 #[test]
2065 fn variant_enums_strum_matches_serde_wire_format() {
2066 fn check<T: AsRef<str> + serde::Serialize>(variants: &[T], label: &str) {
2067 for v in variants {
2068 let json = serde_json::to_string(v).expect("serialize");
2069 let json_stripped = json.trim_matches('"');
2070 assert_eq!(
2071 v.as_ref(),
2072 json_stripped,
2073 "strum AsRefStr drifted from serde for {label} variant"
2074 );
2075 }
2076 }
2077 check(Variant::VARIANTS, "Variant");
2078 check(Tone::VARIANTS, "Tone");
2079 check(Size::VARIANTS, "Size");
2080 }
2081
2082 #[test]
2086 fn canonical_enums_have_expected_variant_counts() {
2087 assert_eq!(Variant::VARIANTS.len(), 5);
2088 assert_eq!(Tone::VARIANTS.len(), 4);
2089 assert_eq!(Size::VARIANTS.len(), 3);
2090 }
2091
2092 #[test]
2093 fn tone_as_ref_str_matches_wire_format() {
2094 assert_eq!(Tone::Neutral.as_ref(), "neutral");
2095 assert_eq!(Tone::Success.as_ref(), "success");
2096 assert_eq!(Tone::Warning.as_ref(), "warning");
2097 assert_eq!(Tone::Destructive.as_ref(), "destructive");
2098 }
2099}
2100
2101#[cfg(test)]
2102mod canonical_enum_tests {
2103 use super::*;
2104
2105 #[test]
2108 fn variant_default_is_primary() {
2109 assert_eq!(Variant::default(), Variant::Primary);
2110 }
2111
2112 #[test]
2113 fn tone_default_is_neutral() {
2114 assert_eq!(Tone::default(), Tone::Neutral);
2115 }
2116
2117 #[test]
2118 fn size_default_is_md() {
2119 assert_eq!(Size::default(), Size::Md);
2120 }
2121
2122 #[test]
2125 fn variant_serde_snake_case_roundtrip() {
2126 use strum::VariantArray;
2127 for v in Variant::VARIANTS {
2128 let json = serde_json::to_value(v).unwrap();
2129 assert_eq!(json, serde_json::json!(v.as_ref()));
2130 let back: Variant = serde_json::from_value(json).unwrap();
2131 assert_eq!(back, *v);
2132 }
2133 assert_eq!(
2134 serde_json::from_str::<Variant>("\"primary\"").unwrap(),
2135 Variant::Primary
2136 );
2137 }
2138
2139 #[test]
2140 fn tone_serde_snake_case_roundtrip() {
2141 use strum::VariantArray;
2142 for t in Tone::VARIANTS {
2143 let json = serde_json::to_value(t).unwrap();
2144 assert_eq!(json, serde_json::json!(t.as_ref()));
2145 let back: Tone = serde_json::from_value(json).unwrap();
2146 assert_eq!(back, *t);
2147 }
2148 }
2149
2150 #[test]
2151 fn size_serde_snake_case_roundtrip() {
2152 use strum::VariantArray;
2153 for s in Size::VARIANTS {
2154 let json = serde_json::to_value(s).unwrap();
2155 assert_eq!(json, serde_json::json!(s.as_ref()));
2156 let back: Size = serde_json::from_value(json).unwrap();
2157 assert_eq!(back, *s);
2158 }
2159 assert!(serde_json::from_str::<Size>("\"md\"").is_ok());
2160 assert!(serde_json::from_str::<Size>("\"sm\"").is_ok());
2161 assert!(serde_json::from_str::<Size>("\"lg\"").is_ok());
2162 }
2163
2164 #[test]
2167 fn retired_size_values_are_rejected() {
2168 assert!(
2169 serde_json::from_str::<Size>("\"xs\"").is_err(),
2170 "size 'xs' was retired (migrate to 'sm')"
2171 );
2172 assert!(
2173 serde_json::from_str::<Size>("\"default\"").is_err(),
2174 "size 'default' was retired (migrate to 'md')"
2175 );
2176 }
2177
2178 #[test]
2179 fn retired_variant_values_are_rejected() {
2180 assert!(
2181 serde_json::from_str::<Variant>("\"default\"").is_err(),
2182 "variant 'default' was retired (migrate to 'primary')"
2183 );
2184 assert!(
2185 serde_json::from_str::<Variant>("\"link\"").is_err(),
2186 "variant 'link' was removed (migrate to 'ghost')"
2187 );
2188 }
2189
2190 #[test]
2191 fn retired_tone_values_are_rejected() {
2192 assert!(
2193 serde_json::from_str::<Tone>("\"info\"").is_err(),
2194 "tone 'info' was retired (migrate to 'neutral')"
2195 );
2196 assert!(
2197 serde_json::from_str::<Tone>("\"error\"").is_err(),
2198 "tone 'error' was retired (migrate to 'destructive')"
2199 );
2200 }
2201
2202 #[test]
2203 fn button_spec_with_link_variant_fails_to_decode() {
2204 let v = serde_json::json!({"variant": "link", "label": "x"});
2205 assert!(
2206 serde_json::from_value::<ButtonProps>(v).is_err(),
2207 "Button variant 'link' must fail decode (migrate to 'ghost')"
2208 );
2209 }
2210
2211 #[test]
2214 fn button_props_defaults_to_primary_md() {
2215 let v = serde_json::json!({"label": "x"});
2216 let p: ButtonProps = serde_json::from_value(v).unwrap();
2217 assert_eq!(p.variant, Variant::Primary);
2218 assert_eq!(p.size, Size::Md);
2219 }
2220
2221 #[test]
2222 fn alert_props_without_tone_defaults_to_neutral() {
2223 let v = serde_json::json!({"message": "x"});
2224 let p: AlertProps = serde_json::from_value(v).unwrap();
2225 assert_eq!(p.tone, Tone::Neutral);
2226 }
2227
2228 #[test]
2229 fn alert_props_with_tone_neutral_decodes() {
2230 let v = serde_json::json!({"message": "x", "tone": "neutral"});
2231 let p: AlertProps = serde_json::from_value(v).unwrap();
2232 assert_eq!(p.tone, Tone::Neutral);
2233 }
2234
2235 #[test]
2236 fn badge_props_without_tone_defaults_to_neutral() {
2237 let v = serde_json::json!({"label": "x"});
2238 let p: BadgeProps = serde_json::from_value(v).unwrap();
2239 assert_eq!(p.tone, Tone::Neutral);
2240 }
2241
2242 #[test]
2243 fn toast_props_without_tone_defaults_to_neutral() {
2244 let v = serde_json::json!({"message": "x"});
2245 let p: ToastProps = serde_json::from_value(v).unwrap();
2246 assert_eq!(p.tone, Tone::Neutral);
2247 }
2248
2249 #[test]
2250 fn action_card_props_with_success_tone_decodes() {
2251 let v = serde_json::json!({"title": "x", "description": "y", "tone": "success"});
2252 let p: ActionCardProps = serde_json::from_value(v).unwrap();
2253 assert_eq!(p.tone, Tone::Success);
2254 }
2255
2256 #[test]
2257 fn stat_card_props_without_tone_defaults_to_neutral() {
2258 let v = serde_json::json!({"label": "x", "value": "1"});
2259 let p: StatCardProps = serde_json::from_value(v).unwrap();
2260 assert_eq!(p.tone, Tone::Neutral);
2261 }
2262
2263 #[test]
2264 fn stat_card_props_roundtrip_preserves_tone() {
2265 let v = serde_json::json!({"label": "x", "value": "1", "tone": "warning"});
2266 let p: StatCardProps = serde_json::from_value(v).unwrap();
2267 assert_eq!(p.tone, Tone::Warning);
2268 let j = serde_json::to_value(&p).unwrap();
2269 let back: StatCardProps = serde_json::from_value(j).unwrap();
2270 assert_eq!(back.tone, Tone::Warning);
2271 }
2272}
2273
2274#[cfg(test)]
2275mod card_appearance_tests {
2276 use super::*;
2277
2278 #[test]
2279 fn card_appearance_default_is_bordered() {
2280 assert_eq!(CardAppearance::default(), CardAppearance::Bordered);
2281 }
2282
2283 #[test]
2284 fn card_appearance_serializes_snake_case() {
2285 assert_eq!(
2286 serde_json::to_value(CardAppearance::Bordered).unwrap(),
2287 serde_json::json!("bordered")
2288 );
2289 assert_eq!(
2290 serde_json::to_value(CardAppearance::Elevated).unwrap(),
2291 serde_json::json!("elevated")
2292 );
2293 }
2294
2295 #[test]
2296 fn card_appearance_deserializes_snake_case() {
2297 assert_eq!(
2298 serde_json::from_value::<CardAppearance>(serde_json::json!("bordered")).unwrap(),
2299 CardAppearance::Bordered
2300 );
2301 assert_eq!(
2302 serde_json::from_value::<CardAppearance>(serde_json::json!("elevated")).unwrap(),
2303 CardAppearance::Elevated
2304 );
2305 }
2306
2307 #[test]
2308 fn card_props_without_appearance_defaults_to_bordered() {
2309 let v = serde_json::json!({"title": "x"});
2310 let p: CardProps = serde_json::from_value(v).unwrap();
2311 assert_eq!(p.appearance, CardAppearance::Bordered);
2312 }
2313
2314 #[test]
2315 fn card_props_with_elevated_appearance() {
2316 let v = serde_json::json!({"title": "x", "appearance": "elevated"});
2317 let p: CardProps = serde_json::from_value(v).unwrap();
2318 assert_eq!(p.appearance, CardAppearance::Elevated);
2319 }
2320
2321 #[test]
2322 fn card_props_roundtrip_preserves_appearance() {
2323 let p = CardProps {
2324 title: "x".into(),
2325 description: None,
2326 subtitle: None,
2327 badge: None,
2328 max_width: None,
2329 footer: vec![],
2330 appearance: CardAppearance::Elevated,
2331 };
2332 let j = serde_json::to_value(&p).unwrap();
2333 let back: CardProps = serde_json::from_value(j).unwrap();
2334 assert_eq!(back.appearance, CardAppearance::Elevated);
2335 }
2336}
2337
2338#[cfg(test)]
2339mod kanban_board_props_tests {
2340 use super::*;
2341
2342 #[test]
2343 fn kanban_board_props_serde_static_columns() {
2344 let v = serde_json::json!({
2345 "columns": [{"title": "To Do", "id": "todo", "count": 0}]
2346 });
2347 let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
2348 assert_eq!(p.columns.len(), 1);
2349 assert!(p.items_path.is_none());
2350 assert!(p.group_by.is_none());
2351 }
2352
2353 #[test]
2354 fn kanban_board_props_serde_data_bound() {
2355 let v = serde_json::json!({
2356 "columns": [{"title": "Open", "id": "open"}],
2357 "items_path": "/data/order",
2358 "group_by": "status",
2359 "card_title_key": "name"
2360 });
2361 let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
2362 assert_eq!(p.columns.len(), 1);
2363 assert_eq!(p.items_path.as_deref(), Some("/data/order"));
2364 assert_eq!(p.group_by.as_deref(), Some("status"));
2365 assert_eq!(p.card_title_key.as_deref(), Some("name"));
2366 }
2367
2368 #[test]
2369 fn kanban_board_props_serde_neither() {
2370 let v = serde_json::json!({});
2371 let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
2372 assert!(p.columns.is_empty());
2373 assert!(p.items_path.is_none());
2374 assert!(p.group_by.is_none());
2375 }
2376
2377 #[test]
2378 fn kanban_board_props_empty_columns_skipped_on_serialize() {
2379 let p = KanbanBoardProps {
2380 columns: vec![],
2381 items_path: Some("/data/order".into()),
2382 group_by: Some("status".into()),
2383 card_title_key: None,
2384 card_description_key: None,
2385 row_actions: None,
2386 row_key: None,
2387 mobile_default_column: None,
2388 empty_label: None,
2389 };
2390 let j = serde_json::to_value(&p).unwrap();
2391 assert!(
2392 j.get("columns").is_none(),
2393 "empty columns must be skipped, got: {j}"
2394 );
2395 assert_eq!(
2396 j.get("items_path").and_then(|v| v.as_str()),
2397 Some("/data/order")
2398 );
2399 }
2400}
2401
2402#[cfg(test)]
2403mod page_header_actions_tests {
2404 use super::*;
2405
2406 #[test]
2407 fn page_header_actions_missing_field() {
2408 let v = serde_json::json!({"title": "X"});
2409 let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2410 assert!(p.actions.is_empty());
2411 }
2412
2413 #[test]
2414 fn page_header_actions_null() {
2415 let v = serde_json::json!({"title": "X", "actions": null});
2416 let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2417 assert!(p.actions.is_empty());
2418 }
2419
2420 #[test]
2421 fn page_header_actions_empty_string() {
2422 let v = serde_json::json!({"title": "X", "actions": ""});
2423 let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2424 assert!(p.actions.is_empty());
2425 }
2426
2427 #[test]
2428 fn page_header_actions_empty_array() {
2429 let v = serde_json::json!({"title": "X", "actions": []});
2430 let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2431 assert!(p.actions.is_empty());
2432 }
2433
2434 #[test]
2435 fn page_header_actions_non_empty_array() {
2436 let v = serde_json::json!({"title": "X", "actions": ["a", "b"]});
2437 let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2438 assert_eq!(p.actions, vec!["a".to_string(), "b".to_string()]);
2439 }
2440
2441 #[test]
2442 fn page_header_actions_non_empty_string_rejected() {
2443 let v = serde_json::json!({"title": "X", "actions": "not-empty"});
2444 let result: Result<PageHeaderProps, _> = serde_json::from_value(v);
2445 assert!(result.is_err(), "non-empty string must be rejected");
2446 }
2447
2448 #[test]
2449 fn page_header_actions_non_string_array_rejected() {
2450 let v = serde_json::json!({"title": "X", "actions": [1, 2, 3]});
2451 let result: Result<PageHeaderProps, _> = serde_json::from_value(v);
2452 assert!(result.is_err(), "array of non-strings must be rejected");
2453 }
2454}
2455
2456#[cfg(test)]
2457mod tile_contract_tests {
2458 use super::*;
2463
2464 #[test]
2467 fn tile_legacy_json_round_trips_unchanged() {
2468 let json = r#"{"item_id":"p1","name":"Widget","price":"€10,00","field":"qty_p1"}"#;
2469 let tile: TileProps = serde_json::from_str(json).expect("legacy json must deserialize");
2470 assert!(
2471 tile.categories.is_empty(),
2472 "categories must default to empty vec"
2473 );
2474 assert!(tile.image_url.is_none(), "image_url must default to None");
2475 assert!(tile.color.is_none(), "color must default to None");
2476 assert!(
2477 tile.stock_badge.is_none(),
2478 "stock_badge must default to None"
2479 );
2480 let serialized = serde_json::to_string(&tile).expect("must serialize");
2481 assert!(
2482 !serialized.contains("categories"),
2483 "re-serialized must not contain 'categories'; got: {serialized}"
2484 );
2485 assert!(
2486 !serialized.contains("image_url"),
2487 "re-serialized must not contain 'image_url'; got: {serialized}"
2488 );
2489 assert!(
2490 !serialized.contains("color"),
2491 "re-serialized must not contain 'color'; got: {serialized}"
2492 );
2493 assert!(
2494 !serialized.contains("stock_badge"),
2495 "re-serialized must not contain 'stock_badge'; got: {serialized}"
2496 );
2497 }
2498
2499 #[test]
2501 fn tile_with_categories_serializes() {
2502 let tile = TileProps {
2503 item_id: "p2".to_string(),
2504 name: "Espresso".to_string(),
2505 price: "\u{20ac}2,00".to_string(),
2506 field: "qty_p2".to_string(),
2507 default_quantity: None,
2508 categories: vec!["drinks".to_string(), "food".to_string()],
2509 image_url: None,
2510 color: None,
2511 stock_badge: None,
2512 price_cents: None,
2513 };
2514 let serialized = serde_json::to_string(&tile).expect("must serialize");
2515 assert!(
2516 serialized.contains(r#""categories":["drinks","food"]"#),
2517 "serialized must contain categories array; got: {serialized}"
2518 );
2519 }
2520
2521 #[test]
2523 fn grid_props_row_weights_round_trips() {
2524 let default_grid: GridProps = serde_json::from_value(serde_json::json!({}))
2526 .expect("must deserialize default GridProps");
2527 let json = serde_json::to_string(&default_grid).expect("must serialize");
2528 assert!(
2529 !json.contains("row_weights"),
2530 "empty row_weights must be skipped in serialization; got: {json}"
2531 );
2532
2533 let with_weights: GridProps =
2535 serde_json::from_value(serde_json::json!({"row_weights": [2, 1]}))
2536 .expect("must deserialize GridProps with row_weights");
2537 let json2 = serde_json::to_string(&with_weights).expect("must serialize with weights");
2538 assert!(
2539 json2.contains(r#""row_weights":[2,1]"#),
2540 "row_weights must appear in serialization; got: {json2}"
2541 );
2542 let parsed: GridProps =
2543 serde_json::from_str(&json2).expect("must deserialize from serialized");
2544 assert_eq!(
2545 parsed.row_weights,
2546 vec![2u8, 1u8],
2547 "row_weights must round-trip unchanged"
2548 );
2549 }
2550
2551 #[test]
2553 fn tile_props_price_cents_round_trips() {
2554 let no_price: TileProps = serde_json::from_str(
2556 r#"{"item_id":"p1","name":"Coffee","price":"€2,00","field":"qty_p1"}"#,
2557 )
2558 .expect("must deserialize");
2559 assert!(
2560 no_price.price_cents.is_none(),
2561 "price_cents must default to None"
2562 );
2563 let json = serde_json::to_string(&no_price).expect("must serialize");
2564 assert!(
2565 !json.contains("price_cents"),
2566 "absent price_cents must be skipped; got: {json}"
2567 );
2568
2569 let with_price: TileProps =
2571 serde_json::from_str(r#"{"item_id":"p1","name":"Coffee","price":"€2,50","field":"qty_p1","price_cents":250}"#)
2572 .expect("must deserialize with price_cents");
2573 assert_eq!(
2574 with_price.price_cents,
2575 Some(250u64),
2576 "price_cents must round-trip"
2577 );
2578 let json2 = serde_json::to_string(&with_price).expect("must serialize");
2579 assert!(
2580 json2.contains(r#""price_cents":250"#),
2581 "price_cents must appear in serialization; got: {json2}"
2582 );
2583 let parsed: TileProps =
2584 serde_json::from_str(&json2).expect("must deserialize from serialized");
2585 assert_eq!(
2586 parsed.price_cents,
2587 Some(250u64),
2588 "price_cents must round-trip unchanged"
2589 );
2590 }
2591
2592 #[test]
2594 fn tile_props_color_tone_round_trips_and_rejects_unknown() {
2595 let with_color: TileProps = serde_json::from_str(
2597 r#"{"item_id":"p1","name":"Tea","price":"€1,00","field":"qty_p1","color":"success"}"#,
2598 )
2599 .expect("must deserialize color:success");
2600 assert_eq!(
2601 with_color.color,
2602 Some(Tone::Success),
2603 "color:success must deserialize to Tone::Success"
2604 );
2605 let json = serde_json::to_string(&with_color).expect("must serialize");
2606 assert!(
2607 json.contains(r#""color":"success""#),
2608 "Tone::Success must serialize as \"success\"; got: {json}"
2609 );
2610 let parsed: TileProps =
2611 serde_json::from_str(&json).expect("must deserialize from serialized");
2612 assert_eq!(
2613 parsed.color,
2614 Some(Tone::Success),
2615 "color must round-trip unchanged"
2616 );
2617
2618 let result: Result<TileProps, _> = serde_json::from_str(
2620 r#"{"item_id":"p1","name":"Tea","price":"€1,00","field":"qty_p1","color":"blue"}"#,
2621 );
2622 assert!(
2623 result.is_err(),
2624 "color:\"blue\" must fail to deserialize — Tone enum enforced"
2625 );
2626 }
2627
2628 #[test]
2630 fn selection_panel_props_currency_round_trips() {
2631 let no_currency: SelectionPanelProps =
2633 serde_json::from_str(r#"{"form_id":"order-form"}"#).expect("must deserialize");
2634 assert!(
2635 no_currency.currency.is_none(),
2636 "currency must default to None"
2637 );
2638 let json = serde_json::to_string(&no_currency).expect("must serialize");
2639 assert!(
2640 !json.contains("currency"),
2641 "absent currency must be skipped; got: {json}"
2642 );
2643
2644 let with_currency: SelectionPanelProps =
2646 serde_json::from_str(r#"{"form_id":"order-form","currency":"€"}"#)
2647 .expect("must deserialize with currency");
2648 assert_eq!(
2649 with_currency.currency,
2650 Some("€".to_string()),
2651 "currency must round-trip"
2652 );
2653 let json2 = serde_json::to_string(&with_currency).expect("must serialize");
2654 assert!(
2655 json2.contains(r#""currency":"€""#),
2656 "currency must appear in serialization; got: {json2}"
2657 );
2658 }
2659}