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