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, Serialize, Deserialize, JsonSchema)]
753pub struct LiveFragmentProps {
754 #[serde(default)]
756 pub projection: String,
757 #[serde(default)]
759 pub key: String,
760 pub template: serde_json::Value,
763}
764
765#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
767pub struct ChecklistItem {
768 pub label: String,
769 #[serde(default)]
770 pub checked: bool,
771 #[serde(default, skip_serializing_if = "Option::is_none")]
772 pub href: Option<String>,
773}
774
775#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
777pub struct NotificationItem {
778 #[serde(default, skip_serializing_if = "Option::is_none")]
779 pub icon: Option<String>,
780 pub text: String,
781 #[serde(default, skip_serializing_if = "Option::is_none")]
782 pub timestamp: Option<String>,
783 #[serde(default)]
784 pub read: bool,
785 #[serde(default, skip_serializing_if = "Option::is_none")]
786 pub action_url: Option<String>,
787}
788
789#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
791pub struct SidebarNavItem {
792 pub label: String,
793 pub href: String,
794 #[serde(default, skip_serializing_if = "Option::is_none")]
795 pub icon: Option<String>,
796 #[serde(default)]
797 pub active: bool,
798 #[serde(default, skip_serializing_if = "Option::is_none")]
801 pub disabled: Option<bool>,
802}
803
804#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
806pub struct SidebarGroup {
807 pub label: String,
808 #[serde(default)]
809 pub collapsed: bool,
810 pub items: Vec<SidebarNavItem>,
811}
812
813#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
815pub struct StatCardProps {
816 pub label: String,
817 pub value: String,
818 #[serde(default)]
821 pub tone: Tone,
822 #[serde(default, skip_serializing_if = "Option::is_none")]
823 pub icon: Option<String>,
824 #[serde(default, skip_serializing_if = "Option::is_none")]
825 pub subtitle: Option<String>,
826 #[serde(default, skip_serializing_if = "Option::is_none")]
828 pub sse_target: Option<String>,
829 #[serde(default, skip_serializing_if = "Option::is_none")]
834 pub value_path: Option<String>,
835}
836
837#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
839pub struct ChecklistProps {
840 pub title: String,
841 pub items: Vec<ChecklistItem>,
842 #[serde(default = "default_true")]
843 pub dismissible: bool,
844 #[serde(default, skip_serializing_if = "Option::is_none")]
845 pub dismiss_label: Option<String>,
846 #[serde(default, skip_serializing_if = "Option::is_none")]
848 pub data_key: Option<String>,
849}
850
851fn default_true() -> bool {
852 true
853}
854
855#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
860pub struct ToastProps {
861 pub message: String,
862 #[serde(default)]
863 pub tone: Tone,
864 #[serde(default, skip_serializing_if = "Option::is_none")]
867 pub timeout: Option<u32>,
868 #[serde(default = "default_true")]
871 pub dismissible: bool,
872}
873
874#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
876pub struct NotificationDropdownProps {
877 pub notifications: Vec<NotificationItem>,
878 #[serde(default, skip_serializing_if = "Option::is_none")]
879 pub empty_text: Option<String>,
880}
881
882#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
884pub struct SidebarProps {
885 #[serde(default, skip_serializing_if = "Vec::is_empty")]
886 pub fixed_top: Vec<SidebarNavItem>,
887 #[serde(default, skip_serializing_if = "Vec::is_empty")]
888 pub groups: Vec<SidebarGroup>,
889 #[serde(default, skip_serializing_if = "Vec::is_empty")]
890 pub fixed_bottom: Vec<SidebarNavItem>,
891}
892
893#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
895pub struct HeaderProps {
896 pub business_name: String,
897 #[serde(default, skip_serializing_if = "Option::is_none")]
899 pub notification_count: Option<u32>,
900 #[serde(default, skip_serializing_if = "Option::is_none")]
901 pub user_name: Option<String>,
902 #[serde(default, skip_serializing_if = "Option::is_none")]
903 pub user_avatar: Option<String>,
904 #[serde(default, skip_serializing_if = "Option::is_none")]
905 pub logout_url: Option<String>,
906}
907
908#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
910#[serde(rename_all = "snake_case")]
911pub enum GapSize {
912 None,
913 Sm,
914 #[default]
915 Md,
916 Lg,
917 Xl,
918}
919
920#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
922pub struct GridProps {
923 #[serde(default = "default_grid_columns")]
925 pub columns: u8,
926 #[serde(default, skip_serializing_if = "Option::is_none")]
928 pub md_columns: Option<u8>,
929 #[serde(default, skip_serializing_if = "Option::is_none")]
931 pub lg_columns: Option<u8>,
932 #[serde(default)]
934 pub gap: GapSize,
935 #[serde(default, skip_serializing_if = "Option::is_none")]
938 pub scrollable: Option<bool>,
939 #[serde(default, skip_serializing_if = "Vec::is_empty")]
945 pub spans: Vec<u8>,
946 #[serde(default, skip_serializing_if = "Vec::is_empty")]
953 pub row_weights: Vec<u8>,
954 #[serde(default, skip_serializing_if = "Option::is_none")]
961 pub fill: Option<bool>,
962}
963
964fn default_grid_columns() -> u8 {
965 2
966}
967
968#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
970pub struct CollapsibleProps {
971 pub title: String,
972 #[serde(default)]
973 pub expanded: bool,
974}
975
976#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
978pub struct EmptyStateProps {
979 pub title: String,
980 #[serde(default, skip_serializing_if = "Option::is_none")]
981 pub description: Option<String>,
982 #[serde(default, skip_serializing_if = "Option::is_none")]
983 pub action: Option<Action>,
984 #[serde(default, skip_serializing_if = "Option::is_none")]
985 pub action_label: Option<String>,
986}
987
988#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
990#[serde(rename_all = "snake_case")]
991pub enum FormSectionLayout {
992 #[default]
993 Stacked,
994 TwoColumn,
995}
996
997#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
999pub struct FormSectionProps {
1000 pub title: String,
1001 #[serde(default, skip_serializing_if = "Option::is_none")]
1002 pub description: Option<String>,
1003 #[serde(default, skip_serializing_if = "Option::is_none")]
1005 pub layout: Option<FormSectionLayout>,
1006}
1007
1008#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1010pub struct PageHeaderProps {
1011 pub title: String,
1012 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1013 pub breadcrumb: Vec<BreadcrumbItem>,
1014 #[serde(
1016 default,
1017 deserialize_with = "deserialize_actions_lax",
1018 skip_serializing_if = "Vec::is_empty"
1019 )]
1020 pub actions: Vec<String>,
1021}
1022
1023#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1025pub struct ButtonGroupProps {
1026 #[serde(default)]
1028 pub gap: GapSize,
1029}
1030
1031#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1042pub struct ActionItem {
1043 pub label: String,
1044 pub action: Action,
1045 #[serde(default)]
1048 pub destructive: bool,
1049 #[serde(default, skip_serializing_if = "Option::is_none")]
1050 pub variant: Option<Variant>,
1051 #[serde(default, skip_serializing_if = "Option::is_none")]
1052 pub icon: Option<String>,
1053 #[serde(default, skip_serializing_if = "Option::is_none")]
1057 pub visible_if: Option<String>,
1058}
1059
1060#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1071pub struct ActionGroupProps {
1072 pub items: Vec<ActionItem>,
1073 pub menu_id: String,
1076 #[serde(default, skip_serializing_if = "Option::is_none")]
1078 pub max_inline: Option<u8>,
1079 #[serde(default, skip_serializing_if = "Option::is_none")]
1081 pub overflow_label: Option<String>,
1082 #[serde(default, skip_serializing_if = "Option::is_none")]
1084 pub row_key: Option<String>,
1085}
1086
1087#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1103pub struct SegmentedControlProps {
1104 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1106 pub items: Vec<SegmentedItem>,
1107 #[serde(default, skip_serializing_if = "Option::is_none")]
1110 pub data_path: Option<String>,
1111 #[serde(default)]
1113 pub size: Size,
1114 #[serde(default, skip_serializing_if = "Option::is_none")]
1117 pub aria_label: Option<String>,
1118}
1119
1120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1122pub struct SegmentedItem {
1123 pub label: String,
1125 pub href: String,
1127 #[serde(default)]
1129 pub active: bool,
1130 #[serde(default, skip_serializing_if = "Option::is_none")]
1133 pub aria_label: Option<String>,
1134}
1135
1136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1152pub struct SidebarLayoutProps {
1153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1155 pub items: Vec<SidebarLayoutItem>,
1156 #[serde(default, skip_serializing_if = "Option::is_none")]
1158 pub data_path: Option<String>,
1159 pub active: String,
1162 #[serde(default, skip_serializing_if = "Option::is_none")]
1164 pub aria_label: Option<String>,
1165}
1166
1167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1169pub struct SidebarLayoutItem {
1170 pub slug: String,
1173 pub label: String,
1175 pub url: String,
1178}
1179
1180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1189pub struct DetailPageProps {
1190 pub title: String,
1191 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1192 pub breadcrumb: Vec<BreadcrumbItem>,
1193 #[serde(
1195 default,
1196 deserialize_with = "deserialize_actions_lax",
1197 skip_serializing_if = "Vec::is_empty"
1198 )]
1199 pub actions: Vec<String>,
1200 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1203 pub info: Vec<String>,
1204}
1205
1206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1208pub struct DropdownMenuAction {
1209 pub label: String,
1210 pub action: Action,
1211 #[serde(default)]
1212 pub destructive: bool,
1213 #[serde(default, skip_serializing_if = "Option::is_none")]
1220 pub visible_if: Option<String>,
1221}
1222
1223#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1226pub struct DataTableProps {
1227 pub columns: Vec<Column>,
1228 pub data_path: String,
1229 #[serde(default, skip_serializing_if = "Option::is_none")]
1230 pub row_actions: Option<Vec<DropdownMenuAction>>,
1231 #[serde(default, skip_serializing_if = "Option::is_none")]
1232 pub empty_message: Option<String>,
1233 #[serde(default, skip_serializing_if = "Option::is_none")]
1234 pub row_key: Option<String>,
1235 #[serde(default, skip_serializing_if = "Option::is_none")]
1237 pub row_href: Option<String>,
1238}
1239
1240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1244pub struct MediaCardGridProps {
1245 pub data_path: String,
1246 pub title_key: String,
1248 #[serde(default, skip_serializing_if = "Option::is_none")]
1250 pub description_key: Option<String>,
1251 #[serde(default, skip_serializing_if = "Option::is_none")]
1253 pub image_key: Option<String>,
1254 #[serde(default, skip_serializing_if = "Option::is_none")]
1256 pub image_href_key: Option<String>,
1257 #[serde(default, skip_serializing_if = "Option::is_none")]
1259 pub image_aspect_ratio: Option<String>,
1260 #[serde(default, skip_serializing_if = "Option::is_none")]
1263 pub image_position: Option<String>,
1264 #[serde(default, skip_serializing_if = "Option::is_none")]
1266 pub badge_key: Option<String>,
1267 #[serde(default, skip_serializing_if = "Option::is_none")]
1269 pub badge_tone_key: Option<String>,
1270 #[serde(default, skip_serializing_if = "Option::is_none")]
1272 pub row_key: Option<String>,
1273 #[serde(default, skip_serializing_if = "Option::is_none")]
1274 pub row_actions: Option<Vec<DropdownMenuAction>>,
1275 #[serde(default, skip_serializing_if = "Option::is_none")]
1276 pub empty_message: Option<String>,
1277 #[serde(default, skip_serializing_if = "Option::is_none")]
1279 pub columns: Option<u8>,
1280}
1281
1282#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1290pub struct KanbanColumnProps {
1291 pub id: String,
1292 pub title: String,
1293 #[serde(default)]
1294 pub count: u32,
1295 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1297 pub children: Vec<String>,
1298}
1299
1300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1313pub struct KanbanBoardProps {
1314 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1316 pub columns: Vec<KanbanColumnProps>,
1317 #[serde(default, skip_serializing_if = "Option::is_none")]
1320 pub items_path: Option<String>,
1321 #[serde(default, skip_serializing_if = "Option::is_none")]
1323 pub group_by: Option<String>,
1324 #[serde(default, skip_serializing_if = "Option::is_none")]
1326 pub card_title_key: Option<String>,
1327 #[serde(default, skip_serializing_if = "Option::is_none")]
1329 pub card_description_key: Option<String>,
1330 #[serde(default, skip_serializing_if = "Option::is_none")]
1333 pub row_actions: Option<Vec<DropdownMenuAction>>,
1334 #[serde(default, skip_serializing_if = "Option::is_none")]
1337 pub row_key: Option<String>,
1338 #[serde(default, skip_serializing_if = "Option::is_none")]
1339 pub mobile_default_column: Option<String>,
1340 #[serde(default, skip_serializing_if = "Option::is_none")]
1344 pub empty_label: Option<String>,
1345}
1346
1347#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1352pub struct CalendarCellProps {
1353 pub day: u8,
1354 #[serde(default)]
1355 pub is_today: bool,
1356 #[serde(default)]
1357 pub is_current_month: bool,
1358 #[serde(default)]
1359 pub event_count: u32,
1360 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1363 pub dot_colors: Vec<String>,
1364 #[serde(default)]
1368 pub closed: bool,
1369}
1370
1371#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1376pub struct ActionCardProps {
1377 pub title: String,
1378 pub description: String,
1379 #[serde(default, skip_serializing_if = "Option::is_none")]
1380 pub icon: Option<String>,
1381 #[serde(default)]
1382 pub tone: Tone,
1383 #[serde(default, skip_serializing_if = "Option::is_none")]
1385 pub href: Option<String>,
1386}
1387
1388#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1393pub struct TileProps {
1394 pub item_id: String,
1395 pub name: String,
1396 pub price: String,
1397 pub field: String,
1398 #[serde(default, skip_serializing_if = "Option::is_none")]
1399 pub default_quantity: Option<u32>,
1400 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1411 pub categories: Vec<String>,
1412 #[serde(default, skip_serializing_if = "Option::is_none")]
1415 pub image_url: Option<String>,
1416 #[serde(default, skip_serializing_if = "Option::is_none")]
1420 pub color: Option<Tone>,
1421 #[serde(default, skip_serializing_if = "Option::is_none")]
1423 pub stock_badge: Option<String>,
1424 #[serde(default, skip_serializing_if = "Option::is_none")]
1432 pub price_cents: Option<u64>,
1433}
1434
1435#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1439pub struct TileGridProps {
1440 pub data_path: String,
1442 pub form_id: String,
1451 #[serde(default, skip_serializing_if = "Option::is_none")]
1454 pub categories_path: Option<String>,
1455 #[serde(default, skip_serializing_if = "Option::is_none")]
1457 pub columns: Option<u8>,
1458 #[serde(default, skip_serializing_if = "Option::is_none")]
1460 pub search: Option<bool>,
1461 #[serde(default, skip_serializing_if = "Option::is_none")]
1466 pub search_placeholder: Option<String>,
1467 #[serde(default, skip_serializing_if = "Option::is_none")]
1472 pub all_label: Option<String>,
1473}
1474
1475#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1478pub struct SelectionPanelProps {
1479 pub form_id: String,
1481 #[serde(default, skip_serializing_if = "Option::is_none")]
1483 pub empty_message: Option<String>,
1484 #[serde(default, skip_serializing_if = "Option::is_none")]
1489 pub currency: Option<String>,
1490 #[serde(default, skip_serializing_if = "Option::is_none")]
1494 pub total_label: Option<String>,
1495}
1496
1497#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1501pub struct FilterTabsProps {
1502 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1506 pub items: Vec<String>,
1507 #[serde(default, skip_serializing_if = "Option::is_none")]
1511 pub all_label: Option<String>,
1512}
1513
1514#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1517pub struct QuantityStepperProps {
1518 pub field: String,
1520 #[serde(default, skip_serializing_if = "Option::is_none")]
1522 pub min: Option<u32>,
1523 #[serde(default, skip_serializing_if = "Option::is_none")]
1525 pub max: Option<u32>,
1526 #[serde(default, skip_serializing_if = "Option::is_none")]
1528 pub step: Option<u32>,
1529}
1530
1531#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
1533#[serde(rename_all = "snake_case")]
1534pub enum NumpadMode {
1535 #[default]
1537 Quantity,
1538 Price,
1540}
1541
1542#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1546pub struct NumpadProps {
1547 pub target_field: String,
1549 #[serde(default)]
1551 pub mode: NumpadMode,
1552}
1553
1554fn deserialize_actions_lax<'de, D: serde::Deserializer<'de>>(
1560 d: D,
1561) -> Result<Vec<String>, D::Error> {
1562 use serde::de::Error;
1563 let v = serde_json::Value::deserialize(d)?;
1564 match v {
1565 serde_json::Value::Null => Ok(Vec::new()),
1566 serde_json::Value::String(s) if s.is_empty() => Ok(Vec::new()),
1567 serde_json::Value::Array(arr) => arr
1568 .into_iter()
1569 .map(|item| {
1570 item.as_str()
1571 .map(String::from)
1572 .ok_or_else(|| D::Error::custom("PageHeader.actions: expected string in array"))
1573 })
1574 .collect(),
1575 other => Err(D::Error::custom(format!(
1576 "PageHeader.actions: expected null, empty string, or array of strings; got {other:?}"
1577 ))),
1578 }
1579}
1580
1581#[cfg(test)]
1582mod schema_smoke_tests {
1583 use super::*;
1594
1595 fn assert_schema_nonempty_object<T: schemars::JsonSchema>(type_label: &str) {
1596 let schema = schemars::schema_for!(T);
1597 let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1598 assert!(
1599 value.is_object(),
1600 "{type_label}: schema must be a JSON object"
1601 );
1602 let props = value
1603 .get("properties")
1604 .and_then(|p| p.as_object())
1605 .map(|o| !o.is_empty())
1606 .unwrap_or(false);
1607 assert!(
1608 props,
1609 "{type_label}: schema must have a non-empty `properties` field"
1610 );
1611 }
1612
1613 #[test]
1614 fn schema_for_card_props_generates() {
1615 assert_schema_nonempty_object::<CardProps>("CardProps");
1616 }
1617
1618 #[test]
1619 fn schema_for_table_props_generates() {
1620 assert_schema_nonempty_object::<TableProps>("TableProps");
1621 }
1622
1623 #[test]
1624 fn schema_for_form_props_generates() {
1625 assert_schema_nonempty_object::<FormProps>("FormProps");
1626 }
1627
1628 #[test]
1629 fn schema_for_button_props_generates() {
1630 assert_schema_nonempty_object::<ButtonProps>("ButtonProps");
1631 }
1632
1633 #[test]
1634 fn schema_for_input_props_generates() {
1635 assert_schema_nonempty_object::<InputProps>("InputProps");
1636 }
1637
1638 #[test]
1639 fn schema_for_select_props_generates() {
1640 assert_schema_nonempty_object::<SelectProps>("SelectProps");
1641 }
1642
1643 #[test]
1644 fn schema_for_alert_props_generates() {
1645 assert_schema_nonempty_object::<AlertProps>("AlertProps");
1646 }
1647
1648 #[test]
1649 fn schema_for_badge_props_generates() {
1650 assert_schema_nonempty_object::<BadgeProps>("BadgeProps");
1651 }
1652
1653 #[test]
1654 fn schema_for_modal_props_generates() {
1655 assert_schema_nonempty_object::<ModalProps>("ModalProps");
1656 }
1657
1658 #[test]
1659 fn schema_for_text_props_generates() {
1660 assert_schema_nonempty_object::<TextProps>("TextProps");
1661 }
1662
1663 #[test]
1664 fn schema_for_checkbox_props_generates() {
1665 assert_schema_nonempty_object::<CheckboxProps>("CheckboxProps");
1666 }
1667
1668 #[test]
1669 fn schema_for_switch_props_generates() {
1670 assert_schema_nonempty_object::<SwitchProps>("SwitchProps");
1671 }
1672
1673 #[test]
1674 fn schema_for_separator_props_generates() {
1675 assert_schema_nonempty_object::<SeparatorProps>("SeparatorProps");
1676 }
1677
1678 #[test]
1679 fn schema_for_description_list_props_generates() {
1680 assert_schema_nonempty_object::<DescriptionListProps>("DescriptionListProps");
1681 }
1682
1683 #[test]
1684 fn schema_for_tab_generates() {
1685 assert_schema_nonempty_object::<Tab>("Tab");
1686 }
1687
1688 #[test]
1689 fn schema_for_tabs_props_generates() {
1690 assert_schema_nonempty_object::<TabsProps>("TabsProps");
1691 }
1692
1693 #[test]
1694 fn schema_for_breadcrumb_props_generates() {
1695 assert_schema_nonempty_object::<BreadcrumbProps>("BreadcrumbProps");
1696 }
1697
1698 #[test]
1699 fn schema_for_pagination_props_generates() {
1700 assert_schema_nonempty_object::<PaginationProps>("PaginationProps");
1701 }
1702
1703 #[test]
1704 fn schema_for_progress_props_generates() {
1705 assert_schema_nonempty_object::<ProgressProps>("ProgressProps");
1706 }
1707
1708 #[test]
1709 fn schema_for_image_props_generates() {
1710 assert_schema_nonempty_object::<ImageProps>("ImageProps");
1711 }
1712
1713 #[test]
1714 fn image_inline_svg_factory_roundtrips_via_serde() {
1715 let p = ImageProps::inline_svg("<svg/>", "alt");
1716 let json = serde_json::to_value(&p).expect("serialization must not fail");
1717 let parsed: ImageProps =
1718 serde_json::from_value(json).expect("deserialization must not fail");
1719 assert_eq!(parsed.inline_svg, Some("<svg/>".to_string()));
1720 assert_eq!(parsed.alt, "alt");
1721 assert_eq!(parsed.src, "");
1722 }
1723
1724 #[test]
1725 fn schema_for_avatar_props_generates() {
1726 assert_schema_nonempty_object::<AvatarProps>("AvatarProps");
1727 }
1728
1729 #[test]
1730 fn schema_for_skeleton_props_generates() {
1731 assert_schema_nonempty_object::<SkeletonProps>("SkeletonProps");
1732 }
1733
1734 #[test]
1735 fn schema_for_stat_card_props_generates() {
1736 assert_schema_nonempty_object::<StatCardProps>("StatCardProps");
1737 }
1738
1739 #[test]
1740 fn schema_for_checklist_props_generates() {
1741 assert_schema_nonempty_object::<ChecklistProps>("ChecklistProps");
1742 }
1743
1744 #[test]
1745 fn schema_for_toast_props_generates() {
1746 assert_schema_nonempty_object::<ToastProps>("ToastProps");
1747 }
1748
1749 #[test]
1750 fn schema_for_notification_dropdown_props_generates() {
1751 assert_schema_nonempty_object::<NotificationDropdownProps>("NotificationDropdownProps");
1752 }
1753
1754 #[test]
1755 fn schema_for_sidebar_props_generates() {
1756 assert_schema_nonempty_object::<SidebarProps>("SidebarProps");
1757 }
1758
1759 #[test]
1760 fn schema_for_header_props_generates() {
1761 assert_schema_nonempty_object::<HeaderProps>("HeaderProps");
1762 }
1763
1764 #[test]
1765 fn schema_for_grid_props_generates() {
1766 assert_schema_nonempty_object::<GridProps>("GridProps");
1767 }
1768
1769 #[test]
1770 fn schema_for_collapsible_props_generates() {
1771 assert_schema_nonempty_object::<CollapsibleProps>("CollapsibleProps");
1772 }
1773
1774 #[test]
1775 fn schema_for_empty_state_props_generates() {
1776 assert_schema_nonempty_object::<EmptyStateProps>("EmptyStateProps");
1777 }
1778
1779 #[test]
1780 fn schema_for_form_section_props_generates() {
1781 assert_schema_nonempty_object::<FormSectionProps>("FormSectionProps");
1782 }
1783
1784 #[test]
1785 fn schema_for_page_header_props_generates() {
1786 assert_schema_nonempty_object::<PageHeaderProps>("PageHeaderProps");
1787 }
1788
1789 #[test]
1790 fn schema_for_button_group_props_generates() {
1791 assert_schema_nonempty_object::<ButtonGroupProps>("ButtonGroupProps");
1792 }
1793
1794 #[test]
1795 fn schema_for_action_item_generates() {
1796 assert_schema_nonempty_object::<ActionItem>("ActionItem");
1797 }
1798
1799 #[test]
1800 fn schema_for_action_group_props_generates() {
1801 assert_schema_nonempty_object::<ActionGroupProps>("ActionGroupProps");
1802 }
1803
1804 #[test]
1805 fn schema_for_dropdown_menu_action_generates() {
1806 assert_schema_nonempty_object::<DropdownMenuAction>("DropdownMenuAction");
1807 }
1808
1809 #[test]
1810 fn schema_for_data_table_props_generates() {
1811 assert_schema_nonempty_object::<DataTableProps>("DataTableProps");
1812 }
1813
1814 #[test]
1815 fn schema_for_kanban_column_props_generates() {
1816 assert_schema_nonempty_object::<KanbanColumnProps>("KanbanColumnProps");
1817 }
1818
1819 #[test]
1820 fn schema_for_kanban_board_props_generates() {
1821 assert_schema_nonempty_object::<KanbanBoardProps>("KanbanBoardProps");
1822 }
1823
1824 #[test]
1825 fn schema_for_calendar_cell_props_generates() {
1826 assert_schema_nonempty_object::<CalendarCellProps>("CalendarCellProps");
1827 }
1828
1829 #[test]
1830 fn schema_for_action_card_props_generates() {
1831 assert_schema_nonempty_object::<ActionCardProps>("ActionCardProps");
1832 }
1833
1834 #[test]
1835 fn schema_for_tile_props_generates() {
1836 assert_schema_nonempty_object::<TileProps>("TileProps");
1837 }
1838
1839 #[test]
1840 fn card_props_round_trips_footer() {
1841 let original = CardProps {
1842 title: "Hero".to_string(),
1843 description: None,
1844 subtitle: None,
1845 badge: None,
1846 max_width: None,
1847 footer: vec!["btn1".to_string(), "btn2".to_string()],
1848 appearance: CardAppearance::Bordered,
1849 };
1850 let json = serde_json::to_string(&original).unwrap();
1851 let parsed: CardProps = serde_json::from_str(&json).unwrap();
1852 assert_eq!(original.footer, parsed.footer);
1853 }
1854
1855 #[test]
1856 fn tab_round_trips_children() {
1857 let original = Tab {
1858 value: "overview".to_string(),
1859 label: "Overview".to_string(),
1860 children: vec!["panel1".to_string()],
1861 };
1862 let json = serde_json::to_string(&original).unwrap();
1863 let parsed: Tab = serde_json::from_str(&json).unwrap();
1864 assert_eq!(original.children, parsed.children);
1865 }
1866
1867 #[test]
1868 fn card_props_omits_empty_footer_in_json() {
1869 let card = CardProps {
1870 title: "Card".to_string(),
1871 description: None,
1872 subtitle: None,
1873 badge: None,
1874 max_width: None,
1875 footer: Vec::new(),
1876 appearance: CardAppearance::Bordered,
1877 };
1878 let json = serde_json::to_string(&card).unwrap();
1879 assert!(
1880 !json.contains("\"footer\""),
1881 "empty footer must be skipped, got: {json}"
1882 );
1883 }
1884
1885 #[test]
1886 fn card_props_round_trips_badge() {
1887 let original = CardProps {
1888 title: "Hero".to_string(),
1889 description: None,
1890 subtitle: None,
1891 badge: Some("Scade tra 9m".to_string()),
1892 max_width: None,
1893 footer: Vec::new(),
1894 appearance: CardAppearance::Bordered,
1895 };
1896 let json = serde_json::to_string(&original).unwrap();
1897 let parsed: CardProps = serde_json::from_str(&json).unwrap();
1898 assert_eq!(original.badge, parsed.badge);
1899 }
1900
1901 #[test]
1902 fn card_props_omits_empty_badge_in_json() {
1903 let card = CardProps {
1904 title: "Card".to_string(),
1905 description: None,
1906 subtitle: None,
1907 badge: None,
1908 max_width: None,
1909 footer: Vec::new(),
1910 appearance: CardAppearance::Bordered,
1911 };
1912 let json = serde_json::to_string(&card).unwrap();
1913 assert!(
1914 !json.contains("\"badge\""),
1915 "empty badge must be skipped, got: {json}"
1916 );
1917 }
1918
1919 #[test]
1920 fn card_props_round_trips_subtitle() {
1921 let original = CardProps {
1922 title: "Hero".to_string(),
1923 description: None,
1924 subtitle: Some("Marco Rossi".to_string()),
1925 badge: None,
1926 max_width: None,
1927 footer: Vec::new(),
1928 appearance: CardAppearance::Bordered,
1929 };
1930 let json = serde_json::to_string(&original).unwrap();
1931 let parsed: CardProps = serde_json::from_str(&json).unwrap();
1932 assert_eq!(original.subtitle, parsed.subtitle);
1933 }
1934
1935 #[test]
1936 fn card_props_omits_empty_subtitle_in_json() {
1937 let card = CardProps {
1938 title: "Card".to_string(),
1939 description: None,
1940 subtitle: None,
1941 badge: None,
1942 max_width: None,
1943 footer: Vec::new(),
1944 appearance: CardAppearance::Bordered,
1945 };
1946 let json = serde_json::to_string(&card).unwrap();
1947 assert!(
1948 !json.contains("\"subtitle\""),
1949 "empty subtitle must be skipped, got: {json}"
1950 );
1951 }
1952
1953 #[test]
1954 fn card_props_schema_includes_badge() {
1955 let schema = schemars::schema_for!(CardProps);
1956 let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1957 let props = value
1958 .get("properties")
1959 .and_then(|p| p.as_object())
1960 .expect("schema has a properties object");
1961 assert!(
1962 props.contains_key("badge"),
1963 "CardProps schema must expose a `badge` property; got keys: {:?}",
1964 props.keys().collect::<Vec<_>>()
1965 );
1966 let badge_schema = props.get("badge").expect("badge entry");
1971 let badge_json = badge_schema.to_string();
1972 assert!(
1973 badge_json.contains("\"string\""),
1974 "badge schema entry must mention string type; got: {badge_json}"
1975 );
1976 }
1977
1978 #[test]
1979 fn card_props_schema_includes_subtitle() {
1980 let schema = schemars::schema_for!(CardProps);
1981 let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1982 let props = value
1983 .get("properties")
1984 .and_then(|p| p.as_object())
1985 .expect("schema has a properties object");
1986 assert!(
1987 props.contains_key("subtitle"),
1988 "CardProps schema must expose a `subtitle` property; got keys: {:?}",
1989 props.keys().collect::<Vec<_>>()
1990 );
1991 let subtitle_schema = props.get("subtitle").expect("subtitle entry");
1996 let subtitle_json = subtitle_schema.to_string();
1997 assert!(
1998 subtitle_json.contains("\"string\""),
1999 "subtitle schema entry must mention string type; got: {subtitle_json}"
2000 );
2001 }
2002
2003 #[test]
2004 fn schema_for_checkbox_list_props_generates() {
2005 assert_schema_nonempty_object::<CheckboxListProps>("CheckboxListProps");
2006 }
2007
2008 #[test]
2009 fn checkbox_list_props_serde_roundtrip() {
2010 let json = serde_json::json!({
2011 "field": "services",
2012 "options": [{"value": "a", "label": "Alpha"}, {"value": "b", "label": "Beta"}],
2013 "selected_path": "/preselected"
2014 });
2015 let parsed: CheckboxListProps = serde_json::from_value(json.clone()).expect("decode");
2016 assert_eq!(parsed.field, "services");
2017 assert_eq!(parsed.options.len(), 2);
2018 assert_eq!(parsed.selected_path.as_deref(), Some("/preselected"));
2019 let reserialized = serde_json::to_value(&parsed).expect("encode");
2020 assert!(reserialized.get("label").is_none());
2022 assert!(reserialized.get("disabled").is_none());
2023 }
2024
2025 #[test]
2026 fn schema_for_rich_text_editor_props_generates() {
2027 assert_schema_nonempty_object::<RichTextEditorProps>("RichTextEditorProps");
2028 }
2029
2030 #[test]
2031 fn rich_text_editor_props_serde_roundtrip() {
2032 let json = serde_json::json!({
2033 "field": "body",
2034 "label": "Body"
2035 });
2036 let parsed: RichTextEditorProps = serde_json::from_value(json).expect("decode");
2037 assert_eq!(parsed.field, "body");
2038 assert_eq!(parsed.label, "Body");
2039 assert!(parsed.placeholder.is_none());
2040 assert!(parsed.default_value.is_none());
2041 assert!(parsed.data_path.is_none());
2042 assert!(parsed.error.is_none());
2043 let reserialized = serde_json::to_value(&parsed).expect("encode");
2044 assert!(reserialized.get("placeholder").is_none());
2046 assert!(reserialized.get("error").is_none());
2047 }
2048
2049 #[test]
2050 fn schema_for_tile_grid_props_generates() {
2051 assert_schema_nonempty_object::<TileGridProps>("TileGridProps");
2052 }
2053
2054 #[test]
2055 fn schema_for_selection_panel_props_generates() {
2056 assert_schema_nonempty_object::<SelectionPanelProps>("SelectionPanelProps");
2057 }
2058
2059 #[test]
2060 fn schema_for_filter_tabs_props_generates() {
2061 assert_schema_nonempty_object::<FilterTabsProps>("FilterTabsProps");
2062 }
2063
2064 #[test]
2065 fn schema_for_quantity_stepper_props_generates() {
2066 assert_schema_nonempty_object::<QuantityStepperProps>("QuantityStepperProps");
2067 }
2068
2069 #[test]
2070 fn schema_for_numpad_props_generates() {
2071 assert_schema_nonempty_object::<NumpadProps>("NumpadProps");
2072 }
2073}
2074
2075#[cfg(test)]
2076mod strum_tests {
2077 use super::*;
2078
2079 use strum::VariantArray;
2080
2081 #[test]
2088 fn variant_enums_strum_matches_serde_wire_format() {
2089 fn check<T: AsRef<str> + serde::Serialize>(variants: &[T], label: &str) {
2090 for v in variants {
2091 let json = serde_json::to_string(v).expect("serialize");
2092 let json_stripped = json.trim_matches('"');
2093 assert_eq!(
2094 v.as_ref(),
2095 json_stripped,
2096 "strum AsRefStr drifted from serde for {label} variant"
2097 );
2098 }
2099 }
2100 check(Variant::VARIANTS, "Variant");
2101 check(Tone::VARIANTS, "Tone");
2102 check(Size::VARIANTS, "Size");
2103 }
2104
2105 #[test]
2109 fn canonical_enums_have_expected_variant_counts() {
2110 assert_eq!(Variant::VARIANTS.len(), 5);
2111 assert_eq!(Tone::VARIANTS.len(), 4);
2112 assert_eq!(Size::VARIANTS.len(), 3);
2113 }
2114
2115 #[test]
2116 fn tone_as_ref_str_matches_wire_format() {
2117 assert_eq!(Tone::Neutral.as_ref(), "neutral");
2118 assert_eq!(Tone::Success.as_ref(), "success");
2119 assert_eq!(Tone::Warning.as_ref(), "warning");
2120 assert_eq!(Tone::Destructive.as_ref(), "destructive");
2121 }
2122}
2123
2124#[cfg(test)]
2125mod canonical_enum_tests {
2126 use super::*;
2127
2128 #[test]
2131 fn variant_default_is_primary() {
2132 assert_eq!(Variant::default(), Variant::Primary);
2133 }
2134
2135 #[test]
2136 fn tone_default_is_neutral() {
2137 assert_eq!(Tone::default(), Tone::Neutral);
2138 }
2139
2140 #[test]
2141 fn size_default_is_md() {
2142 assert_eq!(Size::default(), Size::Md);
2143 }
2144
2145 #[test]
2148 fn variant_serde_snake_case_roundtrip() {
2149 use strum::VariantArray;
2150 for v in Variant::VARIANTS {
2151 let json = serde_json::to_value(v).unwrap();
2152 assert_eq!(json, serde_json::json!(v.as_ref()));
2153 let back: Variant = serde_json::from_value(json).unwrap();
2154 assert_eq!(back, *v);
2155 }
2156 assert_eq!(
2157 serde_json::from_str::<Variant>("\"primary\"").unwrap(),
2158 Variant::Primary
2159 );
2160 }
2161
2162 #[test]
2163 fn tone_serde_snake_case_roundtrip() {
2164 use strum::VariantArray;
2165 for t in Tone::VARIANTS {
2166 let json = serde_json::to_value(t).unwrap();
2167 assert_eq!(json, serde_json::json!(t.as_ref()));
2168 let back: Tone = serde_json::from_value(json).unwrap();
2169 assert_eq!(back, *t);
2170 }
2171 }
2172
2173 #[test]
2174 fn size_serde_snake_case_roundtrip() {
2175 use strum::VariantArray;
2176 for s in Size::VARIANTS {
2177 let json = serde_json::to_value(s).unwrap();
2178 assert_eq!(json, serde_json::json!(s.as_ref()));
2179 let back: Size = serde_json::from_value(json).unwrap();
2180 assert_eq!(back, *s);
2181 }
2182 assert!(serde_json::from_str::<Size>("\"md\"").is_ok());
2183 assert!(serde_json::from_str::<Size>("\"sm\"").is_ok());
2184 assert!(serde_json::from_str::<Size>("\"lg\"").is_ok());
2185 }
2186
2187 #[test]
2190 fn retired_size_values_are_rejected() {
2191 assert!(
2192 serde_json::from_str::<Size>("\"xs\"").is_err(),
2193 "size 'xs' was retired (migrate to 'sm')"
2194 );
2195 assert!(
2196 serde_json::from_str::<Size>("\"default\"").is_err(),
2197 "size 'default' was retired (migrate to 'md')"
2198 );
2199 }
2200
2201 #[test]
2202 fn retired_variant_values_are_rejected() {
2203 assert!(
2204 serde_json::from_str::<Variant>("\"default\"").is_err(),
2205 "variant 'default' was retired (migrate to 'primary')"
2206 );
2207 assert!(
2208 serde_json::from_str::<Variant>("\"link\"").is_err(),
2209 "variant 'link' was removed (migrate to 'ghost')"
2210 );
2211 }
2212
2213 #[test]
2214 fn retired_tone_values_are_rejected() {
2215 assert!(
2216 serde_json::from_str::<Tone>("\"info\"").is_err(),
2217 "tone 'info' was retired (migrate to 'neutral')"
2218 );
2219 assert!(
2220 serde_json::from_str::<Tone>("\"error\"").is_err(),
2221 "tone 'error' was retired (migrate to 'destructive')"
2222 );
2223 }
2224
2225 #[test]
2226 fn button_spec_with_link_variant_fails_to_decode() {
2227 let v = serde_json::json!({"variant": "link", "label": "x"});
2228 assert!(
2229 serde_json::from_value::<ButtonProps>(v).is_err(),
2230 "Button variant 'link' must fail decode (migrate to 'ghost')"
2231 );
2232 }
2233
2234 #[test]
2237 fn button_props_defaults_to_primary_md() {
2238 let v = serde_json::json!({"label": "x"});
2239 let p: ButtonProps = serde_json::from_value(v).unwrap();
2240 assert_eq!(p.variant, Variant::Primary);
2241 assert_eq!(p.size, Size::Md);
2242 }
2243
2244 #[test]
2245 fn alert_props_without_tone_defaults_to_neutral() {
2246 let v = serde_json::json!({"message": "x"});
2247 let p: AlertProps = serde_json::from_value(v).unwrap();
2248 assert_eq!(p.tone, Tone::Neutral);
2249 }
2250
2251 #[test]
2252 fn alert_props_with_tone_neutral_decodes() {
2253 let v = serde_json::json!({"message": "x", "tone": "neutral"});
2254 let p: AlertProps = serde_json::from_value(v).unwrap();
2255 assert_eq!(p.tone, Tone::Neutral);
2256 }
2257
2258 #[test]
2259 fn badge_props_without_tone_defaults_to_neutral() {
2260 let v = serde_json::json!({"label": "x"});
2261 let p: BadgeProps = serde_json::from_value(v).unwrap();
2262 assert_eq!(p.tone, Tone::Neutral);
2263 }
2264
2265 #[test]
2266 fn toast_props_without_tone_defaults_to_neutral() {
2267 let v = serde_json::json!({"message": "x"});
2268 let p: ToastProps = serde_json::from_value(v).unwrap();
2269 assert_eq!(p.tone, Tone::Neutral);
2270 }
2271
2272 #[test]
2273 fn action_card_props_with_success_tone_decodes() {
2274 let v = serde_json::json!({"title": "x", "description": "y", "tone": "success"});
2275 let p: ActionCardProps = serde_json::from_value(v).unwrap();
2276 assert_eq!(p.tone, Tone::Success);
2277 }
2278
2279 #[test]
2280 fn stat_card_props_without_tone_defaults_to_neutral() {
2281 let v = serde_json::json!({"label": "x", "value": "1"});
2282 let p: StatCardProps = serde_json::from_value(v).unwrap();
2283 assert_eq!(p.tone, Tone::Neutral);
2284 }
2285
2286 #[test]
2287 fn stat_card_props_roundtrip_preserves_tone() {
2288 let v = serde_json::json!({"label": "x", "value": "1", "tone": "warning"});
2289 let p: StatCardProps = serde_json::from_value(v).unwrap();
2290 assert_eq!(p.tone, Tone::Warning);
2291 let j = serde_json::to_value(&p).unwrap();
2292 let back: StatCardProps = serde_json::from_value(j).unwrap();
2293 assert_eq!(back.tone, Tone::Warning);
2294 }
2295}
2296
2297#[cfg(test)]
2298mod card_appearance_tests {
2299 use super::*;
2300
2301 #[test]
2302 fn card_appearance_default_is_bordered() {
2303 assert_eq!(CardAppearance::default(), CardAppearance::Bordered);
2304 }
2305
2306 #[test]
2307 fn card_appearance_serializes_snake_case() {
2308 assert_eq!(
2309 serde_json::to_value(CardAppearance::Bordered).unwrap(),
2310 serde_json::json!("bordered")
2311 );
2312 assert_eq!(
2313 serde_json::to_value(CardAppearance::Elevated).unwrap(),
2314 serde_json::json!("elevated")
2315 );
2316 }
2317
2318 #[test]
2319 fn card_appearance_deserializes_snake_case() {
2320 assert_eq!(
2321 serde_json::from_value::<CardAppearance>(serde_json::json!("bordered")).unwrap(),
2322 CardAppearance::Bordered
2323 );
2324 assert_eq!(
2325 serde_json::from_value::<CardAppearance>(serde_json::json!("elevated")).unwrap(),
2326 CardAppearance::Elevated
2327 );
2328 }
2329
2330 #[test]
2331 fn card_props_without_appearance_defaults_to_bordered() {
2332 let v = serde_json::json!({"title": "x"});
2333 let p: CardProps = serde_json::from_value(v).unwrap();
2334 assert_eq!(p.appearance, CardAppearance::Bordered);
2335 }
2336
2337 #[test]
2338 fn card_props_with_elevated_appearance() {
2339 let v = serde_json::json!({"title": "x", "appearance": "elevated"});
2340 let p: CardProps = serde_json::from_value(v).unwrap();
2341 assert_eq!(p.appearance, CardAppearance::Elevated);
2342 }
2343
2344 #[test]
2345 fn card_props_roundtrip_preserves_appearance() {
2346 let p = CardProps {
2347 title: "x".into(),
2348 description: None,
2349 subtitle: None,
2350 badge: None,
2351 max_width: None,
2352 footer: vec![],
2353 appearance: CardAppearance::Elevated,
2354 };
2355 let j = serde_json::to_value(&p).unwrap();
2356 let back: CardProps = serde_json::from_value(j).unwrap();
2357 assert_eq!(back.appearance, CardAppearance::Elevated);
2358 }
2359}
2360
2361#[cfg(test)]
2362mod kanban_board_props_tests {
2363 use super::*;
2364
2365 #[test]
2366 fn kanban_board_props_serde_static_columns() {
2367 let v = serde_json::json!({
2368 "columns": [{"title": "To Do", "id": "todo", "count": 0}]
2369 });
2370 let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
2371 assert_eq!(p.columns.len(), 1);
2372 assert!(p.items_path.is_none());
2373 assert!(p.group_by.is_none());
2374 }
2375
2376 #[test]
2377 fn kanban_board_props_serde_data_bound() {
2378 let v = serde_json::json!({
2379 "columns": [{"title": "Open", "id": "open"}],
2380 "items_path": "/data/order",
2381 "group_by": "status",
2382 "card_title_key": "name"
2383 });
2384 let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
2385 assert_eq!(p.columns.len(), 1);
2386 assert_eq!(p.items_path.as_deref(), Some("/data/order"));
2387 assert_eq!(p.group_by.as_deref(), Some("status"));
2388 assert_eq!(p.card_title_key.as_deref(), Some("name"));
2389 }
2390
2391 #[test]
2392 fn kanban_board_props_serde_neither() {
2393 let v = serde_json::json!({});
2394 let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
2395 assert!(p.columns.is_empty());
2396 assert!(p.items_path.is_none());
2397 assert!(p.group_by.is_none());
2398 }
2399
2400 #[test]
2401 fn kanban_board_props_empty_columns_skipped_on_serialize() {
2402 let p = KanbanBoardProps {
2403 columns: vec![],
2404 items_path: Some("/data/order".into()),
2405 group_by: Some("status".into()),
2406 card_title_key: None,
2407 card_description_key: None,
2408 row_actions: None,
2409 row_key: None,
2410 mobile_default_column: None,
2411 empty_label: None,
2412 };
2413 let j = serde_json::to_value(&p).unwrap();
2414 assert!(
2415 j.get("columns").is_none(),
2416 "empty columns must be skipped, got: {j}"
2417 );
2418 assert_eq!(
2419 j.get("items_path").and_then(|v| v.as_str()),
2420 Some("/data/order")
2421 );
2422 }
2423}
2424
2425#[cfg(test)]
2426mod page_header_actions_tests {
2427 use super::*;
2428
2429 #[test]
2430 fn page_header_actions_missing_field() {
2431 let v = serde_json::json!({"title": "X"});
2432 let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2433 assert!(p.actions.is_empty());
2434 }
2435
2436 #[test]
2437 fn page_header_actions_null() {
2438 let v = serde_json::json!({"title": "X", "actions": null});
2439 let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2440 assert!(p.actions.is_empty());
2441 }
2442
2443 #[test]
2444 fn page_header_actions_empty_string() {
2445 let v = serde_json::json!({"title": "X", "actions": ""});
2446 let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2447 assert!(p.actions.is_empty());
2448 }
2449
2450 #[test]
2451 fn page_header_actions_empty_array() {
2452 let v = serde_json::json!({"title": "X", "actions": []});
2453 let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2454 assert!(p.actions.is_empty());
2455 }
2456
2457 #[test]
2458 fn page_header_actions_non_empty_array() {
2459 let v = serde_json::json!({"title": "X", "actions": ["a", "b"]});
2460 let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2461 assert_eq!(p.actions, vec!["a".to_string(), "b".to_string()]);
2462 }
2463
2464 #[test]
2465 fn page_header_actions_non_empty_string_rejected() {
2466 let v = serde_json::json!({"title": "X", "actions": "not-empty"});
2467 let result: Result<PageHeaderProps, _> = serde_json::from_value(v);
2468 assert!(result.is_err(), "non-empty string must be rejected");
2469 }
2470
2471 #[test]
2472 fn page_header_actions_non_string_array_rejected() {
2473 let v = serde_json::json!({"title": "X", "actions": [1, 2, 3]});
2474 let result: Result<PageHeaderProps, _> = serde_json::from_value(v);
2475 assert!(result.is_err(), "array of non-strings must be rejected");
2476 }
2477}
2478
2479#[cfg(test)]
2480mod tile_contract_tests {
2481 use super::*;
2486
2487 #[test]
2490 fn tile_legacy_json_round_trips_unchanged() {
2491 let json = r#"{"item_id":"p1","name":"Widget","price":"€10,00","field":"qty_p1"}"#;
2492 let tile: TileProps = serde_json::from_str(json).expect("legacy json must deserialize");
2493 assert!(
2494 tile.categories.is_empty(),
2495 "categories must default to empty vec"
2496 );
2497 assert!(tile.image_url.is_none(), "image_url must default to None");
2498 assert!(tile.color.is_none(), "color must default to None");
2499 assert!(
2500 tile.stock_badge.is_none(),
2501 "stock_badge must default to None"
2502 );
2503 let serialized = serde_json::to_string(&tile).expect("must serialize");
2504 assert!(
2505 !serialized.contains("categories"),
2506 "re-serialized must not contain 'categories'; got: {serialized}"
2507 );
2508 assert!(
2509 !serialized.contains("image_url"),
2510 "re-serialized must not contain 'image_url'; got: {serialized}"
2511 );
2512 assert!(
2513 !serialized.contains("color"),
2514 "re-serialized must not contain 'color'; got: {serialized}"
2515 );
2516 assert!(
2517 !serialized.contains("stock_badge"),
2518 "re-serialized must not contain 'stock_badge'; got: {serialized}"
2519 );
2520 }
2521
2522 #[test]
2524 fn tile_with_categories_serializes() {
2525 let tile = TileProps {
2526 item_id: "p2".to_string(),
2527 name: "Espresso".to_string(),
2528 price: "\u{20ac}2,00".to_string(),
2529 field: "qty_p2".to_string(),
2530 default_quantity: None,
2531 categories: vec!["drinks".to_string(), "food".to_string()],
2532 image_url: None,
2533 color: None,
2534 stock_badge: None,
2535 price_cents: None,
2536 };
2537 let serialized = serde_json::to_string(&tile).expect("must serialize");
2538 assert!(
2539 serialized.contains(r#""categories":["drinks","food"]"#),
2540 "serialized must contain categories array; got: {serialized}"
2541 );
2542 }
2543
2544 #[test]
2546 fn grid_props_row_weights_round_trips() {
2547 let default_grid: GridProps = serde_json::from_value(serde_json::json!({}))
2549 .expect("must deserialize default GridProps");
2550 let json = serde_json::to_string(&default_grid).expect("must serialize");
2551 assert!(
2552 !json.contains("row_weights"),
2553 "empty row_weights must be skipped in serialization; got: {json}"
2554 );
2555
2556 let with_weights: GridProps =
2558 serde_json::from_value(serde_json::json!({"row_weights": [2, 1]}))
2559 .expect("must deserialize GridProps with row_weights");
2560 let json2 = serde_json::to_string(&with_weights).expect("must serialize with weights");
2561 assert!(
2562 json2.contains(r#""row_weights":[2,1]"#),
2563 "row_weights must appear in serialization; got: {json2}"
2564 );
2565 let parsed: GridProps =
2566 serde_json::from_str(&json2).expect("must deserialize from serialized");
2567 assert_eq!(
2568 parsed.row_weights,
2569 vec![2u8, 1u8],
2570 "row_weights must round-trip unchanged"
2571 );
2572 }
2573
2574 #[test]
2576 fn tile_props_price_cents_round_trips() {
2577 let no_price: TileProps = serde_json::from_str(
2579 r#"{"item_id":"p1","name":"Coffee","price":"€2,00","field":"qty_p1"}"#,
2580 )
2581 .expect("must deserialize");
2582 assert!(
2583 no_price.price_cents.is_none(),
2584 "price_cents must default to None"
2585 );
2586 let json = serde_json::to_string(&no_price).expect("must serialize");
2587 assert!(
2588 !json.contains("price_cents"),
2589 "absent price_cents must be skipped; got: {json}"
2590 );
2591
2592 let with_price: TileProps =
2594 serde_json::from_str(r#"{"item_id":"p1","name":"Coffee","price":"€2,50","field":"qty_p1","price_cents":250}"#)
2595 .expect("must deserialize with price_cents");
2596 assert_eq!(
2597 with_price.price_cents,
2598 Some(250u64),
2599 "price_cents must round-trip"
2600 );
2601 let json2 = serde_json::to_string(&with_price).expect("must serialize");
2602 assert!(
2603 json2.contains(r#""price_cents":250"#),
2604 "price_cents must appear in serialization; got: {json2}"
2605 );
2606 let parsed: TileProps =
2607 serde_json::from_str(&json2).expect("must deserialize from serialized");
2608 assert_eq!(
2609 parsed.price_cents,
2610 Some(250u64),
2611 "price_cents must round-trip unchanged"
2612 );
2613 }
2614
2615 #[test]
2617 fn tile_props_color_tone_round_trips_and_rejects_unknown() {
2618 let with_color: TileProps = serde_json::from_str(
2620 r#"{"item_id":"p1","name":"Tea","price":"€1,00","field":"qty_p1","color":"success"}"#,
2621 )
2622 .expect("must deserialize color:success");
2623 assert_eq!(
2624 with_color.color,
2625 Some(Tone::Success),
2626 "color:success must deserialize to Tone::Success"
2627 );
2628 let json = serde_json::to_string(&with_color).expect("must serialize");
2629 assert!(
2630 json.contains(r#""color":"success""#),
2631 "Tone::Success must serialize as \"success\"; got: {json}"
2632 );
2633 let parsed: TileProps =
2634 serde_json::from_str(&json).expect("must deserialize from serialized");
2635 assert_eq!(
2636 parsed.color,
2637 Some(Tone::Success),
2638 "color must round-trip unchanged"
2639 );
2640
2641 let result: Result<TileProps, _> = serde_json::from_str(
2643 r#"{"item_id":"p1","name":"Tea","price":"€1,00","field":"qty_p1","color":"blue"}"#,
2644 );
2645 assert!(
2646 result.is_err(),
2647 "color:\"blue\" must fail to deserialize — Tone enum enforced"
2648 );
2649 }
2650
2651 #[test]
2653 fn selection_panel_props_currency_round_trips() {
2654 let no_currency: SelectionPanelProps =
2656 serde_json::from_str(r#"{"form_id":"order-form"}"#).expect("must deserialize");
2657 assert!(
2658 no_currency.currency.is_none(),
2659 "currency must default to None"
2660 );
2661 let json = serde_json::to_string(&no_currency).expect("must serialize");
2662 assert!(
2663 !json.contains("currency"),
2664 "absent currency must be skipped; got: {json}"
2665 );
2666
2667 let with_currency: SelectionPanelProps =
2669 serde_json::from_str(r#"{"form_id":"order-form","currency":"€"}"#)
2670 .expect("must deserialize with currency");
2671 assert_eq!(
2672 with_currency.currency,
2673 Some("€".to_string()),
2674 "currency must round-trip"
2675 );
2676 let json2 = serde_json::to_string(&with_currency).expect("must serialize");
2677 assert!(
2678 json2.contains(r#""currency":"€""#),
2679 "currency must appear in serialization; got: {json2}"
2680 );
2681 }
2682}