1use schemars::JsonSchema;
7use serde::de::{self, Deserializer};
8use serde::ser::{SerializeMap, Serializer};
9use serde::{Deserialize, Serialize};
10
11use crate::action::Action;
12use crate::visibility::Visibility;
13
14#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
16#[serde(rename_all = "snake_case")]
17pub enum Size {
18 Xs,
19 Sm,
20 #[default]
21 Default,
22 Lg,
23}
24
25#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
27#[serde(rename_all = "snake_case")]
28pub enum IconPosition {
29 #[default]
30 Left,
31 Right,
32}
33
34#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
36#[serde(rename_all = "snake_case")]
37pub enum SortDirection {
38 #[default]
39 Asc,
40 Desc,
41}
42
43#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
45#[serde(rename_all = "snake_case")]
46pub enum Orientation {
47 #[default]
48 Horizontal,
49 Vertical,
50}
51
52#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
54#[serde(rename_all = "snake_case")]
55pub enum ButtonVariant {
56 #[default]
57 Default,
58 Secondary,
59 Destructive,
60 Outline,
61 Ghost,
62 Link,
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67#[serde(rename_all = "snake_case")]
68pub enum InputType {
69 #[default]
70 Text,
71 Email,
72 Password,
73 Number,
74 Textarea,
75 Hidden,
76 Date,
77 Time,
78 Url,
79 Tel,
80 Search,
81}
82
83#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
85#[serde(rename_all = "snake_case")]
86pub enum AlertVariant {
87 #[default]
88 Info,
89 Success,
90 Warning,
91 Error,
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
96#[serde(rename_all = "snake_case")]
97pub enum BadgeVariant {
98 #[default]
99 Default,
100 Secondary,
101 Destructive,
102 Outline,
103}
104
105#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
107#[serde(rename_all = "snake_case")]
108pub enum TextElement {
109 #[default]
110 P,
111 H1,
112 H2,
113 H3,
114 Span,
115 Div,
116 Section,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
121#[serde(rename_all = "snake_case")]
122pub enum ColumnFormat {
123 Date,
124 DateTime,
125 Currency,
126 Boolean,
127}
128
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
131pub struct Column {
132 pub key: String,
133 pub label: String,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub format: Option<ColumnFormat>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
140pub struct SelectOption {
141 pub value: String,
142 pub label: String,
143}
144
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub struct CardProps {
149 pub title: String,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub description: Option<String>,
152 #[serde(default, skip_serializing_if = "Vec::is_empty")]
153 pub children: Vec<ComponentNode>,
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
155 pub footer: Vec<ComponentNode>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub max_width: Option<FormMaxWidth>,
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
162pub struct TableProps {
163 pub columns: Vec<Column>,
164 pub data_path: String,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub row_actions: Option<Vec<Action>>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub empty_message: Option<String>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub sortable: Option<bool>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub sort_column: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub sort_direction: Option<SortDirection>,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
179#[serde(rename_all = "snake_case")]
180pub enum FormMaxWidth {
181 #[default]
182 Default,
183 Narrow,
184 Wide,
185}
186
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct FormProps {
191 pub action: Action,
192 pub fields: Vec<ComponentNode>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub method: Option<crate::action::HttpMethod>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub guard: Option<String>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub max_width: Option<FormMaxWidth>,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
210#[serde(rename_all = "snake_case")]
211pub enum EditMode {
212 #[default]
214 View,
215 Edit,
217}
218
219impl EditMode {
220 pub fn from_query(raw: Option<&str>) -> Self {
228 match raw {
229 Some(s) if s.eq_ignore_ascii_case("edit") => EditMode::Edit,
230 _ => EditMode::View,
231 }
232 }
233}
234
235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
250pub struct DetailField {
251 pub label: String,
253 pub value: String,
255 pub input: ComponentNode,
257}
258
259impl DetailField {
260 pub fn new(label: impl Into<String>, value: impl Into<String>, input: ComponentNode) -> Self {
262 Self {
263 label: label.into(),
264 value: value.into(),
265 input,
266 }
267 }
268}
269
270#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
284pub struct DetailFormProps {
285 #[serde(default)]
287 pub mode: EditMode,
288 pub action: crate::action::Action,
290 pub fields: Vec<DetailField>,
292 pub edit_url: String,
294 pub cancel_url: String,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub edit_label: Option<String>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub save_label: Option<String>,
302 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub cancel_label: Option<String>,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub method: Option<crate::action::HttpMethod>,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
313#[serde(rename_all = "snake_case")]
314pub enum ButtonType {
315 #[default]
316 Button,
317 Submit,
318}
319
320#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
322pub struct ButtonProps {
323 pub label: String,
324 #[serde(default)]
325 pub variant: ButtonVariant,
326 #[serde(default)]
327 pub size: Size,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub disabled: Option<bool>,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub icon: Option<String>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub icon_position: Option<IconPosition>,
334 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub button_type: Option<ButtonType>,
336}
337
338#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
340pub struct InputProps {
341 pub field: String,
343 pub label: String,
344 #[serde(default)]
345 pub input_type: InputType,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub placeholder: Option<String>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub required: Option<bool>,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub disabled: Option<bool>,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub error: Option<String>,
354 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub description: Option<String>,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub default_value: Option<String>,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub data_path: Option<String>,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub step: Option<String>,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub list: Option<String>,
369}
370
371#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
373pub struct SelectProps {
374 pub field: String,
376 pub label: String,
377 pub options: Vec<SelectOption>,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub placeholder: Option<String>,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub required: Option<bool>,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub disabled: Option<bool>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub error: Option<String>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub description: Option<String>,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub default_value: Option<String>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub data_path: Option<String>,
393}
394
395#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
397pub struct AlertProps {
398 pub message: String,
399 #[serde(default)]
400 pub variant: AlertVariant,
401 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub title: Option<String>,
403}
404
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
407pub struct BadgeProps {
408 pub label: String,
409 #[serde(default)]
410 pub variant: BadgeVariant,
411}
412
413#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
416pub struct ModalProps {
417 pub id: String,
418 pub title: String,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub description: Option<String>,
421 #[serde(default, skip_serializing_if = "Vec::is_empty")]
422 pub children: Vec<ComponentNode>,
423 #[serde(default, skip_serializing_if = "Vec::is_empty")]
424 pub footer: Vec<ComponentNode>,
425 #[serde(default, skip_serializing_if = "Option::is_none")]
426 pub trigger_label: Option<String>,
427}
428
429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
431pub struct TextProps {
432 pub content: String,
433 #[serde(default)]
434 pub element: TextElement,
435}
436
437#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
439pub struct CheckboxProps {
440 pub field: String,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub value: Option<String>,
446 pub label: String,
447 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub description: Option<String>,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub checked: Option<bool>,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
453 pub data_path: Option<String>,
454 #[serde(default, skip_serializing_if = "Option::is_none")]
455 pub required: Option<bool>,
456 #[serde(default, skip_serializing_if = "Option::is_none")]
457 pub disabled: Option<bool>,
458 #[serde(default, skip_serializing_if = "Option::is_none")]
459 pub error: Option<String>,
460}
461
462#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
465pub struct SwitchProps {
466 pub field: String,
468 pub label: String,
469 #[serde(default, skip_serializing_if = "Option::is_none")]
470 pub description: Option<String>,
471 #[serde(default, skip_serializing_if = "Option::is_none")]
472 pub checked: Option<bool>,
473 #[serde(default, skip_serializing_if = "Option::is_none")]
475 pub data_path: Option<String>,
476 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub required: Option<bool>,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
479 pub disabled: Option<bool>,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
481 pub error: Option<String>,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub action: Option<Action>,
486 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
489 pub compact: bool,
490}
491
492#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
503pub struct KeyValueEditorProps {
504 pub field: String,
506 #[serde(default, skip_serializing_if = "Option::is_none")]
508 pub label: Option<String>,
509 #[serde(default)]
511 pub suggested_keys: Vec<String>,
512 #[serde(default = "default_true")]
515 pub allow_custom_keys: bool,
516 #[serde(default, skip_serializing_if = "Option::is_none")]
518 pub data_path: Option<String>,
519 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub error: Option<String>,
522}
523
524#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
526pub struct SeparatorProps {
527 #[serde(default, skip_serializing_if = "Option::is_none")]
528 pub orientation: Option<Orientation>,
529}
530
531#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
533pub struct DescriptionItem {
534 pub label: String,
535 pub value: String,
536 #[serde(default, skip_serializing_if = "Option::is_none")]
537 pub format: Option<ColumnFormat>,
538}
539
540#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
542pub struct DescriptionListProps {
543 pub items: Vec<DescriptionItem>,
544 #[serde(default, skip_serializing_if = "Option::is_none")]
545 pub columns: Option<u8>,
546}
547
548#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
551pub struct Tab {
552 pub value: String,
553 pub label: String,
554 #[serde(default, skip_serializing_if = "Vec::is_empty")]
555 pub children: Vec<ComponentNode>,
556}
557
558#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
561pub struct TabsProps {
562 pub default_tab: String,
563 pub tabs: Vec<Tab>,
564}
565
566#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
568pub struct BreadcrumbItem {
569 pub label: String,
570 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub url: Option<String>,
572}
573
574#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
576pub struct BreadcrumbProps {
577 pub items: Vec<BreadcrumbItem>,
578}
579
580#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
582pub struct PaginationProps {
583 pub current_page: u32,
584 pub per_page: u32,
585 pub total: u32,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
587 pub base_url: Option<String>,
588}
589
590#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
592pub struct ProgressProps {
593 pub value: u8,
595 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub max: Option<u8>,
597 #[serde(default, skip_serializing_if = "Option::is_none")]
598 pub label: Option<String>,
599}
600
601#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
611#[serde(untagged)]
612pub enum ImageSource {
613 Url {
615 src: String,
617 },
618 InlineSvg {
632 svg: String,
634 },
635}
636
637#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
644pub struct ImageProps {
645 #[serde(flatten)]
650 pub source: ImageSource,
651 pub alt: String,
653 #[serde(default, skip_serializing_if = "Option::is_none")]
655 pub aspect_ratio: Option<String>,
656 #[serde(default, skip_serializing_if = "Option::is_none")]
661 pub placeholder_label: Option<String>,
662}
663
664impl ImageProps {
665 pub fn url(src: impl Into<String>, alt: impl Into<String>) -> Self {
671 Self {
672 source: ImageSource::Url { src: src.into() },
673 alt: alt.into(),
674 aspect_ratio: None,
675 placeholder_label: None,
676 }
677 }
678
679 pub fn inline_svg(svg: impl Into<String>, alt: impl Into<String>) -> Self {
689 Self {
690 source: ImageSource::InlineSvg { svg: svg.into() },
691 alt: alt.into(),
692 aspect_ratio: None,
693 placeholder_label: None,
694 }
695 }
696}
697
698#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
700pub struct AvatarProps {
701 #[serde(default, skip_serializing_if = "Option::is_none")]
702 pub src: Option<String>,
703 pub alt: String,
704 #[serde(default, skip_serializing_if = "Option::is_none")]
705 pub fallback: Option<String>,
706 #[serde(default, skip_serializing_if = "Option::is_none")]
707 pub size: Option<Size>,
708}
709
710#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
712pub struct SkeletonProps {
713 #[serde(default, skip_serializing_if = "Option::is_none")]
714 pub width: Option<String>,
715 #[serde(default, skip_serializing_if = "Option::is_none")]
716 pub height: Option<String>,
717 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub rounded: Option<bool>,
719}
720
721#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
723#[serde(rename_all = "snake_case")]
724pub enum ToastVariant {
725 #[default]
726 Info,
727 Success,
728 Warning,
729 Error,
730}
731
732#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
734pub struct ChecklistItem {
735 pub label: String,
736 #[serde(default)]
737 pub checked: bool,
738 #[serde(default, skip_serializing_if = "Option::is_none")]
739 pub href: Option<String>,
740}
741
742#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
744pub struct NotificationItem {
745 #[serde(default, skip_serializing_if = "Option::is_none")]
746 pub icon: Option<String>,
747 pub text: String,
748 #[serde(default, skip_serializing_if = "Option::is_none")]
749 pub timestamp: Option<String>,
750 #[serde(default)]
751 pub read: bool,
752 #[serde(default, skip_serializing_if = "Option::is_none")]
753 pub action_url: Option<String>,
754}
755
756#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
758pub struct SidebarNavItem {
759 pub label: String,
760 pub href: String,
761 #[serde(default, skip_serializing_if = "Option::is_none")]
762 pub icon: Option<String>,
763 #[serde(default)]
764 pub active: bool,
765}
766
767#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
769pub struct SidebarGroup {
770 pub label: String,
771 #[serde(default)]
772 pub collapsed: bool,
773 pub items: Vec<SidebarNavItem>,
774}
775
776#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
778pub struct StatCardProps {
779 pub label: String,
780 pub value: String,
781 #[serde(default, skip_serializing_if = "Option::is_none")]
782 pub icon: Option<String>,
783 #[serde(default, skip_serializing_if = "Option::is_none")]
784 pub subtitle: Option<String>,
785 #[serde(default, skip_serializing_if = "Option::is_none")]
787 pub sse_target: Option<String>,
788}
789
790#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
792pub struct ChecklistProps {
793 pub title: String,
794 pub items: Vec<ChecklistItem>,
795 #[serde(default = "default_true")]
796 pub dismissible: bool,
797 #[serde(default, skip_serializing_if = "Option::is_none")]
798 pub dismiss_label: Option<String>,
799 #[serde(default, skip_serializing_if = "Option::is_none")]
801 pub data_key: Option<String>,
802}
803
804fn default_true() -> bool {
805 true
806}
807
808#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
813pub struct ToastProps {
814 pub message: String,
815 #[serde(default)]
816 pub variant: ToastVariant,
817 #[serde(default, skip_serializing_if = "Option::is_none")]
819 pub timeout: Option<u32>,
820 #[serde(default = "default_true")]
821 pub dismissible: bool,
822}
823
824#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
826pub struct NotificationDropdownProps {
827 pub notifications: Vec<NotificationItem>,
828 #[serde(default, skip_serializing_if = "Option::is_none")]
829 pub empty_text: Option<String>,
830}
831
832#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
834pub struct SidebarProps {
835 #[serde(default, skip_serializing_if = "Vec::is_empty")]
836 pub fixed_top: Vec<SidebarNavItem>,
837 #[serde(default, skip_serializing_if = "Vec::is_empty")]
838 pub groups: Vec<SidebarGroup>,
839 #[serde(default, skip_serializing_if = "Vec::is_empty")]
840 pub fixed_bottom: Vec<SidebarNavItem>,
841}
842
843#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
845pub struct HeaderProps {
846 pub business_name: String,
847 #[serde(default, skip_serializing_if = "Option::is_none")]
849 pub notification_count: Option<u32>,
850 #[serde(default, skip_serializing_if = "Option::is_none")]
851 pub user_name: Option<String>,
852 #[serde(default, skip_serializing_if = "Option::is_none")]
853 pub user_avatar: Option<String>,
854 #[serde(default, skip_serializing_if = "Option::is_none")]
855 pub logout_url: Option<String>,
856}
857
858#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
860#[serde(rename_all = "snake_case")]
861pub enum GapSize {
862 None,
863 Sm,
864 #[default]
865 Md,
866 Lg,
867 Xl,
868}
869
870#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
873pub struct GridProps {
874 #[serde(default = "default_grid_columns")]
876 pub columns: u8,
877 #[serde(default, skip_serializing_if = "Option::is_none")]
879 pub md_columns: Option<u8>,
880 #[serde(default, skip_serializing_if = "Option::is_none")]
882 pub lg_columns: Option<u8>,
883 #[serde(default)]
885 pub gap: GapSize,
886 #[serde(default, skip_serializing_if = "Option::is_none")]
889 pub scrollable: Option<bool>,
890 #[serde(default, skip_serializing_if = "Vec::is_empty")]
891 pub children: Vec<ComponentNode>,
892}
893
894fn default_grid_columns() -> u8 {
895 2
896}
897
898#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
901pub struct CollapsibleProps {
902 pub title: String,
903 #[serde(default)]
904 pub expanded: bool,
905 #[serde(default, skip_serializing_if = "Vec::is_empty")]
906 pub children: Vec<ComponentNode>,
907}
908
909#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
911pub struct EmptyStateProps {
912 pub title: String,
913 #[serde(default, skip_serializing_if = "Option::is_none")]
914 pub description: Option<String>,
915 #[serde(default, skip_serializing_if = "Option::is_none")]
916 pub action: Option<Action>,
917 #[serde(default, skip_serializing_if = "Option::is_none")]
918 pub action_label: Option<String>,
919}
920
921#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
923#[serde(rename_all = "snake_case")]
924pub enum FormSectionLayout {
925 #[default]
926 Stacked,
927 TwoColumn,
928}
929
930#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
933pub struct FormSectionProps {
934 pub title: String,
935 #[serde(default, skip_serializing_if = "Option::is_none")]
936 pub description: Option<String>,
937 #[serde(default, skip_serializing_if = "Vec::is_empty")]
938 pub children: Vec<ComponentNode>,
939 #[serde(default, skip_serializing_if = "Option::is_none")]
941 pub layout: Option<FormSectionLayout>,
942}
943
944#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
947pub struct PageHeaderProps {
948 pub title: String,
949 #[serde(default, skip_serializing_if = "Vec::is_empty")]
950 pub breadcrumb: Vec<BreadcrumbItem>,
951 #[serde(default, skip_serializing_if = "Vec::is_empty")]
952 pub actions: Vec<ComponentNode>,
953}
954
955#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
958pub struct ButtonGroupProps {
959 #[serde(default, skip_serializing_if = "Vec::is_empty")]
960 pub buttons: Vec<ComponentNode>,
961}
962
963#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
965pub struct DropdownMenuAction {
966 pub label: String,
967 pub action: Action,
968 #[serde(default)]
969 pub destructive: bool,
970}
971
972#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
974pub struct DropdownMenuProps {
975 pub menu_id: String,
976 pub trigger_label: String,
977 pub items: Vec<DropdownMenuAction>,
978 #[serde(default, skip_serializing_if = "Option::is_none")]
979 pub trigger_variant: Option<ButtonVariant>,
980}
981
982#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
985pub struct DataTableProps {
986 pub columns: Vec<Column>,
987 pub data_path: String,
988 #[serde(default, skip_serializing_if = "Option::is_none")]
989 pub row_actions: Option<Vec<DropdownMenuAction>>,
990 #[serde(default, skip_serializing_if = "Option::is_none")]
991 pub empty_message: Option<String>,
992 #[serde(default, skip_serializing_if = "Option::is_none")]
993 pub row_key: Option<String>,
994 #[serde(default, skip_serializing_if = "Option::is_none")]
996 pub row_href: Option<String>,
997}
998
999#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1002pub struct KanbanColumnProps {
1003 pub id: String,
1004 pub title: String,
1005 pub count: u32,
1006 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1007 pub children: Vec<ComponentNode>,
1008}
1009
1010#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1013pub struct KanbanBoardProps {
1014 pub columns: Vec<KanbanColumnProps>,
1015 #[serde(default, skip_serializing_if = "Option::is_none")]
1016 pub mobile_default_column: Option<String>,
1017}
1018
1019#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1024pub struct CalendarCellProps {
1025 pub day: u8,
1026 #[serde(default)]
1027 pub is_today: bool,
1028 #[serde(default)]
1029 pub is_current_month: bool,
1030 #[serde(default)]
1031 pub event_count: u32,
1032 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1035 pub dot_colors: Vec<String>,
1036}
1037
1038#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1040#[serde(rename_all = "snake_case")]
1041pub enum ActionCardVariant {
1042 #[default]
1043 Default,
1044 Setup,
1045 Danger,
1046}
1047
1048#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1053pub struct ActionCardProps {
1054 pub title: String,
1055 pub description: String,
1056 #[serde(default, skip_serializing_if = "Option::is_none")]
1057 pub icon: Option<String>,
1058 #[serde(default)]
1059 pub variant: ActionCardVariant,
1060 #[serde(default, skip_serializing_if = "Option::is_none")]
1062 pub href: Option<String>,
1063}
1064
1065#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1070pub struct ProductTileProps {
1071 pub product_id: String,
1072 pub name: String,
1073 pub price: String,
1074 pub field: String,
1075 #[serde(default, skip_serializing_if = "Option::is_none")]
1076 pub default_quantity: Option<u32>,
1077}
1078
1079#[derive(Debug, Clone, PartialEq)]
1086pub struct PluginProps {
1087 pub plugin_type: String,
1089 pub props: serde_json::Value,
1091}
1092
1093impl Serialize for PluginProps {
1094 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1095 let obj = self.props.as_object();
1097 let extra_len = obj.map_or(0, |m| m.len());
1098 let mut map = serializer.serialize_map(Some(1 + extra_len))?;
1099 map.serialize_entry("type", &self.plugin_type)?;
1100 if let Some(obj) = obj {
1101 for (k, v) in obj {
1102 if k != "type" {
1103 map.serialize_entry(k, v)?;
1104 }
1105 }
1106 }
1107 map.end()
1108 }
1109}
1110
1111impl<'de> Deserialize<'de> for PluginProps {
1112 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1113 let mut value = serde_json::Value::deserialize(deserializer)?;
1114 let plugin_type = value
1115 .get("type")
1116 .and_then(|v| v.as_str())
1117 .map(|s| s.to_string())
1118 .ok_or_else(|| de::Error::missing_field("type"))?;
1119 if let Some(obj) = value.as_object_mut() {
1121 obj.remove("type");
1122 }
1123 Ok(PluginProps {
1124 plugin_type,
1125 props: value,
1126 })
1127 }
1128}
1129
1130#[derive(Debug, Clone, PartialEq)]
1137pub enum Component {
1138 Card(CardProps),
1139 Table(TableProps),
1140 Form(FormProps),
1141 Button(ButtonProps),
1142 Input(InputProps),
1143 Select(SelectProps),
1144 Alert(AlertProps),
1145 Badge(BadgeProps),
1146 Modal(ModalProps),
1147 Text(TextProps),
1148 Checkbox(CheckboxProps),
1149 Switch(SwitchProps),
1150 Separator(SeparatorProps),
1151 DescriptionList(DescriptionListProps),
1152 Tabs(TabsProps),
1153 Breadcrumb(BreadcrumbProps),
1154 Pagination(PaginationProps),
1155 Progress(ProgressProps),
1156 Avatar(AvatarProps),
1157 Skeleton(SkeletonProps),
1158 StatCard(StatCardProps),
1159 Checklist(ChecklistProps),
1160 Toast(ToastProps),
1161 NotificationDropdown(NotificationDropdownProps),
1162 Sidebar(SidebarProps),
1163 Header(HeaderProps),
1164 Grid(GridProps),
1165 Collapsible(CollapsibleProps),
1166 EmptyState(EmptyStateProps),
1167 FormSection(FormSectionProps),
1168 PageHeader(PageHeaderProps),
1169 ButtonGroup(ButtonGroupProps),
1170 DropdownMenu(DropdownMenuProps),
1171 KanbanBoard(KanbanBoardProps),
1172 CalendarCell(CalendarCellProps),
1173 ActionCard(ActionCardProps),
1174 ProductTile(ProductTileProps),
1175 DataTable(DataTableProps),
1176 Image(ImageProps),
1177 KeyValueEditor(KeyValueEditorProps),
1178 DetailForm(DetailFormProps),
1179 Plugin(PluginProps),
1180}
1181
1182fn serialize_tagged<S: Serializer, T: Serialize>(
1187 serializer: S,
1188 type_name: &str,
1189 props: &T,
1190) -> Result<S::Ok, S::Error> {
1191 let mut value = serde_json::to_value(props).map_err(serde::ser::Error::custom)?;
1192 if let Some(obj) = value.as_object_mut() {
1193 obj.insert(
1194 "type".to_string(),
1195 serde_json::Value::String(type_name.to_string()),
1196 );
1197 }
1198 value.serialize(serializer)
1199}
1200
1201impl Serialize for Component {
1202 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1203 match self {
1204 Component::Card(p) => serialize_tagged(serializer, "Card", p),
1205 Component::Table(p) => serialize_tagged(serializer, "Table", p),
1206 Component::Form(p) => serialize_tagged(serializer, "Form", p),
1207 Component::Button(p) => serialize_tagged(serializer, "Button", p),
1208 Component::Input(p) => serialize_tagged(serializer, "Input", p),
1209 Component::Select(p) => serialize_tagged(serializer, "Select", p),
1210 Component::Alert(p) => serialize_tagged(serializer, "Alert", p),
1211 Component::Badge(p) => serialize_tagged(serializer, "Badge", p),
1212 Component::Modal(p) => serialize_tagged(serializer, "Modal", p),
1213 Component::Text(p) => serialize_tagged(serializer, "Text", p),
1214 Component::Checkbox(p) => serialize_tagged(serializer, "Checkbox", p),
1215 Component::Switch(p) => serialize_tagged(serializer, "Switch", p),
1216 Component::Separator(p) => serialize_tagged(serializer, "Separator", p),
1217 Component::DescriptionList(p) => serialize_tagged(serializer, "DescriptionList", p),
1218 Component::Tabs(p) => serialize_tagged(serializer, "Tabs", p),
1219 Component::Breadcrumb(p) => serialize_tagged(serializer, "Breadcrumb", p),
1220 Component::Pagination(p) => serialize_tagged(serializer, "Pagination", p),
1221 Component::Progress(p) => serialize_tagged(serializer, "Progress", p),
1222 Component::Avatar(p) => serialize_tagged(serializer, "Avatar", p),
1223 Component::Skeleton(p) => serialize_tagged(serializer, "Skeleton", p),
1224 Component::StatCard(p) => serialize_tagged(serializer, "StatCard", p),
1225 Component::Checklist(p) => serialize_tagged(serializer, "Checklist", p),
1226 Component::Toast(p) => serialize_tagged(serializer, "Toast", p),
1227 Component::NotificationDropdown(p) => {
1228 serialize_tagged(serializer, "NotificationDropdown", p)
1229 }
1230 Component::Sidebar(p) => serialize_tagged(serializer, "Sidebar", p),
1231 Component::Header(p) => serialize_tagged(serializer, "Header", p),
1232 Component::Grid(p) => serialize_tagged(serializer, "Grid", p),
1233 Component::Collapsible(p) => serialize_tagged(serializer, "Collapsible", p),
1234 Component::EmptyState(p) => serialize_tagged(serializer, "EmptyState", p),
1235 Component::FormSection(p) => serialize_tagged(serializer, "FormSection", p),
1236 Component::PageHeader(p) => serialize_tagged(serializer, "PageHeader", p),
1237 Component::ButtonGroup(p) => serialize_tagged(serializer, "ButtonGroup", p),
1238 Component::DropdownMenu(p) => serialize_tagged(serializer, "DropdownMenu", p),
1239 Component::KanbanBoard(p) => serialize_tagged(serializer, "KanbanBoard", p),
1240 Component::CalendarCell(p) => serialize_tagged(serializer, "CalendarCell", p),
1241 Component::ActionCard(p) => serialize_tagged(serializer, "ActionCard", p),
1242 Component::ProductTile(p) => serialize_tagged(serializer, "ProductTile", p),
1243 Component::DataTable(p) => serialize_tagged(serializer, "DataTable", p),
1244 Component::Image(p) => serialize_tagged(serializer, "Image", p),
1245 Component::KeyValueEditor(p) => serialize_tagged(serializer, "KeyValueEditor", p),
1246 Component::DetailForm(p) => serialize_tagged(serializer, "DetailForm", p),
1247 Component::Plugin(p) => p.serialize(serializer),
1248 }
1249 }
1250}
1251
1252impl<'de> Deserialize<'de> for Component {
1255 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1256 let value = serde_json::Value::deserialize(deserializer)?;
1257 let type_str = value
1258 .get("type")
1259 .and_then(|v| v.as_str())
1260 .ok_or_else(|| de::Error::missing_field("type"))?;
1261
1262 match type_str {
1263 "Card" => serde_json::from_value::<CardProps>(value)
1264 .map(Component::Card)
1265 .map_err(de::Error::custom),
1266 "Table" => serde_json::from_value::<TableProps>(value)
1267 .map(Component::Table)
1268 .map_err(de::Error::custom),
1269 "Form" => serde_json::from_value::<FormProps>(value)
1270 .map(Component::Form)
1271 .map_err(de::Error::custom),
1272 "Button" => serde_json::from_value::<ButtonProps>(value)
1273 .map(Component::Button)
1274 .map_err(de::Error::custom),
1275 "Input" => serde_json::from_value::<InputProps>(value)
1276 .map(Component::Input)
1277 .map_err(de::Error::custom),
1278 "Select" => serde_json::from_value::<SelectProps>(value)
1279 .map(Component::Select)
1280 .map_err(de::Error::custom),
1281 "Alert" => serde_json::from_value::<AlertProps>(value)
1282 .map(Component::Alert)
1283 .map_err(de::Error::custom),
1284 "Badge" => serde_json::from_value::<BadgeProps>(value)
1285 .map(Component::Badge)
1286 .map_err(de::Error::custom),
1287 "Modal" => serde_json::from_value::<ModalProps>(value)
1288 .map(Component::Modal)
1289 .map_err(de::Error::custom),
1290 "Text" => serde_json::from_value::<TextProps>(value)
1291 .map(Component::Text)
1292 .map_err(de::Error::custom),
1293 "Checkbox" => serde_json::from_value::<CheckboxProps>(value)
1294 .map(Component::Checkbox)
1295 .map_err(de::Error::custom),
1296 "Switch" => serde_json::from_value::<SwitchProps>(value)
1297 .map(Component::Switch)
1298 .map_err(de::Error::custom),
1299 "Separator" => serde_json::from_value::<SeparatorProps>(value)
1300 .map(Component::Separator)
1301 .map_err(de::Error::custom),
1302 "DescriptionList" => serde_json::from_value::<DescriptionListProps>(value)
1303 .map(Component::DescriptionList)
1304 .map_err(de::Error::custom),
1305 "Tabs" => serde_json::from_value::<TabsProps>(value)
1306 .map(Component::Tabs)
1307 .map_err(de::Error::custom),
1308 "Breadcrumb" => serde_json::from_value::<BreadcrumbProps>(value)
1309 .map(Component::Breadcrumb)
1310 .map_err(de::Error::custom),
1311 "Pagination" => serde_json::from_value::<PaginationProps>(value)
1312 .map(Component::Pagination)
1313 .map_err(de::Error::custom),
1314 "Progress" => serde_json::from_value::<ProgressProps>(value)
1315 .map(Component::Progress)
1316 .map_err(de::Error::custom),
1317 "Avatar" => serde_json::from_value::<AvatarProps>(value)
1318 .map(Component::Avatar)
1319 .map_err(de::Error::custom),
1320 "Skeleton" => serde_json::from_value::<SkeletonProps>(value)
1321 .map(Component::Skeleton)
1322 .map_err(de::Error::custom),
1323 "StatCard" => serde_json::from_value::<StatCardProps>(value)
1324 .map(Component::StatCard)
1325 .map_err(de::Error::custom),
1326 "Checklist" => serde_json::from_value::<ChecklistProps>(value)
1327 .map(Component::Checklist)
1328 .map_err(de::Error::custom),
1329 "Toast" => serde_json::from_value::<ToastProps>(value)
1330 .map(Component::Toast)
1331 .map_err(de::Error::custom),
1332 "NotificationDropdown" => serde_json::from_value::<NotificationDropdownProps>(value)
1333 .map(Component::NotificationDropdown)
1334 .map_err(de::Error::custom),
1335 "Sidebar" => serde_json::from_value::<SidebarProps>(value)
1336 .map(Component::Sidebar)
1337 .map_err(de::Error::custom),
1338 "Header" => serde_json::from_value::<HeaderProps>(value)
1339 .map(Component::Header)
1340 .map_err(de::Error::custom),
1341 "Grid" => serde_json::from_value::<GridProps>(value)
1342 .map(Component::Grid)
1343 .map_err(de::Error::custom),
1344 "Collapsible" => serde_json::from_value::<CollapsibleProps>(value)
1345 .map(Component::Collapsible)
1346 .map_err(de::Error::custom),
1347 "EmptyState" => serde_json::from_value::<EmptyStateProps>(value)
1348 .map(Component::EmptyState)
1349 .map_err(de::Error::custom),
1350 "FormSection" => serde_json::from_value::<FormSectionProps>(value)
1351 .map(Component::FormSection)
1352 .map_err(de::Error::custom),
1353 "PageHeader" => serde_json::from_value::<PageHeaderProps>(value)
1354 .map(Component::PageHeader)
1355 .map_err(de::Error::custom),
1356 "ButtonGroup" => serde_json::from_value::<ButtonGroupProps>(value)
1357 .map(Component::ButtonGroup)
1358 .map_err(de::Error::custom),
1359 "DropdownMenu" => serde_json::from_value::<DropdownMenuProps>(value)
1360 .map(Component::DropdownMenu)
1361 .map_err(de::Error::custom),
1362 "KanbanBoard" => serde_json::from_value::<KanbanBoardProps>(value)
1363 .map(Component::KanbanBoard)
1364 .map_err(de::Error::custom),
1365 "CalendarCell" => serde_json::from_value::<CalendarCellProps>(value)
1366 .map(Component::CalendarCell)
1367 .map_err(de::Error::custom),
1368 "ActionCard" => serde_json::from_value::<ActionCardProps>(value)
1369 .map(Component::ActionCard)
1370 .map_err(de::Error::custom),
1371 "ProductTile" => serde_json::from_value::<ProductTileProps>(value)
1372 .map(Component::ProductTile)
1373 .map_err(de::Error::custom),
1374 "DataTable" => serde_json::from_value::<DataTableProps>(value)
1375 .map(Component::DataTable)
1376 .map_err(de::Error::custom),
1377 "Image" => serde_json::from_value::<ImageProps>(value)
1378 .map(Component::Image)
1379 .map_err(de::Error::custom),
1380 "KeyValueEditor" => serde_json::from_value::<KeyValueEditorProps>(value)
1381 .map(Component::KeyValueEditor)
1382 .map_err(de::Error::custom),
1383 "DetailForm" => serde_json::from_value::<DetailFormProps>(value)
1384 .map(Component::DetailForm)
1385 .map_err(de::Error::custom),
1386 _ => {
1387 let plugin_type = type_str.to_string();
1389 let mut props = value;
1390 if let Some(obj) = props.as_object_mut() {
1391 obj.remove("type");
1392 }
1393 Ok(Component::Plugin(PluginProps { plugin_type, props }))
1394 }
1395 }
1396 }
1397}
1398
1399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1406pub struct ComponentNode {
1407 pub key: String,
1408 #[serde(flatten)]
1409 pub component: Component,
1410 #[serde(default, skip_serializing_if = "Option::is_none")]
1411 pub action: Option<Action>,
1412 #[serde(default, skip_serializing_if = "Option::is_none")]
1413 pub visibility: Option<Visibility>,
1414}
1415
1416impl ComponentNode {
1417 pub fn card(key: impl Into<String>, props: CardProps) -> Self {
1419 Self {
1420 key: key.into(),
1421 component: Component::Card(props),
1422 action: None,
1423 visibility: None,
1424 }
1425 }
1426
1427 pub fn table(key: impl Into<String>, props: TableProps) -> Self {
1429 Self {
1430 key: key.into(),
1431 component: Component::Table(props),
1432 action: None,
1433 visibility: None,
1434 }
1435 }
1436
1437 pub fn form(key: impl Into<String>, props: FormProps) -> Self {
1439 Self {
1440 key: key.into(),
1441 component: Component::Form(props),
1442 action: None,
1443 visibility: None,
1444 }
1445 }
1446
1447 pub fn detail_form(key: impl Into<String>, props: DetailFormProps) -> Self {
1462 Self {
1463 key: key.into(),
1464 component: Component::DetailForm(props),
1465 action: None,
1466 visibility: None,
1467 }
1468 }
1469
1470 pub fn button(key: impl Into<String>, props: ButtonProps) -> Self {
1472 Self {
1473 key: key.into(),
1474 component: Component::Button(props),
1475 action: None,
1476 visibility: None,
1477 }
1478 }
1479
1480 pub fn input(key: impl Into<String>, props: InputProps) -> Self {
1482 Self {
1483 key: key.into(),
1484 component: Component::Input(props),
1485 action: None,
1486 visibility: None,
1487 }
1488 }
1489
1490 pub fn select(key: impl Into<String>, props: SelectProps) -> Self {
1492 Self {
1493 key: key.into(),
1494 component: Component::Select(props),
1495 action: None,
1496 visibility: None,
1497 }
1498 }
1499
1500 pub fn alert(key: impl Into<String>, props: AlertProps) -> Self {
1502 Self {
1503 key: key.into(),
1504 component: Component::Alert(props),
1505 action: None,
1506 visibility: None,
1507 }
1508 }
1509
1510 pub fn badge(key: impl Into<String>, props: BadgeProps) -> Self {
1512 Self {
1513 key: key.into(),
1514 component: Component::Badge(props),
1515 action: None,
1516 visibility: None,
1517 }
1518 }
1519
1520 pub fn modal(key: impl Into<String>, props: ModalProps) -> Self {
1522 Self {
1523 key: key.into(),
1524 component: Component::Modal(props),
1525 action: None,
1526 visibility: None,
1527 }
1528 }
1529
1530 pub fn text(key: impl Into<String>, props: TextProps) -> Self {
1532 Self {
1533 key: key.into(),
1534 component: Component::Text(props),
1535 action: None,
1536 visibility: None,
1537 }
1538 }
1539
1540 pub fn checkbox(key: impl Into<String>, props: CheckboxProps) -> Self {
1542 Self {
1543 key: key.into(),
1544 component: Component::Checkbox(props),
1545 action: None,
1546 visibility: None,
1547 }
1548 }
1549
1550 pub fn switch(key: impl Into<String>, props: SwitchProps) -> Self {
1552 Self {
1553 key: key.into(),
1554 component: Component::Switch(props),
1555 action: None,
1556 visibility: None,
1557 }
1558 }
1559
1560 pub fn separator(key: impl Into<String>, props: SeparatorProps) -> Self {
1562 Self {
1563 key: key.into(),
1564 component: Component::Separator(props),
1565 action: None,
1566 visibility: None,
1567 }
1568 }
1569
1570 pub fn description_list(key: impl Into<String>, props: DescriptionListProps) -> Self {
1572 Self {
1573 key: key.into(),
1574 component: Component::DescriptionList(props),
1575 action: None,
1576 visibility: None,
1577 }
1578 }
1579
1580 pub fn tabs(key: impl Into<String>, props: TabsProps) -> Self {
1582 Self {
1583 key: key.into(),
1584 component: Component::Tabs(props),
1585 action: None,
1586 visibility: None,
1587 }
1588 }
1589
1590 pub fn breadcrumb(key: impl Into<String>, props: BreadcrumbProps) -> Self {
1592 Self {
1593 key: key.into(),
1594 component: Component::Breadcrumb(props),
1595 action: None,
1596 visibility: None,
1597 }
1598 }
1599
1600 pub fn pagination(key: impl Into<String>, props: PaginationProps) -> Self {
1602 Self {
1603 key: key.into(),
1604 component: Component::Pagination(props),
1605 action: None,
1606 visibility: None,
1607 }
1608 }
1609
1610 pub fn progress(key: impl Into<String>, props: ProgressProps) -> Self {
1612 Self {
1613 key: key.into(),
1614 component: Component::Progress(props),
1615 action: None,
1616 visibility: None,
1617 }
1618 }
1619
1620 pub fn avatar(key: impl Into<String>, props: AvatarProps) -> Self {
1622 Self {
1623 key: key.into(),
1624 component: Component::Avatar(props),
1625 action: None,
1626 visibility: None,
1627 }
1628 }
1629
1630 pub fn skeleton(key: impl Into<String>, props: SkeletonProps) -> Self {
1632 Self {
1633 key: key.into(),
1634 component: Component::Skeleton(props),
1635 action: None,
1636 visibility: None,
1637 }
1638 }
1639
1640 pub fn stat_card(key: impl Into<String>, props: StatCardProps) -> Self {
1642 Self {
1643 key: key.into(),
1644 component: Component::StatCard(props),
1645 action: None,
1646 visibility: None,
1647 }
1648 }
1649
1650 pub fn checklist(key: impl Into<String>, props: ChecklistProps) -> Self {
1652 Self {
1653 key: key.into(),
1654 component: Component::Checklist(props),
1655 action: None,
1656 visibility: None,
1657 }
1658 }
1659
1660 pub fn toast(key: impl Into<String>, props: ToastProps) -> Self {
1662 Self {
1663 key: key.into(),
1664 component: Component::Toast(props),
1665 action: None,
1666 visibility: None,
1667 }
1668 }
1669
1670 pub fn notification_dropdown(key: impl Into<String>, props: NotificationDropdownProps) -> Self {
1672 Self {
1673 key: key.into(),
1674 component: Component::NotificationDropdown(props),
1675 action: None,
1676 visibility: None,
1677 }
1678 }
1679
1680 pub fn sidebar(key: impl Into<String>, props: SidebarProps) -> Self {
1682 Self {
1683 key: key.into(),
1684 component: Component::Sidebar(props),
1685 action: None,
1686 visibility: None,
1687 }
1688 }
1689
1690 pub fn header(key: impl Into<String>, props: HeaderProps) -> Self {
1692 Self {
1693 key: key.into(),
1694 component: Component::Header(props),
1695 action: None,
1696 visibility: None,
1697 }
1698 }
1699
1700 pub fn grid(key: impl Into<String>, props: GridProps) -> Self {
1702 Self {
1703 key: key.into(),
1704 component: Component::Grid(props),
1705 action: None,
1706 visibility: None,
1707 }
1708 }
1709
1710 pub fn collapsible(key: impl Into<String>, props: CollapsibleProps) -> Self {
1712 Self {
1713 key: key.into(),
1714 component: Component::Collapsible(props),
1715 action: None,
1716 visibility: None,
1717 }
1718 }
1719
1720 pub fn empty_state(key: impl Into<String>, props: EmptyStateProps) -> Self {
1722 Self {
1723 key: key.into(),
1724 component: Component::EmptyState(props),
1725 action: None,
1726 visibility: None,
1727 }
1728 }
1729
1730 pub fn form_section(key: impl Into<String>, props: FormSectionProps) -> Self {
1732 Self {
1733 key: key.into(),
1734 component: Component::FormSection(props),
1735 action: None,
1736 visibility: None,
1737 }
1738 }
1739
1740 pub fn dropdown_menu(key: impl Into<String>, props: DropdownMenuProps) -> Self {
1742 Self {
1743 key: key.into(),
1744 component: Component::DropdownMenu(props),
1745 action: None,
1746 visibility: None,
1747 }
1748 }
1749
1750 pub fn kanban_board(key: impl Into<String>, props: KanbanBoardProps) -> Self {
1752 Self {
1753 key: key.into(),
1754 component: Component::KanbanBoard(props),
1755 action: None,
1756 visibility: None,
1757 }
1758 }
1759
1760 pub fn calendar_cell(key: impl Into<String>, props: CalendarCellProps) -> Self {
1762 Self {
1763 key: key.into(),
1764 component: Component::CalendarCell(props),
1765 action: None,
1766 visibility: None,
1767 }
1768 }
1769
1770 pub fn action_card(key: impl Into<String>, props: ActionCardProps) -> Self {
1772 Self {
1773 key: key.into(),
1774 component: Component::ActionCard(props),
1775 action: None,
1776 visibility: None,
1777 }
1778 }
1779
1780 pub fn product_tile(key: impl Into<String>, props: ProductTileProps) -> Self {
1782 Self {
1783 key: key.into(),
1784 component: Component::ProductTile(props),
1785 action: None,
1786 visibility: None,
1787 }
1788 }
1789
1790 pub fn data_table(key: impl Into<String>, props: DataTableProps) -> Self {
1792 Self {
1793 key: key.into(),
1794 component: Component::DataTable(props),
1795 action: None,
1796 visibility: None,
1797 }
1798 }
1799
1800 pub fn image(key: impl Into<String>, props: ImageProps) -> Self {
1802 Self {
1803 key: key.into(),
1804 component: Component::Image(props),
1805 action: None,
1806 visibility: None,
1807 }
1808 }
1809
1810 pub fn plugin_component(key: impl Into<String>, props: PluginProps) -> Self {
1814 Self {
1815 key: key.into(),
1816 component: Component::Plugin(props),
1817 action: None,
1818 visibility: None,
1819 }
1820 }
1821}
1822
1823#[cfg(test)]
1824mod tests {
1825 use super::*;
1826 use crate::action::HttpMethod;
1827 use crate::visibility::{VisibilityCondition, VisibilityOperator};
1828
1829 #[test]
1830 fn card_component_tagged_serialization() {
1831 let card = Component::Card(CardProps {
1832 title: "Test Card".to_string(),
1833 description: Some("A description".to_string()),
1834 children: vec![],
1835 footer: vec![],
1836 max_width: None,
1837 });
1838 let json = serde_json::to_value(&card).unwrap();
1839 assert_eq!(json["type"], "Card");
1840 assert_eq!(json["title"], "Test Card");
1841 assert_eq!(json["description"], "A description");
1842 }
1843
1844 #[test]
1845 fn button_variant_defaults_to_default() {
1846 let json = r#"{"type": "Button", "label": "Click me"}"#;
1847 let component: Component = serde_json::from_str(json).unwrap();
1848 match component {
1849 Component::Button(props) => {
1850 assert_eq!(props.variant, ButtonVariant::Default);
1851 assert_eq!(props.label, "Click me");
1852 }
1853 _ => panic!("expected Button"),
1854 }
1855 }
1856
1857 #[test]
1858 fn input_type_defaults_to_text() {
1859 let json = r#"{"type": "Input", "field": "email", "label": "Email"}"#;
1860 let component: Component = serde_json::from_str(json).unwrap();
1861 match component {
1862 Component::Input(props) => {
1863 assert_eq!(props.input_type, InputType::Text);
1864 assert_eq!(props.field, "email");
1865 }
1866 _ => panic!("expected Input"),
1867 }
1868 }
1869
1870 #[test]
1871 fn alert_variant_defaults_to_info() {
1872 let json = r#"{"type": "Alert", "message": "Hello"}"#;
1873 let component: Component = serde_json::from_str(json).unwrap();
1874 match component {
1875 Component::Alert(props) => assert_eq!(props.variant, AlertVariant::Info),
1876 _ => panic!("expected Alert"),
1877 }
1878 }
1879
1880 #[test]
1881 fn badge_variant_defaults_to_default() {
1882 let json = r#"{"type": "Badge", "label": "New"}"#;
1883 let component: Component = serde_json::from_str(json).unwrap();
1884 match component {
1885 Component::Badge(props) => assert_eq!(props.variant, BadgeVariant::Default),
1886 _ => panic!("expected Badge"),
1887 }
1888 }
1889
1890 #[test]
1891 fn text_element_defaults_to_p() {
1892 let json = r#"{"type": "Text", "content": "Hello world"}"#;
1893 let component: Component = serde_json::from_str(json).unwrap();
1894 match component {
1895 Component::Text(props) => {
1896 assert_eq!(props.element, TextElement::P);
1897 assert_eq!(props.content, "Hello world");
1898 }
1899 _ => panic!("expected Text"),
1900 }
1901 }
1902
1903 #[test]
1904 fn table_component_round_trips() {
1905 let table = Component::Table(TableProps {
1906 columns: vec![
1907 Column {
1908 key: "name".to_string(),
1909 label: "Name".to_string(),
1910 format: None,
1911 },
1912 Column {
1913 key: "created_at".to_string(),
1914 label: "Created".to_string(),
1915 format: Some(ColumnFormat::Date),
1916 },
1917 ],
1918 data_path: "/data/users".to_string(),
1919 row_actions: None,
1920 empty_message: Some("No users found".to_string()),
1921 sortable: None,
1922 sort_column: None,
1923 sort_direction: None,
1924 });
1925 let json = serde_json::to_string(&table).unwrap();
1926 let parsed: Component = serde_json::from_str(&json).unwrap();
1927 assert_eq!(parsed, table);
1928 }
1929
1930 #[test]
1931 fn select_component_round_trips() {
1932 let select = Component::Select(SelectProps {
1933 field: "role".to_string(),
1934 label: "Role".to_string(),
1935 options: vec![
1936 SelectOption {
1937 value: "admin".to_string(),
1938 label: "Administrator".to_string(),
1939 },
1940 SelectOption {
1941 value: "user".to_string(),
1942 label: "User".to_string(),
1943 },
1944 ],
1945 placeholder: Some("Select a role".to_string()),
1946 required: Some(true),
1947 disabled: None,
1948 error: None,
1949 description: None,
1950 default_value: None,
1951 data_path: None,
1952 });
1953 let json = serde_json::to_string(&select).unwrap();
1954 let parsed: Component = serde_json::from_str(&json).unwrap();
1955 assert_eq!(parsed, select);
1956 }
1957
1958 #[test]
1959 fn modal_component_round_trips() {
1960 let modal = Component::Modal(ModalProps {
1961 id: "modal-confirm".to_string(),
1962 title: "Confirm".to_string(),
1963 description: None,
1964 children: vec![ComponentNode {
1965 key: "msg".to_string(),
1966 component: Component::Text(TextProps {
1967 content: "Are you sure?".to_string(),
1968 element: TextElement::P,
1969 }),
1970 action: None,
1971 visibility: None,
1972 }],
1973 footer: vec![],
1974 trigger_label: Some("Open".to_string()),
1975 });
1976 let json = serde_json::to_string(&modal).unwrap();
1977 let parsed: Component = serde_json::from_str(&json).unwrap();
1978 assert_eq!(parsed, modal);
1979 }
1980
1981 #[test]
1982 fn form_component_round_trips() {
1983 let form = Component::Form(FormProps {
1984 action: Action {
1985 handler: "users.store".to_string(),
1986 url: None,
1987 method: HttpMethod::Post,
1988 confirm: None,
1989 on_success: None,
1990 on_error: None,
1991 target: None,
1992 },
1993 fields: vec![ComponentNode {
1994 key: "email-input".to_string(),
1995 component: Component::Input(InputProps {
1996 field: "email".to_string(),
1997 label: "Email".to_string(),
1998 input_type: InputType::Email,
1999 placeholder: Some("user@example.com".to_string()),
2000 required: Some(true),
2001 disabled: None,
2002 error: None,
2003 description: None,
2004 default_value: None,
2005 data_path: None,
2006 step: None,
2007 list: None,
2008 }),
2009 action: None,
2010 visibility: None,
2011 }],
2012 method: None,
2013 guard: None,
2014 max_width: None,
2015 });
2016 let json = serde_json::to_string(&form).unwrap();
2017 let parsed: Component = serde_json::from_str(&json).unwrap();
2018 assert_eq!(parsed, form);
2019 }
2020
2021 #[test]
2022 fn component_node_with_action_and_visibility() {
2023 let node = ComponentNode {
2024 key: "create-btn".to_string(),
2025 component: Component::Button(ButtonProps {
2026 label: "Create User".to_string(),
2027 variant: ButtonVariant::Default,
2028 size: Size::Default,
2029 disabled: None,
2030 icon: None,
2031 icon_position: None,
2032 button_type: None,
2033 }),
2034 action: Some(Action {
2035 handler: "users.create".to_string(),
2036 url: None,
2037 method: HttpMethod::Post,
2038 confirm: None,
2039 on_success: None,
2040 on_error: None,
2041 target: None,
2042 }),
2043 visibility: Some(Visibility::Condition(VisibilityCondition {
2044 path: "/auth/user/role".to_string(),
2045 operator: VisibilityOperator::Eq,
2046 value: Some(serde_json::Value::String("admin".to_string())),
2047 })),
2048 };
2049 let json = serde_json::to_string(&node).unwrap();
2050 let parsed: ComponentNode = serde_json::from_str(&json).unwrap();
2051 assert_eq!(parsed, node);
2052
2053 let value = serde_json::to_value(&node).unwrap();
2055 assert_eq!(value["type"], "Button");
2056 assert_eq!(value["key"], "create-btn");
2057 assert!(value.get("action").is_some());
2058 assert!(value.get("visibility").is_some());
2059 }
2060
2061 #[test]
2062 fn all_component_variants_serialize() {
2063 let components: Vec<Component> = vec![
2064 Component::Card(CardProps {
2065 title: "t".to_string(),
2066 description: None,
2067 children: vec![],
2068 footer: vec![],
2069 max_width: None,
2070 }),
2071 Component::Table(TableProps {
2072 columns: vec![],
2073 data_path: "/d".to_string(),
2074 row_actions: None,
2075 empty_message: None,
2076 sortable: None,
2077 sort_column: None,
2078 sort_direction: None,
2079 }),
2080 Component::Form(FormProps {
2081 action: Action {
2082 handler: "h.m".to_string(),
2083 url: None,
2084 method: HttpMethod::Post,
2085 confirm: None,
2086 on_success: None,
2087 on_error: None,
2088 target: None,
2089 },
2090 fields: vec![],
2091 method: None,
2092 guard: None,
2093 max_width: None,
2094 }),
2095 Component::Button(ButtonProps {
2096 label: "b".to_string(),
2097 variant: ButtonVariant::Default,
2098 size: Size::Default,
2099 disabled: None,
2100 icon: None,
2101 icon_position: None,
2102 button_type: None,
2103 }),
2104 Component::Input(InputProps {
2105 field: "f".to_string(),
2106 label: "l".to_string(),
2107 input_type: InputType::Text,
2108 placeholder: None,
2109 required: None,
2110 disabled: None,
2111 error: None,
2112 description: None,
2113 default_value: None,
2114 data_path: None,
2115 step: None,
2116 list: None,
2117 }),
2118 Component::Select(SelectProps {
2119 field: "f".to_string(),
2120 label: "l".to_string(),
2121 options: vec![],
2122 placeholder: None,
2123 required: None,
2124 disabled: None,
2125 error: None,
2126 description: None,
2127 default_value: None,
2128 data_path: None,
2129 }),
2130 Component::Alert(AlertProps {
2131 message: "m".to_string(),
2132 variant: AlertVariant::Info,
2133 title: None,
2134 }),
2135 Component::Badge(BadgeProps {
2136 label: "b".to_string(),
2137 variant: BadgeVariant::Default,
2138 }),
2139 Component::Modal(ModalProps {
2140 id: "modal-t".to_string(),
2141 title: "t".to_string(),
2142 description: None,
2143 children: vec![],
2144 footer: vec![],
2145 trigger_label: None,
2146 }),
2147 Component::Text(TextProps {
2148 content: "c".to_string(),
2149 element: TextElement::P,
2150 }),
2151 Component::Checkbox(CheckboxProps {
2152 field: "f".to_string(),
2153 value: None,
2154 label: "l".to_string(),
2155 description: None,
2156 checked: None,
2157 data_path: None,
2158 required: None,
2159 disabled: None,
2160 error: None,
2161 }),
2162 Component::Switch(SwitchProps {
2163 field: "f".to_string(),
2164 label: "l".to_string(),
2165 description: None,
2166 checked: None,
2167 data_path: None,
2168 required: None,
2169 disabled: None,
2170 error: None,
2171 action: None,
2172 compact: false,
2173 }),
2174 Component::Separator(SeparatorProps { orientation: None }),
2175 Component::DescriptionList(DescriptionListProps {
2176 items: vec![DescriptionItem {
2177 label: "k".to_string(),
2178 value: "v".to_string(),
2179 format: None,
2180 }],
2181 columns: None,
2182 }),
2183 Component::Tabs(TabsProps {
2184 default_tab: "t1".to_string(),
2185 tabs: vec![Tab {
2186 value: "t1".to_string(),
2187 label: "Tab 1".to_string(),
2188 children: vec![],
2189 }],
2190 }),
2191 Component::Breadcrumb(BreadcrumbProps {
2192 items: vec![BreadcrumbItem {
2193 label: "Home".to_string(),
2194 url: Some("/".to_string()),
2195 }],
2196 }),
2197 Component::Pagination(PaginationProps {
2198 current_page: 1,
2199 per_page: 10,
2200 total: 100,
2201 base_url: None,
2202 }),
2203 Component::Progress(ProgressProps {
2204 value: 50,
2205 max: None,
2206 label: None,
2207 }),
2208 Component::Avatar(AvatarProps {
2209 src: None,
2210 alt: "User".to_string(),
2211 fallback: Some("U".to_string()),
2212 size: None,
2213 }),
2214 Component::Skeleton(SkeletonProps {
2215 width: None,
2216 height: None,
2217 rounded: None,
2218 }),
2219 Component::StatCard(StatCardProps {
2220 label: "Revenue".to_string(),
2221 value: "$1,234".to_string(),
2222 icon: None,
2223 subtitle: None,
2224 sse_target: None,
2225 }),
2226 Component::Checklist(ChecklistProps {
2227 title: "Tasks".to_string(),
2228 items: vec![],
2229 dismissible: true,
2230 dismiss_label: None,
2231 data_key: None,
2232 }),
2233 Component::Toast(ToastProps {
2234 message: "Saved!".to_string(),
2235 variant: ToastVariant::Success,
2236 timeout: None,
2237 dismissible: true,
2238 }),
2239 Component::NotificationDropdown(NotificationDropdownProps {
2240 notifications: vec![],
2241 empty_text: None,
2242 }),
2243 Component::Sidebar(SidebarProps {
2244 fixed_top: vec![],
2245 groups: vec![],
2246 fixed_bottom: vec![],
2247 }),
2248 Component::Header(HeaderProps {
2249 business_name: "Acme".to_string(),
2250 notification_count: None,
2251 user_name: None,
2252 user_avatar: None,
2253 logout_url: None,
2254 }),
2255 Component::Image(ImageProps::url("/img/screenshot.png", "Page screenshot")),
2256 ];
2257 assert_eq!(components.len(), 27, "should have 27 component variants");
2258 let expected_types = [
2259 "Card",
2260 "Table",
2261 "Form",
2262 "Button",
2263 "Input",
2264 "Select",
2265 "Alert",
2266 "Badge",
2267 "Modal",
2268 "Text",
2269 "Checkbox",
2270 "Switch",
2271 "Separator",
2272 "DescriptionList",
2273 "Tabs",
2274 "Breadcrumb",
2275 "Pagination",
2276 "Progress",
2277 "Avatar",
2278 "Skeleton",
2279 "StatCard",
2280 "Checklist",
2281 "Toast",
2282 "NotificationDropdown",
2283 "Sidebar",
2284 "Header",
2285 "Image",
2286 ];
2287 for (component, expected_type) in components.iter().zip(expected_types.iter()) {
2288 let json = serde_json::to_value(component).unwrap();
2289 assert_eq!(
2290 json["type"], *expected_type,
2291 "component should serialize with type={expected_type}"
2292 );
2293 let roundtripped: Component = serde_json::from_value(json).unwrap();
2294 assert_eq!(&roundtripped, component);
2295 }
2296 }
2297
2298 #[test]
2299 fn size_enum_serialization() {
2300 let cases = [
2301 (Size::Xs, "xs"),
2302 (Size::Sm, "sm"),
2303 (Size::Default, "default"),
2304 (Size::Lg, "lg"),
2305 ];
2306 for (size, expected) in &cases {
2307 let json = serde_json::to_value(size).unwrap();
2308 assert_eq!(json, *expected);
2309 let parsed: Size = serde_json::from_value(json).unwrap();
2310 assert_eq!(&parsed, size);
2311 }
2312 }
2313
2314 #[test]
2315 fn icon_position_serialization() {
2316 let cases = [(IconPosition::Left, "left"), (IconPosition::Right, "right")];
2317 for (pos, expected) in &cases {
2318 let json = serde_json::to_value(pos).unwrap();
2319 assert_eq!(json, *expected);
2320 let parsed: IconPosition = serde_json::from_value(json).unwrap();
2321 assert_eq!(&parsed, pos);
2322 }
2323 }
2324
2325 #[test]
2326 fn sort_direction_serialization() {
2327 let cases = [(SortDirection::Asc, "asc"), (SortDirection::Desc, "desc")];
2328 for (dir, expected) in &cases {
2329 let json = serde_json::to_value(dir).unwrap();
2330 assert_eq!(json, *expected);
2331 let parsed: SortDirection = serde_json::from_value(json).unwrap();
2332 assert_eq!(&parsed, dir);
2333 }
2334 }
2335
2336 #[test]
2337 fn button_with_size_and_icon() {
2338 let button = Component::Button(ButtonProps {
2339 label: "Save".to_string(),
2340 variant: ButtonVariant::Default,
2341 size: Size::Lg,
2342 disabled: None,
2343 icon: Some("save".to_string()),
2344 icon_position: Some(IconPosition::Left),
2345 button_type: None,
2346 });
2347 let json = serde_json::to_value(&button).unwrap();
2348 assert_eq!(json["size"], "lg");
2349 assert_eq!(json["icon"], "save");
2350 assert_eq!(json["icon_position"], "left");
2351 let parsed: Component = serde_json::from_value(json).unwrap();
2352 assert_eq!(parsed, button);
2353 }
2354
2355 #[test]
2356 fn card_with_footer() {
2357 let card = Component::Card(CardProps {
2358 title: "Actions".to_string(),
2359 description: None,
2360 children: vec![],
2361 max_width: None,
2362 footer: vec![ComponentNode {
2363 key: "cancel".to_string(),
2364 component: Component::Button(ButtonProps {
2365 label: "Cancel".to_string(),
2366 variant: ButtonVariant::Outline,
2367 size: Size::Default,
2368 disabled: None,
2369 icon: None,
2370 icon_position: None,
2371 button_type: None,
2372 }),
2373 action: None,
2374 visibility: None,
2375 }],
2376 });
2377 let json = serde_json::to_value(&card).unwrap();
2378 assert!(json["footer"].is_array());
2379 assert_eq!(json["footer"][0]["label"], "Cancel");
2380 let parsed: Component = serde_json::from_value(json).unwrap();
2381 assert_eq!(parsed, card);
2382 }
2383
2384 #[test]
2385 fn input_with_error_and_description() {
2386 let input = Component::Input(InputProps {
2387 field: "email".to_string(),
2388 label: "Email".to_string(),
2389 input_type: InputType::Email,
2390 placeholder: None,
2391 required: Some(true),
2392 disabled: Some(false),
2393 error: Some("Invalid email".to_string()),
2394 description: Some("Your work email".to_string()),
2395 default_value: Some("user@example.com".to_string()),
2396 data_path: None,
2397 step: None,
2398 list: None,
2399 });
2400 let json = serde_json::to_value(&input).unwrap();
2401 assert_eq!(json["error"], "Invalid email");
2402 assert_eq!(json["description"], "Your work email");
2403 assert_eq!(json["default_value"], "user@example.com");
2404 assert_eq!(json["disabled"], false);
2405 let parsed: Component = serde_json::from_value(json).unwrap();
2406 assert_eq!(parsed, input);
2407 }
2408
2409 #[test]
2410 fn select_with_default_value() {
2411 let select = Component::Select(SelectProps {
2412 field: "role".to_string(),
2413 label: "Role".to_string(),
2414 options: vec![SelectOption {
2415 value: "admin".to_string(),
2416 label: "Admin".to_string(),
2417 }],
2418 placeholder: None,
2419 required: None,
2420 disabled: Some(true),
2421 error: Some("Required field".to_string()),
2422 description: Some("User role".to_string()),
2423 default_value: Some("admin".to_string()),
2424 data_path: None,
2425 });
2426 let json = serde_json::to_value(&select).unwrap();
2427 assert_eq!(json["default_value"], "admin");
2428 assert_eq!(json["error"], "Required field");
2429 assert_eq!(json["description"], "User role");
2430 assert_eq!(json["disabled"], true);
2431 let parsed: Component = serde_json::from_value(json).unwrap();
2432 assert_eq!(parsed, select);
2433 }
2434
2435 #[test]
2436 fn alert_with_title() {
2437 let alert = Component::Alert(AlertProps {
2438 message: "Something happened".to_string(),
2439 variant: AlertVariant::Warning,
2440 title: Some("Warning".to_string()),
2441 });
2442 let json = serde_json::to_value(&alert).unwrap();
2443 assert_eq!(json["title"], "Warning");
2444 assert_eq!(json["message"], "Something happened");
2445 let parsed: Component = serde_json::from_value(json).unwrap();
2446 assert_eq!(parsed, alert);
2447 }
2448
2449 #[test]
2450 fn modal_with_footer_and_description() {
2451 let modal = Component::Modal(ModalProps {
2452 id: "modal-delete-item".to_string(),
2453 title: "Delete Item".to_string(),
2454 description: Some("This action cannot be undone.".to_string()),
2455 children: vec![],
2456 footer: vec![ComponentNode {
2457 key: "confirm".to_string(),
2458 component: Component::Button(ButtonProps {
2459 label: "Delete".to_string(),
2460 variant: ButtonVariant::Destructive,
2461 size: Size::Default,
2462 disabled: None,
2463 icon: None,
2464 icon_position: None,
2465 button_type: None,
2466 }),
2467 action: None,
2468 visibility: None,
2469 }],
2470 trigger_label: Some("Delete".to_string()),
2471 });
2472 let json = serde_json::to_value(&modal).unwrap();
2473 assert_eq!(json["description"], "This action cannot be undone.");
2474 assert!(json["footer"].is_array());
2475 assert_eq!(json["footer"][0]["label"], "Delete");
2476 let parsed: Component = serde_json::from_value(json).unwrap();
2477 assert_eq!(parsed, modal);
2478 }
2479
2480 #[test]
2481 fn table_with_sort_props() {
2482 let table = Component::Table(TableProps {
2483 columns: vec![Column {
2484 key: "name".to_string(),
2485 label: "Name".to_string(),
2486 format: None,
2487 }],
2488 data_path: "/data/users".to_string(),
2489 row_actions: None,
2490 empty_message: None,
2491 sortable: Some(true),
2492 sort_column: Some("name".to_string()),
2493 sort_direction: Some(SortDirection::Desc),
2494 });
2495 let json = serde_json::to_value(&table).unwrap();
2496 assert_eq!(json["sortable"], true);
2497 assert_eq!(json["sort_column"], "name");
2498 assert_eq!(json["sort_direction"], "desc");
2499 let parsed: Component = serde_json::from_value(json).unwrap();
2500 assert_eq!(parsed, table);
2501 }
2502
2503 #[test]
2504 fn aligned_button_variants_serialize() {
2505 let cases = [
2506 (ButtonVariant::Default, "default"),
2507 (ButtonVariant::Secondary, "secondary"),
2508 (ButtonVariant::Destructive, "destructive"),
2509 (ButtonVariant::Outline, "outline"),
2510 (ButtonVariant::Ghost, "ghost"),
2511 (ButtonVariant::Link, "link"),
2512 ];
2513 for (variant, expected) in &cases {
2514 let json = serde_json::to_value(variant).unwrap();
2515 assert_eq!(
2516 json, *expected,
2517 "ButtonVariant::{variant:?} should serialize as {expected}"
2518 );
2519 let parsed: ButtonVariant = serde_json::from_value(json).unwrap();
2520 assert_eq!(&parsed, variant);
2521 }
2522 }
2523
2524 #[test]
2525 fn aligned_badge_variants_serialize() {
2526 let cases = [
2527 (BadgeVariant::Default, "default"),
2528 (BadgeVariant::Secondary, "secondary"),
2529 (BadgeVariant::Destructive, "destructive"),
2530 (BadgeVariant::Outline, "outline"),
2531 ];
2532 for (variant, expected) in &cases {
2533 let json = serde_json::to_value(variant).unwrap();
2534 assert_eq!(
2535 json, *expected,
2536 "BadgeVariant::{variant:?} should serialize as {expected}"
2537 );
2538 let parsed: BadgeVariant = serde_json::from_value(json).unwrap();
2539 assert_eq!(&parsed, variant);
2540 }
2541 }
2542
2543 #[test]
2544 fn checkbox_round_trips() {
2545 let checkbox = Component::Checkbox(CheckboxProps {
2546 field: "terms".to_string(),
2547 value: None,
2548 label: "Accept Terms".to_string(),
2549 description: Some("You must accept the terms".to_string()),
2550 checked: Some(true),
2551 data_path: None,
2552 required: Some(true),
2553 disabled: Some(false),
2554 error: None,
2555 });
2556 let json = serde_json::to_value(&checkbox).unwrap();
2557 assert_eq!(json["type"], "Checkbox");
2558 assert_eq!(json["field"], "terms");
2559 assert_eq!(json["checked"], true);
2560 assert_eq!(json["description"], "You must accept the terms");
2561 let parsed: Component = serde_json::from_value(json).unwrap();
2562 assert_eq!(parsed, checkbox);
2563 }
2564
2565 #[test]
2566 fn switch_round_trips() {
2567 let switch = Component::Switch(SwitchProps {
2568 field: "notifications".to_string(),
2569 label: "Enable Notifications".to_string(),
2570 description: Some("Receive email notifications".to_string()),
2571 checked: Some(false),
2572 data_path: None,
2573 required: None,
2574 disabled: Some(false),
2575 error: None,
2576 action: None,
2577 compact: false,
2578 });
2579 let json = serde_json::to_value(&switch).unwrap();
2580 assert_eq!(json["type"], "Switch");
2581 assert_eq!(json["field"], "notifications");
2582 assert_eq!(json["checked"], false);
2583 let parsed: Component = serde_json::from_value(json).unwrap();
2584 assert_eq!(parsed, switch);
2585 }
2586
2587 #[test]
2588 fn separator_defaults_to_horizontal() {
2589 let json = r#"{"type": "Separator"}"#;
2590 let component: Component = serde_json::from_str(json).unwrap();
2591 match component {
2592 Component::Separator(props) => {
2593 assert_eq!(props.orientation, None);
2594 let explicit = Component::Separator(SeparatorProps {
2597 orientation: Some(Orientation::Horizontal),
2598 });
2599 let v = serde_json::to_value(&explicit).unwrap();
2600 assert_eq!(v["orientation"], "horizontal");
2601 let parsed: Component = serde_json::from_value(v).unwrap();
2602 assert_eq!(parsed, explicit);
2603 }
2604 _ => panic!("expected Separator"),
2605 }
2606 }
2607
2608 #[test]
2609 fn description_list_with_format() {
2610 let dl = Component::DescriptionList(DescriptionListProps {
2611 items: vec![
2612 DescriptionItem {
2613 label: "Created".to_string(),
2614 value: "2026-01-15".to_string(),
2615 format: Some(ColumnFormat::Date),
2616 },
2617 DescriptionItem {
2618 label: "Name".to_string(),
2619 value: "Alice".to_string(),
2620 format: None,
2621 },
2622 ],
2623 columns: Some(2),
2624 });
2625 let json = serde_json::to_value(&dl).unwrap();
2626 assert_eq!(json["type"], "DescriptionList");
2627 assert_eq!(json["columns"], 2);
2628 assert_eq!(json["items"][0]["format"], "date");
2629 assert!(json["items"][1].get("format").is_none());
2630 let parsed: Component = serde_json::from_value(json).unwrap();
2631 assert_eq!(parsed, dl);
2632 }
2633
2634 #[test]
2635 fn checkbox_with_error() {
2636 let checkbox = Component::Checkbox(CheckboxProps {
2637 field: "agree".to_string(),
2638 value: None,
2639 label: "I agree".to_string(),
2640 description: None,
2641 checked: None,
2642 data_path: None,
2643 required: Some(true),
2644 disabled: None,
2645 error: Some("You must agree".to_string()),
2646 });
2647 let json = serde_json::to_value(&checkbox).unwrap();
2648 assert_eq!(json["error"], "You must agree");
2649 assert!(json.get("description").is_none());
2650 assert!(json.get("checked").is_none());
2651 let parsed: Component = serde_json::from_value(json).unwrap();
2652 assert_eq!(parsed, checkbox);
2653 }
2654
2655 #[test]
2656 fn tabs_round_trips() {
2657 let tabs = Component::Tabs(TabsProps {
2658 default_tab: "general".to_string(),
2659 tabs: vec![
2660 Tab {
2661 value: "general".to_string(),
2662 label: "General".to_string(),
2663 children: vec![ComponentNode {
2664 key: "name-input".to_string(),
2665 component: Component::Input(InputProps {
2666 field: "name".to_string(),
2667 label: "Name".to_string(),
2668 input_type: InputType::Text,
2669 placeholder: None,
2670 required: None,
2671 disabled: None,
2672 error: None,
2673 description: None,
2674 default_value: None,
2675 data_path: None,
2676 step: None,
2677 list: None,
2678 }),
2679 action: None,
2680 visibility: None,
2681 }],
2682 },
2683 Tab {
2684 value: "security".to_string(),
2685 label: "Security".to_string(),
2686 children: vec![ComponentNode {
2687 key: "password-input".to_string(),
2688 component: Component::Input(InputProps {
2689 field: "password".to_string(),
2690 label: "Password".to_string(),
2691 input_type: InputType::Password,
2692 placeholder: None,
2693 required: None,
2694 disabled: None,
2695 error: None,
2696 description: None,
2697 default_value: None,
2698 data_path: None,
2699 step: None,
2700 list: None,
2701 }),
2702 action: None,
2703 visibility: None,
2704 }],
2705 },
2706 ],
2707 });
2708 let json = serde_json::to_string(&tabs).unwrap();
2709 let parsed: Component = serde_json::from_str(&json).unwrap();
2710 assert_eq!(parsed, tabs);
2711 }
2712
2713 #[test]
2714 fn breadcrumb_round_trips() {
2715 let breadcrumb = Component::Breadcrumb(BreadcrumbProps {
2716 items: vec![
2717 BreadcrumbItem {
2718 label: "Home".to_string(),
2719 url: Some("/".to_string()),
2720 },
2721 BreadcrumbItem {
2722 label: "Users".to_string(),
2723 url: Some("/users".to_string()),
2724 },
2725 BreadcrumbItem {
2726 label: "Edit User".to_string(),
2727 url: None,
2728 },
2729 ],
2730 });
2731 let json = serde_json::to_string(&breadcrumb).unwrap();
2732 let parsed: Component = serde_json::from_str(&json).unwrap();
2733 assert_eq!(parsed, breadcrumb);
2734
2735 let value = serde_json::to_value(&breadcrumb).unwrap();
2737 assert!(value["items"][2].get("url").is_none());
2738 }
2739
2740 #[test]
2741 fn pagination_round_trips() {
2742 let pagination = Component::Pagination(PaginationProps {
2743 current_page: 3,
2744 per_page: 25,
2745 total: 150,
2746 base_url: None,
2747 });
2748 let json = serde_json::to_string(&pagination).unwrap();
2749 let parsed: Component = serde_json::from_str(&json).unwrap();
2750 assert_eq!(parsed, pagination);
2751 }
2752
2753 #[test]
2754 fn progress_round_trips() {
2755 let progress = Component::Progress(ProgressProps {
2756 value: 75,
2757 max: Some(100),
2758 label: Some("Uploading...".to_string()),
2759 });
2760 let json = serde_json::to_string(&progress).unwrap();
2761 let parsed: Component = serde_json::from_str(&json).unwrap();
2762 assert_eq!(parsed, progress);
2763
2764 let value = serde_json::to_value(&progress).unwrap();
2765 assert_eq!(value["value"], 75);
2766 assert_eq!(value["max"], 100);
2767 assert_eq!(value["label"], "Uploading...");
2768 }
2769
2770 #[test]
2771 fn avatar_with_fallback() {
2772 let avatar = Component::Avatar(AvatarProps {
2773 src: None,
2774 alt: "John Doe".to_string(),
2775 fallback: Some("JD".to_string()),
2776 size: Some(Size::Lg),
2777 });
2778 let json = serde_json::to_string(&avatar).unwrap();
2779 let parsed: Component = serde_json::from_str(&json).unwrap();
2780 assert_eq!(parsed, avatar);
2781
2782 let value = serde_json::to_value(&avatar).unwrap();
2783 assert!(value.get("src").is_none());
2784 assert_eq!(value["fallback"], "JD");
2785 assert_eq!(value["size"], "lg");
2786 }
2787
2788 #[test]
2789 fn skeleton_round_trips() {
2790 let skeleton = Component::Skeleton(SkeletonProps {
2791 width: Some("100%".to_string()),
2792 height: Some("40px".to_string()),
2793 rounded: Some(true),
2794 });
2795 let json = serde_json::to_string(&skeleton).unwrap();
2796 let parsed: Component = serde_json::from_str(&json).unwrap();
2797 assert_eq!(parsed, skeleton);
2798
2799 let value = serde_json::to_value(&skeleton).unwrap();
2800 assert_eq!(value["width"], "100%");
2801 assert_eq!(value["height"], "40px");
2802 assert_eq!(value["rounded"], true);
2803 }
2804
2805 #[test]
2806 fn tabs_deserializes_from_json() {
2807 let json = r#"{
2808 "type": "Tabs",
2809 "default_tab": "general",
2810 "tabs": [
2811 {
2812 "value": "general",
2813 "label": "General",
2814 "children": [
2815 {
2816 "key": "name-input",
2817 "type": "Input",
2818 "field": "name",
2819 "label": "Name"
2820 }
2821 ]
2822 },
2823 {
2824 "value": "security",
2825 "label": "Security"
2826 }
2827 ]
2828 }"#;
2829 let component: Component = serde_json::from_str(json).unwrap();
2830 match component {
2831 Component::Tabs(props) => {
2832 assert_eq!(props.default_tab, "general");
2833 assert_eq!(props.tabs.len(), 2);
2834 assert_eq!(props.tabs[0].value, "general");
2835 assert_eq!(props.tabs[0].children.len(), 1);
2836 assert_eq!(props.tabs[1].value, "security");
2837 assert!(props.tabs[1].children.is_empty());
2838 }
2839 _ => panic!("expected Tabs"),
2840 }
2841 }
2842
2843 #[test]
2844 fn input_data_path_round_trips() {
2845 let input = Component::Input(InputProps {
2846 field: "name".to_string(),
2847 label: "Name".to_string(),
2848 input_type: InputType::Text,
2849 placeholder: None,
2850 required: None,
2851 disabled: None,
2852 error: None,
2853 description: None,
2854 default_value: None,
2855 data_path: Some("/data/user/name".to_string()),
2856 step: None,
2857 list: None,
2858 });
2859 let json = serde_json::to_value(&input).unwrap();
2860 assert_eq!(json["data_path"], "/data/user/name");
2861 let parsed: Component = serde_json::from_value(json).unwrap();
2862 assert_eq!(parsed, input);
2863 }
2864
2865 #[test]
2866 fn select_data_path_round_trips() {
2867 let select = Component::Select(SelectProps {
2868 field: "role".to_string(),
2869 label: "Role".to_string(),
2870 options: vec![SelectOption {
2871 value: "admin".to_string(),
2872 label: "Admin".to_string(),
2873 }],
2874 placeholder: None,
2875 required: None,
2876 disabled: None,
2877 error: None,
2878 description: None,
2879 default_value: None,
2880 data_path: Some("/data/user/role".to_string()),
2881 });
2882 let json = serde_json::to_value(&select).unwrap();
2883 assert_eq!(json["data_path"], "/data/user/role");
2884 let parsed: Component = serde_json::from_value(json).unwrap();
2885 assert_eq!(parsed, select);
2886 }
2887
2888 #[test]
2889 fn checkbox_data_path_round_trips() {
2890 let checkbox = Component::Checkbox(CheckboxProps {
2891 field: "terms".to_string(),
2892 value: None,
2893 label: "Accept Terms".to_string(),
2894 description: None,
2895 checked: None,
2896 data_path: Some("/data/user/accepted_terms".to_string()),
2897 required: None,
2898 disabled: None,
2899 error: None,
2900 });
2901 let json = serde_json::to_value(&checkbox).unwrap();
2902 assert_eq!(json["data_path"], "/data/user/accepted_terms");
2903 let parsed: Component = serde_json::from_value(json).unwrap();
2904 assert_eq!(parsed, checkbox);
2905 }
2906
2907 #[test]
2908 fn switch_data_path_round_trips() {
2909 let switch = Component::Switch(SwitchProps {
2910 field: "notifications".to_string(),
2911 label: "Enable Notifications".to_string(),
2912 description: None,
2913 checked: None,
2914 data_path: Some("/data/user/notifications_enabled".to_string()),
2915 required: None,
2916 disabled: None,
2917 error: None,
2918 action: None,
2919 compact: false,
2920 });
2921 let json = serde_json::to_value(&switch).unwrap();
2922 assert_eq!(json["data_path"], "/data/user/notifications_enabled");
2923 let parsed: Component = serde_json::from_value(json).unwrap();
2924 assert_eq!(parsed, switch);
2925 }
2926
2927 #[test]
2930 fn unknown_type_deserializes_as_plugin() {
2931 let json = r#"{"type": "Map", "center": [40.7, -74.0], "zoom": 12}"#;
2932 let component: Component = serde_json::from_str(json).unwrap();
2933 match component {
2934 Component::Plugin(props) => {
2935 assert_eq!(props.plugin_type, "Map");
2936 assert_eq!(props.props["center"][0], 40.7);
2937 assert_eq!(props.props["center"][1], -74.0);
2938 assert_eq!(props.props["zoom"], 12);
2939 assert!(props.props.get("type").is_none());
2941 }
2942 _ => panic!("expected Plugin"),
2943 }
2944 }
2945
2946 #[test]
2947 fn plugin_round_trips() {
2948 let plugin = Component::Plugin(PluginProps {
2949 plugin_type: "Chart".to_string(),
2950 props: serde_json::json!({"data": [1, 2, 3], "style": "bar"}),
2951 });
2952 let json = serde_json::to_value(&plugin).unwrap();
2953 assert_eq!(json["type"], "Chart");
2954 assert_eq!(json["data"], serde_json::json!([1, 2, 3]));
2955 assert_eq!(json["style"], "bar");
2956
2957 let parsed: Component = serde_json::from_value(json).unwrap();
2958 assert_eq!(parsed, plugin);
2959 }
2960
2961 #[test]
2962 fn plugin_serializes_with_type_field() {
2963 let plugin = Component::Plugin(PluginProps {
2964 plugin_type: "Map".to_string(),
2965 props: serde_json::json!({"lat": 51.5, "lng": -0.1}),
2966 });
2967 let json = serde_json::to_value(&plugin).unwrap();
2968 assert_eq!(json["type"], "Map");
2969 assert_eq!(json["lat"], 51.5);
2970 assert_eq!(json["lng"], -0.1);
2971 }
2972
2973 #[test]
2974 fn plugin_with_empty_props() {
2975 let json = r#"{"type": "CustomWidget"}"#;
2976 let component: Component = serde_json::from_str(json).unwrap();
2977 match component {
2978 Component::Plugin(props) => {
2979 assert_eq!(props.plugin_type, "CustomWidget");
2980 assert!(props.props.as_object().unwrap().is_empty());
2981 }
2982 _ => panic!("expected Plugin"),
2983 }
2984 }
2985
2986 #[test]
2987 fn plugin_in_component_node() {
2988 let node = ComponentNode {
2989 key: "map-1".to_string(),
2990 component: Component::Plugin(PluginProps {
2991 plugin_type: "Map".to_string(),
2992 props: serde_json::json!({"center": [0.0, 0.0]}),
2993 }),
2994 action: None,
2995 visibility: None,
2996 };
2997 let json = serde_json::to_string(&node).unwrap();
2998 let parsed: ComponentNode = serde_json::from_str(&json).unwrap();
2999 assert_eq!(parsed, node);
3000
3001 let value = serde_json::to_value(&node).unwrap();
3002 assert_eq!(value["type"], "Map");
3003 assert_eq!(value["key"], "map-1");
3004 }
3005
3006 #[test]
3007 fn known_types_not_treated_as_plugin() {
3008 let known_types = [
3010 "Card",
3011 "Table",
3012 "Form",
3013 "Button",
3014 "Input",
3015 "Select",
3016 "Alert",
3017 "Badge",
3018 "Modal",
3019 "Text",
3020 "Checkbox",
3021 "Switch",
3022 "Separator",
3023 "DescriptionList",
3024 "Tabs",
3025 "Breadcrumb",
3026 "Pagination",
3027 "Progress",
3028 "Avatar",
3029 "Skeleton",
3030 ];
3031 for type_name in &known_types {
3032 let json_str = match *type_name {
3035 "Card" => r#"{"type":"Card","title":"t"}"#,
3036 "Table" => r#"{"type":"Table","columns":[],"data_path":"/d"}"#,
3037 "Form" => r#"{"type":"Form","action":{"handler":"h","method":"POST"},"fields":[]}"#,
3038 "Button" => r#"{"type":"Button","label":"b"}"#,
3039 "Input" => r#"{"type":"Input","field":"f","label":"l"}"#,
3040 "Select" => r#"{"type":"Select","field":"f","label":"l","options":[]}"#,
3041 "Alert" => r#"{"type":"Alert","message":"m"}"#,
3042 "Badge" => r#"{"type":"Badge","label":"b"}"#,
3043 "Modal" => r#"{"type":"Modal","id":"modal-t","title":"t"}"#,
3044 "Text" => r#"{"type":"Text","content":"c"}"#,
3045 "Checkbox" => r#"{"type":"Checkbox","field":"f","label":"l"}"#,
3046 "Switch" => r#"{"type":"Switch","field":"f","label":"l"}"#,
3047 "Separator" => r#"{"type":"Separator"}"#,
3048 "DescriptionList" => r#"{"type":"DescriptionList","items":[]}"#,
3049 "Tabs" => r#"{"type":"Tabs","default_tab":"t","tabs":[]}"#,
3050 "Breadcrumb" => r#"{"type":"Breadcrumb","items":[]}"#,
3051 "Pagination" => r#"{"type":"Pagination","current_page":1,"per_page":10,"total":0}"#,
3052 "Progress" => r#"{"type":"Progress","value":0}"#,
3053 "Avatar" => r#"{"type":"Avatar","alt":"a"}"#,
3054 "Skeleton" => r#"{"type":"Skeleton"}"#,
3055 _ => unreachable!(),
3056 };
3057 let component: Component = serde_json::from_str(json_str).unwrap();
3058 assert!(
3059 !matches!(component, Component::Plugin(_)),
3060 "type {type_name} should not deserialize as Plugin"
3061 );
3062 }
3063 }
3064
3065 #[test]
3068 fn test_stat_card_serde_round_trip() {
3069 let component = Component::StatCard(StatCardProps {
3070 label: "Orders".into(),
3071 value: "42".into(),
3072 icon: Some("package".into()),
3073 subtitle: Some("today".into()),
3074 sse_target: Some("orders_today".into()),
3075 });
3076 let json = serde_json::to_string(&component).unwrap();
3077 assert!(json.contains("\"type\":\"StatCard\""));
3078 assert!(json.contains("\"sse_target\":\"orders_today\""));
3079 let deserialized: Component = serde_json::from_str(&json).unwrap();
3080 assert_eq!(component, deserialized);
3081 }
3082
3083 #[test]
3084 fn test_checklist_serde_round_trip() {
3085 let component = Component::Checklist(ChecklistProps {
3086 title: "Getting Started".into(),
3087 items: vec![
3088 ChecklistItem {
3089 label: "Install dependencies".into(),
3090 checked: true,
3091 href: None,
3092 },
3093 ChecklistItem {
3094 label: "Read the docs".into(),
3095 checked: false,
3096 href: Some("/docs".into()),
3097 },
3098 ],
3099 dismissible: true,
3100 dismiss_label: Some("Dismiss".into()),
3101 data_key: Some("onboarding".into()),
3102 });
3103 let json = serde_json::to_string(&component).unwrap();
3104 assert!(json.contains("\"type\":\"Checklist\""));
3105 assert!(json.contains("\"data_key\":\"onboarding\""));
3106 let deserialized: Component = serde_json::from_str(&json).unwrap();
3107 assert_eq!(component, deserialized);
3108 }
3109
3110 #[test]
3111 fn test_toast_serde_round_trip() {
3112 let component = Component::Toast(ToastProps {
3113 message: "Operation completed".into(),
3114 variant: ToastVariant::Success,
3115 timeout: Some(10),
3116 dismissible: true,
3117 });
3118 let json = serde_json::to_string(&component).unwrap();
3119 assert!(json.contains("\"type\":\"Toast\""));
3120 assert!(json.contains("\"timeout\":10"));
3121 let deserialized: Component = serde_json::from_str(&json).unwrap();
3122 assert_eq!(component, deserialized);
3123 }
3124
3125 #[test]
3126 fn test_notification_dropdown_serde_round_trip() {
3127 let component = Component::NotificationDropdown(NotificationDropdownProps {
3128 notifications: vec![
3129 NotificationItem {
3130 icon: Some("bell".into()),
3131 text: "New message".into(),
3132 timestamp: Some("2m ago".into()),
3133 read: false,
3134 action_url: Some("/messages/1".into()),
3135 },
3136 NotificationItem {
3137 icon: None,
3138 text: "Old notification".into(),
3139 timestamp: None,
3140 read: true,
3141 action_url: None,
3142 },
3143 ],
3144 empty_text: Some("No notifications".into()),
3145 });
3146 let json = serde_json::to_string(&component).unwrap();
3147 assert!(json.contains("\"type\":\"NotificationDropdown\""));
3148 assert!(json.contains("\"empty_text\":\"No notifications\""));
3149 let deserialized: Component = serde_json::from_str(&json).unwrap();
3150 assert_eq!(component, deserialized);
3151 }
3152
3153 #[test]
3154 fn test_sidebar_serde_round_trip() {
3155 let component = Component::Sidebar(SidebarProps {
3156 fixed_top: vec![SidebarNavItem {
3157 label: "Dashboard".into(),
3158 href: "/dashboard".into(),
3159 icon: Some("home".into()),
3160 active: true,
3161 }],
3162 groups: vec![SidebarGroup {
3163 label: "Management".into(),
3164 collapsed: false,
3165 items: vec![SidebarNavItem {
3166 label: "Users".into(),
3167 href: "/users".into(),
3168 icon: None,
3169 active: false,
3170 }],
3171 }],
3172 fixed_bottom: vec![SidebarNavItem {
3173 label: "Settings".into(),
3174 href: "/settings".into(),
3175 icon: Some("gear".into()),
3176 active: false,
3177 }],
3178 });
3179 let json = serde_json::to_string(&component).unwrap();
3180 assert!(json.contains("\"type\":\"Sidebar\""));
3181 assert!(json.contains("\"fixed_top\""));
3182 let deserialized: Component = serde_json::from_str(&json).unwrap();
3183 assert_eq!(component, deserialized);
3184 }
3185
3186 #[test]
3187 fn test_header_serde_round_trip() {
3188 let component = Component::Header(HeaderProps {
3189 business_name: "Acme Corp".into(),
3190 notification_count: Some(5),
3191 user_name: Some("Jane Doe".into()),
3192 user_avatar: Some("/avatar.jpg".into()),
3193 logout_url: Some("/logout".into()),
3194 });
3195 let json = serde_json::to_string(&component).unwrap();
3196 assert!(json.contains("\"type\":\"Header\""));
3197 assert!(json.contains("\"business_name\":\"Acme Corp\""));
3198 assert!(json.contains("\"notification_count\":5"));
3199 let deserialized: Component = serde_json::from_str(&json).unwrap();
3200 assert_eq!(component, deserialized);
3201 }
3202
3203 #[test]
3206 fn test_stat_card_constructor() {
3207 let props = StatCardProps {
3208 label: "Revenue".into(),
3209 value: "$1,000".into(),
3210 icon: None,
3211 subtitle: None,
3212 sse_target: None,
3213 };
3214 let node = ComponentNode::stat_card("revenue-card", props.clone());
3215 assert_eq!(node.key, "revenue-card");
3216 assert!(node.action.is_none());
3217 assert!(node.visibility.is_none());
3218 assert_eq!(node.component, Component::StatCard(props));
3219 }
3220
3221 #[test]
3222 fn test_checklist_constructor() {
3223 let props = ChecklistProps {
3224 title: "Tasks".into(),
3225 items: vec![],
3226 dismissible: true,
3227 dismiss_label: None,
3228 data_key: None,
3229 };
3230 let node = ComponentNode::checklist("task-list", props.clone());
3231 assert_eq!(node.key, "task-list");
3232 assert!(node.action.is_none());
3233 assert!(node.visibility.is_none());
3234 assert_eq!(node.component, Component::Checklist(props));
3235 }
3236
3237 #[test]
3238 fn test_toast_constructor() {
3239 let props = ToastProps {
3240 message: "Done!".into(),
3241 variant: ToastVariant::Success,
3242 timeout: None,
3243 dismissible: true,
3244 };
3245 let node = ComponentNode::toast("success-toast", props.clone());
3246 assert_eq!(node.key, "success-toast");
3247 assert!(node.action.is_none());
3248 assert!(node.visibility.is_none());
3249 assert_eq!(node.component, Component::Toast(props));
3250 }
3251
3252 #[test]
3253 fn test_notification_dropdown_constructor() {
3254 let props = NotificationDropdownProps {
3255 notifications: vec![],
3256 empty_text: Some("All caught up!".into()),
3257 };
3258 let node = ComponentNode::notification_dropdown("notifs", props.clone());
3259 assert_eq!(node.key, "notifs");
3260 assert!(node.action.is_none());
3261 assert!(node.visibility.is_none());
3262 assert_eq!(node.component, Component::NotificationDropdown(props));
3263 }
3264
3265 #[test]
3266 fn test_sidebar_constructor() {
3267 let props = SidebarProps {
3268 fixed_top: vec![],
3269 groups: vec![],
3270 fixed_bottom: vec![],
3271 };
3272 let node = ComponentNode::sidebar("main-nav", props.clone());
3273 assert_eq!(node.key, "main-nav");
3274 assert!(node.action.is_none());
3275 assert!(node.visibility.is_none());
3276 assert_eq!(node.component, Component::Sidebar(props));
3277 }
3278
3279 #[test]
3280 fn test_header_constructor() {
3281 let props = HeaderProps {
3282 business_name: "MyApp".into(),
3283 notification_count: None,
3284 user_name: None,
3285 user_avatar: None,
3286 logout_url: None,
3287 };
3288 let node = ComponentNode::header("page-header", props.clone());
3289 assert_eq!(node.key, "page-header");
3290 assert!(node.action.is_none());
3291 assert!(node.visibility.is_none());
3292 assert_eq!(node.component, Component::Header(props));
3293 }
3294
3295 #[test]
3298 fn test_checklist_item_round_trip() {
3299 let checked_item = ChecklistItem {
3300 label: "Completed task".into(),
3301 checked: true,
3302 href: Some("/task/1".into()),
3303 };
3304 let json = serde_json::to_string(&checked_item).unwrap();
3305 let parsed: ChecklistItem = serde_json::from_str(&json).unwrap();
3306 assert_eq!(parsed, checked_item);
3307
3308 let unchecked_item = ChecklistItem {
3309 label: "Pending task".into(),
3310 checked: false,
3311 href: None,
3312 };
3313 let json = serde_json::to_string(&unchecked_item).unwrap();
3314 let parsed: ChecklistItem = serde_json::from_str(&json).unwrap();
3315 assert_eq!(parsed, unchecked_item);
3316 assert!(!json.contains("href"));
3318 }
3319
3320 #[test]
3321 fn test_sidebar_group_round_trip() {
3322 let expanded = SidebarGroup {
3323 label: "Main".into(),
3324 collapsed: false,
3325 items: vec![
3326 SidebarNavItem {
3327 label: "Home".into(),
3328 href: "/".into(),
3329 icon: Some("home".into()),
3330 active: true,
3331 },
3332 SidebarNavItem {
3333 label: "About".into(),
3334 href: "/about".into(),
3335 icon: None,
3336 active: false,
3337 },
3338 ],
3339 };
3340 let json = serde_json::to_string(&expanded).unwrap();
3341 let parsed: SidebarGroup = serde_json::from_str(&json).unwrap();
3342 assert_eq!(parsed, expanded);
3343 assert_eq!(parsed.items.len(), 2);
3344
3345 let collapsed = SidebarGroup {
3346 label: "Advanced".into(),
3347 collapsed: true,
3348 items: vec![],
3349 };
3350 let json = serde_json::to_string(&collapsed).unwrap();
3351 let parsed: SidebarGroup = serde_json::from_str(&json).unwrap();
3352 assert_eq!(parsed, collapsed);
3353 assert!(parsed.collapsed);
3354 }
3355
3356 #[test]
3357 fn test_notification_item_round_trip() {
3358 let unread = NotificationItem {
3359 icon: Some("mail".into()),
3360 text: "You have a new message".into(),
3361 timestamp: Some("5m ago".into()),
3362 read: false,
3363 action_url: Some("/messages/42".into()),
3364 };
3365 let json = serde_json::to_string(&unread).unwrap();
3366 let parsed: NotificationItem = serde_json::from_str(&json).unwrap();
3367 assert_eq!(parsed, unread);
3368 assert!(!parsed.read);
3369
3370 let read_notif = NotificationItem {
3371 icon: None,
3372 text: "Welcome to the platform".into(),
3373 timestamp: None,
3374 read: true,
3375 action_url: None,
3376 };
3377 let json = serde_json::to_string(&read_notif).unwrap();
3378 let parsed: NotificationItem = serde_json::from_str(&json).unwrap();
3379 assert_eq!(parsed, read_notif);
3380 assert!(parsed.read);
3381 assert!(!json.contains("\"icon\""));
3383 assert!(!json.contains("\"action_url\""));
3384 }
3385
3386 #[test]
3389 fn test_stat_card_all_optionals_none() {
3390 let component = Component::StatCard(StatCardProps {
3391 label: "Count".into(),
3392 value: "0".into(),
3393 icon: None,
3394 subtitle: None,
3395 sse_target: None,
3396 });
3397 let json = serde_json::to_string(&component).unwrap();
3398 assert!(json.contains("\"type\":\"StatCard\""));
3399 assert!(!json.contains("\"icon\""));
3400 assert!(!json.contains("\"subtitle\""));
3401 assert!(!json.contains("\"sse_target\""));
3402 let deserialized: Component = serde_json::from_str(&json).unwrap();
3403 assert_eq!(component, deserialized);
3404 }
3405
3406 #[test]
3407 fn test_checklist_empty_items() {
3408 let component = Component::Checklist(ChecklistProps {
3409 title: "Empty List".into(),
3410 items: vec![],
3411 dismissible: true,
3412 dismiss_label: None,
3413 data_key: None,
3414 });
3415 let json = serde_json::to_string(&component).unwrap();
3416 assert!(json.contains("\"type\":\"Checklist\""));
3417 let deserialized: Component = serde_json::from_str(&json).unwrap();
3418 assert_eq!(component, deserialized);
3419 match &deserialized {
3420 Component::Checklist(props) => assert!(props.items.is_empty()),
3421 _ => panic!("expected Checklist"),
3422 }
3423 }
3424
3425 #[test]
3426 fn test_sidebar_empty_groups_and_fixed() {
3427 let component = Component::Sidebar(SidebarProps {
3428 fixed_top: vec![],
3429 groups: vec![],
3430 fixed_bottom: vec![],
3431 });
3432 let json = serde_json::to_string(&component).unwrap();
3433 assert!(json.contains("\"type\":\"Sidebar\""));
3434 assert!(!json.contains("\"fixed_top\""));
3436 assert!(!json.contains("\"groups\""));
3437 assert!(!json.contains("\"fixed_bottom\""));
3438 let deserialized: Component = serde_json::from_str(&json).unwrap();
3439 assert_eq!(component, deserialized);
3440 }
3441
3442 #[test]
3443 fn test_notification_dropdown_empty_uses_empty_text() {
3444 let component = Component::NotificationDropdown(NotificationDropdownProps {
3445 notifications: vec![],
3446 empty_text: Some("Nothing here!".into()),
3447 });
3448 let json = serde_json::to_string(&component).unwrap();
3449 assert!(json.contains("\"type\":\"NotificationDropdown\""));
3450 assert!(json.contains("\"empty_text\":\"Nothing here!\""));
3451 let deserialized: Component = serde_json::from_str(&json).unwrap();
3452 assert_eq!(component, deserialized);
3453 }
3454
3455 #[test]
3458 fn test_stat_card_omits_sse_target_when_none() {
3459 let component = Component::StatCard(StatCardProps {
3460 label: "Revenue".into(),
3461 value: "$500".into(),
3462 icon: None,
3463 subtitle: None,
3464 sse_target: None,
3465 });
3466 let json = serde_json::to_string(&component).unwrap();
3467 assert!(
3468 !json.contains("sse_target"),
3469 "sse_target must be omitted when None"
3470 );
3471 }
3472
3473 #[test]
3476 fn grid_round_trips() {
3477 let grid = Component::Grid(GridProps {
3478 columns: 3,
3479 md_columns: None,
3480 lg_columns: None,
3481 gap: GapSize::Lg,
3482 scrollable: None,
3483 children: vec![ComponentNode::text(
3484 "t",
3485 TextProps {
3486 content: "cell".into(),
3487 element: TextElement::P,
3488 },
3489 )],
3490 });
3491 let json = serde_json::to_value(&grid).unwrap();
3492 assert_eq!(json["type"], "Grid");
3493 assert_eq!(json["columns"], 3);
3494 assert_eq!(json["gap"], "lg");
3495 let parsed: Component = serde_json::from_value(json).unwrap();
3496 assert_eq!(parsed, grid);
3497 }
3498
3499 #[test]
3500 fn grid_defaults() {
3501 let json = serde_json::json!({"type": "Grid"});
3502 let parsed: Component = serde_json::from_value(json).unwrap();
3503 match parsed {
3504 Component::Grid(props) => {
3505 assert_eq!(props.columns, 2);
3506 assert_eq!(props.gap, GapSize::Md);
3507 assert!(props.children.is_empty());
3508 }
3509 _ => panic!("expected Grid"),
3510 }
3511 }
3512
3513 #[test]
3516 fn collapsible_round_trips() {
3517 let c = Component::Collapsible(CollapsibleProps {
3518 title: "Details".into(),
3519 expanded: true,
3520 children: vec![],
3521 });
3522 let json = serde_json::to_value(&c).unwrap();
3523 assert_eq!(json["type"], "Collapsible");
3524 assert_eq!(json["title"], "Details");
3525 assert_eq!(json["expanded"], true);
3526 let parsed: Component = serde_json::from_value(json).unwrap();
3527 assert_eq!(parsed, c);
3528 }
3529
3530 #[test]
3533 fn empty_state_round_trips() {
3534 let es = Component::EmptyState(EmptyStateProps {
3535 title: "No items".into(),
3536 description: Some("Create one".into()),
3537 action: Some(Action::get("items.create")),
3538 action_label: Some("New item".into()),
3539 });
3540 let json = serde_json::to_value(&es).unwrap();
3541 assert_eq!(json["type"], "EmptyState");
3542 assert_eq!(json["title"], "No items");
3543 let parsed: Component = serde_json::from_value(json).unwrap();
3544 assert_eq!(parsed, es);
3545 }
3546
3547 #[test]
3548 fn empty_state_minimal() {
3549 let json = serde_json::json!({"type": "EmptyState", "title": "Nothing"});
3550 let parsed: Component = serde_json::from_value(json).unwrap();
3551 match parsed {
3552 Component::EmptyState(props) => {
3553 assert_eq!(props.title, "Nothing");
3554 assert!(props.description.is_none());
3555 assert!(props.action.is_none());
3556 assert!(props.action_label.is_none());
3557 }
3558 _ => panic!("expected EmptyState"),
3559 }
3560 }
3561
3562 #[test]
3565 fn form_section_round_trips() {
3566 let fs = Component::FormSection(FormSectionProps {
3567 title: "Contact".into(),
3568 description: Some("Your details".into()),
3569 children: vec![],
3570 layout: None,
3571 });
3572 let json = serde_json::to_value(&fs).unwrap();
3573 assert_eq!(json["type"], "FormSection");
3574 assert_eq!(json["title"], "Contact");
3575 let parsed: Component = serde_json::from_value(json).unwrap();
3576 assert_eq!(parsed, fs);
3577 }
3578
3579 #[test]
3582 fn switch_with_action_round_trips() {
3583 let sw = Component::Switch(SwitchProps {
3584 field: "active".into(),
3585 label: "Active".into(),
3586 description: None,
3587 checked: Some(true),
3588 data_path: None,
3589 required: None,
3590 disabled: None,
3591 error: None,
3592 action: Some(Action::new("settings.toggle")),
3593 compact: false,
3594 });
3595 let json = serde_json::to_value(&sw).unwrap();
3596 assert!(json["action"].is_object());
3597 assert_eq!(json["action"]["handler"], "settings.toggle");
3598 let parsed: Component = serde_json::from_value(json).unwrap();
3599 assert_eq!(parsed, sw);
3600 }
3601
3602 #[test]
3603 fn switch_without_action_omits_field() {
3604 let sw = Component::Switch(SwitchProps {
3605 field: "f".into(),
3606 label: "l".into(),
3607 description: None,
3608 checked: None,
3609 data_path: None,
3610 required: None,
3611 disabled: None,
3612 error: None,
3613 action: None,
3614 compact: false,
3615 });
3616 let json = serde_json::to_string(&sw).unwrap();
3617 assert!(!json.contains("\"action\""));
3618 }
3619
3620 #[test]
3621 fn test_toast_omits_timeout_when_none() {
3622 let component = Component::Toast(ToastProps {
3623 message: "Hello".into(),
3624 variant: ToastVariant::Info,
3625 timeout: None,
3626 dismissible: false,
3627 });
3628 let json = serde_json::to_string(&component).unwrap();
3629 assert!(
3630 !json.contains("\"timeout\""),
3631 "timeout must be omitted when None"
3632 );
3633 }
3634
3635 #[test]
3636 fn page_header_round_trip_title_only() {
3637 let component = Component::PageHeader(PageHeaderProps {
3638 title: "Test Title".to_string(),
3639 breadcrumb: vec![],
3640 actions: vec![],
3641 });
3642 let json = serde_json::to_value(&component).unwrap();
3643 assert_eq!(json["type"], "PageHeader");
3644 assert_eq!(json["title"], "Test Title");
3645 assert!(json.get("breadcrumb").is_none());
3647 assert!(json.get("actions").is_none());
3648 let parsed: Component = serde_json::from_value(json).unwrap();
3649 assert_eq!(parsed, component);
3650 }
3651
3652 #[test]
3653 fn page_header_round_trip_with_breadcrumb_and_actions() {
3654 let component = Component::PageHeader(PageHeaderProps {
3655 title: "Users".to_string(),
3656 breadcrumb: vec![
3657 BreadcrumbItem {
3658 label: "Home".to_string(),
3659 url: Some("/".to_string()),
3660 },
3661 BreadcrumbItem {
3662 label: "Users".to_string(),
3663 url: None,
3664 },
3665 ],
3666 actions: vec![ComponentNode {
3667 key: "add-btn".to_string(),
3668 component: Component::Button(ButtonProps {
3669 label: "Add User".to_string(),
3670 variant: ButtonVariant::Default,
3671 size: Size::Default,
3672 disabled: None,
3673 icon: None,
3674 icon_position: None,
3675 button_type: None,
3676 }),
3677 action: None,
3678 visibility: None,
3679 }],
3680 });
3681 let json = serde_json::to_string(&component).unwrap();
3682 let parsed: Component = serde_json::from_str(&json).unwrap();
3683 assert_eq!(parsed, component);
3684 let value = serde_json::to_value(&component).unwrap();
3686 assert_eq!(value["type"], "PageHeader");
3687 assert_eq!(value["title"], "Users");
3688 assert!(value["breadcrumb"].is_array());
3689 assert!(value["actions"].is_array());
3690 }
3691
3692 #[test]
3693 fn page_header_deserialize_from_json() {
3694 let json = r#"{"type":"PageHeader","title":"Test"}"#;
3695 let component: Component = serde_json::from_str(json).unwrap();
3696 match component {
3697 Component::PageHeader(props) => {
3698 assert_eq!(props.title, "Test");
3699 assert!(props.breadcrumb.is_empty());
3700 assert!(props.actions.is_empty());
3701 }
3702 _ => panic!("expected PageHeader"),
3703 }
3704 }
3705
3706 #[test]
3707 fn button_group_round_trip_empty() {
3708 let component = Component::ButtonGroup(ButtonGroupProps { buttons: vec![] });
3709 let json = serde_json::to_value(&component).unwrap();
3710 assert_eq!(json["type"], "ButtonGroup");
3711 assert!(json.get("buttons").is_none());
3713 let parsed: Component = serde_json::from_value(json).unwrap();
3714 assert_eq!(parsed, component);
3715 }
3716
3717 #[test]
3718 fn button_group_round_trip_with_buttons() {
3719 let component = Component::ButtonGroup(ButtonGroupProps {
3720 buttons: vec![
3721 ComponentNode {
3722 key: "save".to_string(),
3723 component: Component::Button(ButtonProps {
3724 label: "Save".to_string(),
3725 variant: ButtonVariant::Default,
3726 size: Size::Default,
3727 disabled: None,
3728 icon: None,
3729 icon_position: None,
3730 button_type: None,
3731 }),
3732 action: None,
3733 visibility: None,
3734 },
3735 ComponentNode {
3736 key: "cancel".to_string(),
3737 component: Component::Button(ButtonProps {
3738 label: "Cancel".to_string(),
3739 variant: ButtonVariant::Outline,
3740 size: Size::Default,
3741 disabled: None,
3742 icon: None,
3743 icon_position: None,
3744 button_type: None,
3745 }),
3746 action: None,
3747 visibility: None,
3748 },
3749 ],
3750 });
3751 let json = serde_json::to_string(&component).unwrap();
3752 let parsed: Component = serde_json::from_str(&json).unwrap();
3753 assert_eq!(parsed, component);
3754 let value = serde_json::to_value(&component).unwrap();
3755 assert_eq!(value["type"], "ButtonGroup");
3756 assert!(value["buttons"].is_array());
3757 assert_eq!(value["buttons"].as_array().unwrap().len(), 2);
3758 }
3759
3760 #[test]
3761 fn button_group_deserialize_from_json() {
3762 let json = r#"{"type":"ButtonGroup","buttons":[]}"#;
3763 let component: Component = serde_json::from_str(json).unwrap();
3764 match component {
3765 Component::ButtonGroup(props) => {
3766 assert!(props.buttons.is_empty());
3767 }
3768 _ => panic!("expected ButtonGroup"),
3769 }
3770 }
3771
3772 #[test]
3773 fn image_round_trips() {
3774 let json = r#"{"type": "Image", "src": "/img/s.png", "alt": "Screenshot"}"#;
3776 let component: Component = serde_json::from_str(json).expect("URL variant");
3777 match component {
3778 Component::Image(props) => {
3779 assert!(
3780 matches!(props.source, ImageSource::Url { .. }),
3781 "URL JSON must deserialize to ImageSource::Url"
3782 );
3783 assert_eq!(props.alt, "Screenshot");
3784 assert!(props.aspect_ratio.is_none());
3785 }
3786 _ => panic!("expected Component::Image"),
3787 }
3788
3789 let json_svg = r#"{"type": "Image", "svg": "<svg></svg>", "alt": "Chart"}"#;
3791 let component_svg: Component = serde_json::from_str(json_svg).expect("InlineSvg variant");
3792 match component_svg {
3793 Component::Image(props) => {
3794 assert!(
3795 matches!(props.source, ImageSource::InlineSvg { .. }),
3796 "SVG JSON must deserialize to ImageSource::InlineSvg"
3797 );
3798 assert_eq!(props.alt, "Chart");
3799 }
3800 _ => panic!("expected Component::Image"),
3801 }
3802
3803 let json_neither = r#"{"type": "Image", "alt": "Bad"}"#;
3805 serde_json::from_str::<Component>(json_neither)
3806 .expect_err("input without src or svg must be rejected");
3807 }
3808
3809 #[test]
3810 fn all_known_types_round_trip() {
3811 let known_types: &[(&str, &str)] = &[
3812 ("Alert", r#"{"type":"Alert","message":"m"}"#),
3813 ("Avatar", r#"{"type":"Avatar","alt":"a"}"#),
3814 ("Badge", r#"{"type":"Badge","label":"b"}"#),
3815 ("Breadcrumb", r#"{"type":"Breadcrumb","items":[]}"#),
3816 ("Button", r#"{"type":"Button","label":"b"}"#),
3817 ("CalendarCell", r#"{"type":"CalendarCell","day":1}"#),
3818 ("Checkbox", r#"{"type":"Checkbox","field":"f","label":"l"}"#),
3819 ("Image", r#"{"type":"Image","src":"/img/s.png","alt":"a"}"#),
3820 ("Input", r#"{"type":"Input","field":"f","label":"l"}"#),
3821 (
3822 "Pagination",
3823 r#"{"type":"Pagination","current_page":1,"per_page":10,"total":0}"#,
3824 ),
3825 ("Progress", r#"{"type":"Progress","value":50}"#),
3826 (
3827 "Select",
3828 r#"{"type":"Select","field":"f","label":"l","options":[]}"#,
3829 ),
3830 ("Separator", r#"{"type":"Separator"}"#),
3831 ("Skeleton", r#"{"type":"Skeleton"}"#),
3832 ("Text", r#"{"type":"Text","content":"c"}"#),
3833 ];
3834 for (type_name, json_str) in known_types {
3835 let component: Component = serde_json::from_str(json_str)
3836 .unwrap_or_else(|e| panic!("failed to parse {type_name}: {e}"));
3837 let serialized = serde_json::to_value(&component).unwrap();
3838 assert_eq!(
3839 serialized["type"], *type_name,
3840 "type mismatch for {type_name}"
3841 );
3842 let reparsed: Component = serde_json::from_value(serialized)
3843 .unwrap_or_else(|e| panic!("failed to reparse {type_name}: {e}"));
3844 assert_eq!(
3845 serde_json::to_value(&reparsed).unwrap()["type"],
3846 *type_name,
3847 "round-trip type mismatch for {type_name}"
3848 );
3849 }
3850
3851 let svg_json = r#"{"type":"Image","svg":"<svg/>","alt":"chart"}"#;
3856 let parsed: Component =
3857 serde_json::from_str(svg_json).expect("InlineSvg JSON must deserialize");
3858 let serialized = serde_json::to_value(&parsed).expect("InlineSvg component must serialize");
3859 assert_eq!(
3860 serialized.get("type").and_then(|v| v.as_str()),
3861 Some("Image"),
3862 "InlineSvg variant must serialize with type=Image"
3863 );
3864 assert!(
3865 serialized.get("svg").is_some(),
3866 "InlineSvg serialization must carry the svg field"
3867 );
3868 assert!(
3869 serialized.get("src").is_none(),
3870 "InlineSvg serialization must NOT carry a src field"
3871 );
3872 let reparsed: Component = serde_json::from_value(serialized).expect("round-trip reparse");
3873 assert_eq!(parsed, reparsed, "round-trip must preserve equality");
3874 }
3875}
3876
3877#[cfg(test)]
3878mod key_value_editor_tests {
3879 use super::*;
3880 use serde_json::json;
3881
3882 #[test]
3883 fn key_value_editor_serde_roundtrip() {
3884 let original = Component::KeyValueEditor(KeyValueEditorProps {
3885 field: "metadata".to_string(),
3886 label: Some("Metadata".to_string()),
3887 suggested_keys: vec!["env".to_string(), "region".to_string()],
3888 allow_custom_keys: false,
3889 data_path: Some("/meta".to_string()),
3890 error: Some("required".to_string()),
3891 });
3892
3893 let serialized =
3894 serde_json::to_value(&original).expect("serialize KeyValueEditor component");
3895
3896 assert_eq!(
3898 serialized.get("type").and_then(|v| v.as_str()),
3899 Some("KeyValueEditor"),
3900 "serialized form must have type=KeyValueEditor: {serialized}"
3901 );
3902 assert_eq!(
3903 serialized.get("field").and_then(|v| v.as_str()),
3904 Some("metadata")
3905 );
3906 assert_eq!(
3907 serialized
3908 .get("allow_custom_keys")
3909 .and_then(|v| v.as_bool()),
3910 Some(false)
3911 );
3912
3913 let deserialized: Component =
3915 serde_json::from_value(serialized).expect("deserialize KeyValueEditor component");
3916 match deserialized {
3917 Component::KeyValueEditor(ref p) => {
3918 assert_eq!(p.field, "metadata");
3919 assert_eq!(p.label.as_deref(), Some("Metadata"));
3920 assert_eq!(
3921 p.suggested_keys,
3922 vec!["env".to_string(), "region".to_string()]
3923 );
3924 assert!(!p.allow_custom_keys);
3925 assert_eq!(p.data_path.as_deref(), Some("/meta"));
3926 assert_eq!(p.error.as_deref(), Some("required"));
3927 }
3928 other => panic!("expected KeyValueEditor, got {other:?}"),
3929 }
3930 assert_eq!(original, deserialized, "PartialEq round-trip failed");
3931 }
3932
3933 #[test]
3934 fn key_value_editor_allow_custom_keys_defaults_to_true() {
3935 let json_input = json!({
3937 "type": "KeyValueEditor",
3938 "field": "meta",
3939 });
3940 let parsed: Component =
3941 serde_json::from_value(json_input).expect("deserialize minimal KeyValueEditor");
3942 match parsed {
3943 Component::KeyValueEditor(p) => {
3944 assert!(
3945 p.allow_custom_keys,
3946 "allow_custom_keys default must be true"
3947 );
3948 assert!(
3949 p.suggested_keys.is_empty(),
3950 "suggested_keys default must be empty"
3951 );
3952 assert!(p.label.is_none());
3953 assert!(p.data_path.is_none());
3954 assert!(p.error.is_none());
3955 }
3956 other => panic!("expected KeyValueEditor, got {other:?}"),
3957 }
3958 }
3959}
3960
3961#[cfg(test)]
3962mod detail_form_tests {
3963 use super::*;
3964 use crate::action::{Action, HttpMethod};
3965 use serde_json::json;
3966
3967 #[test]
3970 fn edit_mode_default_is_view() {
3971 assert_eq!(EditMode::default(), EditMode::View);
3972 }
3973
3974 #[test]
3975 fn edit_mode_from_query_exact_edit() {
3976 assert_eq!(EditMode::from_query(Some("edit")), EditMode::Edit);
3977 }
3978
3979 #[test]
3980 fn edit_mode_from_query_case_insensitive_upper() {
3981 assert_eq!(EditMode::from_query(Some("EDIT")), EditMode::Edit);
3982 }
3983
3984 #[test]
3985 fn edit_mode_from_query_case_insensitive_mixed() {
3986 assert_eq!(EditMode::from_query(Some("eDiT")), EditMode::Edit);
3987 }
3988
3989 #[test]
3990 fn edit_mode_from_query_title_case() {
3991 assert_eq!(EditMode::from_query(Some("Edit")), EditMode::Edit);
3992 }
3993
3994 #[test]
3995 fn edit_mode_from_query_none_is_view() {
3996 assert_eq!(EditMode::from_query(None), EditMode::View);
3997 }
3998
3999 #[test]
4000 fn edit_mode_from_query_empty_is_view() {
4001 assert_eq!(EditMode::from_query(Some("")), EditMode::View);
4002 }
4003
4004 #[test]
4005 fn edit_mode_from_query_view_literal_is_view() {
4006 assert_eq!(EditMode::from_query(Some("view")), EditMode::View);
4007 }
4008
4009 #[test]
4010 fn edit_mode_from_query_unknown_is_view() {
4011 assert_eq!(EditMode::from_query(Some("anything-else")), EditMode::View);
4012 }
4013
4014 #[test]
4015 fn edit_mode_serializes_as_snake_case() {
4016 assert_eq!(
4017 serde_json::to_value(EditMode::Edit).expect("serialize Edit"),
4018 json!("edit")
4019 );
4020 assert_eq!(
4021 serde_json::to_value(EditMode::View).expect("serialize View"),
4022 json!("view")
4023 );
4024 let parsed_edit: EditMode =
4025 serde_json::from_value(json!("edit")).expect("deserialize 'edit'");
4026 assert_eq!(parsed_edit, EditMode::Edit);
4027 let parsed_view: EditMode =
4028 serde_json::from_value(json!("view")).expect("deserialize 'view'");
4029 assert_eq!(parsed_view, EditMode::View);
4030 }
4031
4032 fn sample_detail_form_props() -> DetailFormProps {
4035 DetailFormProps {
4036 mode: EditMode::Edit,
4037 action: Action {
4038 handler: "users.update".to_string(),
4039 url: Some("/users/1".to_string()),
4040 method: HttpMethod::Put,
4041 confirm: None,
4042 on_success: None,
4043 on_error: None,
4044 target: None,
4045 },
4046 fields: vec![
4047 DetailField {
4048 label: "Name".to_string(),
4049 value: "Ada".to_string(),
4050 input: ComponentNode::input(
4051 "name",
4052 InputProps {
4053 field: "name".to_string(),
4054 label: "".to_string(),
4055 input_type: InputType::Text,
4056 placeholder: None,
4057 required: None,
4058 disabled: None,
4059 error: None,
4060 description: None,
4061 default_value: None,
4062 data_path: None,
4063 step: None,
4064 list: None,
4065 },
4066 ),
4067 },
4068 DetailField {
4069 label: "Email".to_string(),
4070 value: "ada@example.com".to_string(),
4071 input: ComponentNode::input(
4072 "email",
4073 InputProps {
4074 field: "email".to_string(),
4075 label: "".to_string(),
4076 input_type: InputType::Email,
4077 placeholder: None,
4078 required: None,
4079 disabled: None,
4080 error: None,
4081 description: None,
4082 default_value: None,
4083 data_path: None,
4084 step: None,
4085 list: None,
4086 },
4087 ),
4088 },
4089 ],
4090 edit_url: "/users/1?mode=edit".to_string(),
4091 cancel_url: "/users/1".to_string(),
4092 edit_label: Some("Modifica".to_string()),
4093 save_label: Some("Salva".to_string()),
4094 cancel_label: Some("Annulla".to_string()),
4095 method: Some(HttpMethod::Put),
4096 }
4097 }
4098
4099 #[test]
4100 fn detail_form_props_serde_roundtrip() {
4101 let original = Component::DetailForm(sample_detail_form_props());
4102 let serialized = serde_json::to_value(&original).expect("serialize DetailForm component");
4103 assert_eq!(
4104 serialized.get("type").and_then(|v| v.as_str()),
4105 Some("DetailForm"),
4106 "serialized form must have type=DetailForm: {serialized}"
4107 );
4108 let deserialized: Component =
4109 serde_json::from_value(serialized).expect("deserialize DetailForm component");
4110 assert_eq!(original, deserialized, "PartialEq round-trip failed");
4111 }
4112
4113 #[test]
4114 fn detail_form_props_omits_optional_nones() {
4115 let props = DetailFormProps {
4116 mode: EditMode::View,
4117 action: Action {
4118 handler: "x".to_string(),
4119 url: None,
4120 method: HttpMethod::Post,
4121 confirm: None,
4122 on_success: None,
4123 on_error: None,
4124 target: None,
4125 },
4126 fields: Vec::new(),
4127 edit_url: "/x?mode=edit".to_string(),
4128 cancel_url: "/x".to_string(),
4129 edit_label: None,
4130 save_label: None,
4131 cancel_label: None,
4132 method: None,
4133 };
4134 let v = serde_json::to_value(&props).expect("serialize");
4135 assert!(
4136 v.get("edit_label").is_none(),
4137 "edit_label=None must be skipped, got: {v}"
4138 );
4139 assert!(
4140 v.get("save_label").is_none(),
4141 "save_label=None must be skipped"
4142 );
4143 assert!(
4144 v.get("cancel_label").is_none(),
4145 "cancel_label=None must be skipped"
4146 );
4147 assert!(v.get("method").is_none(), "method=None must be skipped");
4148 }
4149
4150 #[test]
4151 fn detail_form_props_defaults_mode_to_view() {
4152 let v = json!({
4153 "action": {"handler": "x", "method": "POST"},
4154 "fields": [],
4155 "edit_url": "/x?mode=edit",
4156 "cancel_url": "/x"
4157 });
4158 let props: DetailFormProps =
4159 serde_json::from_value(v).expect("deserialize DetailFormProps without mode");
4160 assert_eq!(
4161 props.mode,
4162 EditMode::View,
4163 "missing 'mode' must default to View"
4164 );
4165 }
4166
4167 #[test]
4170 fn component_node_detail_form_factory_shape() {
4171 let node = ComponentNode::detail_form("details", sample_detail_form_props());
4172 assert_eq!(node.key, "details");
4173 assert!(node.action.is_none());
4174 assert!(node.visibility.is_none());
4175 assert!(
4176 matches!(node.component, Component::DetailForm(_)),
4177 "expected Component::DetailForm variant"
4178 );
4179 }
4180}
4181
4182#[cfg(test)]
4183mod image_source_tests {
4184 use super::*;
4185 use serde_json::json;
4186
4187 #[test]
4188 fn image_source_url_roundtrip() {
4189 let parsed: ImageSource =
4190 serde_json::from_value(json!({"src": "/a.png"})).expect("Url variant");
4191 match parsed {
4192 ImageSource::Url { src } => assert_eq!(src, "/a.png"),
4193 _ => panic!("expected ImageSource::Url"),
4194 }
4195 }
4196
4197 #[test]
4198 fn image_source_inline_svg_roundtrip() {
4199 let parsed: ImageSource =
4200 serde_json::from_value(json!({"svg": "<svg/>"})).expect("InlineSvg variant");
4201 match parsed {
4202 ImageSource::InlineSvg { svg } => assert_eq!(svg, "<svg/>"),
4203 _ => panic!("expected ImageSource::InlineSvg"),
4204 }
4205 }
4206
4207 #[test]
4208 fn image_source_neither_rejected() {
4209 serde_json::from_value::<ImageSource>(json!({}))
4210 .expect_err("empty object (no src, no svg) must fail to deserialize");
4211 }
4212
4213 #[test]
4214 fn image_props_url_constructor() {
4215 let p = ImageProps::url("/a.png", "alt");
4216 assert!(matches!(p.source, ImageSource::Url { .. }));
4217 match &p.source {
4218 ImageSource::Url { src } => assert_eq!(src, "/a.png"),
4219 _ => unreachable!(),
4220 }
4221 assert_eq!(p.alt, "alt");
4222 assert!(p.aspect_ratio.is_none());
4223 assert!(p.placeholder_label.is_none());
4224 }
4225
4226 #[test]
4227 fn image_props_inline_svg_constructor() {
4228 let p = ImageProps::inline_svg("<svg/>", "chart");
4229 assert!(matches!(p.source, ImageSource::InlineSvg { .. }));
4230 match &p.source {
4231 ImageSource::InlineSvg { svg } => assert_eq!(svg, "<svg/>"),
4232 _ => unreachable!(),
4233 }
4234 assert_eq!(p.alt, "chart");
4235 assert!(p.aspect_ratio.is_none());
4236 assert!(p.placeholder_label.is_none());
4237 }
4238}