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, Serialize, Deserialize, JsonSchema)]
538pub struct RichTextEditorProps {
539 pub name: String,
548 #[serde(default, skip_serializing_if = "Option::is_none")]
553 pub value: Option<String>,
554 #[serde(default = "default_rte_formats")]
558 pub formats: Vec<String>,
559 #[serde(default, skip_serializing_if = "Option::is_none")]
561 pub placeholder: Option<String>,
562 #[serde(default = "default_rte_theme")]
564 pub theme: String,
565 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub label: Option<String>,
568 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub error: Option<String>,
572 #[serde(default, skip_serializing_if = "Option::is_none")]
576 pub data_path: Option<String>,
577 #[serde(default, skip_serializing_if = "Option::is_none")]
581 pub required: Option<bool>,
582}
583
584#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
586pub struct SeparatorProps {
587 #[serde(default, skip_serializing_if = "Option::is_none")]
588 pub orientation: Option<Orientation>,
589}
590
591#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
593pub struct DescriptionItem {
594 pub label: String,
595 pub value: String,
596 #[serde(default, skip_serializing_if = "Option::is_none")]
597 pub format: Option<ColumnFormat>,
598}
599
600#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
602pub struct DescriptionListProps {
603 pub items: Vec<DescriptionItem>,
604 #[serde(default, skip_serializing_if = "Option::is_none")]
605 pub columns: Option<u8>,
606}
607
608#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
611pub struct Tab {
612 pub value: String,
613 pub label: String,
614 #[serde(default, skip_serializing_if = "Vec::is_empty")]
615 pub children: Vec<ComponentNode>,
616}
617
618#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
621pub struct TabsProps {
622 pub default_tab: String,
623 pub tabs: Vec<Tab>,
624}
625
626#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
628pub struct BreadcrumbItem {
629 pub label: String,
630 #[serde(default, skip_serializing_if = "Option::is_none")]
631 pub url: Option<String>,
632}
633
634#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
636pub struct BreadcrumbProps {
637 pub items: Vec<BreadcrumbItem>,
638}
639
640#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
642pub struct PaginationProps {
643 pub current_page: u32,
644 pub per_page: u32,
645 pub total: u32,
646 #[serde(default, skip_serializing_if = "Option::is_none")]
647 pub base_url: Option<String>,
648}
649
650#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
652pub struct ProgressProps {
653 pub value: u8,
655 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub max: Option<u8>,
657 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub label: Option<String>,
659}
660
661#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
671#[serde(untagged)]
672pub enum ImageSource {
673 Url {
675 src: String,
677 },
678 InlineSvg {
692 svg: String,
694 },
695}
696
697#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
704pub struct ImageProps {
705 #[serde(flatten)]
710 pub source: ImageSource,
711 pub alt: String,
713 #[serde(default, skip_serializing_if = "Option::is_none")]
715 pub aspect_ratio: Option<String>,
716 #[serde(default, skip_serializing_if = "Option::is_none")]
721 pub placeholder_label: Option<String>,
722}
723
724impl ImageProps {
725 pub fn url(src: impl Into<String>, alt: impl Into<String>) -> Self {
731 Self {
732 source: ImageSource::Url { src: src.into() },
733 alt: alt.into(),
734 aspect_ratio: None,
735 placeholder_label: None,
736 }
737 }
738
739 pub fn inline_svg(svg: impl Into<String>, alt: impl Into<String>) -> Self {
749 Self {
750 source: ImageSource::InlineSvg { svg: svg.into() },
751 alt: alt.into(),
752 aspect_ratio: None,
753 placeholder_label: None,
754 }
755 }
756}
757
758#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
760pub struct AvatarProps {
761 #[serde(default, skip_serializing_if = "Option::is_none")]
762 pub src: Option<String>,
763 pub alt: String,
764 #[serde(default, skip_serializing_if = "Option::is_none")]
765 pub fallback: Option<String>,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
767 pub size: Option<Size>,
768}
769
770#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
772pub struct SkeletonProps {
773 #[serde(default, skip_serializing_if = "Option::is_none")]
774 pub width: Option<String>,
775 #[serde(default, skip_serializing_if = "Option::is_none")]
776 pub height: Option<String>,
777 #[serde(default, skip_serializing_if = "Option::is_none")]
778 pub rounded: Option<bool>,
779}
780
781#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
783#[serde(rename_all = "snake_case")]
784pub enum ToastVariant {
785 #[default]
786 Info,
787 Success,
788 Warning,
789 Error,
790}
791
792#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
794pub struct ChecklistItem {
795 pub label: String,
796 #[serde(default)]
797 pub checked: bool,
798 #[serde(default, skip_serializing_if = "Option::is_none")]
799 pub href: Option<String>,
800}
801
802#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
804pub struct NotificationItem {
805 #[serde(default, skip_serializing_if = "Option::is_none")]
806 pub icon: Option<String>,
807 pub text: String,
808 #[serde(default, skip_serializing_if = "Option::is_none")]
809 pub timestamp: Option<String>,
810 #[serde(default)]
811 pub read: bool,
812 #[serde(default, skip_serializing_if = "Option::is_none")]
813 pub action_url: Option<String>,
814}
815
816#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
818pub struct SidebarNavItem {
819 pub label: String,
820 pub href: String,
821 #[serde(default, skip_serializing_if = "Option::is_none")]
822 pub icon: Option<String>,
823 #[serde(default)]
824 pub active: bool,
825}
826
827#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
829pub struct SidebarGroup {
830 pub label: String,
831 #[serde(default)]
832 pub collapsed: bool,
833 pub items: Vec<SidebarNavItem>,
834}
835
836#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
838pub struct StatCardProps {
839 pub label: String,
840 pub value: String,
841 #[serde(default, skip_serializing_if = "Option::is_none")]
842 pub icon: Option<String>,
843 #[serde(default, skip_serializing_if = "Option::is_none")]
844 pub subtitle: Option<String>,
845 #[serde(default, skip_serializing_if = "Option::is_none")]
847 pub sse_target: Option<String>,
848}
849
850#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
852pub struct ChecklistProps {
853 pub title: String,
854 pub items: Vec<ChecklistItem>,
855 #[serde(default = "default_true")]
856 pub dismissible: bool,
857 #[serde(default, skip_serializing_if = "Option::is_none")]
858 pub dismiss_label: Option<String>,
859 #[serde(default, skip_serializing_if = "Option::is_none")]
861 pub data_key: Option<String>,
862}
863
864fn default_true() -> bool {
865 true
866}
867
868fn default_rte_formats() -> Vec<String> {
876 vec![
877 "bold".to_string(),
878 "italic".to_string(),
879 "underline".to_string(),
880 "list".to_string(),
881 "header".to_string(),
882 "link".to_string(),
883 ]
884}
885
886fn default_rte_theme() -> String {
891 "snow".to_string()
892}
893
894#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
899pub struct ToastProps {
900 pub message: String,
901 #[serde(default)]
902 pub variant: ToastVariant,
903 #[serde(default, skip_serializing_if = "Option::is_none")]
905 pub timeout: Option<u32>,
906 #[serde(default = "default_true")]
907 pub dismissible: bool,
908}
909
910#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
912pub struct NotificationDropdownProps {
913 pub notifications: Vec<NotificationItem>,
914 #[serde(default, skip_serializing_if = "Option::is_none")]
915 pub empty_text: Option<String>,
916}
917
918#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
920pub struct SidebarProps {
921 #[serde(default, skip_serializing_if = "Vec::is_empty")]
922 pub fixed_top: Vec<SidebarNavItem>,
923 #[serde(default, skip_serializing_if = "Vec::is_empty")]
924 pub groups: Vec<SidebarGroup>,
925 #[serde(default, skip_serializing_if = "Vec::is_empty")]
926 pub fixed_bottom: Vec<SidebarNavItem>,
927}
928
929#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
931pub struct HeaderProps {
932 pub business_name: String,
933 #[serde(default, skip_serializing_if = "Option::is_none")]
935 pub notification_count: Option<u32>,
936 #[serde(default, skip_serializing_if = "Option::is_none")]
937 pub user_name: Option<String>,
938 #[serde(default, skip_serializing_if = "Option::is_none")]
939 pub user_avatar: Option<String>,
940 #[serde(default, skip_serializing_if = "Option::is_none")]
941 pub logout_url: Option<String>,
942}
943
944#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
946#[serde(rename_all = "snake_case")]
947pub enum GapSize {
948 None,
949 Sm,
950 #[default]
951 Md,
952 Lg,
953 Xl,
954}
955
956#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
959pub struct GridProps {
960 #[serde(default = "default_grid_columns")]
962 pub columns: u8,
963 #[serde(default, skip_serializing_if = "Option::is_none")]
965 pub md_columns: Option<u8>,
966 #[serde(default, skip_serializing_if = "Option::is_none")]
968 pub lg_columns: Option<u8>,
969 #[serde(default)]
971 pub gap: GapSize,
972 #[serde(default, skip_serializing_if = "Option::is_none")]
975 pub scrollable: Option<bool>,
976 #[serde(default, skip_serializing_if = "Vec::is_empty")]
977 pub children: Vec<ComponentNode>,
978}
979
980fn default_grid_columns() -> u8 {
981 2
982}
983
984#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
987pub struct CollapsibleProps {
988 pub title: String,
989 #[serde(default)]
990 pub expanded: bool,
991 #[serde(default, skip_serializing_if = "Vec::is_empty")]
992 pub children: Vec<ComponentNode>,
993}
994
995#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
997pub struct EmptyStateProps {
998 pub title: String,
999 #[serde(default, skip_serializing_if = "Option::is_none")]
1000 pub description: Option<String>,
1001 #[serde(default, skip_serializing_if = "Option::is_none")]
1002 pub action: Option<Action>,
1003 #[serde(default, skip_serializing_if = "Option::is_none")]
1004 pub action_label: Option<String>,
1005}
1006
1007#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
1009#[serde(rename_all = "snake_case")]
1010pub enum FormSectionLayout {
1011 #[default]
1012 Stacked,
1013 TwoColumn,
1014}
1015
1016#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1019pub struct FormSectionProps {
1020 pub title: String,
1021 #[serde(default, skip_serializing_if = "Option::is_none")]
1022 pub description: Option<String>,
1023 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1024 pub children: Vec<ComponentNode>,
1025 #[serde(default, skip_serializing_if = "Option::is_none")]
1027 pub layout: Option<FormSectionLayout>,
1028}
1029
1030#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1033pub struct PageHeaderProps {
1034 pub title: String,
1035 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1036 pub breadcrumb: Vec<BreadcrumbItem>,
1037 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1038 pub actions: Vec<ComponentNode>,
1039}
1040
1041#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1044pub struct ButtonGroupProps {
1045 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1046 pub buttons: Vec<ComponentNode>,
1047}
1048
1049#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1051pub struct DropdownMenuAction {
1052 pub label: String,
1053 pub action: Action,
1054 #[serde(default)]
1055 pub destructive: bool,
1056}
1057
1058#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1060pub struct DropdownMenuProps {
1061 pub menu_id: String,
1062 pub trigger_label: String,
1063 pub items: Vec<DropdownMenuAction>,
1064 #[serde(default, skip_serializing_if = "Option::is_none")]
1065 pub trigger_variant: Option<ButtonVariant>,
1066}
1067
1068#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1071pub struct DataTableProps {
1072 pub columns: Vec<Column>,
1073 pub data_path: String,
1074 #[serde(default, skip_serializing_if = "Option::is_none")]
1075 pub row_actions: Option<Vec<DropdownMenuAction>>,
1076 #[serde(default, skip_serializing_if = "Option::is_none")]
1077 pub empty_message: Option<String>,
1078 #[serde(default, skip_serializing_if = "Option::is_none")]
1079 pub row_key: Option<String>,
1080 #[serde(default, skip_serializing_if = "Option::is_none")]
1082 pub row_href: Option<String>,
1083}
1084
1085#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1088pub struct KanbanColumnProps {
1089 pub id: String,
1090 pub title: String,
1091 pub count: u32,
1092 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1093 pub children: Vec<ComponentNode>,
1094}
1095
1096#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1099pub struct KanbanBoardProps {
1100 pub columns: Vec<KanbanColumnProps>,
1101 #[serde(default, skip_serializing_if = "Option::is_none")]
1102 pub mobile_default_column: Option<String>,
1103}
1104
1105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1110pub struct CalendarCellProps {
1111 pub day: u8,
1112 #[serde(default)]
1113 pub is_today: bool,
1114 #[serde(default)]
1115 pub is_current_month: bool,
1116 #[serde(default)]
1117 pub event_count: u32,
1118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1121 pub dot_colors: Vec<String>,
1122}
1123
1124#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1126#[serde(rename_all = "snake_case")]
1127pub enum ActionCardVariant {
1128 #[default]
1129 Default,
1130 Setup,
1131 Danger,
1132}
1133
1134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1139pub struct ActionCardProps {
1140 pub title: String,
1141 pub description: String,
1142 #[serde(default, skip_serializing_if = "Option::is_none")]
1143 pub icon: Option<String>,
1144 #[serde(default)]
1145 pub variant: ActionCardVariant,
1146 #[serde(default, skip_serializing_if = "Option::is_none")]
1148 pub href: Option<String>,
1149}
1150
1151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1156pub struct ProductTileProps {
1157 pub product_id: String,
1158 pub name: String,
1159 pub price: String,
1160 pub field: String,
1161 #[serde(default, skip_serializing_if = "Option::is_none")]
1162 pub default_quantity: Option<u32>,
1163}
1164
1165#[derive(Debug, Clone, PartialEq)]
1172pub struct PluginProps {
1173 pub plugin_type: String,
1175 pub props: serde_json::Value,
1177}
1178
1179impl Serialize for PluginProps {
1180 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1181 let obj = self.props.as_object();
1183 let extra_len = obj.map_or(0, |m| m.len());
1184 let mut map = serializer.serialize_map(Some(1 + extra_len))?;
1185 map.serialize_entry("type", &self.plugin_type)?;
1186 if let Some(obj) = obj {
1187 for (k, v) in obj {
1188 if k != "type" {
1189 map.serialize_entry(k, v)?;
1190 }
1191 }
1192 }
1193 map.end()
1194 }
1195}
1196
1197impl<'de> Deserialize<'de> for PluginProps {
1198 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1199 let mut value = serde_json::Value::deserialize(deserializer)?;
1200 let plugin_type = value
1201 .get("type")
1202 .and_then(|v| v.as_str())
1203 .map(|s| s.to_string())
1204 .ok_or_else(|| de::Error::missing_field("type"))?;
1205 if let Some(obj) = value.as_object_mut() {
1207 obj.remove("type");
1208 }
1209 Ok(PluginProps {
1210 plugin_type,
1211 props: value,
1212 })
1213 }
1214}
1215
1216#[derive(Debug, Clone, PartialEq)]
1223pub enum Component {
1224 Card(CardProps),
1225 Table(TableProps),
1226 Form(FormProps),
1227 Button(ButtonProps),
1228 Input(InputProps),
1229 Select(SelectProps),
1230 Alert(AlertProps),
1231 Badge(BadgeProps),
1232 Modal(ModalProps),
1233 Text(TextProps),
1234 Checkbox(CheckboxProps),
1235 Switch(SwitchProps),
1236 Separator(SeparatorProps),
1237 DescriptionList(DescriptionListProps),
1238 Tabs(TabsProps),
1239 Breadcrumb(BreadcrumbProps),
1240 Pagination(PaginationProps),
1241 Progress(ProgressProps),
1242 Avatar(AvatarProps),
1243 Skeleton(SkeletonProps),
1244 StatCard(StatCardProps),
1245 Checklist(ChecklistProps),
1246 Toast(ToastProps),
1247 NotificationDropdown(NotificationDropdownProps),
1248 Sidebar(SidebarProps),
1249 Header(HeaderProps),
1250 Grid(GridProps),
1251 Collapsible(CollapsibleProps),
1252 EmptyState(EmptyStateProps),
1253 FormSection(FormSectionProps),
1254 PageHeader(PageHeaderProps),
1255 ButtonGroup(ButtonGroupProps),
1256 DropdownMenu(DropdownMenuProps),
1257 KanbanBoard(KanbanBoardProps),
1258 CalendarCell(CalendarCellProps),
1259 ActionCard(ActionCardProps),
1260 ProductTile(ProductTileProps),
1261 DataTable(DataTableProps),
1262 Image(ImageProps),
1263 KeyValueEditor(KeyValueEditorProps),
1264 RichTextEditor(RichTextEditorProps),
1265 DetailForm(DetailFormProps),
1266 Plugin(PluginProps),
1267}
1268
1269fn serialize_tagged<S: Serializer, T: Serialize>(
1274 serializer: S,
1275 type_name: &str,
1276 props: &T,
1277) -> Result<S::Ok, S::Error> {
1278 let mut value = serde_json::to_value(props).map_err(serde::ser::Error::custom)?;
1279 if let Some(obj) = value.as_object_mut() {
1280 obj.insert(
1281 "type".to_string(),
1282 serde_json::Value::String(type_name.to_string()),
1283 );
1284 }
1285 value.serialize(serializer)
1286}
1287
1288impl Serialize for Component {
1289 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1290 match self {
1291 Component::Card(p) => serialize_tagged(serializer, "Card", p),
1292 Component::Table(p) => serialize_tagged(serializer, "Table", p),
1293 Component::Form(p) => serialize_tagged(serializer, "Form", p),
1294 Component::Button(p) => serialize_tagged(serializer, "Button", p),
1295 Component::Input(p) => serialize_tagged(serializer, "Input", p),
1296 Component::Select(p) => serialize_tagged(serializer, "Select", p),
1297 Component::Alert(p) => serialize_tagged(serializer, "Alert", p),
1298 Component::Badge(p) => serialize_tagged(serializer, "Badge", p),
1299 Component::Modal(p) => serialize_tagged(serializer, "Modal", p),
1300 Component::Text(p) => serialize_tagged(serializer, "Text", p),
1301 Component::Checkbox(p) => serialize_tagged(serializer, "Checkbox", p),
1302 Component::Switch(p) => serialize_tagged(serializer, "Switch", p),
1303 Component::Separator(p) => serialize_tagged(serializer, "Separator", p),
1304 Component::DescriptionList(p) => serialize_tagged(serializer, "DescriptionList", p),
1305 Component::Tabs(p) => serialize_tagged(serializer, "Tabs", p),
1306 Component::Breadcrumb(p) => serialize_tagged(serializer, "Breadcrumb", p),
1307 Component::Pagination(p) => serialize_tagged(serializer, "Pagination", p),
1308 Component::Progress(p) => serialize_tagged(serializer, "Progress", p),
1309 Component::Avatar(p) => serialize_tagged(serializer, "Avatar", p),
1310 Component::Skeleton(p) => serialize_tagged(serializer, "Skeleton", p),
1311 Component::StatCard(p) => serialize_tagged(serializer, "StatCard", p),
1312 Component::Checklist(p) => serialize_tagged(serializer, "Checklist", p),
1313 Component::Toast(p) => serialize_tagged(serializer, "Toast", p),
1314 Component::NotificationDropdown(p) => {
1315 serialize_tagged(serializer, "NotificationDropdown", p)
1316 }
1317 Component::Sidebar(p) => serialize_tagged(serializer, "Sidebar", p),
1318 Component::Header(p) => serialize_tagged(serializer, "Header", p),
1319 Component::Grid(p) => serialize_tagged(serializer, "Grid", p),
1320 Component::Collapsible(p) => serialize_tagged(serializer, "Collapsible", p),
1321 Component::EmptyState(p) => serialize_tagged(serializer, "EmptyState", p),
1322 Component::FormSection(p) => serialize_tagged(serializer, "FormSection", p),
1323 Component::PageHeader(p) => serialize_tagged(serializer, "PageHeader", p),
1324 Component::ButtonGroup(p) => serialize_tagged(serializer, "ButtonGroup", p),
1325 Component::DropdownMenu(p) => serialize_tagged(serializer, "DropdownMenu", p),
1326 Component::KanbanBoard(p) => serialize_tagged(serializer, "KanbanBoard", p),
1327 Component::CalendarCell(p) => serialize_tagged(serializer, "CalendarCell", p),
1328 Component::ActionCard(p) => serialize_tagged(serializer, "ActionCard", p),
1329 Component::ProductTile(p) => serialize_tagged(serializer, "ProductTile", p),
1330 Component::DataTable(p) => serialize_tagged(serializer, "DataTable", p),
1331 Component::Image(p) => serialize_tagged(serializer, "Image", p),
1332 Component::KeyValueEditor(p) => serialize_tagged(serializer, "KeyValueEditor", p),
1333 Component::RichTextEditor(p) => serialize_tagged(serializer, "RichTextEditor", p),
1334 Component::DetailForm(p) => serialize_tagged(serializer, "DetailForm", p),
1335 Component::Plugin(p) => p.serialize(serializer),
1336 }
1337 }
1338}
1339
1340impl<'de> Deserialize<'de> for Component {
1343 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1344 let value = serde_json::Value::deserialize(deserializer)?;
1345 let type_str = value
1346 .get("type")
1347 .and_then(|v| v.as_str())
1348 .ok_or_else(|| de::Error::missing_field("type"))?;
1349
1350 match type_str {
1351 "Card" => serde_json::from_value::<CardProps>(value)
1352 .map(Component::Card)
1353 .map_err(de::Error::custom),
1354 "Table" => serde_json::from_value::<TableProps>(value)
1355 .map(Component::Table)
1356 .map_err(de::Error::custom),
1357 "Form" => serde_json::from_value::<FormProps>(value)
1358 .map(Component::Form)
1359 .map_err(de::Error::custom),
1360 "Button" => serde_json::from_value::<ButtonProps>(value)
1361 .map(Component::Button)
1362 .map_err(de::Error::custom),
1363 "Input" => serde_json::from_value::<InputProps>(value)
1364 .map(Component::Input)
1365 .map_err(de::Error::custom),
1366 "Select" => serde_json::from_value::<SelectProps>(value)
1367 .map(Component::Select)
1368 .map_err(de::Error::custom),
1369 "Alert" => serde_json::from_value::<AlertProps>(value)
1370 .map(Component::Alert)
1371 .map_err(de::Error::custom),
1372 "Badge" => serde_json::from_value::<BadgeProps>(value)
1373 .map(Component::Badge)
1374 .map_err(de::Error::custom),
1375 "Modal" => serde_json::from_value::<ModalProps>(value)
1376 .map(Component::Modal)
1377 .map_err(de::Error::custom),
1378 "Text" => serde_json::from_value::<TextProps>(value)
1379 .map(Component::Text)
1380 .map_err(de::Error::custom),
1381 "Checkbox" => serde_json::from_value::<CheckboxProps>(value)
1382 .map(Component::Checkbox)
1383 .map_err(de::Error::custom),
1384 "Switch" => serde_json::from_value::<SwitchProps>(value)
1385 .map(Component::Switch)
1386 .map_err(de::Error::custom),
1387 "Separator" => serde_json::from_value::<SeparatorProps>(value)
1388 .map(Component::Separator)
1389 .map_err(de::Error::custom),
1390 "DescriptionList" => serde_json::from_value::<DescriptionListProps>(value)
1391 .map(Component::DescriptionList)
1392 .map_err(de::Error::custom),
1393 "Tabs" => serde_json::from_value::<TabsProps>(value)
1394 .map(Component::Tabs)
1395 .map_err(de::Error::custom),
1396 "Breadcrumb" => serde_json::from_value::<BreadcrumbProps>(value)
1397 .map(Component::Breadcrumb)
1398 .map_err(de::Error::custom),
1399 "Pagination" => serde_json::from_value::<PaginationProps>(value)
1400 .map(Component::Pagination)
1401 .map_err(de::Error::custom),
1402 "Progress" => serde_json::from_value::<ProgressProps>(value)
1403 .map(Component::Progress)
1404 .map_err(de::Error::custom),
1405 "Avatar" => serde_json::from_value::<AvatarProps>(value)
1406 .map(Component::Avatar)
1407 .map_err(de::Error::custom),
1408 "Skeleton" => serde_json::from_value::<SkeletonProps>(value)
1409 .map(Component::Skeleton)
1410 .map_err(de::Error::custom),
1411 "StatCard" => serde_json::from_value::<StatCardProps>(value)
1412 .map(Component::StatCard)
1413 .map_err(de::Error::custom),
1414 "Checklist" => serde_json::from_value::<ChecklistProps>(value)
1415 .map(Component::Checklist)
1416 .map_err(de::Error::custom),
1417 "Toast" => serde_json::from_value::<ToastProps>(value)
1418 .map(Component::Toast)
1419 .map_err(de::Error::custom),
1420 "NotificationDropdown" => serde_json::from_value::<NotificationDropdownProps>(value)
1421 .map(Component::NotificationDropdown)
1422 .map_err(de::Error::custom),
1423 "Sidebar" => serde_json::from_value::<SidebarProps>(value)
1424 .map(Component::Sidebar)
1425 .map_err(de::Error::custom),
1426 "Header" => serde_json::from_value::<HeaderProps>(value)
1427 .map(Component::Header)
1428 .map_err(de::Error::custom),
1429 "Grid" => serde_json::from_value::<GridProps>(value)
1430 .map(Component::Grid)
1431 .map_err(de::Error::custom),
1432 "Collapsible" => serde_json::from_value::<CollapsibleProps>(value)
1433 .map(Component::Collapsible)
1434 .map_err(de::Error::custom),
1435 "EmptyState" => serde_json::from_value::<EmptyStateProps>(value)
1436 .map(Component::EmptyState)
1437 .map_err(de::Error::custom),
1438 "FormSection" => serde_json::from_value::<FormSectionProps>(value)
1439 .map(Component::FormSection)
1440 .map_err(de::Error::custom),
1441 "PageHeader" => serde_json::from_value::<PageHeaderProps>(value)
1442 .map(Component::PageHeader)
1443 .map_err(de::Error::custom),
1444 "ButtonGroup" => serde_json::from_value::<ButtonGroupProps>(value)
1445 .map(Component::ButtonGroup)
1446 .map_err(de::Error::custom),
1447 "DropdownMenu" => serde_json::from_value::<DropdownMenuProps>(value)
1448 .map(Component::DropdownMenu)
1449 .map_err(de::Error::custom),
1450 "KanbanBoard" => serde_json::from_value::<KanbanBoardProps>(value)
1451 .map(Component::KanbanBoard)
1452 .map_err(de::Error::custom),
1453 "CalendarCell" => serde_json::from_value::<CalendarCellProps>(value)
1454 .map(Component::CalendarCell)
1455 .map_err(de::Error::custom),
1456 "ActionCard" => serde_json::from_value::<ActionCardProps>(value)
1457 .map(Component::ActionCard)
1458 .map_err(de::Error::custom),
1459 "ProductTile" => serde_json::from_value::<ProductTileProps>(value)
1460 .map(Component::ProductTile)
1461 .map_err(de::Error::custom),
1462 "DataTable" => serde_json::from_value::<DataTableProps>(value)
1463 .map(Component::DataTable)
1464 .map_err(de::Error::custom),
1465 "Image" => serde_json::from_value::<ImageProps>(value)
1466 .map(Component::Image)
1467 .map_err(de::Error::custom),
1468 "KeyValueEditor" => serde_json::from_value::<KeyValueEditorProps>(value)
1469 .map(Component::KeyValueEditor)
1470 .map_err(de::Error::custom),
1471 "RichTextEditor" => serde_json::from_value::<RichTextEditorProps>(value)
1472 .map(Component::RichTextEditor)
1473 .map_err(de::Error::custom),
1474 "DetailForm" => serde_json::from_value::<DetailFormProps>(value)
1475 .map(Component::DetailForm)
1476 .map_err(de::Error::custom),
1477 _ => {
1478 let plugin_type = type_str.to_string();
1480 let mut props = value;
1481 if let Some(obj) = props.as_object_mut() {
1482 obj.remove("type");
1483 }
1484 Ok(Component::Plugin(PluginProps { plugin_type, props }))
1485 }
1486 }
1487 }
1488}
1489
1490#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1497pub struct ComponentNode {
1498 pub key: String,
1499 #[serde(flatten)]
1500 pub component: Component,
1501 #[serde(default, skip_serializing_if = "Option::is_none")]
1502 pub action: Option<Action>,
1503 #[serde(default, skip_serializing_if = "Option::is_none")]
1504 pub visibility: Option<Visibility>,
1505}
1506
1507impl ComponentNode {
1508 pub fn card(key: impl Into<String>, props: CardProps) -> Self {
1510 Self {
1511 key: key.into(),
1512 component: Component::Card(props),
1513 action: None,
1514 visibility: None,
1515 }
1516 }
1517
1518 pub fn table(key: impl Into<String>, props: TableProps) -> Self {
1520 Self {
1521 key: key.into(),
1522 component: Component::Table(props),
1523 action: None,
1524 visibility: None,
1525 }
1526 }
1527
1528 pub fn form(key: impl Into<String>, props: FormProps) -> Self {
1530 Self {
1531 key: key.into(),
1532 component: Component::Form(props),
1533 action: None,
1534 visibility: None,
1535 }
1536 }
1537
1538 pub fn detail_form(key: impl Into<String>, props: DetailFormProps) -> Self {
1553 Self {
1554 key: key.into(),
1555 component: Component::DetailForm(props),
1556 action: None,
1557 visibility: None,
1558 }
1559 }
1560
1561 pub fn rich_text_editor(name: impl Into<String>, props: RichTextEditorProps) -> Self {
1567 let name_arg: String = name.into();
1568 let final_props = if props.name.is_empty() {
1569 let mut p = props;
1570 p.name = name_arg.clone();
1571 p
1572 } else {
1573 props
1574 };
1575 Self {
1576 key: name_arg,
1577 component: Component::RichTextEditor(final_props),
1578 action: None,
1579 visibility: None,
1580 }
1581 }
1582
1583 pub fn button(key: impl Into<String>, props: ButtonProps) -> Self {
1585 Self {
1586 key: key.into(),
1587 component: Component::Button(props),
1588 action: None,
1589 visibility: None,
1590 }
1591 }
1592
1593 pub fn input(key: impl Into<String>, props: InputProps) -> Self {
1595 Self {
1596 key: key.into(),
1597 component: Component::Input(props),
1598 action: None,
1599 visibility: None,
1600 }
1601 }
1602
1603 pub fn select(key: impl Into<String>, props: SelectProps) -> Self {
1605 Self {
1606 key: key.into(),
1607 component: Component::Select(props),
1608 action: None,
1609 visibility: None,
1610 }
1611 }
1612
1613 pub fn alert(key: impl Into<String>, props: AlertProps) -> Self {
1615 Self {
1616 key: key.into(),
1617 component: Component::Alert(props),
1618 action: None,
1619 visibility: None,
1620 }
1621 }
1622
1623 pub fn badge(key: impl Into<String>, props: BadgeProps) -> Self {
1625 Self {
1626 key: key.into(),
1627 component: Component::Badge(props),
1628 action: None,
1629 visibility: None,
1630 }
1631 }
1632
1633 pub fn modal(key: impl Into<String>, props: ModalProps) -> Self {
1635 Self {
1636 key: key.into(),
1637 component: Component::Modal(props),
1638 action: None,
1639 visibility: None,
1640 }
1641 }
1642
1643 pub fn text(key: impl Into<String>, props: TextProps) -> Self {
1645 Self {
1646 key: key.into(),
1647 component: Component::Text(props),
1648 action: None,
1649 visibility: None,
1650 }
1651 }
1652
1653 pub fn checkbox(key: impl Into<String>, props: CheckboxProps) -> Self {
1655 Self {
1656 key: key.into(),
1657 component: Component::Checkbox(props),
1658 action: None,
1659 visibility: None,
1660 }
1661 }
1662
1663 pub fn switch(key: impl Into<String>, props: SwitchProps) -> Self {
1665 Self {
1666 key: key.into(),
1667 component: Component::Switch(props),
1668 action: None,
1669 visibility: None,
1670 }
1671 }
1672
1673 pub fn separator(key: impl Into<String>, props: SeparatorProps) -> Self {
1675 Self {
1676 key: key.into(),
1677 component: Component::Separator(props),
1678 action: None,
1679 visibility: None,
1680 }
1681 }
1682
1683 pub fn description_list(key: impl Into<String>, props: DescriptionListProps) -> Self {
1685 Self {
1686 key: key.into(),
1687 component: Component::DescriptionList(props),
1688 action: None,
1689 visibility: None,
1690 }
1691 }
1692
1693 pub fn tabs(key: impl Into<String>, props: TabsProps) -> Self {
1695 Self {
1696 key: key.into(),
1697 component: Component::Tabs(props),
1698 action: None,
1699 visibility: None,
1700 }
1701 }
1702
1703 pub fn breadcrumb(key: impl Into<String>, props: BreadcrumbProps) -> Self {
1705 Self {
1706 key: key.into(),
1707 component: Component::Breadcrumb(props),
1708 action: None,
1709 visibility: None,
1710 }
1711 }
1712
1713 pub fn pagination(key: impl Into<String>, props: PaginationProps) -> Self {
1715 Self {
1716 key: key.into(),
1717 component: Component::Pagination(props),
1718 action: None,
1719 visibility: None,
1720 }
1721 }
1722
1723 pub fn progress(key: impl Into<String>, props: ProgressProps) -> Self {
1725 Self {
1726 key: key.into(),
1727 component: Component::Progress(props),
1728 action: None,
1729 visibility: None,
1730 }
1731 }
1732
1733 pub fn avatar(key: impl Into<String>, props: AvatarProps) -> Self {
1735 Self {
1736 key: key.into(),
1737 component: Component::Avatar(props),
1738 action: None,
1739 visibility: None,
1740 }
1741 }
1742
1743 pub fn skeleton(key: impl Into<String>, props: SkeletonProps) -> Self {
1745 Self {
1746 key: key.into(),
1747 component: Component::Skeleton(props),
1748 action: None,
1749 visibility: None,
1750 }
1751 }
1752
1753 pub fn stat_card(key: impl Into<String>, props: StatCardProps) -> Self {
1755 Self {
1756 key: key.into(),
1757 component: Component::StatCard(props),
1758 action: None,
1759 visibility: None,
1760 }
1761 }
1762
1763 pub fn checklist(key: impl Into<String>, props: ChecklistProps) -> Self {
1765 Self {
1766 key: key.into(),
1767 component: Component::Checklist(props),
1768 action: None,
1769 visibility: None,
1770 }
1771 }
1772
1773 pub fn toast(key: impl Into<String>, props: ToastProps) -> Self {
1775 Self {
1776 key: key.into(),
1777 component: Component::Toast(props),
1778 action: None,
1779 visibility: None,
1780 }
1781 }
1782
1783 pub fn notification_dropdown(key: impl Into<String>, props: NotificationDropdownProps) -> Self {
1785 Self {
1786 key: key.into(),
1787 component: Component::NotificationDropdown(props),
1788 action: None,
1789 visibility: None,
1790 }
1791 }
1792
1793 pub fn sidebar(key: impl Into<String>, props: SidebarProps) -> Self {
1795 Self {
1796 key: key.into(),
1797 component: Component::Sidebar(props),
1798 action: None,
1799 visibility: None,
1800 }
1801 }
1802
1803 pub fn header(key: impl Into<String>, props: HeaderProps) -> Self {
1805 Self {
1806 key: key.into(),
1807 component: Component::Header(props),
1808 action: None,
1809 visibility: None,
1810 }
1811 }
1812
1813 pub fn grid(key: impl Into<String>, props: GridProps) -> Self {
1815 Self {
1816 key: key.into(),
1817 component: Component::Grid(props),
1818 action: None,
1819 visibility: None,
1820 }
1821 }
1822
1823 pub fn collapsible(key: impl Into<String>, props: CollapsibleProps) -> Self {
1825 Self {
1826 key: key.into(),
1827 component: Component::Collapsible(props),
1828 action: None,
1829 visibility: None,
1830 }
1831 }
1832
1833 pub fn empty_state(key: impl Into<String>, props: EmptyStateProps) -> Self {
1835 Self {
1836 key: key.into(),
1837 component: Component::EmptyState(props),
1838 action: None,
1839 visibility: None,
1840 }
1841 }
1842
1843 pub fn form_section(key: impl Into<String>, props: FormSectionProps) -> Self {
1845 Self {
1846 key: key.into(),
1847 component: Component::FormSection(props),
1848 action: None,
1849 visibility: None,
1850 }
1851 }
1852
1853 pub fn dropdown_menu(key: impl Into<String>, props: DropdownMenuProps) -> Self {
1855 Self {
1856 key: key.into(),
1857 component: Component::DropdownMenu(props),
1858 action: None,
1859 visibility: None,
1860 }
1861 }
1862
1863 pub fn kanban_board(key: impl Into<String>, props: KanbanBoardProps) -> Self {
1865 Self {
1866 key: key.into(),
1867 component: Component::KanbanBoard(props),
1868 action: None,
1869 visibility: None,
1870 }
1871 }
1872
1873 pub fn calendar_cell(key: impl Into<String>, props: CalendarCellProps) -> Self {
1875 Self {
1876 key: key.into(),
1877 component: Component::CalendarCell(props),
1878 action: None,
1879 visibility: None,
1880 }
1881 }
1882
1883 pub fn action_card(key: impl Into<String>, props: ActionCardProps) -> Self {
1885 Self {
1886 key: key.into(),
1887 component: Component::ActionCard(props),
1888 action: None,
1889 visibility: None,
1890 }
1891 }
1892
1893 pub fn product_tile(key: impl Into<String>, props: ProductTileProps) -> Self {
1895 Self {
1896 key: key.into(),
1897 component: Component::ProductTile(props),
1898 action: None,
1899 visibility: None,
1900 }
1901 }
1902
1903 pub fn data_table(key: impl Into<String>, props: DataTableProps) -> Self {
1905 Self {
1906 key: key.into(),
1907 component: Component::DataTable(props),
1908 action: None,
1909 visibility: None,
1910 }
1911 }
1912
1913 pub fn image(key: impl Into<String>, props: ImageProps) -> Self {
1915 Self {
1916 key: key.into(),
1917 component: Component::Image(props),
1918 action: None,
1919 visibility: None,
1920 }
1921 }
1922
1923 pub fn plugin_component(key: impl Into<String>, props: PluginProps) -> Self {
1927 Self {
1928 key: key.into(),
1929 component: Component::Plugin(props),
1930 action: None,
1931 visibility: None,
1932 }
1933 }
1934}
1935
1936#[cfg(test)]
1937mod tests {
1938 use super::*;
1939 use crate::action::HttpMethod;
1940 use crate::visibility::{VisibilityCondition, VisibilityOperator};
1941
1942 #[test]
1943 fn card_component_tagged_serialization() {
1944 let card = Component::Card(CardProps {
1945 title: "Test Card".to_string(),
1946 description: Some("A description".to_string()),
1947 children: vec![],
1948 footer: vec![],
1949 max_width: None,
1950 });
1951 let json = serde_json::to_value(&card).unwrap();
1952 assert_eq!(json["type"], "Card");
1953 assert_eq!(json["title"], "Test Card");
1954 assert_eq!(json["description"], "A description");
1955 }
1956
1957 #[test]
1958 fn button_variant_defaults_to_default() {
1959 let json = r#"{"type": "Button", "label": "Click me"}"#;
1960 let component: Component = serde_json::from_str(json).unwrap();
1961 match component {
1962 Component::Button(props) => {
1963 assert_eq!(props.variant, ButtonVariant::Default);
1964 assert_eq!(props.label, "Click me");
1965 }
1966 _ => panic!("expected Button"),
1967 }
1968 }
1969
1970 #[test]
1971 fn input_type_defaults_to_text() {
1972 let json = r#"{"type": "Input", "field": "email", "label": "Email"}"#;
1973 let component: Component = serde_json::from_str(json).unwrap();
1974 match component {
1975 Component::Input(props) => {
1976 assert_eq!(props.input_type, InputType::Text);
1977 assert_eq!(props.field, "email");
1978 }
1979 _ => panic!("expected Input"),
1980 }
1981 }
1982
1983 #[test]
1984 fn alert_variant_defaults_to_info() {
1985 let json = r#"{"type": "Alert", "message": "Hello"}"#;
1986 let component: Component = serde_json::from_str(json).unwrap();
1987 match component {
1988 Component::Alert(props) => assert_eq!(props.variant, AlertVariant::Info),
1989 _ => panic!("expected Alert"),
1990 }
1991 }
1992
1993 #[test]
1994 fn badge_variant_defaults_to_default() {
1995 let json = r#"{"type": "Badge", "label": "New"}"#;
1996 let component: Component = serde_json::from_str(json).unwrap();
1997 match component {
1998 Component::Badge(props) => assert_eq!(props.variant, BadgeVariant::Default),
1999 _ => panic!("expected Badge"),
2000 }
2001 }
2002
2003 #[test]
2004 fn text_element_defaults_to_p() {
2005 let json = r#"{"type": "Text", "content": "Hello world"}"#;
2006 let component: Component = serde_json::from_str(json).unwrap();
2007 match component {
2008 Component::Text(props) => {
2009 assert_eq!(props.element, TextElement::P);
2010 assert_eq!(props.content, "Hello world");
2011 }
2012 _ => panic!("expected Text"),
2013 }
2014 }
2015
2016 #[test]
2017 fn table_component_round_trips() {
2018 let table = Component::Table(TableProps {
2019 columns: vec![
2020 Column {
2021 key: "name".to_string(),
2022 label: "Name".to_string(),
2023 format: None,
2024 },
2025 Column {
2026 key: "created_at".to_string(),
2027 label: "Created".to_string(),
2028 format: Some(ColumnFormat::Date),
2029 },
2030 ],
2031 data_path: "/data/users".to_string(),
2032 row_actions: None,
2033 empty_message: Some("No users found".to_string()),
2034 sortable: None,
2035 sort_column: None,
2036 sort_direction: None,
2037 });
2038 let json = serde_json::to_string(&table).unwrap();
2039 let parsed: Component = serde_json::from_str(&json).unwrap();
2040 assert_eq!(parsed, table);
2041 }
2042
2043 #[test]
2044 fn select_component_round_trips() {
2045 let select = Component::Select(SelectProps {
2046 field: "role".to_string(),
2047 label: "Role".to_string(),
2048 options: vec![
2049 SelectOption {
2050 value: "admin".to_string(),
2051 label: "Administrator".to_string(),
2052 },
2053 SelectOption {
2054 value: "user".to_string(),
2055 label: "User".to_string(),
2056 },
2057 ],
2058 placeholder: Some("Select a role".to_string()),
2059 required: Some(true),
2060 disabled: None,
2061 error: None,
2062 description: None,
2063 default_value: None,
2064 data_path: None,
2065 });
2066 let json = serde_json::to_string(&select).unwrap();
2067 let parsed: Component = serde_json::from_str(&json).unwrap();
2068 assert_eq!(parsed, select);
2069 }
2070
2071 #[test]
2072 fn modal_component_round_trips() {
2073 let modal = Component::Modal(ModalProps {
2074 id: "modal-confirm".to_string(),
2075 title: "Confirm".to_string(),
2076 description: None,
2077 children: vec![ComponentNode {
2078 key: "msg".to_string(),
2079 component: Component::Text(TextProps {
2080 content: "Are you sure?".to_string(),
2081 element: TextElement::P,
2082 }),
2083 action: None,
2084 visibility: None,
2085 }],
2086 footer: vec![],
2087 trigger_label: Some("Open".to_string()),
2088 });
2089 let json = serde_json::to_string(&modal).unwrap();
2090 let parsed: Component = serde_json::from_str(&json).unwrap();
2091 assert_eq!(parsed, modal);
2092 }
2093
2094 #[test]
2095 fn form_component_round_trips() {
2096 let form = Component::Form(FormProps {
2097 action: Action {
2098 handler: "users.store".to_string(),
2099 url: None,
2100 method: HttpMethod::Post,
2101 confirm: None,
2102 on_success: None,
2103 on_error: None,
2104 target: None,
2105 },
2106 fields: vec![ComponentNode {
2107 key: "email-input".to_string(),
2108 component: Component::Input(InputProps {
2109 field: "email".to_string(),
2110 label: "Email".to_string(),
2111 input_type: InputType::Email,
2112 placeholder: Some("user@example.com".to_string()),
2113 required: Some(true),
2114 disabled: None,
2115 error: None,
2116 description: None,
2117 default_value: None,
2118 data_path: None,
2119 step: None,
2120 list: None,
2121 }),
2122 action: None,
2123 visibility: None,
2124 }],
2125 method: None,
2126 guard: None,
2127 max_width: None,
2128 });
2129 let json = serde_json::to_string(&form).unwrap();
2130 let parsed: Component = serde_json::from_str(&json).unwrap();
2131 assert_eq!(parsed, form);
2132 }
2133
2134 #[test]
2135 fn component_node_with_action_and_visibility() {
2136 let node = ComponentNode {
2137 key: "create-btn".to_string(),
2138 component: Component::Button(ButtonProps {
2139 label: "Create User".to_string(),
2140 variant: ButtonVariant::Default,
2141 size: Size::Default,
2142 disabled: None,
2143 icon: None,
2144 icon_position: None,
2145 button_type: None,
2146 }),
2147 action: Some(Action {
2148 handler: "users.create".to_string(),
2149 url: None,
2150 method: HttpMethod::Post,
2151 confirm: None,
2152 on_success: None,
2153 on_error: None,
2154 target: None,
2155 }),
2156 visibility: Some(Visibility::Condition(VisibilityCondition {
2157 path: "/auth/user/role".to_string(),
2158 operator: VisibilityOperator::Eq,
2159 value: Some(serde_json::Value::String("admin".to_string())),
2160 })),
2161 };
2162 let json = serde_json::to_string(&node).unwrap();
2163 let parsed: ComponentNode = serde_json::from_str(&json).unwrap();
2164 assert_eq!(parsed, node);
2165
2166 let value = serde_json::to_value(&node).unwrap();
2168 assert_eq!(value["type"], "Button");
2169 assert_eq!(value["key"], "create-btn");
2170 assert!(value.get("action").is_some());
2171 assert!(value.get("visibility").is_some());
2172 }
2173
2174 #[test]
2175 fn all_component_variants_serialize() {
2176 let components: Vec<Component> = vec![
2177 Component::Card(CardProps {
2178 title: "t".to_string(),
2179 description: None,
2180 children: vec![],
2181 footer: vec![],
2182 max_width: None,
2183 }),
2184 Component::Table(TableProps {
2185 columns: vec![],
2186 data_path: "/d".to_string(),
2187 row_actions: None,
2188 empty_message: None,
2189 sortable: None,
2190 sort_column: None,
2191 sort_direction: None,
2192 }),
2193 Component::Form(FormProps {
2194 action: Action {
2195 handler: "h.m".to_string(),
2196 url: None,
2197 method: HttpMethod::Post,
2198 confirm: None,
2199 on_success: None,
2200 on_error: None,
2201 target: None,
2202 },
2203 fields: vec![],
2204 method: None,
2205 guard: None,
2206 max_width: None,
2207 }),
2208 Component::Button(ButtonProps {
2209 label: "b".to_string(),
2210 variant: ButtonVariant::Default,
2211 size: Size::Default,
2212 disabled: None,
2213 icon: None,
2214 icon_position: None,
2215 button_type: None,
2216 }),
2217 Component::Input(InputProps {
2218 field: "f".to_string(),
2219 label: "l".to_string(),
2220 input_type: InputType::Text,
2221 placeholder: None,
2222 required: None,
2223 disabled: None,
2224 error: None,
2225 description: None,
2226 default_value: None,
2227 data_path: None,
2228 step: None,
2229 list: None,
2230 }),
2231 Component::Select(SelectProps {
2232 field: "f".to_string(),
2233 label: "l".to_string(),
2234 options: vec![],
2235 placeholder: None,
2236 required: None,
2237 disabled: None,
2238 error: None,
2239 description: None,
2240 default_value: None,
2241 data_path: None,
2242 }),
2243 Component::Alert(AlertProps {
2244 message: "m".to_string(),
2245 variant: AlertVariant::Info,
2246 title: None,
2247 }),
2248 Component::Badge(BadgeProps {
2249 label: "b".to_string(),
2250 variant: BadgeVariant::Default,
2251 }),
2252 Component::Modal(ModalProps {
2253 id: "modal-t".to_string(),
2254 title: "t".to_string(),
2255 description: None,
2256 children: vec![],
2257 footer: vec![],
2258 trigger_label: None,
2259 }),
2260 Component::Text(TextProps {
2261 content: "c".to_string(),
2262 element: TextElement::P,
2263 }),
2264 Component::Checkbox(CheckboxProps {
2265 field: "f".to_string(),
2266 value: None,
2267 label: "l".to_string(),
2268 description: None,
2269 checked: None,
2270 data_path: None,
2271 required: None,
2272 disabled: None,
2273 error: None,
2274 }),
2275 Component::Switch(SwitchProps {
2276 field: "f".to_string(),
2277 label: "l".to_string(),
2278 description: None,
2279 checked: None,
2280 data_path: None,
2281 required: None,
2282 disabled: None,
2283 error: None,
2284 action: None,
2285 compact: false,
2286 }),
2287 Component::Separator(SeparatorProps { orientation: None }),
2288 Component::DescriptionList(DescriptionListProps {
2289 items: vec![DescriptionItem {
2290 label: "k".to_string(),
2291 value: "v".to_string(),
2292 format: None,
2293 }],
2294 columns: None,
2295 }),
2296 Component::Tabs(TabsProps {
2297 default_tab: "t1".to_string(),
2298 tabs: vec![Tab {
2299 value: "t1".to_string(),
2300 label: "Tab 1".to_string(),
2301 children: vec![],
2302 }],
2303 }),
2304 Component::Breadcrumb(BreadcrumbProps {
2305 items: vec![BreadcrumbItem {
2306 label: "Home".to_string(),
2307 url: Some("/".to_string()),
2308 }],
2309 }),
2310 Component::Pagination(PaginationProps {
2311 current_page: 1,
2312 per_page: 10,
2313 total: 100,
2314 base_url: None,
2315 }),
2316 Component::Progress(ProgressProps {
2317 value: 50,
2318 max: None,
2319 label: None,
2320 }),
2321 Component::Avatar(AvatarProps {
2322 src: None,
2323 alt: "User".to_string(),
2324 fallback: Some("U".to_string()),
2325 size: None,
2326 }),
2327 Component::Skeleton(SkeletonProps {
2328 width: None,
2329 height: None,
2330 rounded: None,
2331 }),
2332 Component::StatCard(StatCardProps {
2333 label: "Revenue".to_string(),
2334 value: "$1,234".to_string(),
2335 icon: None,
2336 subtitle: None,
2337 sse_target: None,
2338 }),
2339 Component::Checklist(ChecklistProps {
2340 title: "Tasks".to_string(),
2341 items: vec![],
2342 dismissible: true,
2343 dismiss_label: None,
2344 data_key: None,
2345 }),
2346 Component::Toast(ToastProps {
2347 message: "Saved!".to_string(),
2348 variant: ToastVariant::Success,
2349 timeout: None,
2350 dismissible: true,
2351 }),
2352 Component::NotificationDropdown(NotificationDropdownProps {
2353 notifications: vec![],
2354 empty_text: None,
2355 }),
2356 Component::Sidebar(SidebarProps {
2357 fixed_top: vec![],
2358 groups: vec![],
2359 fixed_bottom: vec![],
2360 }),
2361 Component::Header(HeaderProps {
2362 business_name: "Acme".to_string(),
2363 notification_count: None,
2364 user_name: None,
2365 user_avatar: None,
2366 logout_url: None,
2367 }),
2368 Component::Image(ImageProps::url("/img/screenshot.png", "Page screenshot")),
2369 ];
2370 assert_eq!(components.len(), 27, "should have 27 component variants");
2371 let expected_types = [
2372 "Card",
2373 "Table",
2374 "Form",
2375 "Button",
2376 "Input",
2377 "Select",
2378 "Alert",
2379 "Badge",
2380 "Modal",
2381 "Text",
2382 "Checkbox",
2383 "Switch",
2384 "Separator",
2385 "DescriptionList",
2386 "Tabs",
2387 "Breadcrumb",
2388 "Pagination",
2389 "Progress",
2390 "Avatar",
2391 "Skeleton",
2392 "StatCard",
2393 "Checklist",
2394 "Toast",
2395 "NotificationDropdown",
2396 "Sidebar",
2397 "Header",
2398 "Image",
2399 ];
2400 for (component, expected_type) in components.iter().zip(expected_types.iter()) {
2401 let json = serde_json::to_value(component).unwrap();
2402 assert_eq!(
2403 json["type"], *expected_type,
2404 "component should serialize with type={expected_type}"
2405 );
2406 let roundtripped: Component = serde_json::from_value(json).unwrap();
2407 assert_eq!(&roundtripped, component);
2408 }
2409 }
2410
2411 #[test]
2412 fn size_enum_serialization() {
2413 let cases = [
2414 (Size::Xs, "xs"),
2415 (Size::Sm, "sm"),
2416 (Size::Default, "default"),
2417 (Size::Lg, "lg"),
2418 ];
2419 for (size, expected) in &cases {
2420 let json = serde_json::to_value(size).unwrap();
2421 assert_eq!(json, *expected);
2422 let parsed: Size = serde_json::from_value(json).unwrap();
2423 assert_eq!(&parsed, size);
2424 }
2425 }
2426
2427 #[test]
2428 fn icon_position_serialization() {
2429 let cases = [(IconPosition::Left, "left"), (IconPosition::Right, "right")];
2430 for (pos, expected) in &cases {
2431 let json = serde_json::to_value(pos).unwrap();
2432 assert_eq!(json, *expected);
2433 let parsed: IconPosition = serde_json::from_value(json).unwrap();
2434 assert_eq!(&parsed, pos);
2435 }
2436 }
2437
2438 #[test]
2439 fn sort_direction_serialization() {
2440 let cases = [(SortDirection::Asc, "asc"), (SortDirection::Desc, "desc")];
2441 for (dir, expected) in &cases {
2442 let json = serde_json::to_value(dir).unwrap();
2443 assert_eq!(json, *expected);
2444 let parsed: SortDirection = serde_json::from_value(json).unwrap();
2445 assert_eq!(&parsed, dir);
2446 }
2447 }
2448
2449 #[test]
2450 fn button_with_size_and_icon() {
2451 let button = Component::Button(ButtonProps {
2452 label: "Save".to_string(),
2453 variant: ButtonVariant::Default,
2454 size: Size::Lg,
2455 disabled: None,
2456 icon: Some("save".to_string()),
2457 icon_position: Some(IconPosition::Left),
2458 button_type: None,
2459 });
2460 let json = serde_json::to_value(&button).unwrap();
2461 assert_eq!(json["size"], "lg");
2462 assert_eq!(json["icon"], "save");
2463 assert_eq!(json["icon_position"], "left");
2464 let parsed: Component = serde_json::from_value(json).unwrap();
2465 assert_eq!(parsed, button);
2466 }
2467
2468 #[test]
2469 fn card_with_footer() {
2470 let card = Component::Card(CardProps {
2471 title: "Actions".to_string(),
2472 description: None,
2473 children: vec![],
2474 max_width: None,
2475 footer: vec![ComponentNode {
2476 key: "cancel".to_string(),
2477 component: Component::Button(ButtonProps {
2478 label: "Cancel".to_string(),
2479 variant: ButtonVariant::Outline,
2480 size: Size::Default,
2481 disabled: None,
2482 icon: None,
2483 icon_position: None,
2484 button_type: None,
2485 }),
2486 action: None,
2487 visibility: None,
2488 }],
2489 });
2490 let json = serde_json::to_value(&card).unwrap();
2491 assert!(json["footer"].is_array());
2492 assert_eq!(json["footer"][0]["label"], "Cancel");
2493 let parsed: Component = serde_json::from_value(json).unwrap();
2494 assert_eq!(parsed, card);
2495 }
2496
2497 #[test]
2498 fn input_with_error_and_description() {
2499 let input = Component::Input(InputProps {
2500 field: "email".to_string(),
2501 label: "Email".to_string(),
2502 input_type: InputType::Email,
2503 placeholder: None,
2504 required: Some(true),
2505 disabled: Some(false),
2506 error: Some("Invalid email".to_string()),
2507 description: Some("Your work email".to_string()),
2508 default_value: Some("user@example.com".to_string()),
2509 data_path: None,
2510 step: None,
2511 list: None,
2512 });
2513 let json = serde_json::to_value(&input).unwrap();
2514 assert_eq!(json["error"], "Invalid email");
2515 assert_eq!(json["description"], "Your work email");
2516 assert_eq!(json["default_value"], "user@example.com");
2517 assert_eq!(json["disabled"], false);
2518 let parsed: Component = serde_json::from_value(json).unwrap();
2519 assert_eq!(parsed, input);
2520 }
2521
2522 #[test]
2523 fn select_with_default_value() {
2524 let select = Component::Select(SelectProps {
2525 field: "role".to_string(),
2526 label: "Role".to_string(),
2527 options: vec![SelectOption {
2528 value: "admin".to_string(),
2529 label: "Admin".to_string(),
2530 }],
2531 placeholder: None,
2532 required: None,
2533 disabled: Some(true),
2534 error: Some("Required field".to_string()),
2535 description: Some("User role".to_string()),
2536 default_value: Some("admin".to_string()),
2537 data_path: None,
2538 });
2539 let json = serde_json::to_value(&select).unwrap();
2540 assert_eq!(json["default_value"], "admin");
2541 assert_eq!(json["error"], "Required field");
2542 assert_eq!(json["description"], "User role");
2543 assert_eq!(json["disabled"], true);
2544 let parsed: Component = serde_json::from_value(json).unwrap();
2545 assert_eq!(parsed, select);
2546 }
2547
2548 #[test]
2549 fn alert_with_title() {
2550 let alert = Component::Alert(AlertProps {
2551 message: "Something happened".to_string(),
2552 variant: AlertVariant::Warning,
2553 title: Some("Warning".to_string()),
2554 });
2555 let json = serde_json::to_value(&alert).unwrap();
2556 assert_eq!(json["title"], "Warning");
2557 assert_eq!(json["message"], "Something happened");
2558 let parsed: Component = serde_json::from_value(json).unwrap();
2559 assert_eq!(parsed, alert);
2560 }
2561
2562 #[test]
2563 fn modal_with_footer_and_description() {
2564 let modal = Component::Modal(ModalProps {
2565 id: "modal-delete-item".to_string(),
2566 title: "Delete Item".to_string(),
2567 description: Some("This action cannot be undone.".to_string()),
2568 children: vec![],
2569 footer: vec![ComponentNode {
2570 key: "confirm".to_string(),
2571 component: Component::Button(ButtonProps {
2572 label: "Delete".to_string(),
2573 variant: ButtonVariant::Destructive,
2574 size: Size::Default,
2575 disabled: None,
2576 icon: None,
2577 icon_position: None,
2578 button_type: None,
2579 }),
2580 action: None,
2581 visibility: None,
2582 }],
2583 trigger_label: Some("Delete".to_string()),
2584 });
2585 let json = serde_json::to_value(&modal).unwrap();
2586 assert_eq!(json["description"], "This action cannot be undone.");
2587 assert!(json["footer"].is_array());
2588 assert_eq!(json["footer"][0]["label"], "Delete");
2589 let parsed: Component = serde_json::from_value(json).unwrap();
2590 assert_eq!(parsed, modal);
2591 }
2592
2593 #[test]
2594 fn table_with_sort_props() {
2595 let table = Component::Table(TableProps {
2596 columns: vec![Column {
2597 key: "name".to_string(),
2598 label: "Name".to_string(),
2599 format: None,
2600 }],
2601 data_path: "/data/users".to_string(),
2602 row_actions: None,
2603 empty_message: None,
2604 sortable: Some(true),
2605 sort_column: Some("name".to_string()),
2606 sort_direction: Some(SortDirection::Desc),
2607 });
2608 let json = serde_json::to_value(&table).unwrap();
2609 assert_eq!(json["sortable"], true);
2610 assert_eq!(json["sort_column"], "name");
2611 assert_eq!(json["sort_direction"], "desc");
2612 let parsed: Component = serde_json::from_value(json).unwrap();
2613 assert_eq!(parsed, table);
2614 }
2615
2616 #[test]
2617 fn aligned_button_variants_serialize() {
2618 let cases = [
2619 (ButtonVariant::Default, "default"),
2620 (ButtonVariant::Secondary, "secondary"),
2621 (ButtonVariant::Destructive, "destructive"),
2622 (ButtonVariant::Outline, "outline"),
2623 (ButtonVariant::Ghost, "ghost"),
2624 (ButtonVariant::Link, "link"),
2625 ];
2626 for (variant, expected) in &cases {
2627 let json = serde_json::to_value(variant).unwrap();
2628 assert_eq!(
2629 json, *expected,
2630 "ButtonVariant::{variant:?} should serialize as {expected}"
2631 );
2632 let parsed: ButtonVariant = serde_json::from_value(json).unwrap();
2633 assert_eq!(&parsed, variant);
2634 }
2635 }
2636
2637 #[test]
2638 fn aligned_badge_variants_serialize() {
2639 let cases = [
2640 (BadgeVariant::Default, "default"),
2641 (BadgeVariant::Secondary, "secondary"),
2642 (BadgeVariant::Destructive, "destructive"),
2643 (BadgeVariant::Outline, "outline"),
2644 ];
2645 for (variant, expected) in &cases {
2646 let json = serde_json::to_value(variant).unwrap();
2647 assert_eq!(
2648 json, *expected,
2649 "BadgeVariant::{variant:?} should serialize as {expected}"
2650 );
2651 let parsed: BadgeVariant = serde_json::from_value(json).unwrap();
2652 assert_eq!(&parsed, variant);
2653 }
2654 }
2655
2656 #[test]
2657 fn checkbox_round_trips() {
2658 let checkbox = Component::Checkbox(CheckboxProps {
2659 field: "terms".to_string(),
2660 value: None,
2661 label: "Accept Terms".to_string(),
2662 description: Some("You must accept the terms".to_string()),
2663 checked: Some(true),
2664 data_path: None,
2665 required: Some(true),
2666 disabled: Some(false),
2667 error: None,
2668 });
2669 let json = serde_json::to_value(&checkbox).unwrap();
2670 assert_eq!(json["type"], "Checkbox");
2671 assert_eq!(json["field"], "terms");
2672 assert_eq!(json["checked"], true);
2673 assert_eq!(json["description"], "You must accept the terms");
2674 let parsed: Component = serde_json::from_value(json).unwrap();
2675 assert_eq!(parsed, checkbox);
2676 }
2677
2678 #[test]
2679 fn switch_round_trips() {
2680 let switch = Component::Switch(SwitchProps {
2681 field: "notifications".to_string(),
2682 label: "Enable Notifications".to_string(),
2683 description: Some("Receive email notifications".to_string()),
2684 checked: Some(false),
2685 data_path: None,
2686 required: None,
2687 disabled: Some(false),
2688 error: None,
2689 action: None,
2690 compact: false,
2691 });
2692 let json = serde_json::to_value(&switch).unwrap();
2693 assert_eq!(json["type"], "Switch");
2694 assert_eq!(json["field"], "notifications");
2695 assert_eq!(json["checked"], false);
2696 let parsed: Component = serde_json::from_value(json).unwrap();
2697 assert_eq!(parsed, switch);
2698 }
2699
2700 #[test]
2701 fn separator_defaults_to_horizontal() {
2702 let json = r#"{"type": "Separator"}"#;
2703 let component: Component = serde_json::from_str(json).unwrap();
2704 match component {
2705 Component::Separator(props) => {
2706 assert_eq!(props.orientation, None);
2707 let explicit = Component::Separator(SeparatorProps {
2710 orientation: Some(Orientation::Horizontal),
2711 });
2712 let v = serde_json::to_value(&explicit).unwrap();
2713 assert_eq!(v["orientation"], "horizontal");
2714 let parsed: Component = serde_json::from_value(v).unwrap();
2715 assert_eq!(parsed, explicit);
2716 }
2717 _ => panic!("expected Separator"),
2718 }
2719 }
2720
2721 #[test]
2722 fn description_list_with_format() {
2723 let dl = Component::DescriptionList(DescriptionListProps {
2724 items: vec![
2725 DescriptionItem {
2726 label: "Created".to_string(),
2727 value: "2026-01-15".to_string(),
2728 format: Some(ColumnFormat::Date),
2729 },
2730 DescriptionItem {
2731 label: "Name".to_string(),
2732 value: "Alice".to_string(),
2733 format: None,
2734 },
2735 ],
2736 columns: Some(2),
2737 });
2738 let json = serde_json::to_value(&dl).unwrap();
2739 assert_eq!(json["type"], "DescriptionList");
2740 assert_eq!(json["columns"], 2);
2741 assert_eq!(json["items"][0]["format"], "date");
2742 assert!(json["items"][1].get("format").is_none());
2743 let parsed: Component = serde_json::from_value(json).unwrap();
2744 assert_eq!(parsed, dl);
2745 }
2746
2747 #[test]
2748 fn checkbox_with_error() {
2749 let checkbox = Component::Checkbox(CheckboxProps {
2750 field: "agree".to_string(),
2751 value: None,
2752 label: "I agree".to_string(),
2753 description: None,
2754 checked: None,
2755 data_path: None,
2756 required: Some(true),
2757 disabled: None,
2758 error: Some("You must agree".to_string()),
2759 });
2760 let json = serde_json::to_value(&checkbox).unwrap();
2761 assert_eq!(json["error"], "You must agree");
2762 assert!(json.get("description").is_none());
2763 assert!(json.get("checked").is_none());
2764 let parsed: Component = serde_json::from_value(json).unwrap();
2765 assert_eq!(parsed, checkbox);
2766 }
2767
2768 #[test]
2769 fn tabs_round_trips() {
2770 let tabs = Component::Tabs(TabsProps {
2771 default_tab: "general".to_string(),
2772 tabs: vec![
2773 Tab {
2774 value: "general".to_string(),
2775 label: "General".to_string(),
2776 children: vec![ComponentNode {
2777 key: "name-input".to_string(),
2778 component: Component::Input(InputProps {
2779 field: "name".to_string(),
2780 label: "Name".to_string(),
2781 input_type: InputType::Text,
2782 placeholder: None,
2783 required: None,
2784 disabled: None,
2785 error: None,
2786 description: None,
2787 default_value: None,
2788 data_path: None,
2789 step: None,
2790 list: None,
2791 }),
2792 action: None,
2793 visibility: None,
2794 }],
2795 },
2796 Tab {
2797 value: "security".to_string(),
2798 label: "Security".to_string(),
2799 children: vec![ComponentNode {
2800 key: "password-input".to_string(),
2801 component: Component::Input(InputProps {
2802 field: "password".to_string(),
2803 label: "Password".to_string(),
2804 input_type: InputType::Password,
2805 placeholder: None,
2806 required: None,
2807 disabled: None,
2808 error: None,
2809 description: None,
2810 default_value: None,
2811 data_path: None,
2812 step: None,
2813 list: None,
2814 }),
2815 action: None,
2816 visibility: None,
2817 }],
2818 },
2819 ],
2820 });
2821 let json = serde_json::to_string(&tabs).unwrap();
2822 let parsed: Component = serde_json::from_str(&json).unwrap();
2823 assert_eq!(parsed, tabs);
2824 }
2825
2826 #[test]
2827 fn breadcrumb_round_trips() {
2828 let breadcrumb = Component::Breadcrumb(BreadcrumbProps {
2829 items: vec![
2830 BreadcrumbItem {
2831 label: "Home".to_string(),
2832 url: Some("/".to_string()),
2833 },
2834 BreadcrumbItem {
2835 label: "Users".to_string(),
2836 url: Some("/users".to_string()),
2837 },
2838 BreadcrumbItem {
2839 label: "Edit User".to_string(),
2840 url: None,
2841 },
2842 ],
2843 });
2844 let json = serde_json::to_string(&breadcrumb).unwrap();
2845 let parsed: Component = serde_json::from_str(&json).unwrap();
2846 assert_eq!(parsed, breadcrumb);
2847
2848 let value = serde_json::to_value(&breadcrumb).unwrap();
2850 assert!(value["items"][2].get("url").is_none());
2851 }
2852
2853 #[test]
2854 fn pagination_round_trips() {
2855 let pagination = Component::Pagination(PaginationProps {
2856 current_page: 3,
2857 per_page: 25,
2858 total: 150,
2859 base_url: None,
2860 });
2861 let json = serde_json::to_string(&pagination).unwrap();
2862 let parsed: Component = serde_json::from_str(&json).unwrap();
2863 assert_eq!(parsed, pagination);
2864 }
2865
2866 #[test]
2867 fn progress_round_trips() {
2868 let progress = Component::Progress(ProgressProps {
2869 value: 75,
2870 max: Some(100),
2871 label: Some("Uploading...".to_string()),
2872 });
2873 let json = serde_json::to_string(&progress).unwrap();
2874 let parsed: Component = serde_json::from_str(&json).unwrap();
2875 assert_eq!(parsed, progress);
2876
2877 let value = serde_json::to_value(&progress).unwrap();
2878 assert_eq!(value["value"], 75);
2879 assert_eq!(value["max"], 100);
2880 assert_eq!(value["label"], "Uploading...");
2881 }
2882
2883 #[test]
2884 fn avatar_with_fallback() {
2885 let avatar = Component::Avatar(AvatarProps {
2886 src: None,
2887 alt: "John Doe".to_string(),
2888 fallback: Some("JD".to_string()),
2889 size: Some(Size::Lg),
2890 });
2891 let json = serde_json::to_string(&avatar).unwrap();
2892 let parsed: Component = serde_json::from_str(&json).unwrap();
2893 assert_eq!(parsed, avatar);
2894
2895 let value = serde_json::to_value(&avatar).unwrap();
2896 assert!(value.get("src").is_none());
2897 assert_eq!(value["fallback"], "JD");
2898 assert_eq!(value["size"], "lg");
2899 }
2900
2901 #[test]
2902 fn skeleton_round_trips() {
2903 let skeleton = Component::Skeleton(SkeletonProps {
2904 width: Some("100%".to_string()),
2905 height: Some("40px".to_string()),
2906 rounded: Some(true),
2907 });
2908 let json = serde_json::to_string(&skeleton).unwrap();
2909 let parsed: Component = serde_json::from_str(&json).unwrap();
2910 assert_eq!(parsed, skeleton);
2911
2912 let value = serde_json::to_value(&skeleton).unwrap();
2913 assert_eq!(value["width"], "100%");
2914 assert_eq!(value["height"], "40px");
2915 assert_eq!(value["rounded"], true);
2916 }
2917
2918 #[test]
2919 fn tabs_deserializes_from_json() {
2920 let json = r#"{
2921 "type": "Tabs",
2922 "default_tab": "general",
2923 "tabs": [
2924 {
2925 "value": "general",
2926 "label": "General",
2927 "children": [
2928 {
2929 "key": "name-input",
2930 "type": "Input",
2931 "field": "name",
2932 "label": "Name"
2933 }
2934 ]
2935 },
2936 {
2937 "value": "security",
2938 "label": "Security"
2939 }
2940 ]
2941 }"#;
2942 let component: Component = serde_json::from_str(json).unwrap();
2943 match component {
2944 Component::Tabs(props) => {
2945 assert_eq!(props.default_tab, "general");
2946 assert_eq!(props.tabs.len(), 2);
2947 assert_eq!(props.tabs[0].value, "general");
2948 assert_eq!(props.tabs[0].children.len(), 1);
2949 assert_eq!(props.tabs[1].value, "security");
2950 assert!(props.tabs[1].children.is_empty());
2951 }
2952 _ => panic!("expected Tabs"),
2953 }
2954 }
2955
2956 #[test]
2957 fn input_data_path_round_trips() {
2958 let input = Component::Input(InputProps {
2959 field: "name".to_string(),
2960 label: "Name".to_string(),
2961 input_type: InputType::Text,
2962 placeholder: None,
2963 required: None,
2964 disabled: None,
2965 error: None,
2966 description: None,
2967 default_value: None,
2968 data_path: Some("/data/user/name".to_string()),
2969 step: None,
2970 list: None,
2971 });
2972 let json = serde_json::to_value(&input).unwrap();
2973 assert_eq!(json["data_path"], "/data/user/name");
2974 let parsed: Component = serde_json::from_value(json).unwrap();
2975 assert_eq!(parsed, input);
2976 }
2977
2978 #[test]
2979 fn select_data_path_round_trips() {
2980 let select = Component::Select(SelectProps {
2981 field: "role".to_string(),
2982 label: "Role".to_string(),
2983 options: vec![SelectOption {
2984 value: "admin".to_string(),
2985 label: "Admin".to_string(),
2986 }],
2987 placeholder: None,
2988 required: None,
2989 disabled: None,
2990 error: None,
2991 description: None,
2992 default_value: None,
2993 data_path: Some("/data/user/role".to_string()),
2994 });
2995 let json = serde_json::to_value(&select).unwrap();
2996 assert_eq!(json["data_path"], "/data/user/role");
2997 let parsed: Component = serde_json::from_value(json).unwrap();
2998 assert_eq!(parsed, select);
2999 }
3000
3001 #[test]
3002 fn checkbox_data_path_round_trips() {
3003 let checkbox = Component::Checkbox(CheckboxProps {
3004 field: "terms".to_string(),
3005 value: None,
3006 label: "Accept Terms".to_string(),
3007 description: None,
3008 checked: None,
3009 data_path: Some("/data/user/accepted_terms".to_string()),
3010 required: None,
3011 disabled: None,
3012 error: None,
3013 });
3014 let json = serde_json::to_value(&checkbox).unwrap();
3015 assert_eq!(json["data_path"], "/data/user/accepted_terms");
3016 let parsed: Component = serde_json::from_value(json).unwrap();
3017 assert_eq!(parsed, checkbox);
3018 }
3019
3020 #[test]
3021 fn switch_data_path_round_trips() {
3022 let switch = Component::Switch(SwitchProps {
3023 field: "notifications".to_string(),
3024 label: "Enable Notifications".to_string(),
3025 description: None,
3026 checked: None,
3027 data_path: Some("/data/user/notifications_enabled".to_string()),
3028 required: None,
3029 disabled: None,
3030 error: None,
3031 action: None,
3032 compact: false,
3033 });
3034 let json = serde_json::to_value(&switch).unwrap();
3035 assert_eq!(json["data_path"], "/data/user/notifications_enabled");
3036 let parsed: Component = serde_json::from_value(json).unwrap();
3037 assert_eq!(parsed, switch);
3038 }
3039
3040 #[test]
3043 fn unknown_type_deserializes_as_plugin() {
3044 let json = r#"{"type": "Map", "center": [40.7, -74.0], "zoom": 12}"#;
3045 let component: Component = serde_json::from_str(json).unwrap();
3046 match component {
3047 Component::Plugin(props) => {
3048 assert_eq!(props.plugin_type, "Map");
3049 assert_eq!(props.props["center"][0], 40.7);
3050 assert_eq!(props.props["center"][1], -74.0);
3051 assert_eq!(props.props["zoom"], 12);
3052 assert!(props.props.get("type").is_none());
3054 }
3055 _ => panic!("expected Plugin"),
3056 }
3057 }
3058
3059 #[test]
3060 fn plugin_round_trips() {
3061 let plugin = Component::Plugin(PluginProps {
3062 plugin_type: "Chart".to_string(),
3063 props: serde_json::json!({"data": [1, 2, 3], "style": "bar"}),
3064 });
3065 let json = serde_json::to_value(&plugin).unwrap();
3066 assert_eq!(json["type"], "Chart");
3067 assert_eq!(json["data"], serde_json::json!([1, 2, 3]));
3068 assert_eq!(json["style"], "bar");
3069
3070 let parsed: Component = serde_json::from_value(json).unwrap();
3071 assert_eq!(parsed, plugin);
3072 }
3073
3074 #[test]
3075 fn plugin_serializes_with_type_field() {
3076 let plugin = Component::Plugin(PluginProps {
3077 plugin_type: "Map".to_string(),
3078 props: serde_json::json!({"lat": 51.5, "lng": -0.1}),
3079 });
3080 let json = serde_json::to_value(&plugin).unwrap();
3081 assert_eq!(json["type"], "Map");
3082 assert_eq!(json["lat"], 51.5);
3083 assert_eq!(json["lng"], -0.1);
3084 }
3085
3086 #[test]
3087 fn plugin_with_empty_props() {
3088 let json = r#"{"type": "CustomWidget"}"#;
3089 let component: Component = serde_json::from_str(json).unwrap();
3090 match component {
3091 Component::Plugin(props) => {
3092 assert_eq!(props.plugin_type, "CustomWidget");
3093 assert!(props.props.as_object().unwrap().is_empty());
3094 }
3095 _ => panic!("expected Plugin"),
3096 }
3097 }
3098
3099 #[test]
3100 fn plugin_in_component_node() {
3101 let node = ComponentNode {
3102 key: "map-1".to_string(),
3103 component: Component::Plugin(PluginProps {
3104 plugin_type: "Map".to_string(),
3105 props: serde_json::json!({"center": [0.0, 0.0]}),
3106 }),
3107 action: None,
3108 visibility: None,
3109 };
3110 let json = serde_json::to_string(&node).unwrap();
3111 let parsed: ComponentNode = serde_json::from_str(&json).unwrap();
3112 assert_eq!(parsed, node);
3113
3114 let value = serde_json::to_value(&node).unwrap();
3115 assert_eq!(value["type"], "Map");
3116 assert_eq!(value["key"], "map-1");
3117 }
3118
3119 #[test]
3120 fn known_types_not_treated_as_plugin() {
3121 let known_types = [
3123 "Card",
3124 "Table",
3125 "Form",
3126 "Button",
3127 "Input",
3128 "Select",
3129 "Alert",
3130 "Badge",
3131 "Modal",
3132 "Text",
3133 "Checkbox",
3134 "Switch",
3135 "Separator",
3136 "DescriptionList",
3137 "Tabs",
3138 "Breadcrumb",
3139 "Pagination",
3140 "Progress",
3141 "Avatar",
3142 "Skeleton",
3143 ];
3144 for type_name in &known_types {
3145 let json_str = match *type_name {
3148 "Card" => r#"{"type":"Card","title":"t"}"#,
3149 "Table" => r#"{"type":"Table","columns":[],"data_path":"/d"}"#,
3150 "Form" => r#"{"type":"Form","action":{"handler":"h","method":"POST"},"fields":[]}"#,
3151 "Button" => r#"{"type":"Button","label":"b"}"#,
3152 "Input" => r#"{"type":"Input","field":"f","label":"l"}"#,
3153 "Select" => r#"{"type":"Select","field":"f","label":"l","options":[]}"#,
3154 "Alert" => r#"{"type":"Alert","message":"m"}"#,
3155 "Badge" => r#"{"type":"Badge","label":"b"}"#,
3156 "Modal" => r#"{"type":"Modal","id":"modal-t","title":"t"}"#,
3157 "Text" => r#"{"type":"Text","content":"c"}"#,
3158 "Checkbox" => r#"{"type":"Checkbox","field":"f","label":"l"}"#,
3159 "Switch" => r#"{"type":"Switch","field":"f","label":"l"}"#,
3160 "Separator" => r#"{"type":"Separator"}"#,
3161 "DescriptionList" => r#"{"type":"DescriptionList","items":[]}"#,
3162 "Tabs" => r#"{"type":"Tabs","default_tab":"t","tabs":[]}"#,
3163 "Breadcrumb" => r#"{"type":"Breadcrumb","items":[]}"#,
3164 "Pagination" => r#"{"type":"Pagination","current_page":1,"per_page":10,"total":0}"#,
3165 "Progress" => r#"{"type":"Progress","value":0}"#,
3166 "Avatar" => r#"{"type":"Avatar","alt":"a"}"#,
3167 "Skeleton" => r#"{"type":"Skeleton"}"#,
3168 _ => unreachable!(),
3169 };
3170 let component: Component = serde_json::from_str(json_str).unwrap();
3171 assert!(
3172 !matches!(component, Component::Plugin(_)),
3173 "type {type_name} should not deserialize as Plugin"
3174 );
3175 }
3176 }
3177
3178 #[test]
3181 fn test_stat_card_serde_round_trip() {
3182 let component = Component::StatCard(StatCardProps {
3183 label: "Orders".into(),
3184 value: "42".into(),
3185 icon: Some("package".into()),
3186 subtitle: Some("today".into()),
3187 sse_target: Some("orders_today".into()),
3188 });
3189 let json = serde_json::to_string(&component).unwrap();
3190 assert!(json.contains("\"type\":\"StatCard\""));
3191 assert!(json.contains("\"sse_target\":\"orders_today\""));
3192 let deserialized: Component = serde_json::from_str(&json).unwrap();
3193 assert_eq!(component, deserialized);
3194 }
3195
3196 #[test]
3197 fn test_checklist_serde_round_trip() {
3198 let component = Component::Checklist(ChecklistProps {
3199 title: "Getting Started".into(),
3200 items: vec![
3201 ChecklistItem {
3202 label: "Install dependencies".into(),
3203 checked: true,
3204 href: None,
3205 },
3206 ChecklistItem {
3207 label: "Read the docs".into(),
3208 checked: false,
3209 href: Some("/docs".into()),
3210 },
3211 ],
3212 dismissible: true,
3213 dismiss_label: Some("Dismiss".into()),
3214 data_key: Some("onboarding".into()),
3215 });
3216 let json = serde_json::to_string(&component).unwrap();
3217 assert!(json.contains("\"type\":\"Checklist\""));
3218 assert!(json.contains("\"data_key\":\"onboarding\""));
3219 let deserialized: Component = serde_json::from_str(&json).unwrap();
3220 assert_eq!(component, deserialized);
3221 }
3222
3223 #[test]
3224 fn test_toast_serde_round_trip() {
3225 let component = Component::Toast(ToastProps {
3226 message: "Operation completed".into(),
3227 variant: ToastVariant::Success,
3228 timeout: Some(10),
3229 dismissible: true,
3230 });
3231 let json = serde_json::to_string(&component).unwrap();
3232 assert!(json.contains("\"type\":\"Toast\""));
3233 assert!(json.contains("\"timeout\":10"));
3234 let deserialized: Component = serde_json::from_str(&json).unwrap();
3235 assert_eq!(component, deserialized);
3236 }
3237
3238 #[test]
3239 fn test_notification_dropdown_serde_round_trip() {
3240 let component = Component::NotificationDropdown(NotificationDropdownProps {
3241 notifications: vec![
3242 NotificationItem {
3243 icon: Some("bell".into()),
3244 text: "New message".into(),
3245 timestamp: Some("2m ago".into()),
3246 read: false,
3247 action_url: Some("/messages/1".into()),
3248 },
3249 NotificationItem {
3250 icon: None,
3251 text: "Old notification".into(),
3252 timestamp: None,
3253 read: true,
3254 action_url: None,
3255 },
3256 ],
3257 empty_text: Some("No notifications".into()),
3258 });
3259 let json = serde_json::to_string(&component).unwrap();
3260 assert!(json.contains("\"type\":\"NotificationDropdown\""));
3261 assert!(json.contains("\"empty_text\":\"No notifications\""));
3262 let deserialized: Component = serde_json::from_str(&json).unwrap();
3263 assert_eq!(component, deserialized);
3264 }
3265
3266 #[test]
3267 fn test_sidebar_serde_round_trip() {
3268 let component = Component::Sidebar(SidebarProps {
3269 fixed_top: vec![SidebarNavItem {
3270 label: "Dashboard".into(),
3271 href: "/dashboard".into(),
3272 icon: Some("home".into()),
3273 active: true,
3274 }],
3275 groups: vec![SidebarGroup {
3276 label: "Management".into(),
3277 collapsed: false,
3278 items: vec![SidebarNavItem {
3279 label: "Users".into(),
3280 href: "/users".into(),
3281 icon: None,
3282 active: false,
3283 }],
3284 }],
3285 fixed_bottom: vec![SidebarNavItem {
3286 label: "Settings".into(),
3287 href: "/settings".into(),
3288 icon: Some("gear".into()),
3289 active: false,
3290 }],
3291 });
3292 let json = serde_json::to_string(&component).unwrap();
3293 assert!(json.contains("\"type\":\"Sidebar\""));
3294 assert!(json.contains("\"fixed_top\""));
3295 let deserialized: Component = serde_json::from_str(&json).unwrap();
3296 assert_eq!(component, deserialized);
3297 }
3298
3299 #[test]
3300 fn test_header_serde_round_trip() {
3301 let component = Component::Header(HeaderProps {
3302 business_name: "Acme Corp".into(),
3303 notification_count: Some(5),
3304 user_name: Some("Jane Doe".into()),
3305 user_avatar: Some("/avatar.jpg".into()),
3306 logout_url: Some("/logout".into()),
3307 });
3308 let json = serde_json::to_string(&component).unwrap();
3309 assert!(json.contains("\"type\":\"Header\""));
3310 assert!(json.contains("\"business_name\":\"Acme Corp\""));
3311 assert!(json.contains("\"notification_count\":5"));
3312 let deserialized: Component = serde_json::from_str(&json).unwrap();
3313 assert_eq!(component, deserialized);
3314 }
3315
3316 #[test]
3319 fn test_stat_card_constructor() {
3320 let props = StatCardProps {
3321 label: "Revenue".into(),
3322 value: "$1,000".into(),
3323 icon: None,
3324 subtitle: None,
3325 sse_target: None,
3326 };
3327 let node = ComponentNode::stat_card("revenue-card", props.clone());
3328 assert_eq!(node.key, "revenue-card");
3329 assert!(node.action.is_none());
3330 assert!(node.visibility.is_none());
3331 assert_eq!(node.component, Component::StatCard(props));
3332 }
3333
3334 #[test]
3335 fn test_checklist_constructor() {
3336 let props = ChecklistProps {
3337 title: "Tasks".into(),
3338 items: vec![],
3339 dismissible: true,
3340 dismiss_label: None,
3341 data_key: None,
3342 };
3343 let node = ComponentNode::checklist("task-list", props.clone());
3344 assert_eq!(node.key, "task-list");
3345 assert!(node.action.is_none());
3346 assert!(node.visibility.is_none());
3347 assert_eq!(node.component, Component::Checklist(props));
3348 }
3349
3350 #[test]
3351 fn test_toast_constructor() {
3352 let props = ToastProps {
3353 message: "Done!".into(),
3354 variant: ToastVariant::Success,
3355 timeout: None,
3356 dismissible: true,
3357 };
3358 let node = ComponentNode::toast("success-toast", props.clone());
3359 assert_eq!(node.key, "success-toast");
3360 assert!(node.action.is_none());
3361 assert!(node.visibility.is_none());
3362 assert_eq!(node.component, Component::Toast(props));
3363 }
3364
3365 #[test]
3366 fn test_notification_dropdown_constructor() {
3367 let props = NotificationDropdownProps {
3368 notifications: vec![],
3369 empty_text: Some("All caught up!".into()),
3370 };
3371 let node = ComponentNode::notification_dropdown("notifs", props.clone());
3372 assert_eq!(node.key, "notifs");
3373 assert!(node.action.is_none());
3374 assert!(node.visibility.is_none());
3375 assert_eq!(node.component, Component::NotificationDropdown(props));
3376 }
3377
3378 #[test]
3379 fn test_sidebar_constructor() {
3380 let props = SidebarProps {
3381 fixed_top: vec![],
3382 groups: vec![],
3383 fixed_bottom: vec![],
3384 };
3385 let node = ComponentNode::sidebar("main-nav", props.clone());
3386 assert_eq!(node.key, "main-nav");
3387 assert!(node.action.is_none());
3388 assert!(node.visibility.is_none());
3389 assert_eq!(node.component, Component::Sidebar(props));
3390 }
3391
3392 #[test]
3393 fn test_header_constructor() {
3394 let props = HeaderProps {
3395 business_name: "MyApp".into(),
3396 notification_count: None,
3397 user_name: None,
3398 user_avatar: None,
3399 logout_url: None,
3400 };
3401 let node = ComponentNode::header("page-header", props.clone());
3402 assert_eq!(node.key, "page-header");
3403 assert!(node.action.is_none());
3404 assert!(node.visibility.is_none());
3405 assert_eq!(node.component, Component::Header(props));
3406 }
3407
3408 #[test]
3411 fn test_checklist_item_round_trip() {
3412 let checked_item = ChecklistItem {
3413 label: "Completed task".into(),
3414 checked: true,
3415 href: Some("/task/1".into()),
3416 };
3417 let json = serde_json::to_string(&checked_item).unwrap();
3418 let parsed: ChecklistItem = serde_json::from_str(&json).unwrap();
3419 assert_eq!(parsed, checked_item);
3420
3421 let unchecked_item = ChecklistItem {
3422 label: "Pending task".into(),
3423 checked: false,
3424 href: None,
3425 };
3426 let json = serde_json::to_string(&unchecked_item).unwrap();
3427 let parsed: ChecklistItem = serde_json::from_str(&json).unwrap();
3428 assert_eq!(parsed, unchecked_item);
3429 assert!(!json.contains("href"));
3431 }
3432
3433 #[test]
3434 fn test_sidebar_group_round_trip() {
3435 let expanded = SidebarGroup {
3436 label: "Main".into(),
3437 collapsed: false,
3438 items: vec![
3439 SidebarNavItem {
3440 label: "Home".into(),
3441 href: "/".into(),
3442 icon: Some("home".into()),
3443 active: true,
3444 },
3445 SidebarNavItem {
3446 label: "About".into(),
3447 href: "/about".into(),
3448 icon: None,
3449 active: false,
3450 },
3451 ],
3452 };
3453 let json = serde_json::to_string(&expanded).unwrap();
3454 let parsed: SidebarGroup = serde_json::from_str(&json).unwrap();
3455 assert_eq!(parsed, expanded);
3456 assert_eq!(parsed.items.len(), 2);
3457
3458 let collapsed = SidebarGroup {
3459 label: "Advanced".into(),
3460 collapsed: true,
3461 items: vec![],
3462 };
3463 let json = serde_json::to_string(&collapsed).unwrap();
3464 let parsed: SidebarGroup = serde_json::from_str(&json).unwrap();
3465 assert_eq!(parsed, collapsed);
3466 assert!(parsed.collapsed);
3467 }
3468
3469 #[test]
3470 fn test_notification_item_round_trip() {
3471 let unread = NotificationItem {
3472 icon: Some("mail".into()),
3473 text: "You have a new message".into(),
3474 timestamp: Some("5m ago".into()),
3475 read: false,
3476 action_url: Some("/messages/42".into()),
3477 };
3478 let json = serde_json::to_string(&unread).unwrap();
3479 let parsed: NotificationItem = serde_json::from_str(&json).unwrap();
3480 assert_eq!(parsed, unread);
3481 assert!(!parsed.read);
3482
3483 let read_notif = NotificationItem {
3484 icon: None,
3485 text: "Welcome to the platform".into(),
3486 timestamp: None,
3487 read: true,
3488 action_url: None,
3489 };
3490 let json = serde_json::to_string(&read_notif).unwrap();
3491 let parsed: NotificationItem = serde_json::from_str(&json).unwrap();
3492 assert_eq!(parsed, read_notif);
3493 assert!(parsed.read);
3494 assert!(!json.contains("\"icon\""));
3496 assert!(!json.contains("\"action_url\""));
3497 }
3498
3499 #[test]
3502 fn test_stat_card_all_optionals_none() {
3503 let component = Component::StatCard(StatCardProps {
3504 label: "Count".into(),
3505 value: "0".into(),
3506 icon: None,
3507 subtitle: None,
3508 sse_target: None,
3509 });
3510 let json = serde_json::to_string(&component).unwrap();
3511 assert!(json.contains("\"type\":\"StatCard\""));
3512 assert!(!json.contains("\"icon\""));
3513 assert!(!json.contains("\"subtitle\""));
3514 assert!(!json.contains("\"sse_target\""));
3515 let deserialized: Component = serde_json::from_str(&json).unwrap();
3516 assert_eq!(component, deserialized);
3517 }
3518
3519 #[test]
3520 fn test_checklist_empty_items() {
3521 let component = Component::Checklist(ChecklistProps {
3522 title: "Empty List".into(),
3523 items: vec![],
3524 dismissible: true,
3525 dismiss_label: None,
3526 data_key: None,
3527 });
3528 let json = serde_json::to_string(&component).unwrap();
3529 assert!(json.contains("\"type\":\"Checklist\""));
3530 let deserialized: Component = serde_json::from_str(&json).unwrap();
3531 assert_eq!(component, deserialized);
3532 match &deserialized {
3533 Component::Checklist(props) => assert!(props.items.is_empty()),
3534 _ => panic!("expected Checklist"),
3535 }
3536 }
3537
3538 #[test]
3539 fn test_sidebar_empty_groups_and_fixed() {
3540 let component = Component::Sidebar(SidebarProps {
3541 fixed_top: vec![],
3542 groups: vec![],
3543 fixed_bottom: vec![],
3544 });
3545 let json = serde_json::to_string(&component).unwrap();
3546 assert!(json.contains("\"type\":\"Sidebar\""));
3547 assert!(!json.contains("\"fixed_top\""));
3549 assert!(!json.contains("\"groups\""));
3550 assert!(!json.contains("\"fixed_bottom\""));
3551 let deserialized: Component = serde_json::from_str(&json).unwrap();
3552 assert_eq!(component, deserialized);
3553 }
3554
3555 #[test]
3556 fn test_notification_dropdown_empty_uses_empty_text() {
3557 let component = Component::NotificationDropdown(NotificationDropdownProps {
3558 notifications: vec![],
3559 empty_text: Some("Nothing here!".into()),
3560 });
3561 let json = serde_json::to_string(&component).unwrap();
3562 assert!(json.contains("\"type\":\"NotificationDropdown\""));
3563 assert!(json.contains("\"empty_text\":\"Nothing here!\""));
3564 let deserialized: Component = serde_json::from_str(&json).unwrap();
3565 assert_eq!(component, deserialized);
3566 }
3567
3568 #[test]
3571 fn test_stat_card_omits_sse_target_when_none() {
3572 let component = Component::StatCard(StatCardProps {
3573 label: "Revenue".into(),
3574 value: "$500".into(),
3575 icon: None,
3576 subtitle: None,
3577 sse_target: None,
3578 });
3579 let json = serde_json::to_string(&component).unwrap();
3580 assert!(
3581 !json.contains("sse_target"),
3582 "sse_target must be omitted when None"
3583 );
3584 }
3585
3586 #[test]
3589 fn grid_round_trips() {
3590 let grid = Component::Grid(GridProps {
3591 columns: 3,
3592 md_columns: None,
3593 lg_columns: None,
3594 gap: GapSize::Lg,
3595 scrollable: None,
3596 children: vec![ComponentNode::text(
3597 "t",
3598 TextProps {
3599 content: "cell".into(),
3600 element: TextElement::P,
3601 },
3602 )],
3603 });
3604 let json = serde_json::to_value(&grid).unwrap();
3605 assert_eq!(json["type"], "Grid");
3606 assert_eq!(json["columns"], 3);
3607 assert_eq!(json["gap"], "lg");
3608 let parsed: Component = serde_json::from_value(json).unwrap();
3609 assert_eq!(parsed, grid);
3610 }
3611
3612 #[test]
3613 fn grid_defaults() {
3614 let json = serde_json::json!({"type": "Grid"});
3615 let parsed: Component = serde_json::from_value(json).unwrap();
3616 match parsed {
3617 Component::Grid(props) => {
3618 assert_eq!(props.columns, 2);
3619 assert_eq!(props.gap, GapSize::Md);
3620 assert!(props.children.is_empty());
3621 }
3622 _ => panic!("expected Grid"),
3623 }
3624 }
3625
3626 #[test]
3629 fn collapsible_round_trips() {
3630 let c = Component::Collapsible(CollapsibleProps {
3631 title: "Details".into(),
3632 expanded: true,
3633 children: vec![],
3634 });
3635 let json = serde_json::to_value(&c).unwrap();
3636 assert_eq!(json["type"], "Collapsible");
3637 assert_eq!(json["title"], "Details");
3638 assert_eq!(json["expanded"], true);
3639 let parsed: Component = serde_json::from_value(json).unwrap();
3640 assert_eq!(parsed, c);
3641 }
3642
3643 #[test]
3646 fn empty_state_round_trips() {
3647 let es = Component::EmptyState(EmptyStateProps {
3648 title: "No items".into(),
3649 description: Some("Create one".into()),
3650 action: Some(Action::get("items.create")),
3651 action_label: Some("New item".into()),
3652 });
3653 let json = serde_json::to_value(&es).unwrap();
3654 assert_eq!(json["type"], "EmptyState");
3655 assert_eq!(json["title"], "No items");
3656 let parsed: Component = serde_json::from_value(json).unwrap();
3657 assert_eq!(parsed, es);
3658 }
3659
3660 #[test]
3661 fn empty_state_minimal() {
3662 let json = serde_json::json!({"type": "EmptyState", "title": "Nothing"});
3663 let parsed: Component = serde_json::from_value(json).unwrap();
3664 match parsed {
3665 Component::EmptyState(props) => {
3666 assert_eq!(props.title, "Nothing");
3667 assert!(props.description.is_none());
3668 assert!(props.action.is_none());
3669 assert!(props.action_label.is_none());
3670 }
3671 _ => panic!("expected EmptyState"),
3672 }
3673 }
3674
3675 #[test]
3678 fn form_section_round_trips() {
3679 let fs = Component::FormSection(FormSectionProps {
3680 title: "Contact".into(),
3681 description: Some("Your details".into()),
3682 children: vec![],
3683 layout: None,
3684 });
3685 let json = serde_json::to_value(&fs).unwrap();
3686 assert_eq!(json["type"], "FormSection");
3687 assert_eq!(json["title"], "Contact");
3688 let parsed: Component = serde_json::from_value(json).unwrap();
3689 assert_eq!(parsed, fs);
3690 }
3691
3692 #[test]
3695 fn switch_with_action_round_trips() {
3696 let sw = Component::Switch(SwitchProps {
3697 field: "active".into(),
3698 label: "Active".into(),
3699 description: None,
3700 checked: Some(true),
3701 data_path: None,
3702 required: None,
3703 disabled: None,
3704 error: None,
3705 action: Some(Action::new("settings.toggle")),
3706 compact: false,
3707 });
3708 let json = serde_json::to_value(&sw).unwrap();
3709 assert!(json["action"].is_object());
3710 assert_eq!(json["action"]["handler"], "settings.toggle");
3711 let parsed: Component = serde_json::from_value(json).unwrap();
3712 assert_eq!(parsed, sw);
3713 }
3714
3715 #[test]
3716 fn switch_without_action_omits_field() {
3717 let sw = Component::Switch(SwitchProps {
3718 field: "f".into(),
3719 label: "l".into(),
3720 description: None,
3721 checked: None,
3722 data_path: None,
3723 required: None,
3724 disabled: None,
3725 error: None,
3726 action: None,
3727 compact: false,
3728 });
3729 let json = serde_json::to_string(&sw).unwrap();
3730 assert!(!json.contains("\"action\""));
3731 }
3732
3733 #[test]
3734 fn test_toast_omits_timeout_when_none() {
3735 let component = Component::Toast(ToastProps {
3736 message: "Hello".into(),
3737 variant: ToastVariant::Info,
3738 timeout: None,
3739 dismissible: false,
3740 });
3741 let json = serde_json::to_string(&component).unwrap();
3742 assert!(
3743 !json.contains("\"timeout\""),
3744 "timeout must be omitted when None"
3745 );
3746 }
3747
3748 #[test]
3749 fn page_header_round_trip_title_only() {
3750 let component = Component::PageHeader(PageHeaderProps {
3751 title: "Test Title".to_string(),
3752 breadcrumb: vec![],
3753 actions: vec![],
3754 });
3755 let json = serde_json::to_value(&component).unwrap();
3756 assert_eq!(json["type"], "PageHeader");
3757 assert_eq!(json["title"], "Test Title");
3758 assert!(json.get("breadcrumb").is_none());
3760 assert!(json.get("actions").is_none());
3761 let parsed: Component = serde_json::from_value(json).unwrap();
3762 assert_eq!(parsed, component);
3763 }
3764
3765 #[test]
3766 fn page_header_round_trip_with_breadcrumb_and_actions() {
3767 let component = Component::PageHeader(PageHeaderProps {
3768 title: "Users".to_string(),
3769 breadcrumb: vec![
3770 BreadcrumbItem {
3771 label: "Home".to_string(),
3772 url: Some("/".to_string()),
3773 },
3774 BreadcrumbItem {
3775 label: "Users".to_string(),
3776 url: None,
3777 },
3778 ],
3779 actions: vec![ComponentNode {
3780 key: "add-btn".to_string(),
3781 component: Component::Button(ButtonProps {
3782 label: "Add User".to_string(),
3783 variant: ButtonVariant::Default,
3784 size: Size::Default,
3785 disabled: None,
3786 icon: None,
3787 icon_position: None,
3788 button_type: None,
3789 }),
3790 action: None,
3791 visibility: None,
3792 }],
3793 });
3794 let json = serde_json::to_string(&component).unwrap();
3795 let parsed: Component = serde_json::from_str(&json).unwrap();
3796 assert_eq!(parsed, component);
3797 let value = serde_json::to_value(&component).unwrap();
3799 assert_eq!(value["type"], "PageHeader");
3800 assert_eq!(value["title"], "Users");
3801 assert!(value["breadcrumb"].is_array());
3802 assert!(value["actions"].is_array());
3803 }
3804
3805 #[test]
3806 fn page_header_deserialize_from_json() {
3807 let json = r#"{"type":"PageHeader","title":"Test"}"#;
3808 let component: Component = serde_json::from_str(json).unwrap();
3809 match component {
3810 Component::PageHeader(props) => {
3811 assert_eq!(props.title, "Test");
3812 assert!(props.breadcrumb.is_empty());
3813 assert!(props.actions.is_empty());
3814 }
3815 _ => panic!("expected PageHeader"),
3816 }
3817 }
3818
3819 #[test]
3820 fn button_group_round_trip_empty() {
3821 let component = Component::ButtonGroup(ButtonGroupProps { buttons: vec![] });
3822 let json = serde_json::to_value(&component).unwrap();
3823 assert_eq!(json["type"], "ButtonGroup");
3824 assert!(json.get("buttons").is_none());
3826 let parsed: Component = serde_json::from_value(json).unwrap();
3827 assert_eq!(parsed, component);
3828 }
3829
3830 #[test]
3831 fn button_group_round_trip_with_buttons() {
3832 let component = Component::ButtonGroup(ButtonGroupProps {
3833 buttons: vec![
3834 ComponentNode {
3835 key: "save".to_string(),
3836 component: Component::Button(ButtonProps {
3837 label: "Save".to_string(),
3838 variant: ButtonVariant::Default,
3839 size: Size::Default,
3840 disabled: None,
3841 icon: None,
3842 icon_position: None,
3843 button_type: None,
3844 }),
3845 action: None,
3846 visibility: None,
3847 },
3848 ComponentNode {
3849 key: "cancel".to_string(),
3850 component: Component::Button(ButtonProps {
3851 label: "Cancel".to_string(),
3852 variant: ButtonVariant::Outline,
3853 size: Size::Default,
3854 disabled: None,
3855 icon: None,
3856 icon_position: None,
3857 button_type: None,
3858 }),
3859 action: None,
3860 visibility: None,
3861 },
3862 ],
3863 });
3864 let json = serde_json::to_string(&component).unwrap();
3865 let parsed: Component = serde_json::from_str(&json).unwrap();
3866 assert_eq!(parsed, component);
3867 let value = serde_json::to_value(&component).unwrap();
3868 assert_eq!(value["type"], "ButtonGroup");
3869 assert!(value["buttons"].is_array());
3870 assert_eq!(value["buttons"].as_array().unwrap().len(), 2);
3871 }
3872
3873 #[test]
3874 fn button_group_deserialize_from_json() {
3875 let json = r#"{"type":"ButtonGroup","buttons":[]}"#;
3876 let component: Component = serde_json::from_str(json).unwrap();
3877 match component {
3878 Component::ButtonGroup(props) => {
3879 assert!(props.buttons.is_empty());
3880 }
3881 _ => panic!("expected ButtonGroup"),
3882 }
3883 }
3884
3885 #[test]
3886 fn image_round_trips() {
3887 let json = r#"{"type": "Image", "src": "/img/s.png", "alt": "Screenshot"}"#;
3889 let component: Component = serde_json::from_str(json).expect("URL variant");
3890 match component {
3891 Component::Image(props) => {
3892 assert!(
3893 matches!(props.source, ImageSource::Url { .. }),
3894 "URL JSON must deserialize to ImageSource::Url"
3895 );
3896 assert_eq!(props.alt, "Screenshot");
3897 assert!(props.aspect_ratio.is_none());
3898 }
3899 _ => panic!("expected Component::Image"),
3900 }
3901
3902 let json_svg = r#"{"type": "Image", "svg": "<svg></svg>", "alt": "Chart"}"#;
3904 let component_svg: Component = serde_json::from_str(json_svg).expect("InlineSvg variant");
3905 match component_svg {
3906 Component::Image(props) => {
3907 assert!(
3908 matches!(props.source, ImageSource::InlineSvg { .. }),
3909 "SVG JSON must deserialize to ImageSource::InlineSvg"
3910 );
3911 assert_eq!(props.alt, "Chart");
3912 }
3913 _ => panic!("expected Component::Image"),
3914 }
3915
3916 let json_neither = r#"{"type": "Image", "alt": "Bad"}"#;
3918 serde_json::from_str::<Component>(json_neither)
3919 .expect_err("input without src or svg must be rejected");
3920 }
3921
3922 #[test]
3923 fn all_known_types_round_trip() {
3924 let known_types: &[(&str, &str)] = &[
3925 ("Alert", r#"{"type":"Alert","message":"m"}"#),
3926 ("Avatar", r#"{"type":"Avatar","alt":"a"}"#),
3927 ("Badge", r#"{"type":"Badge","label":"b"}"#),
3928 ("Breadcrumb", r#"{"type":"Breadcrumb","items":[]}"#),
3929 ("Button", r#"{"type":"Button","label":"b"}"#),
3930 ("CalendarCell", r#"{"type":"CalendarCell","day":1}"#),
3931 ("Checkbox", r#"{"type":"Checkbox","field":"f","label":"l"}"#),
3932 ("Image", r#"{"type":"Image","src":"/img/s.png","alt":"a"}"#),
3933 ("Input", r#"{"type":"Input","field":"f","label":"l"}"#),
3934 (
3935 "Pagination",
3936 r#"{"type":"Pagination","current_page":1,"per_page":10,"total":0}"#,
3937 ),
3938 ("Progress", r#"{"type":"Progress","value":50}"#),
3939 (
3940 "Select",
3941 r#"{"type":"Select","field":"f","label":"l","options":[]}"#,
3942 ),
3943 ("Separator", r#"{"type":"Separator"}"#),
3944 ("Skeleton", r#"{"type":"Skeleton"}"#),
3945 ("Text", r#"{"type":"Text","content":"c"}"#),
3946 ];
3947 for (type_name, json_str) in known_types {
3948 let component: Component = serde_json::from_str(json_str)
3949 .unwrap_or_else(|e| panic!("failed to parse {type_name}: {e}"));
3950 let serialized = serde_json::to_value(&component).unwrap();
3951 assert_eq!(
3952 serialized["type"], *type_name,
3953 "type mismatch for {type_name}"
3954 );
3955 let reparsed: Component = serde_json::from_value(serialized)
3956 .unwrap_or_else(|e| panic!("failed to reparse {type_name}: {e}"));
3957 assert_eq!(
3958 serde_json::to_value(&reparsed).unwrap()["type"],
3959 *type_name,
3960 "round-trip type mismatch for {type_name}"
3961 );
3962 }
3963
3964 let svg_json = r#"{"type":"Image","svg":"<svg/>","alt":"chart"}"#;
3969 let parsed: Component =
3970 serde_json::from_str(svg_json).expect("InlineSvg JSON must deserialize");
3971 let serialized = serde_json::to_value(&parsed).expect("InlineSvg component must serialize");
3972 assert_eq!(
3973 serialized.get("type").and_then(|v| v.as_str()),
3974 Some("Image"),
3975 "InlineSvg variant must serialize with type=Image"
3976 );
3977 assert!(
3978 serialized.get("svg").is_some(),
3979 "InlineSvg serialization must carry the svg field"
3980 );
3981 assert!(
3982 serialized.get("src").is_none(),
3983 "InlineSvg serialization must NOT carry a src field"
3984 );
3985 let reparsed: Component = serde_json::from_value(serialized).expect("round-trip reparse");
3986 assert_eq!(parsed, reparsed, "round-trip must preserve equality");
3987 }
3988}
3989
3990#[cfg(test)]
3991mod key_value_editor_tests {
3992 use super::*;
3993 use serde_json::json;
3994
3995 #[test]
3996 fn key_value_editor_serde_roundtrip() {
3997 let original = Component::KeyValueEditor(KeyValueEditorProps {
3998 field: "metadata".to_string(),
3999 label: Some("Metadata".to_string()),
4000 suggested_keys: vec!["env".to_string(), "region".to_string()],
4001 allow_custom_keys: false,
4002 data_path: Some("/meta".to_string()),
4003 error: Some("required".to_string()),
4004 });
4005
4006 let serialized =
4007 serde_json::to_value(&original).expect("serialize KeyValueEditor component");
4008
4009 assert_eq!(
4011 serialized.get("type").and_then(|v| v.as_str()),
4012 Some("KeyValueEditor"),
4013 "serialized form must have type=KeyValueEditor: {serialized}"
4014 );
4015 assert_eq!(
4016 serialized.get("field").and_then(|v| v.as_str()),
4017 Some("metadata")
4018 );
4019 assert_eq!(
4020 serialized
4021 .get("allow_custom_keys")
4022 .and_then(|v| v.as_bool()),
4023 Some(false)
4024 );
4025
4026 let deserialized: Component =
4028 serde_json::from_value(serialized).expect("deserialize KeyValueEditor component");
4029 match deserialized {
4030 Component::KeyValueEditor(ref p) => {
4031 assert_eq!(p.field, "metadata");
4032 assert_eq!(p.label.as_deref(), Some("Metadata"));
4033 assert_eq!(
4034 p.suggested_keys,
4035 vec!["env".to_string(), "region".to_string()]
4036 );
4037 assert!(!p.allow_custom_keys);
4038 assert_eq!(p.data_path.as_deref(), Some("/meta"));
4039 assert_eq!(p.error.as_deref(), Some("required"));
4040 }
4041 other => panic!("expected KeyValueEditor, got {other:?}"),
4042 }
4043 assert_eq!(original, deserialized, "PartialEq round-trip failed");
4044 }
4045
4046 #[test]
4047 fn key_value_editor_allow_custom_keys_defaults_to_true() {
4048 let json_input = json!({
4050 "type": "KeyValueEditor",
4051 "field": "meta",
4052 });
4053 let parsed: Component =
4054 serde_json::from_value(json_input).expect("deserialize minimal KeyValueEditor");
4055 match parsed {
4056 Component::KeyValueEditor(p) => {
4057 assert!(
4058 p.allow_custom_keys,
4059 "allow_custom_keys default must be true"
4060 );
4061 assert!(
4062 p.suggested_keys.is_empty(),
4063 "suggested_keys default must be empty"
4064 );
4065 assert!(p.label.is_none());
4066 assert!(p.data_path.is_none());
4067 assert!(p.error.is_none());
4068 }
4069 other => panic!("expected KeyValueEditor, got {other:?}"),
4070 }
4071 }
4072}
4073
4074#[cfg(test)]
4075mod detail_form_tests {
4076 use super::*;
4077 use crate::action::{Action, HttpMethod};
4078 use serde_json::json;
4079
4080 #[test]
4083 fn edit_mode_default_is_view() {
4084 assert_eq!(EditMode::default(), EditMode::View);
4085 }
4086
4087 #[test]
4088 fn edit_mode_from_query_exact_edit() {
4089 assert_eq!(EditMode::from_query(Some("edit")), EditMode::Edit);
4090 }
4091
4092 #[test]
4093 fn edit_mode_from_query_case_insensitive_upper() {
4094 assert_eq!(EditMode::from_query(Some("EDIT")), EditMode::Edit);
4095 }
4096
4097 #[test]
4098 fn edit_mode_from_query_case_insensitive_mixed() {
4099 assert_eq!(EditMode::from_query(Some("eDiT")), EditMode::Edit);
4100 }
4101
4102 #[test]
4103 fn edit_mode_from_query_title_case() {
4104 assert_eq!(EditMode::from_query(Some("Edit")), EditMode::Edit);
4105 }
4106
4107 #[test]
4108 fn edit_mode_from_query_none_is_view() {
4109 assert_eq!(EditMode::from_query(None), EditMode::View);
4110 }
4111
4112 #[test]
4113 fn edit_mode_from_query_empty_is_view() {
4114 assert_eq!(EditMode::from_query(Some("")), EditMode::View);
4115 }
4116
4117 #[test]
4118 fn edit_mode_from_query_view_literal_is_view() {
4119 assert_eq!(EditMode::from_query(Some("view")), EditMode::View);
4120 }
4121
4122 #[test]
4123 fn edit_mode_from_query_unknown_is_view() {
4124 assert_eq!(EditMode::from_query(Some("anything-else")), EditMode::View);
4125 }
4126
4127 #[test]
4128 fn edit_mode_serializes_as_snake_case() {
4129 assert_eq!(
4130 serde_json::to_value(EditMode::Edit).expect("serialize Edit"),
4131 json!("edit")
4132 );
4133 assert_eq!(
4134 serde_json::to_value(EditMode::View).expect("serialize View"),
4135 json!("view")
4136 );
4137 let parsed_edit: EditMode =
4138 serde_json::from_value(json!("edit")).expect("deserialize 'edit'");
4139 assert_eq!(parsed_edit, EditMode::Edit);
4140 let parsed_view: EditMode =
4141 serde_json::from_value(json!("view")).expect("deserialize 'view'");
4142 assert_eq!(parsed_view, EditMode::View);
4143 }
4144
4145 fn sample_detail_form_props() -> DetailFormProps {
4148 DetailFormProps {
4149 mode: EditMode::Edit,
4150 action: Action {
4151 handler: "users.update".to_string(),
4152 url: Some("/users/1".to_string()),
4153 method: HttpMethod::Put,
4154 confirm: None,
4155 on_success: None,
4156 on_error: None,
4157 target: None,
4158 },
4159 fields: vec![
4160 DetailField {
4161 label: "Name".to_string(),
4162 value: "Ada".to_string(),
4163 input: ComponentNode::input(
4164 "name",
4165 InputProps {
4166 field: "name".to_string(),
4167 label: "".to_string(),
4168 input_type: InputType::Text,
4169 placeholder: None,
4170 required: None,
4171 disabled: None,
4172 error: None,
4173 description: None,
4174 default_value: None,
4175 data_path: None,
4176 step: None,
4177 list: None,
4178 },
4179 ),
4180 },
4181 DetailField {
4182 label: "Email".to_string(),
4183 value: "ada@example.com".to_string(),
4184 input: ComponentNode::input(
4185 "email",
4186 InputProps {
4187 field: "email".to_string(),
4188 label: "".to_string(),
4189 input_type: InputType::Email,
4190 placeholder: None,
4191 required: None,
4192 disabled: None,
4193 error: None,
4194 description: None,
4195 default_value: None,
4196 data_path: None,
4197 step: None,
4198 list: None,
4199 },
4200 ),
4201 },
4202 ],
4203 edit_url: "/users/1?mode=edit".to_string(),
4204 cancel_url: "/users/1".to_string(),
4205 edit_label: Some("Modifica".to_string()),
4206 save_label: Some("Salva".to_string()),
4207 cancel_label: Some("Annulla".to_string()),
4208 method: Some(HttpMethod::Put),
4209 }
4210 }
4211
4212 #[test]
4213 fn detail_form_props_serde_roundtrip() {
4214 let original = Component::DetailForm(sample_detail_form_props());
4215 let serialized = serde_json::to_value(&original).expect("serialize DetailForm component");
4216 assert_eq!(
4217 serialized.get("type").and_then(|v| v.as_str()),
4218 Some("DetailForm"),
4219 "serialized form must have type=DetailForm: {serialized}"
4220 );
4221 let deserialized: Component =
4222 serde_json::from_value(serialized).expect("deserialize DetailForm component");
4223 assert_eq!(original, deserialized, "PartialEq round-trip failed");
4224 }
4225
4226 #[test]
4227 fn detail_form_props_omits_optional_nones() {
4228 let props = DetailFormProps {
4229 mode: EditMode::View,
4230 action: Action {
4231 handler: "x".to_string(),
4232 url: None,
4233 method: HttpMethod::Post,
4234 confirm: None,
4235 on_success: None,
4236 on_error: None,
4237 target: None,
4238 },
4239 fields: Vec::new(),
4240 edit_url: "/x?mode=edit".to_string(),
4241 cancel_url: "/x".to_string(),
4242 edit_label: None,
4243 save_label: None,
4244 cancel_label: None,
4245 method: None,
4246 };
4247 let v = serde_json::to_value(&props).expect("serialize");
4248 assert!(
4249 v.get("edit_label").is_none(),
4250 "edit_label=None must be skipped, got: {v}"
4251 );
4252 assert!(
4253 v.get("save_label").is_none(),
4254 "save_label=None must be skipped"
4255 );
4256 assert!(
4257 v.get("cancel_label").is_none(),
4258 "cancel_label=None must be skipped"
4259 );
4260 assert!(v.get("method").is_none(), "method=None must be skipped");
4261 }
4262
4263 #[test]
4264 fn detail_form_props_defaults_mode_to_view() {
4265 let v = json!({
4266 "action": {"handler": "x", "method": "POST"},
4267 "fields": [],
4268 "edit_url": "/x?mode=edit",
4269 "cancel_url": "/x"
4270 });
4271 let props: DetailFormProps =
4272 serde_json::from_value(v).expect("deserialize DetailFormProps without mode");
4273 assert_eq!(
4274 props.mode,
4275 EditMode::View,
4276 "missing 'mode' must default to View"
4277 );
4278 }
4279
4280 #[test]
4283 fn component_node_detail_form_factory_shape() {
4284 let node = ComponentNode::detail_form("details", sample_detail_form_props());
4285 assert_eq!(node.key, "details");
4286 assert!(node.action.is_none());
4287 assert!(node.visibility.is_none());
4288 assert!(
4289 matches!(node.component, Component::DetailForm(_)),
4290 "expected Component::DetailForm variant"
4291 );
4292 }
4293}
4294
4295#[cfg(test)]
4296mod image_source_tests {
4297 use super::*;
4298 use serde_json::json;
4299
4300 #[test]
4301 fn image_source_url_roundtrip() {
4302 let parsed: ImageSource =
4303 serde_json::from_value(json!({"src": "/a.png"})).expect("Url variant");
4304 match parsed {
4305 ImageSource::Url { src } => assert_eq!(src, "/a.png"),
4306 _ => panic!("expected ImageSource::Url"),
4307 }
4308 }
4309
4310 #[test]
4311 fn image_source_inline_svg_roundtrip() {
4312 let parsed: ImageSource =
4313 serde_json::from_value(json!({"svg": "<svg/>"})).expect("InlineSvg variant");
4314 match parsed {
4315 ImageSource::InlineSvg { svg } => assert_eq!(svg, "<svg/>"),
4316 _ => panic!("expected ImageSource::InlineSvg"),
4317 }
4318 }
4319
4320 #[test]
4321 fn image_source_neither_rejected() {
4322 serde_json::from_value::<ImageSource>(json!({}))
4323 .expect_err("empty object (no src, no svg) must fail to deserialize");
4324 }
4325
4326 #[test]
4327 fn image_props_url_constructor() {
4328 let p = ImageProps::url("/a.png", "alt");
4329 assert!(matches!(p.source, ImageSource::Url { .. }));
4330 match &p.source {
4331 ImageSource::Url { src } => assert_eq!(src, "/a.png"),
4332 _ => unreachable!(),
4333 }
4334 assert_eq!(p.alt, "alt");
4335 assert!(p.aspect_ratio.is_none());
4336 assert!(p.placeholder_label.is_none());
4337 }
4338
4339 #[test]
4340 fn image_props_inline_svg_constructor() {
4341 let p = ImageProps::inline_svg("<svg/>", "chart");
4342 assert!(matches!(p.source, ImageSource::InlineSvg { .. }));
4343 match &p.source {
4344 ImageSource::InlineSvg { svg } => assert_eq!(svg, "<svg/>"),
4345 _ => unreachable!(),
4346 }
4347 assert_eq!(p.alt, "chart");
4348 assert!(p.aspect_ratio.is_none());
4349 assert!(p.placeholder_label.is_none());
4350 }
4351}
4352
4353#[cfg(test)]
4354mod rich_text_editor_tests {
4355 use super::*;
4356 use serde_json::json;
4357
4358 #[test]
4359 fn rich_text_editor_serde_roundtrip() {
4360 let original = Component::RichTextEditor(RichTextEditorProps {
4361 name: "body".to_string(),
4362 value: Some(r#"{"ops":[{"insert":"hello\n"}]}"#.to_string()),
4363 formats: vec!["bold".to_string(), "italic".to_string(), "link".to_string()],
4364 placeholder: Some("Type here...".to_string()),
4365 theme: "snow".to_string(),
4366 label: Some("Body".to_string()),
4367 error: Some("Required".to_string()),
4368 data_path: Some("/article/body".to_string()),
4369 required: Some(true),
4370 });
4371
4372 let serialized =
4373 serde_json::to_value(&original).expect("serialize RichTextEditor component");
4374
4375 assert_eq!(
4376 serialized.get("type").and_then(|v| v.as_str()),
4377 Some("RichTextEditor"),
4378 "tagged form must have type=RichTextEditor: {serialized}"
4379 );
4380 assert_eq!(
4381 serialized.get("name").and_then(|v| v.as_str()),
4382 Some("body")
4383 );
4384 assert_eq!(
4385 serialized.get("theme").and_then(|v| v.as_str()),
4386 Some("snow")
4387 );
4388
4389 let deserialized: Component =
4390 serde_json::from_value(serialized).expect("deserialize RichTextEditor component");
4391
4392 match deserialized {
4393 Component::RichTextEditor(ref p) => {
4394 assert_eq!(p.name, "body");
4395 assert_eq!(p.theme, "snow");
4396 assert_eq!(p.formats.len(), 3);
4397 assert_eq!(p.label.as_deref(), Some("Body"));
4398 assert_eq!(p.error.as_deref(), Some("Required"));
4399 assert_eq!(p.placeholder.as_deref(), Some("Type here..."));
4400 assert_eq!(p.data_path.as_deref(), Some("/article/body"));
4401 assert_eq!(p.required, Some(true));
4402 }
4403 other => panic!("expected RichTextEditor, got {other:?}"),
4404 }
4405 assert_eq!(original, deserialized, "PartialEq round-trip failed");
4406 }
4407
4408 #[test]
4409 fn rich_text_editor_theme_defaults_to_snow() {
4410 let json_input = json!({
4412 "type": "RichTextEditor",
4413 "name": "body",
4414 });
4415 let parsed: Component =
4416 serde_json::from_value(json_input).expect("deserialize minimal RichTextEditor");
4417 match parsed {
4418 Component::RichTextEditor(p) => {
4419 assert_eq!(p.theme, "snow", "theme default must be \"snow\"");
4420 assert_eq!(
4422 p.formats.len(),
4423 6,
4424 "default formats must have 6 entries: {:?}",
4425 p.formats
4426 );
4427 for fmt in ["bold", "italic", "underline", "list", "header", "link"] {
4428 assert!(
4429 p.formats.iter().any(|f| f == fmt),
4430 "default formats missing {fmt}: {:?}",
4431 p.formats
4432 );
4433 }
4434 assert!(p.value.is_none());
4435 assert!(p.placeholder.is_none());
4436 assert!(p.label.is_none());
4437 assert!(p.error.is_none());
4438 assert!(p.data_path.is_none());
4439 assert!(p.required.is_none());
4440 }
4441 other => panic!("expected RichTextEditor, got {other:?}"),
4442 }
4443 }
4444}