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 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub label_true: Option<String>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub label_false: Option<String>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub peek_entity: Option<String>,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
201pub struct SelectOption {
202 pub value: String,
203 pub label: String,
204}
205
206#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
213#[serde(rename_all = "snake_case")]
214pub enum CardAppearance {
215 #[default]
216 Bordered,
217 Elevated,
218}
219
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
222pub struct CardProps {
223 pub title: String,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub description: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub subtitle: Option<String>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub badge: Option<String>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub max_width: Option<FormMaxWidth>,
238 #[serde(default, skip_serializing_if = "Vec::is_empty")]
240 pub footer: Vec<String>,
241 #[serde(default)]
242 pub appearance: CardAppearance,
243}
244
245#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
247pub struct TableProps {
248 pub columns: Vec<Column>,
249 pub data_path: String,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub row_actions: Option<Vec<Action>>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub empty_message: Option<String>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub sortable: Option<bool>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub sort_column: Option<String>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub sort_direction: Option<SortDirection>,
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
264#[serde(rename_all = "snake_case")]
265pub enum FormMaxWidth {
266 #[default]
267 Default,
268 Narrow,
269 Wide,
270}
271
272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
274pub struct FormProps {
275 pub action: Action,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub method: Option<crate::action::HttpMethod>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub guard: Option<String>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub max_width: Option<FormMaxWidth>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub id: Option<String>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub enctype: Option<String>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub fill: Option<bool>,
308}
309
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
312#[serde(rename_all = "snake_case")]
313pub enum ButtonType {
314 #[default]
315 Button,
316 Submit,
317}
318
319#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
321pub struct ButtonProps {
322 pub label: String,
323 #[serde(default)]
324 pub variant: Variant,
325 #[serde(default)]
326 pub size: Size,
327 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub disabled: Option<bool>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub icon: Option<String>,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub icon_position: Option<IconPosition>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub button_type: Option<ButtonType>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub form: Option<String>,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub disable_on_submit: Option<bool>,
346}
347
348#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
350pub struct InputProps {
351 pub field: String,
353 pub label: String,
354 #[serde(default)]
355 pub input_type: InputType,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub placeholder: Option<String>,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub required: Option<bool>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub disabled: Option<bool>,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub error: Option<String>,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub description: Option<String>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub default_value: Option<String>,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub data_path: Option<String>,
371 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub step: Option<String>,
374 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub list: Option<String>,
379 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub accept: Option<String>,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub date_picker: Option<bool>,
393}
394
395#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
406pub struct RichTextEditorProps {
407 pub field: String,
408 pub label: String,
409 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub placeholder: Option<String>,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub default_value: Option<String>,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub data_path: Option<String>,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub error: Option<String>,
417}
418
419#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
421pub struct SelectProps {
422 pub field: String,
424 pub label: String,
425 pub options: Vec<SelectOption>,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub placeholder: Option<String>,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub required: Option<bool>,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub disabled: Option<bool>,
432 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub error: Option<String>,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
435 pub description: Option<String>,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub default_value: Option<String>,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub data_path: Option<String>,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub searchable: Option<bool>,
445}
446
447#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
449pub struct AlertProps {
450 pub message: String,
451 #[serde(default)]
452 pub tone: Tone,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
454 pub title: Option<String>,
455}
456
457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
459pub struct BadgeProps {
460 pub label: String,
461 #[serde(default)]
462 pub tone: Tone,
463}
464
465#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
467pub struct ModalProps {
468 pub id: String,
469 pub title: String,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub description: Option<String>,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
473 pub trigger_label: Option<String>,
474 #[serde(default, skip_serializing_if = "Vec::is_empty")]
476 pub footer: Vec<String>,
477}
478
479#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
481pub struct TextProps {
482 pub content: String,
483 #[serde(default)]
484 pub element: TextElement,
485}
486
487#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
489pub struct CheckboxProps {
490 pub field: String,
492 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub value: Option<String>,
496 pub label: String,
497 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub description: Option<String>,
499 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub checked: Option<bool>,
501 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub data_path: Option<String>,
504 #[serde(default, skip_serializing_if = "Option::is_none")]
505 pub required: Option<bool>,
506 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub disabled: Option<bool>,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
509 pub error: Option<String>,
510}
511
512#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
518pub struct CheckboxListProps {
519 pub field: String,
521 #[serde(default, skip_serializing_if = "Vec::is_empty")]
524 pub options: Vec<SelectOption>,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub options_path: Option<String>,
528 #[serde(default, skip_serializing_if = "Option::is_none")]
530 pub selected_path: Option<String>,
531 #[serde(default, skip_serializing_if = "Option::is_none")]
532 pub label: Option<String>,
533 #[serde(default, skip_serializing_if = "Option::is_none")]
534 pub description: Option<String>,
535 #[serde(default, skip_serializing_if = "Option::is_none")]
536 pub disabled: Option<bool>,
537 #[serde(default, skip_serializing_if = "Option::is_none")]
538 pub error: Option<String>,
539}
540
541#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
543pub struct SwitchProps {
544 pub field: String,
546 pub label: String,
547 #[serde(default, skip_serializing_if = "Option::is_none")]
548 pub description: Option<String>,
549 #[serde(default, skip_serializing_if = "Option::is_none")]
550 pub checked: Option<bool>,
551 #[serde(default, skip_serializing_if = "Option::is_none")]
553 pub data_path: Option<String>,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
555 pub required: Option<bool>,
556 #[serde(default, skip_serializing_if = "Option::is_none")]
557 pub disabled: Option<bool>,
558 #[serde(default, skip_serializing_if = "Option::is_none")]
559 pub error: Option<String>,
560 #[serde(default, skip_serializing_if = "Option::is_none")]
563 pub action: Option<Action>,
564 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub compact: Option<bool>,
568}
569
570#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
572pub struct SeparatorProps {
573 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub orientation: Option<Orientation>,
575}
576
577#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
579pub struct DescriptionItem {
580 pub label: String,
581 pub value: String,
582 #[serde(default, skip_serializing_if = "Option::is_none")]
583 pub format: Option<ColumnFormat>,
584 #[serde(default, skip_serializing_if = "Option::is_none")]
588 pub inline_edit_field: Option<String>,
589 #[serde(default, skip_serializing_if = "Option::is_none")]
591 pub inline_edit_endpoint: Option<String>,
592 #[serde(default, skip_serializing_if = "Option::is_none")]
594 pub inline_edit_kind: Option<String>,
595}
596
597#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
599pub struct DescriptionListProps {
600 #[serde(default, skip_serializing_if = "Vec::is_empty")]
601 pub items: Vec<DescriptionItem>,
602 #[serde(default, skip_serializing_if = "Option::is_none")]
603 pub columns: Option<u8>,
604 #[serde(default, skip_serializing_if = "Option::is_none")]
608 pub data_path: Option<String>,
609}
610
611#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
613pub struct Tab {
614 pub value: String,
615 pub label: String,
616 #[serde(default, skip_serializing_if = "Vec::is_empty")]
618 pub children: Vec<String>,
619}
620
621#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
623pub struct TabsProps {
624 pub default_tab: String,
625 pub tabs: Vec<Tab>,
626}
627
628#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
630pub struct BreadcrumbItem {
631 pub label: String,
632 #[serde(default, skip_serializing_if = "Option::is_none")]
633 pub url: Option<String>,
634}
635
636#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
638pub struct BreadcrumbProps {
639 pub items: Vec<BreadcrumbItem>,
640}
641
642#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
644pub struct PaginationProps {
645 pub current_page: u32,
646 pub per_page: u32,
647 pub total: u32,
648 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub base_url: Option<String>,
650}
651
652#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
654pub struct ProgressProps {
655 pub value: u8,
657 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub max: Option<u8>,
659 #[serde(default, skip_serializing_if = "Option::is_none")]
660 pub label: Option<String>,
661}
662
663#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
665pub struct ImageProps {
666 #[serde(default)]
667 pub src: String,
668 pub alt: String,
669 #[serde(default, skip_serializing_if = "Option::is_none")]
670 pub aspect_ratio: Option<String>,
671 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub placeholder_label: Option<String>,
677 #[serde(default, skip_serializing_if = "Option::is_none")]
685 pub inline_svg: Option<String>,
686 #[serde(default, skip_serializing_if = "Option::is_none")]
690 pub data_path: Option<String>,
691}
692
693impl ImageProps {
694 pub fn inline_svg(svg: impl Into<String>, alt: impl Into<String>) -> Self {
700 Self {
701 src: String::new(),
702 alt: alt.into(),
703 aspect_ratio: None,
704 placeholder_label: None,
705 inline_svg: Some(svg.into()),
706 data_path: None,
707 }
708 }
709}
710
711#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
713pub struct AvatarProps {
714 #[serde(default, skip_serializing_if = "Option::is_none")]
715 pub src: Option<String>,
716 pub alt: String,
717 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub fallback: Option<String>,
719 #[serde(default, skip_serializing_if = "Option::is_none")]
720 pub size: Option<Size>,
721}
722
723#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
725pub struct SkeletonProps {
726 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub width: Option<String>,
728 #[serde(default, skip_serializing_if = "Option::is_none")]
729 pub height: Option<String>,
730 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub rounded: Option<bool>,
732}
733
734#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
751pub struct RawHtmlProps {
752 #[serde(default)]
754 pub html: String,
755}
756
757#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
763pub struct StreamTextProps {
764 #[serde(default)]
767 pub sse_url: String,
768 #[serde(default, skip_serializing_if = "Option::is_none")]
770 pub placeholder: Option<String>,
771 #[serde(default, skip_serializing_if = "Option::is_none")]
773 pub loading_text: Option<String>,
774}
775
776#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
787pub struct LiveFragmentProps {
788 #[serde(default)]
790 pub projection: String,
791 #[serde(default)]
793 pub key: String,
794 pub template: serde_json::Value,
797}
798
799#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
801pub struct ChecklistItem {
802 pub label: String,
803 #[serde(default)]
804 pub checked: bool,
805 #[serde(default, skip_serializing_if = "Option::is_none")]
806 pub href: Option<String>,
807}
808
809#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
811pub struct NotificationItem {
812 #[serde(default, skip_serializing_if = "Option::is_none")]
813 pub icon: Option<String>,
814 pub text: String,
815 #[serde(default, skip_serializing_if = "Option::is_none")]
816 pub timestamp: Option<String>,
817 #[serde(default)]
818 pub read: bool,
819 #[serde(default, skip_serializing_if = "Option::is_none")]
820 pub action_url: Option<String>,
821}
822
823#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
825pub struct SidebarNavItem {
826 pub label: String,
827 pub href: String,
828 #[serde(default, skip_serializing_if = "Option::is_none")]
829 pub icon: Option<String>,
830 #[serde(default)]
831 pub active: bool,
832 #[serde(default, skip_serializing_if = "Option::is_none")]
835 pub disabled: Option<bool>,
836}
837
838#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
840pub struct SidebarGroup {
841 pub label: String,
842 #[serde(default)]
843 pub collapsed: bool,
844 pub items: Vec<SidebarNavItem>,
845}
846
847#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
849pub struct StatCardProps {
850 pub label: String,
851 pub value: String,
852 #[serde(default)]
855 pub tone: Tone,
856 #[serde(default, skip_serializing_if = "Option::is_none")]
857 pub icon: Option<String>,
858 #[serde(default, skip_serializing_if = "Option::is_none")]
859 pub subtitle: Option<String>,
860 #[serde(default, skip_serializing_if = "Option::is_none")]
862 pub sse_target: Option<String>,
863 #[serde(default, skip_serializing_if = "Option::is_none")]
868 pub value_path: Option<String>,
869 #[serde(default, skip_serializing_if = "Option::is_none")]
874 pub sparkline_svg: Option<String>,
875}
876
877#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
879pub struct ChecklistProps {
880 pub title: String,
881 pub items: Vec<ChecklistItem>,
882 #[serde(default = "default_true")]
883 pub dismissible: bool,
884 #[serde(default, skip_serializing_if = "Option::is_none")]
885 pub dismiss_label: Option<String>,
886 #[serde(default, skip_serializing_if = "Option::is_none")]
888 pub data_key: Option<String>,
889}
890
891fn default_true() -> bool {
892 true
893}
894
895#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
900pub struct ToastProps {
901 pub message: String,
902 #[serde(default)]
903 pub tone: Tone,
904 #[serde(default, skip_serializing_if = "Option::is_none")]
907 pub timeout: Option<u32>,
908 #[serde(default = "default_true")]
911 pub dismissible: bool,
912}
913
914#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
916pub struct NotificationDropdownProps {
917 pub notifications: Vec<NotificationItem>,
918 #[serde(default, skip_serializing_if = "Option::is_none")]
919 pub empty_text: Option<String>,
920}
921
922#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
924pub struct SidebarProps {
925 #[serde(default, skip_serializing_if = "Vec::is_empty")]
926 pub fixed_top: Vec<SidebarNavItem>,
927 #[serde(default, skip_serializing_if = "Vec::is_empty")]
928 pub groups: Vec<SidebarGroup>,
929 #[serde(default, skip_serializing_if = "Vec::is_empty")]
930 pub fixed_bottom: Vec<SidebarNavItem>,
931}
932
933#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
935pub struct HeaderProps {
936 pub business_name: String,
937 #[serde(default, skip_serializing_if = "Option::is_none")]
939 pub notification_count: Option<u32>,
940 #[serde(default, skip_serializing_if = "Option::is_none")]
941 pub user_name: Option<String>,
942 #[serde(default, skip_serializing_if = "Option::is_none")]
943 pub user_avatar: Option<String>,
944 #[serde(default, skip_serializing_if = "Option::is_none")]
945 pub logout_url: Option<String>,
946 #[serde(default, skip_serializing_if = "Option::is_none")]
950 pub theme_url: Option<String>,
951 #[serde(default, skip_serializing_if = "Option::is_none")]
954 pub profile_url: Option<String>,
955}
956
957#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
959#[serde(rename_all = "snake_case")]
960pub enum GapSize {
961 None,
962 Sm,
963 #[default]
964 Md,
965 Lg,
966 Xl,
967}
968
969#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
971pub struct GridProps {
972 #[serde(default = "default_grid_columns")]
974 pub columns: u8,
975 #[serde(default, skip_serializing_if = "Option::is_none")]
977 pub md_columns: Option<u8>,
978 #[serde(default, skip_serializing_if = "Option::is_none")]
980 pub lg_columns: Option<u8>,
981 #[serde(default)]
983 pub gap: GapSize,
984 #[serde(default, skip_serializing_if = "Option::is_none")]
987 pub scrollable: Option<bool>,
988 #[serde(default, skip_serializing_if = "Vec::is_empty")]
994 pub spans: Vec<u8>,
995 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1002 pub row_weights: Vec<u8>,
1003 #[serde(default, skip_serializing_if = "Option::is_none")]
1010 pub fill: Option<bool>,
1011}
1012
1013fn default_grid_columns() -> u8 {
1014 2
1015}
1016
1017#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1019pub struct CollapsibleProps {
1020 pub title: String,
1021 #[serde(default)]
1022 pub expanded: bool,
1023}
1024
1025#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1027pub struct EmptyStateProps {
1028 pub title: String,
1029 #[serde(default, skip_serializing_if = "Option::is_none")]
1030 pub description: Option<String>,
1031 #[serde(default, skip_serializing_if = "Option::is_none")]
1032 pub action: Option<Action>,
1033 #[serde(default, skip_serializing_if = "Option::is_none")]
1034 pub action_label: Option<String>,
1035}
1036
1037#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
1039#[serde(rename_all = "snake_case")]
1040pub enum FormSectionLayout {
1041 #[default]
1042 Stacked,
1043 TwoColumn,
1044}
1045
1046#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1048pub struct FormSectionProps {
1049 pub title: String,
1050 #[serde(default, skip_serializing_if = "Option::is_none")]
1051 pub description: Option<String>,
1052 #[serde(default, skip_serializing_if = "Option::is_none")]
1054 pub layout: Option<FormSectionLayout>,
1055}
1056
1057#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1059pub struct PageHeaderProps {
1060 pub title: String,
1061 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1062 pub breadcrumb: Vec<BreadcrumbItem>,
1063 #[serde(
1065 default,
1066 deserialize_with = "deserialize_actions_lax",
1067 skip_serializing_if = "Vec::is_empty"
1068 )]
1069 pub actions: Vec<String>,
1070}
1071
1072#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1074pub struct ButtonGroupProps {
1075 #[serde(default)]
1077 pub gap: GapSize,
1078}
1079
1080#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1091pub struct ActionItem {
1092 pub label: String,
1093 pub action: Action,
1094 #[serde(default)]
1097 pub destructive: bool,
1098 #[serde(default, skip_serializing_if = "Option::is_none")]
1099 pub variant: Option<Variant>,
1100 #[serde(default, skip_serializing_if = "Option::is_none")]
1101 pub icon: Option<String>,
1102 #[serde(default, skip_serializing_if = "Option::is_none")]
1106 pub visible_if: Option<String>,
1107}
1108
1109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1120pub struct ActionGroupProps {
1121 pub items: Vec<ActionItem>,
1122 pub menu_id: String,
1125 #[serde(default, skip_serializing_if = "Option::is_none")]
1127 pub max_inline: Option<u8>,
1128 #[serde(default, skip_serializing_if = "Option::is_none")]
1130 pub overflow_label: Option<String>,
1131 #[serde(default, skip_serializing_if = "Option::is_none")]
1133 pub row_key: Option<String>,
1134}
1135
1136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1152pub struct SegmentedControlProps {
1153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1155 pub items: Vec<SegmentedItem>,
1156 #[serde(default, skip_serializing_if = "Option::is_none")]
1159 pub data_path: Option<String>,
1160 #[serde(default)]
1162 pub size: Size,
1163 #[serde(default, skip_serializing_if = "Option::is_none")]
1166 pub aria_label: Option<String>,
1167}
1168
1169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1171pub struct SegmentedItem {
1172 pub label: String,
1174 pub href: String,
1176 #[serde(default)]
1178 pub active: bool,
1179 #[serde(default, skip_serializing_if = "Option::is_none")]
1182 pub aria_label: Option<String>,
1183}
1184
1185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1201pub struct SidebarLayoutProps {
1202 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1204 pub items: Vec<SidebarLayoutItem>,
1205 #[serde(default, skip_serializing_if = "Option::is_none")]
1207 pub data_path: Option<String>,
1208 pub active: String,
1211 #[serde(default, skip_serializing_if = "Option::is_none")]
1213 pub aria_label: Option<String>,
1214}
1215
1216#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1218pub struct SidebarLayoutItem {
1219 pub slug: String,
1222 pub label: String,
1224 pub url: String,
1227}
1228
1229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1238pub struct DetailPageProps {
1239 pub title: String,
1240 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1241 pub breadcrumb: Vec<BreadcrumbItem>,
1242 #[serde(
1244 default,
1245 deserialize_with = "deserialize_actions_lax",
1246 skip_serializing_if = "Vec::is_empty"
1247 )]
1248 pub actions: Vec<String>,
1249 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1252 pub info: Vec<String>,
1253}
1254
1255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1257pub struct DropdownMenuAction {
1258 pub label: String,
1259 pub action: Action,
1260 #[serde(default)]
1261 pub destructive: bool,
1262 #[serde(default, skip_serializing_if = "Option::is_none")]
1269 pub visible_if: Option<String>,
1270}
1271
1272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1275pub struct DataTableProps {
1276 pub columns: Vec<Column>,
1277 pub data_path: String,
1278 #[serde(default, skip_serializing_if = "Option::is_none")]
1279 pub row_actions: Option<Vec<DropdownMenuAction>>,
1280 #[serde(default, skip_serializing_if = "Option::is_none")]
1281 pub empty_message: Option<String>,
1282 #[serde(default, skip_serializing_if = "Option::is_none")]
1283 pub row_key: Option<String>,
1284 #[serde(default, skip_serializing_if = "Option::is_none")]
1286 pub row_href: Option<String>,
1287 #[serde(default, skip_serializing_if = "Option::is_none")]
1290 pub bulk_select: Option<bool>,
1291}
1292
1293#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1297pub struct MediaCardGridProps {
1298 pub data_path: String,
1299 pub title_key: String,
1301 #[serde(default, skip_serializing_if = "Option::is_none")]
1303 pub description_key: Option<String>,
1304 #[serde(default, skip_serializing_if = "Option::is_none")]
1306 pub image_key: Option<String>,
1307 #[serde(default, skip_serializing_if = "Option::is_none")]
1309 pub image_href_key: Option<String>,
1310 #[serde(default, skip_serializing_if = "Option::is_none")]
1312 pub image_aspect_ratio: Option<String>,
1313 #[serde(default, skip_serializing_if = "Option::is_none")]
1316 pub image_position: Option<String>,
1317 #[serde(default, skip_serializing_if = "Option::is_none")]
1319 pub badge_key: Option<String>,
1320 #[serde(default, skip_serializing_if = "Option::is_none")]
1322 pub badge_tone_key: Option<String>,
1323 #[serde(default, skip_serializing_if = "Option::is_none")]
1325 pub row_key: Option<String>,
1326 #[serde(default, skip_serializing_if = "Option::is_none")]
1327 pub row_actions: Option<Vec<DropdownMenuAction>>,
1328 #[serde(default, skip_serializing_if = "Option::is_none")]
1329 pub empty_message: Option<String>,
1330 #[serde(default, skip_serializing_if = "Option::is_none")]
1332 pub columns: Option<u8>,
1333}
1334
1335#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1343pub struct KanbanColumnProps {
1344 pub id: String,
1345 pub title: String,
1346 #[serde(default)]
1347 pub count: u32,
1348 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1350 pub children: Vec<String>,
1351}
1352
1353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1366pub struct KanbanBoardProps {
1367 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1369 pub columns: Vec<KanbanColumnProps>,
1370 #[serde(default, skip_serializing_if = "Option::is_none")]
1373 pub items_path: Option<String>,
1374 #[serde(default, skip_serializing_if = "Option::is_none")]
1376 pub group_by: Option<String>,
1377 #[serde(default, skip_serializing_if = "Option::is_none")]
1379 pub card_title_key: Option<String>,
1380 #[serde(default, skip_serializing_if = "Option::is_none")]
1382 pub card_description_key: Option<String>,
1383 #[serde(default, skip_serializing_if = "Option::is_none")]
1386 pub row_actions: Option<Vec<DropdownMenuAction>>,
1387 #[serde(default, skip_serializing_if = "Option::is_none")]
1390 pub row_key: Option<String>,
1391 #[serde(default, skip_serializing_if = "Option::is_none")]
1392 pub mobile_default_column: Option<String>,
1393 #[serde(default, skip_serializing_if = "Option::is_none")]
1397 pub empty_label: Option<String>,
1398 #[serde(default, skip_serializing_if = "Option::is_none")]
1404 pub column_empty_label: Option<String>,
1405}
1406
1407#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1412pub struct CalendarCellProps {
1413 pub day: u8,
1414 #[serde(default)]
1415 pub is_today: bool,
1416 #[serde(default)]
1417 pub is_current_month: bool,
1418 #[serde(default)]
1419 pub event_count: u32,
1420 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1423 pub dot_colors: Vec<String>,
1424 #[serde(default)]
1428 pub closed: bool,
1429}
1430
1431#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1436pub struct ActionCardProps {
1437 pub title: String,
1438 pub description: String,
1439 #[serde(default, skip_serializing_if = "Option::is_none")]
1440 pub icon: Option<String>,
1441 #[serde(default)]
1442 pub tone: Tone,
1443 #[serde(default, skip_serializing_if = "Option::is_none")]
1445 pub href: Option<String>,
1446}
1447
1448#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1453pub struct TileProps {
1454 pub item_id: String,
1455 pub name: String,
1456 pub price: String,
1457 pub field: String,
1458 #[serde(default, skip_serializing_if = "Option::is_none")]
1459 pub default_quantity: Option<u32>,
1460 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1471 pub categories: Vec<String>,
1472 #[serde(default, skip_serializing_if = "Option::is_none")]
1475 pub image_url: Option<String>,
1476 #[serde(default, skip_serializing_if = "Option::is_none")]
1480 pub color: Option<Tone>,
1481 #[serde(default, skip_serializing_if = "Option::is_none")]
1483 pub stock_badge: Option<String>,
1484 #[serde(default, skip_serializing_if = "Option::is_none")]
1492 pub price_cents: Option<u64>,
1493}
1494
1495#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1499pub struct TileGridProps {
1500 pub data_path: String,
1502 pub form_id: String,
1511 #[serde(default, skip_serializing_if = "Option::is_none")]
1514 pub categories_path: Option<String>,
1515 #[serde(default, skip_serializing_if = "Option::is_none")]
1517 pub columns: Option<u8>,
1518 #[serde(default, skip_serializing_if = "Option::is_none")]
1520 pub search: Option<bool>,
1521 #[serde(default, skip_serializing_if = "Option::is_none")]
1526 pub search_placeholder: Option<String>,
1527 #[serde(default, skip_serializing_if = "Option::is_none")]
1532 pub all_label: Option<String>,
1533}
1534
1535#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1538pub struct SelectionPanelProps {
1539 pub form_id: String,
1541 #[serde(default, skip_serializing_if = "Option::is_none")]
1543 pub empty_message: Option<String>,
1544 #[serde(default, skip_serializing_if = "Option::is_none")]
1547 pub empty_body: Option<String>,
1548 #[serde(default, skip_serializing_if = "Option::is_none")]
1553 pub currency: Option<String>,
1554 #[serde(default, skip_serializing_if = "Option::is_none")]
1558 pub total_label: Option<String>,
1559}
1560
1561#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1565pub struct FilterTabsProps {
1566 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1570 pub items: Vec<String>,
1571 #[serde(default, skip_serializing_if = "Option::is_none")]
1575 pub all_label: Option<String>,
1576}
1577
1578#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1581pub struct QuantityStepperProps {
1582 pub field: String,
1584 #[serde(default, skip_serializing_if = "Option::is_none")]
1586 pub min: Option<u32>,
1587 #[serde(default, skip_serializing_if = "Option::is_none")]
1589 pub max: Option<u32>,
1590 #[serde(default, skip_serializing_if = "Option::is_none")]
1592 pub step: Option<u32>,
1593}
1594
1595#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
1597#[serde(rename_all = "snake_case")]
1598pub enum NumpadMode {
1599 #[default]
1601 Quantity,
1602 Price,
1604}
1605
1606#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1610pub struct NumpadProps {
1611 pub target_field: String,
1613 #[serde(default)]
1615 pub mode: NumpadMode,
1616}
1617
1618fn deserialize_actions_lax<'de, D: serde::Deserializer<'de>>(
1624 d: D,
1625) -> Result<Vec<String>, D::Error> {
1626 use serde::de::Error;
1627 let v = serde_json::Value::deserialize(d)?;
1628 match v {
1629 serde_json::Value::Null => Ok(Vec::new()),
1630 serde_json::Value::String(s) if s.is_empty() => Ok(Vec::new()),
1631 serde_json::Value::Array(arr) => arr
1632 .into_iter()
1633 .map(|item| {
1634 item.as_str()
1635 .map(String::from)
1636 .ok_or_else(|| D::Error::custom("PageHeader.actions: expected string in array"))
1637 })
1638 .collect(),
1639 other => Err(D::Error::custom(format!(
1640 "PageHeader.actions: expected null, empty string, or array of strings; got {other:?}"
1641 ))),
1642 }
1643}
1644
1645#[cfg(test)]
1646mod schema_smoke_tests {
1647 use super::*;
1658
1659 fn assert_schema_nonempty_object<T: schemars::JsonSchema>(type_label: &str) {
1660 let schema = schemars::schema_for!(T);
1661 let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
1662 assert!(
1663 value.is_object(),
1664 "{type_label}: schema must be a JSON object"
1665 );
1666 let props = value
1667 .get("properties")
1668 .and_then(|p| p.as_object())
1669 .map(|o| !o.is_empty())
1670 .unwrap_or(false);
1671 assert!(
1672 props,
1673 "{type_label}: schema must have a non-empty `properties` field"
1674 );
1675 }
1676
1677 #[test]
1678 fn schema_for_card_props_generates() {
1679 assert_schema_nonempty_object::<CardProps>("CardProps");
1680 }
1681
1682 #[test]
1683 fn schema_for_table_props_generates() {
1684 assert_schema_nonempty_object::<TableProps>("TableProps");
1685 }
1686
1687 #[test]
1688 fn schema_for_form_props_generates() {
1689 assert_schema_nonempty_object::<FormProps>("FormProps");
1690 }
1691
1692 #[test]
1693 fn schema_for_button_props_generates() {
1694 assert_schema_nonempty_object::<ButtonProps>("ButtonProps");
1695 }
1696
1697 #[test]
1698 fn schema_for_input_props_generates() {
1699 assert_schema_nonempty_object::<InputProps>("InputProps");
1700 }
1701
1702 #[test]
1703 fn schema_for_select_props_generates() {
1704 assert_schema_nonempty_object::<SelectProps>("SelectProps");
1705 }
1706
1707 #[test]
1708 fn schema_for_alert_props_generates() {
1709 assert_schema_nonempty_object::<AlertProps>("AlertProps");
1710 }
1711
1712 #[test]
1713 fn schema_for_badge_props_generates() {
1714 assert_schema_nonempty_object::<BadgeProps>("BadgeProps");
1715 }
1716
1717 #[test]
1718 fn schema_for_modal_props_generates() {
1719 assert_schema_nonempty_object::<ModalProps>("ModalProps");
1720 }
1721
1722 #[test]
1723 fn schema_for_text_props_generates() {
1724 assert_schema_nonempty_object::<TextProps>("TextProps");
1725 }
1726
1727 #[test]
1728 fn schema_for_checkbox_props_generates() {
1729 assert_schema_nonempty_object::<CheckboxProps>("CheckboxProps");
1730 }
1731
1732 #[test]
1733 fn schema_for_switch_props_generates() {
1734 assert_schema_nonempty_object::<SwitchProps>("SwitchProps");
1735 }
1736
1737 #[test]
1738 fn schema_for_separator_props_generates() {
1739 assert_schema_nonempty_object::<SeparatorProps>("SeparatorProps");
1740 }
1741
1742 #[test]
1743 fn schema_for_description_list_props_generates() {
1744 assert_schema_nonempty_object::<DescriptionListProps>("DescriptionListProps");
1745 }
1746
1747 #[test]
1748 fn schema_for_tab_generates() {
1749 assert_schema_nonempty_object::<Tab>("Tab");
1750 }
1751
1752 #[test]
1753 fn schema_for_tabs_props_generates() {
1754 assert_schema_nonempty_object::<TabsProps>("TabsProps");
1755 }
1756
1757 #[test]
1758 fn schema_for_breadcrumb_props_generates() {
1759 assert_schema_nonempty_object::<BreadcrumbProps>("BreadcrumbProps");
1760 }
1761
1762 #[test]
1763 fn schema_for_pagination_props_generates() {
1764 assert_schema_nonempty_object::<PaginationProps>("PaginationProps");
1765 }
1766
1767 #[test]
1768 fn schema_for_progress_props_generates() {
1769 assert_schema_nonempty_object::<ProgressProps>("ProgressProps");
1770 }
1771
1772 #[test]
1773 fn schema_for_image_props_generates() {
1774 assert_schema_nonempty_object::<ImageProps>("ImageProps");
1775 }
1776
1777 #[test]
1778 fn image_inline_svg_factory_roundtrips_via_serde() {
1779 let p = ImageProps::inline_svg("<svg/>", "alt");
1780 let json = serde_json::to_value(&p).expect("serialization must not fail");
1781 let parsed: ImageProps =
1782 serde_json::from_value(json).expect("deserialization must not fail");
1783 assert_eq!(parsed.inline_svg, Some("<svg/>".to_string()));
1784 assert_eq!(parsed.alt, "alt");
1785 assert_eq!(parsed.src, "");
1786 }
1787
1788 #[test]
1789 fn schema_for_avatar_props_generates() {
1790 assert_schema_nonempty_object::<AvatarProps>("AvatarProps");
1791 }
1792
1793 #[test]
1794 fn schema_for_skeleton_props_generates() {
1795 assert_schema_nonempty_object::<SkeletonProps>("SkeletonProps");
1796 }
1797
1798 #[test]
1799 fn schema_for_stat_card_props_generates() {
1800 assert_schema_nonempty_object::<StatCardProps>("StatCardProps");
1801 }
1802
1803 #[test]
1804 fn schema_for_checklist_props_generates() {
1805 assert_schema_nonempty_object::<ChecklistProps>("ChecklistProps");
1806 }
1807
1808 #[test]
1809 fn schema_for_toast_props_generates() {
1810 assert_schema_nonempty_object::<ToastProps>("ToastProps");
1811 }
1812
1813 #[test]
1814 fn schema_for_notification_dropdown_props_generates() {
1815 assert_schema_nonempty_object::<NotificationDropdownProps>("NotificationDropdownProps");
1816 }
1817
1818 #[test]
1819 fn schema_for_sidebar_props_generates() {
1820 assert_schema_nonempty_object::<SidebarProps>("SidebarProps");
1821 }
1822
1823 #[test]
1824 fn schema_for_header_props_generates() {
1825 assert_schema_nonempty_object::<HeaderProps>("HeaderProps");
1826 }
1827
1828 #[test]
1829 fn schema_for_grid_props_generates() {
1830 assert_schema_nonempty_object::<GridProps>("GridProps");
1831 }
1832
1833 #[test]
1834 fn schema_for_collapsible_props_generates() {
1835 assert_schema_nonempty_object::<CollapsibleProps>("CollapsibleProps");
1836 }
1837
1838 #[test]
1839 fn schema_for_empty_state_props_generates() {
1840 assert_schema_nonempty_object::<EmptyStateProps>("EmptyStateProps");
1841 }
1842
1843 #[test]
1844 fn schema_for_form_section_props_generates() {
1845 assert_schema_nonempty_object::<FormSectionProps>("FormSectionProps");
1846 }
1847
1848 #[test]
1849 fn schema_for_page_header_props_generates() {
1850 assert_schema_nonempty_object::<PageHeaderProps>("PageHeaderProps");
1851 }
1852
1853 #[test]
1854 fn schema_for_button_group_props_generates() {
1855 assert_schema_nonempty_object::<ButtonGroupProps>("ButtonGroupProps");
1856 }
1857
1858 #[test]
1859 fn schema_for_action_item_generates() {
1860 assert_schema_nonempty_object::<ActionItem>("ActionItem");
1861 }
1862
1863 #[test]
1864 fn schema_for_action_group_props_generates() {
1865 assert_schema_nonempty_object::<ActionGroupProps>("ActionGroupProps");
1866 }
1867
1868 #[test]
1869 fn schema_for_dropdown_menu_action_generates() {
1870 assert_schema_nonempty_object::<DropdownMenuAction>("DropdownMenuAction");
1871 }
1872
1873 #[test]
1874 fn schema_for_data_table_props_generates() {
1875 assert_schema_nonempty_object::<DataTableProps>("DataTableProps");
1876 }
1877
1878 #[test]
1879 fn schema_for_kanban_column_props_generates() {
1880 assert_schema_nonempty_object::<KanbanColumnProps>("KanbanColumnProps");
1881 }
1882
1883 #[test]
1884 fn schema_for_kanban_board_props_generates() {
1885 assert_schema_nonempty_object::<KanbanBoardProps>("KanbanBoardProps");
1886 }
1887
1888 #[test]
1889 fn schema_for_calendar_cell_props_generates() {
1890 assert_schema_nonempty_object::<CalendarCellProps>("CalendarCellProps");
1891 }
1892
1893 #[test]
1894 fn schema_for_action_card_props_generates() {
1895 assert_schema_nonempty_object::<ActionCardProps>("ActionCardProps");
1896 }
1897
1898 #[test]
1899 fn schema_for_tile_props_generates() {
1900 assert_schema_nonempty_object::<TileProps>("TileProps");
1901 }
1902
1903 #[test]
1904 fn card_props_round_trips_footer() {
1905 let original = CardProps {
1906 title: "Hero".to_string(),
1907 description: None,
1908 subtitle: None,
1909 badge: None,
1910 max_width: None,
1911 footer: vec!["btn1".to_string(), "btn2".to_string()],
1912 appearance: CardAppearance::Bordered,
1913 };
1914 let json = serde_json::to_string(&original).unwrap();
1915 let parsed: CardProps = serde_json::from_str(&json).unwrap();
1916 assert_eq!(original.footer, parsed.footer);
1917 }
1918
1919 #[test]
1920 fn tab_round_trips_children() {
1921 let original = Tab {
1922 value: "overview".to_string(),
1923 label: "Overview".to_string(),
1924 children: vec!["panel1".to_string()],
1925 };
1926 let json = serde_json::to_string(&original).unwrap();
1927 let parsed: Tab = serde_json::from_str(&json).unwrap();
1928 assert_eq!(original.children, parsed.children);
1929 }
1930
1931 #[test]
1932 fn card_props_omits_empty_footer_in_json() {
1933 let card = CardProps {
1934 title: "Card".to_string(),
1935 description: None,
1936 subtitle: None,
1937 badge: None,
1938 max_width: None,
1939 footer: Vec::new(),
1940 appearance: CardAppearance::Bordered,
1941 };
1942 let json = serde_json::to_string(&card).unwrap();
1943 assert!(
1944 !json.contains("\"footer\""),
1945 "empty footer must be skipped, got: {json}"
1946 );
1947 }
1948
1949 #[test]
1950 fn card_props_round_trips_badge() {
1951 let original = CardProps {
1952 title: "Hero".to_string(),
1953 description: None,
1954 subtitle: None,
1955 badge: Some("Scade tra 9m".to_string()),
1956 max_width: None,
1957 footer: Vec::new(),
1958 appearance: CardAppearance::Bordered,
1959 };
1960 let json = serde_json::to_string(&original).unwrap();
1961 let parsed: CardProps = serde_json::from_str(&json).unwrap();
1962 assert_eq!(original.badge, parsed.badge);
1963 }
1964
1965 #[test]
1966 fn card_props_omits_empty_badge_in_json() {
1967 let card = CardProps {
1968 title: "Card".to_string(),
1969 description: None,
1970 subtitle: None,
1971 badge: None,
1972 max_width: None,
1973 footer: Vec::new(),
1974 appearance: CardAppearance::Bordered,
1975 };
1976 let json = serde_json::to_string(&card).unwrap();
1977 assert!(
1978 !json.contains("\"badge\""),
1979 "empty badge must be skipped, got: {json}"
1980 );
1981 }
1982
1983 #[test]
1984 fn card_props_round_trips_subtitle() {
1985 let original = CardProps {
1986 title: "Hero".to_string(),
1987 description: None,
1988 subtitle: Some("Marco Rossi".to_string()),
1989 badge: None,
1990 max_width: None,
1991 footer: Vec::new(),
1992 appearance: CardAppearance::Bordered,
1993 };
1994 let json = serde_json::to_string(&original).unwrap();
1995 let parsed: CardProps = serde_json::from_str(&json).unwrap();
1996 assert_eq!(original.subtitle, parsed.subtitle);
1997 }
1998
1999 #[test]
2000 fn card_props_omits_empty_subtitle_in_json() {
2001 let card = CardProps {
2002 title: "Card".to_string(),
2003 description: None,
2004 subtitle: None,
2005 badge: None,
2006 max_width: None,
2007 footer: Vec::new(),
2008 appearance: CardAppearance::Bordered,
2009 };
2010 let json = serde_json::to_string(&card).unwrap();
2011 assert!(
2012 !json.contains("\"subtitle\""),
2013 "empty subtitle must be skipped, got: {json}"
2014 );
2015 }
2016
2017 #[test]
2018 fn card_props_schema_includes_badge() {
2019 let schema = schemars::schema_for!(CardProps);
2020 let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
2021 let props = value
2022 .get("properties")
2023 .and_then(|p| p.as_object())
2024 .expect("schema has a properties object");
2025 assert!(
2026 props.contains_key("badge"),
2027 "CardProps schema must expose a `badge` property; got keys: {:?}",
2028 props.keys().collect::<Vec<_>>()
2029 );
2030 let badge_schema = props.get("badge").expect("badge entry");
2035 let badge_json = badge_schema.to_string();
2036 assert!(
2037 badge_json.contains("\"string\""),
2038 "badge schema entry must mention string type; got: {badge_json}"
2039 );
2040 }
2041
2042 #[test]
2043 fn card_props_schema_includes_subtitle() {
2044 let schema = schemars::schema_for!(CardProps);
2045 let value = serde_json::to_value(&schema).expect("schema serializes to JSON");
2046 let props = value
2047 .get("properties")
2048 .and_then(|p| p.as_object())
2049 .expect("schema has a properties object");
2050 assert!(
2051 props.contains_key("subtitle"),
2052 "CardProps schema must expose a `subtitle` property; got keys: {:?}",
2053 props.keys().collect::<Vec<_>>()
2054 );
2055 let subtitle_schema = props.get("subtitle").expect("subtitle entry");
2060 let subtitle_json = subtitle_schema.to_string();
2061 assert!(
2062 subtitle_json.contains("\"string\""),
2063 "subtitle schema entry must mention string type; got: {subtitle_json}"
2064 );
2065 }
2066
2067 #[test]
2068 fn schema_for_checkbox_list_props_generates() {
2069 assert_schema_nonempty_object::<CheckboxListProps>("CheckboxListProps");
2070 }
2071
2072 #[test]
2073 fn checkbox_list_props_serde_roundtrip() {
2074 let json = serde_json::json!({
2075 "field": "services",
2076 "options": [{"value": "a", "label": "Alpha"}, {"value": "b", "label": "Beta"}],
2077 "selected_path": "/preselected"
2078 });
2079 let parsed: CheckboxListProps = serde_json::from_value(json.clone()).expect("decode");
2080 assert_eq!(parsed.field, "services");
2081 assert_eq!(parsed.options.len(), 2);
2082 assert_eq!(parsed.selected_path.as_deref(), Some("/preselected"));
2083 let reserialized = serde_json::to_value(&parsed).expect("encode");
2084 assert!(reserialized.get("label").is_none());
2086 assert!(reserialized.get("disabled").is_none());
2087 }
2088
2089 #[test]
2090 fn schema_for_rich_text_editor_props_generates() {
2091 assert_schema_nonempty_object::<RichTextEditorProps>("RichTextEditorProps");
2092 }
2093
2094 #[test]
2095 fn rich_text_editor_props_serde_roundtrip() {
2096 let json = serde_json::json!({
2097 "field": "body",
2098 "label": "Body"
2099 });
2100 let parsed: RichTextEditorProps = serde_json::from_value(json).expect("decode");
2101 assert_eq!(parsed.field, "body");
2102 assert_eq!(parsed.label, "Body");
2103 assert!(parsed.placeholder.is_none());
2104 assert!(parsed.default_value.is_none());
2105 assert!(parsed.data_path.is_none());
2106 assert!(parsed.error.is_none());
2107 let reserialized = serde_json::to_value(&parsed).expect("encode");
2108 assert!(reserialized.get("placeholder").is_none());
2110 assert!(reserialized.get("error").is_none());
2111 }
2112
2113 #[test]
2114 fn schema_for_tile_grid_props_generates() {
2115 assert_schema_nonempty_object::<TileGridProps>("TileGridProps");
2116 }
2117
2118 #[test]
2119 fn schema_for_selection_panel_props_generates() {
2120 assert_schema_nonempty_object::<SelectionPanelProps>("SelectionPanelProps");
2121 }
2122
2123 #[test]
2124 fn schema_for_filter_tabs_props_generates() {
2125 assert_schema_nonempty_object::<FilterTabsProps>("FilterTabsProps");
2126 }
2127
2128 #[test]
2129 fn schema_for_quantity_stepper_props_generates() {
2130 assert_schema_nonempty_object::<QuantityStepperProps>("QuantityStepperProps");
2131 }
2132
2133 #[test]
2134 fn schema_for_numpad_props_generates() {
2135 assert_schema_nonempty_object::<NumpadProps>("NumpadProps");
2136 }
2137}
2138
2139#[cfg(test)]
2140mod strum_tests {
2141 use super::*;
2142
2143 use strum::VariantArray;
2144
2145 #[test]
2152 fn variant_enums_strum_matches_serde_wire_format() {
2153 fn check<T: AsRef<str> + serde::Serialize>(variants: &[T], label: &str) {
2154 for v in variants {
2155 let json = serde_json::to_string(v).expect("serialize");
2156 let json_stripped = json.trim_matches('"');
2157 assert_eq!(
2158 v.as_ref(),
2159 json_stripped,
2160 "strum AsRefStr drifted from serde for {label} variant"
2161 );
2162 }
2163 }
2164 check(Variant::VARIANTS, "Variant");
2165 check(Tone::VARIANTS, "Tone");
2166 check(Size::VARIANTS, "Size");
2167 }
2168
2169 #[test]
2173 fn canonical_enums_have_expected_variant_counts() {
2174 assert_eq!(Variant::VARIANTS.len(), 5);
2175 assert_eq!(Tone::VARIANTS.len(), 4);
2176 assert_eq!(Size::VARIANTS.len(), 3);
2177 }
2178
2179 #[test]
2180 fn tone_as_ref_str_matches_wire_format() {
2181 assert_eq!(Tone::Neutral.as_ref(), "neutral");
2182 assert_eq!(Tone::Success.as_ref(), "success");
2183 assert_eq!(Tone::Warning.as_ref(), "warning");
2184 assert_eq!(Tone::Destructive.as_ref(), "destructive");
2185 }
2186}
2187
2188#[cfg(test)]
2189mod canonical_enum_tests {
2190 use super::*;
2191
2192 #[test]
2195 fn variant_default_is_primary() {
2196 assert_eq!(Variant::default(), Variant::Primary);
2197 }
2198
2199 #[test]
2200 fn tone_default_is_neutral() {
2201 assert_eq!(Tone::default(), Tone::Neutral);
2202 }
2203
2204 #[test]
2205 fn size_default_is_md() {
2206 assert_eq!(Size::default(), Size::Md);
2207 }
2208
2209 #[test]
2212 fn variant_serde_snake_case_roundtrip() {
2213 use strum::VariantArray;
2214 for v in Variant::VARIANTS {
2215 let json = serde_json::to_value(v).unwrap();
2216 assert_eq!(json, serde_json::json!(v.as_ref()));
2217 let back: Variant = serde_json::from_value(json).unwrap();
2218 assert_eq!(back, *v);
2219 }
2220 assert_eq!(
2221 serde_json::from_str::<Variant>("\"primary\"").unwrap(),
2222 Variant::Primary
2223 );
2224 }
2225
2226 #[test]
2227 fn tone_serde_snake_case_roundtrip() {
2228 use strum::VariantArray;
2229 for t in Tone::VARIANTS {
2230 let json = serde_json::to_value(t).unwrap();
2231 assert_eq!(json, serde_json::json!(t.as_ref()));
2232 let back: Tone = serde_json::from_value(json).unwrap();
2233 assert_eq!(back, *t);
2234 }
2235 }
2236
2237 #[test]
2238 fn size_serde_snake_case_roundtrip() {
2239 use strum::VariantArray;
2240 for s in Size::VARIANTS {
2241 let json = serde_json::to_value(s).unwrap();
2242 assert_eq!(json, serde_json::json!(s.as_ref()));
2243 let back: Size = serde_json::from_value(json).unwrap();
2244 assert_eq!(back, *s);
2245 }
2246 assert!(serde_json::from_str::<Size>("\"md\"").is_ok());
2247 assert!(serde_json::from_str::<Size>("\"sm\"").is_ok());
2248 assert!(serde_json::from_str::<Size>("\"lg\"").is_ok());
2249 }
2250
2251 #[test]
2254 fn retired_size_values_are_rejected() {
2255 assert!(
2256 serde_json::from_str::<Size>("\"xs\"").is_err(),
2257 "size 'xs' was retired (migrate to 'sm')"
2258 );
2259 assert!(
2260 serde_json::from_str::<Size>("\"default\"").is_err(),
2261 "size 'default' was retired (migrate to 'md')"
2262 );
2263 }
2264
2265 #[test]
2266 fn retired_variant_values_are_rejected() {
2267 assert!(
2268 serde_json::from_str::<Variant>("\"default\"").is_err(),
2269 "variant 'default' was retired (migrate to 'primary')"
2270 );
2271 assert!(
2272 serde_json::from_str::<Variant>("\"link\"").is_err(),
2273 "variant 'link' was removed (migrate to 'ghost')"
2274 );
2275 }
2276
2277 #[test]
2278 fn retired_tone_values_are_rejected() {
2279 assert!(
2280 serde_json::from_str::<Tone>("\"info\"").is_err(),
2281 "tone 'info' was retired (migrate to 'neutral')"
2282 );
2283 assert!(
2284 serde_json::from_str::<Tone>("\"error\"").is_err(),
2285 "tone 'error' was retired (migrate to 'destructive')"
2286 );
2287 }
2288
2289 #[test]
2290 fn button_spec_with_link_variant_fails_to_decode() {
2291 let v = serde_json::json!({"variant": "link", "label": "x"});
2292 assert!(
2293 serde_json::from_value::<ButtonProps>(v).is_err(),
2294 "Button variant 'link' must fail decode (migrate to 'ghost')"
2295 );
2296 }
2297
2298 #[test]
2301 fn button_props_defaults_to_primary_md() {
2302 let v = serde_json::json!({"label": "x"});
2303 let p: ButtonProps = serde_json::from_value(v).unwrap();
2304 assert_eq!(p.variant, Variant::Primary);
2305 assert_eq!(p.size, Size::Md);
2306 }
2307
2308 #[test]
2309 fn alert_props_without_tone_defaults_to_neutral() {
2310 let v = serde_json::json!({"message": "x"});
2311 let p: AlertProps = serde_json::from_value(v).unwrap();
2312 assert_eq!(p.tone, Tone::Neutral);
2313 }
2314
2315 #[test]
2316 fn alert_props_with_tone_neutral_decodes() {
2317 let v = serde_json::json!({"message": "x", "tone": "neutral"});
2318 let p: AlertProps = serde_json::from_value(v).unwrap();
2319 assert_eq!(p.tone, Tone::Neutral);
2320 }
2321
2322 #[test]
2323 fn badge_props_without_tone_defaults_to_neutral() {
2324 let v = serde_json::json!({"label": "x"});
2325 let p: BadgeProps = serde_json::from_value(v).unwrap();
2326 assert_eq!(p.tone, Tone::Neutral);
2327 }
2328
2329 #[test]
2330 fn toast_props_without_tone_defaults_to_neutral() {
2331 let v = serde_json::json!({"message": "x"});
2332 let p: ToastProps = serde_json::from_value(v).unwrap();
2333 assert_eq!(p.tone, Tone::Neutral);
2334 }
2335
2336 #[test]
2337 fn action_card_props_with_success_tone_decodes() {
2338 let v = serde_json::json!({"title": "x", "description": "y", "tone": "success"});
2339 let p: ActionCardProps = serde_json::from_value(v).unwrap();
2340 assert_eq!(p.tone, Tone::Success);
2341 }
2342
2343 #[test]
2344 fn stat_card_props_without_tone_defaults_to_neutral() {
2345 let v = serde_json::json!({"label": "x", "value": "1"});
2346 let p: StatCardProps = serde_json::from_value(v).unwrap();
2347 assert_eq!(p.tone, Tone::Neutral);
2348 }
2349
2350 #[test]
2351 fn stat_card_props_roundtrip_preserves_tone() {
2352 let v = serde_json::json!({"label": "x", "value": "1", "tone": "warning"});
2353 let p: StatCardProps = serde_json::from_value(v).unwrap();
2354 assert_eq!(p.tone, Tone::Warning);
2355 let j = serde_json::to_value(&p).unwrap();
2356 let back: StatCardProps = serde_json::from_value(j).unwrap();
2357 assert_eq!(back.tone, Tone::Warning);
2358 }
2359}
2360
2361#[cfg(test)]
2362mod card_appearance_tests {
2363 use super::*;
2364
2365 #[test]
2366 fn card_appearance_default_is_bordered() {
2367 assert_eq!(CardAppearance::default(), CardAppearance::Bordered);
2368 }
2369
2370 #[test]
2371 fn card_appearance_serializes_snake_case() {
2372 assert_eq!(
2373 serde_json::to_value(CardAppearance::Bordered).unwrap(),
2374 serde_json::json!("bordered")
2375 );
2376 assert_eq!(
2377 serde_json::to_value(CardAppearance::Elevated).unwrap(),
2378 serde_json::json!("elevated")
2379 );
2380 }
2381
2382 #[test]
2383 fn card_appearance_deserializes_snake_case() {
2384 assert_eq!(
2385 serde_json::from_value::<CardAppearance>(serde_json::json!("bordered")).unwrap(),
2386 CardAppearance::Bordered
2387 );
2388 assert_eq!(
2389 serde_json::from_value::<CardAppearance>(serde_json::json!("elevated")).unwrap(),
2390 CardAppearance::Elevated
2391 );
2392 }
2393
2394 #[test]
2395 fn card_props_without_appearance_defaults_to_bordered() {
2396 let v = serde_json::json!({"title": "x"});
2397 let p: CardProps = serde_json::from_value(v).unwrap();
2398 assert_eq!(p.appearance, CardAppearance::Bordered);
2399 }
2400
2401 #[test]
2402 fn card_props_with_elevated_appearance() {
2403 let v = serde_json::json!({"title": "x", "appearance": "elevated"});
2404 let p: CardProps = serde_json::from_value(v).unwrap();
2405 assert_eq!(p.appearance, CardAppearance::Elevated);
2406 }
2407
2408 #[test]
2409 fn card_props_roundtrip_preserves_appearance() {
2410 let p = CardProps {
2411 title: "x".into(),
2412 description: None,
2413 subtitle: None,
2414 badge: None,
2415 max_width: None,
2416 footer: vec![],
2417 appearance: CardAppearance::Elevated,
2418 };
2419 let j = serde_json::to_value(&p).unwrap();
2420 let back: CardProps = serde_json::from_value(j).unwrap();
2421 assert_eq!(back.appearance, CardAppearance::Elevated);
2422 }
2423}
2424
2425#[cfg(test)]
2426mod kanban_board_props_tests {
2427 use super::*;
2428
2429 #[test]
2430 fn kanban_board_props_serde_static_columns() {
2431 let v = serde_json::json!({
2432 "columns": [{"title": "To Do", "id": "todo", "count": 0}]
2433 });
2434 let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
2435 assert_eq!(p.columns.len(), 1);
2436 assert!(p.items_path.is_none());
2437 assert!(p.group_by.is_none());
2438 }
2439
2440 #[test]
2441 fn kanban_board_props_serde_data_bound() {
2442 let v = serde_json::json!({
2443 "columns": [{"title": "Open", "id": "open"}],
2444 "items_path": "/data/order",
2445 "group_by": "status",
2446 "card_title_key": "name"
2447 });
2448 let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
2449 assert_eq!(p.columns.len(), 1);
2450 assert_eq!(p.items_path.as_deref(), Some("/data/order"));
2451 assert_eq!(p.group_by.as_deref(), Some("status"));
2452 assert_eq!(p.card_title_key.as_deref(), Some("name"));
2453 }
2454
2455 #[test]
2456 fn kanban_board_props_serde_neither() {
2457 let v = serde_json::json!({});
2458 let p: KanbanBoardProps = serde_json::from_value(v).unwrap();
2459 assert!(p.columns.is_empty());
2460 assert!(p.items_path.is_none());
2461 assert!(p.group_by.is_none());
2462 }
2463
2464 #[test]
2465 fn kanban_board_props_empty_columns_skipped_on_serialize() {
2466 let p = KanbanBoardProps {
2467 columns: vec![],
2468 items_path: Some("/data/order".into()),
2469 group_by: Some("status".into()),
2470 card_title_key: None,
2471 card_description_key: None,
2472 row_actions: None,
2473 row_key: None,
2474 mobile_default_column: None,
2475 empty_label: None,
2476 column_empty_label: None,
2477 };
2478 let j = serde_json::to_value(&p).unwrap();
2479 assert!(
2480 j.get("columns").is_none(),
2481 "empty columns must be skipped, got: {j}"
2482 );
2483 assert_eq!(
2484 j.get("items_path").and_then(|v| v.as_str()),
2485 Some("/data/order")
2486 );
2487 }
2488}
2489
2490#[cfg(test)]
2491mod page_header_actions_tests {
2492 use super::*;
2493
2494 #[test]
2495 fn page_header_actions_missing_field() {
2496 let v = serde_json::json!({"title": "X"});
2497 let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2498 assert!(p.actions.is_empty());
2499 }
2500
2501 #[test]
2502 fn page_header_actions_null() {
2503 let v = serde_json::json!({"title": "X", "actions": null});
2504 let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2505 assert!(p.actions.is_empty());
2506 }
2507
2508 #[test]
2509 fn page_header_actions_empty_string() {
2510 let v = serde_json::json!({"title": "X", "actions": ""});
2511 let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2512 assert!(p.actions.is_empty());
2513 }
2514
2515 #[test]
2516 fn page_header_actions_empty_array() {
2517 let v = serde_json::json!({"title": "X", "actions": []});
2518 let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2519 assert!(p.actions.is_empty());
2520 }
2521
2522 #[test]
2523 fn page_header_actions_non_empty_array() {
2524 let v = serde_json::json!({"title": "X", "actions": ["a", "b"]});
2525 let p: PageHeaderProps = serde_json::from_value(v).unwrap();
2526 assert_eq!(p.actions, vec!["a".to_string(), "b".to_string()]);
2527 }
2528
2529 #[test]
2530 fn page_header_actions_non_empty_string_rejected() {
2531 let v = serde_json::json!({"title": "X", "actions": "not-empty"});
2532 let result: Result<PageHeaderProps, _> = serde_json::from_value(v);
2533 assert!(result.is_err(), "non-empty string must be rejected");
2534 }
2535
2536 #[test]
2537 fn page_header_actions_non_string_array_rejected() {
2538 let v = serde_json::json!({"title": "X", "actions": [1, 2, 3]});
2539 let result: Result<PageHeaderProps, _> = serde_json::from_value(v);
2540 assert!(result.is_err(), "array of non-strings must be rejected");
2541 }
2542}
2543
2544#[cfg(test)]
2545mod tile_contract_tests {
2546 use super::*;
2551
2552 #[test]
2555 fn tile_legacy_json_round_trips_unchanged() {
2556 let json = r#"{"item_id":"p1","name":"Widget","price":"€10,00","field":"qty_p1"}"#;
2557 let tile: TileProps = serde_json::from_str(json).expect("legacy json must deserialize");
2558 assert!(
2559 tile.categories.is_empty(),
2560 "categories must default to empty vec"
2561 );
2562 assert!(tile.image_url.is_none(), "image_url must default to None");
2563 assert!(tile.color.is_none(), "color must default to None");
2564 assert!(
2565 tile.stock_badge.is_none(),
2566 "stock_badge must default to None"
2567 );
2568 let serialized = serde_json::to_string(&tile).expect("must serialize");
2569 assert!(
2570 !serialized.contains("categories"),
2571 "re-serialized must not contain 'categories'; got: {serialized}"
2572 );
2573 assert!(
2574 !serialized.contains("image_url"),
2575 "re-serialized must not contain 'image_url'; got: {serialized}"
2576 );
2577 assert!(
2578 !serialized.contains("color"),
2579 "re-serialized must not contain 'color'; got: {serialized}"
2580 );
2581 assert!(
2582 !serialized.contains("stock_badge"),
2583 "re-serialized must not contain 'stock_badge'; got: {serialized}"
2584 );
2585 }
2586
2587 #[test]
2589 fn tile_with_categories_serializes() {
2590 let tile = TileProps {
2591 item_id: "p2".to_string(),
2592 name: "Espresso".to_string(),
2593 price: "\u{20ac}2,00".to_string(),
2594 field: "qty_p2".to_string(),
2595 default_quantity: None,
2596 categories: vec!["drinks".to_string(), "food".to_string()],
2597 image_url: None,
2598 color: None,
2599 stock_badge: None,
2600 price_cents: None,
2601 };
2602 let serialized = serde_json::to_string(&tile).expect("must serialize");
2603 assert!(
2604 serialized.contains(r#""categories":["drinks","food"]"#),
2605 "serialized must contain categories array; got: {serialized}"
2606 );
2607 }
2608
2609 #[test]
2611 fn grid_props_row_weights_round_trips() {
2612 let default_grid: GridProps = serde_json::from_value(serde_json::json!({}))
2614 .expect("must deserialize default GridProps");
2615 let json = serde_json::to_string(&default_grid).expect("must serialize");
2616 assert!(
2617 !json.contains("row_weights"),
2618 "empty row_weights must be skipped in serialization; got: {json}"
2619 );
2620
2621 let with_weights: GridProps =
2623 serde_json::from_value(serde_json::json!({"row_weights": [2, 1]}))
2624 .expect("must deserialize GridProps with row_weights");
2625 let json2 = serde_json::to_string(&with_weights).expect("must serialize with weights");
2626 assert!(
2627 json2.contains(r#""row_weights":[2,1]"#),
2628 "row_weights must appear in serialization; got: {json2}"
2629 );
2630 let parsed: GridProps =
2631 serde_json::from_str(&json2).expect("must deserialize from serialized");
2632 assert_eq!(
2633 parsed.row_weights,
2634 vec![2u8, 1u8],
2635 "row_weights must round-trip unchanged"
2636 );
2637 }
2638
2639 #[test]
2641 fn tile_props_price_cents_round_trips() {
2642 let no_price: TileProps = serde_json::from_str(
2644 r#"{"item_id":"p1","name":"Coffee","price":"€2,00","field":"qty_p1"}"#,
2645 )
2646 .expect("must deserialize");
2647 assert!(
2648 no_price.price_cents.is_none(),
2649 "price_cents must default to None"
2650 );
2651 let json = serde_json::to_string(&no_price).expect("must serialize");
2652 assert!(
2653 !json.contains("price_cents"),
2654 "absent price_cents must be skipped; got: {json}"
2655 );
2656
2657 let with_price: TileProps =
2659 serde_json::from_str(r#"{"item_id":"p1","name":"Coffee","price":"€2,50","field":"qty_p1","price_cents":250}"#)
2660 .expect("must deserialize with price_cents");
2661 assert_eq!(
2662 with_price.price_cents,
2663 Some(250u64),
2664 "price_cents must round-trip"
2665 );
2666 let json2 = serde_json::to_string(&with_price).expect("must serialize");
2667 assert!(
2668 json2.contains(r#""price_cents":250"#),
2669 "price_cents must appear in serialization; got: {json2}"
2670 );
2671 let parsed: TileProps =
2672 serde_json::from_str(&json2).expect("must deserialize from serialized");
2673 assert_eq!(
2674 parsed.price_cents,
2675 Some(250u64),
2676 "price_cents must round-trip unchanged"
2677 );
2678 }
2679
2680 #[test]
2682 fn tile_props_color_tone_round_trips_and_rejects_unknown() {
2683 let with_color: TileProps = serde_json::from_str(
2685 r#"{"item_id":"p1","name":"Tea","price":"€1,00","field":"qty_p1","color":"success"}"#,
2686 )
2687 .expect("must deserialize color:success");
2688 assert_eq!(
2689 with_color.color,
2690 Some(Tone::Success),
2691 "color:success must deserialize to Tone::Success"
2692 );
2693 let json = serde_json::to_string(&with_color).expect("must serialize");
2694 assert!(
2695 json.contains(r#""color":"success""#),
2696 "Tone::Success must serialize as \"success\"; got: {json}"
2697 );
2698 let parsed: TileProps =
2699 serde_json::from_str(&json).expect("must deserialize from serialized");
2700 assert_eq!(
2701 parsed.color,
2702 Some(Tone::Success),
2703 "color must round-trip unchanged"
2704 );
2705
2706 let result: Result<TileProps, _> = serde_json::from_str(
2708 r#"{"item_id":"p1","name":"Tea","price":"€1,00","field":"qty_p1","color":"blue"}"#,
2709 );
2710 assert!(
2711 result.is_err(),
2712 "color:\"blue\" must fail to deserialize — Tone enum enforced"
2713 );
2714 }
2715
2716 #[test]
2718 fn selection_panel_props_currency_round_trips() {
2719 let no_currency: SelectionPanelProps =
2721 serde_json::from_str(r#"{"form_id":"order-form"}"#).expect("must deserialize");
2722 assert!(
2723 no_currency.currency.is_none(),
2724 "currency must default to None"
2725 );
2726 let json = serde_json::to_string(&no_currency).expect("must serialize");
2727 assert!(
2728 !json.contains("currency"),
2729 "absent currency must be skipped; got: {json}"
2730 );
2731
2732 let with_currency: SelectionPanelProps =
2734 serde_json::from_str(r#"{"form_id":"order-form","currency":"€"}"#)
2735 .expect("must deserialize with currency");
2736 assert_eq!(
2737 with_currency.currency,
2738 Some("€".to_string()),
2739 "currency must round-trip"
2740 );
2741 let json2 = serde_json::to_string(&with_currency).expect("must serialize");
2742 assert!(
2743 json2.contains(r#""currency":"€""#),
2744 "currency must appear in serialization; got: {json2}"
2745 );
2746 }
2747}