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, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
207#[serde(rename_all = "snake_case")]
208pub enum ButtonType {
209 #[default]
210 Button,
211 Submit,
212}
213
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
216pub struct ButtonProps {
217 pub label: String,
218 #[serde(default)]
219 pub variant: ButtonVariant,
220 #[serde(default)]
221 pub size: Size,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub disabled: Option<bool>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub icon: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub icon_position: Option<IconPosition>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub button_type: Option<ButtonType>,
230}
231
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
234pub struct InputProps {
235 pub field: String,
237 pub label: String,
238 #[serde(default)]
239 pub input_type: InputType,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub placeholder: Option<String>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub required: Option<bool>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub disabled: Option<bool>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub error: Option<String>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub description: Option<String>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub default_value: Option<String>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub data_path: Option<String>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub step: Option<String>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub list: Option<String>,
263}
264
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
267pub struct SelectProps {
268 pub field: String,
270 pub label: String,
271 pub options: Vec<SelectOption>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub placeholder: Option<String>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub required: Option<bool>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub disabled: Option<bool>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub error: Option<String>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub description: Option<String>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub default_value: Option<String>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub data_path: Option<String>,
287}
288
289#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
291pub struct AlertProps {
292 pub message: String,
293 #[serde(default)]
294 pub variant: AlertVariant,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub title: Option<String>,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
301pub struct BadgeProps {
302 pub label: String,
303 #[serde(default)]
304 pub variant: BadgeVariant,
305}
306
307#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
310pub struct ModalProps {
311 pub id: String,
312 pub title: String,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub description: Option<String>,
315 #[serde(default, skip_serializing_if = "Vec::is_empty")]
316 pub children: Vec<ComponentNode>,
317 #[serde(default, skip_serializing_if = "Vec::is_empty")]
318 pub footer: Vec<ComponentNode>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub trigger_label: Option<String>,
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
325pub struct TextProps {
326 pub content: String,
327 #[serde(default)]
328 pub element: TextElement,
329}
330
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
333pub struct CheckboxProps {
334 pub field: String,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub value: Option<String>,
340 pub label: String,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub description: Option<String>,
343 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub checked: Option<bool>,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub data_path: 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}
355
356#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
359pub struct SwitchProps {
360 pub field: String,
362 pub label: String,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub description: Option<String>,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub checked: Option<bool>,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub data_path: Option<String>,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub required: Option<bool>,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub disabled: Option<bool>,
374 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub error: Option<String>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub action: Option<Action>,
380 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
383 pub compact: bool,
384}
385
386#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
397pub struct KeyValueEditorProps {
398 pub field: String,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub label: Option<String>,
403 #[serde(default)]
405 pub suggested_keys: Vec<String>,
406 #[serde(default = "default_true")]
409 pub allow_custom_keys: bool,
410 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub data_path: Option<String>,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub error: Option<String>,
416}
417
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
420pub struct SeparatorProps {
421 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub orientation: Option<Orientation>,
423}
424
425#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
427pub struct DescriptionItem {
428 pub label: String,
429 pub value: String,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub format: Option<ColumnFormat>,
432}
433
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
436pub struct DescriptionListProps {
437 pub items: Vec<DescriptionItem>,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub columns: Option<u8>,
440}
441
442#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
445pub struct Tab {
446 pub value: String,
447 pub label: String,
448 #[serde(default, skip_serializing_if = "Vec::is_empty")]
449 pub children: Vec<ComponentNode>,
450}
451
452#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
455pub struct TabsProps {
456 pub default_tab: String,
457 pub tabs: Vec<Tab>,
458}
459
460#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
462pub struct BreadcrumbItem {
463 pub label: String,
464 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub url: Option<String>,
466}
467
468#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
470pub struct BreadcrumbProps {
471 pub items: Vec<BreadcrumbItem>,
472}
473
474#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
476pub struct PaginationProps {
477 pub current_page: u32,
478 pub per_page: u32,
479 pub total: u32,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
481 pub base_url: Option<String>,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
486pub struct ProgressProps {
487 pub value: u8,
489 #[serde(default, skip_serializing_if = "Option::is_none")]
490 pub max: Option<u8>,
491 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub label: Option<String>,
493}
494
495#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
497pub struct ImageProps {
498 pub src: String,
499 pub alt: String,
500 #[serde(default, skip_serializing_if = "Option::is_none")]
501 pub aspect_ratio: Option<String>,
502 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub placeholder_label: Option<String>,
508}
509
510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
512pub struct AvatarProps {
513 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub src: Option<String>,
515 pub alt: String,
516 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub fallback: Option<String>,
518 #[serde(default, skip_serializing_if = "Option::is_none")]
519 pub size: Option<Size>,
520}
521
522#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
524pub struct SkeletonProps {
525 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub width: Option<String>,
527 #[serde(default, skip_serializing_if = "Option::is_none")]
528 pub height: Option<String>,
529 #[serde(default, skip_serializing_if = "Option::is_none")]
530 pub rounded: Option<bool>,
531}
532
533#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
535#[serde(rename_all = "snake_case")]
536pub enum ToastVariant {
537 #[default]
538 Info,
539 Success,
540 Warning,
541 Error,
542}
543
544#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
546pub struct ChecklistItem {
547 pub label: String,
548 #[serde(default)]
549 pub checked: bool,
550 #[serde(default, skip_serializing_if = "Option::is_none")]
551 pub href: Option<String>,
552}
553
554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
556pub struct NotificationItem {
557 #[serde(default, skip_serializing_if = "Option::is_none")]
558 pub icon: Option<String>,
559 pub text: String,
560 #[serde(default, skip_serializing_if = "Option::is_none")]
561 pub timestamp: Option<String>,
562 #[serde(default)]
563 pub read: bool,
564 #[serde(default, skip_serializing_if = "Option::is_none")]
565 pub action_url: Option<String>,
566}
567
568#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
570pub struct SidebarNavItem {
571 pub label: String,
572 pub href: String,
573 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub icon: Option<String>,
575 #[serde(default)]
576 pub active: bool,
577}
578
579#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
581pub struct SidebarGroup {
582 pub label: String,
583 #[serde(default)]
584 pub collapsed: bool,
585 pub items: Vec<SidebarNavItem>,
586}
587
588#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
590pub struct StatCardProps {
591 pub label: String,
592 pub value: String,
593 #[serde(default, skip_serializing_if = "Option::is_none")]
594 pub icon: Option<String>,
595 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub subtitle: Option<String>,
597 #[serde(default, skip_serializing_if = "Option::is_none")]
599 pub sse_target: Option<String>,
600}
601
602#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
604pub struct ChecklistProps {
605 pub title: String,
606 pub items: Vec<ChecklistItem>,
607 #[serde(default = "default_true")]
608 pub dismissible: bool,
609 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub dismiss_label: Option<String>,
611 #[serde(default, skip_serializing_if = "Option::is_none")]
613 pub data_key: Option<String>,
614}
615
616fn default_true() -> bool {
617 true
618}
619
620#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
625pub struct ToastProps {
626 pub message: String,
627 #[serde(default)]
628 pub variant: ToastVariant,
629 #[serde(default, skip_serializing_if = "Option::is_none")]
631 pub timeout: Option<u32>,
632 #[serde(default = "default_true")]
633 pub dismissible: bool,
634}
635
636#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
638pub struct NotificationDropdownProps {
639 pub notifications: Vec<NotificationItem>,
640 #[serde(default, skip_serializing_if = "Option::is_none")]
641 pub empty_text: Option<String>,
642}
643
644#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
646pub struct SidebarProps {
647 #[serde(default, skip_serializing_if = "Vec::is_empty")]
648 pub fixed_top: Vec<SidebarNavItem>,
649 #[serde(default, skip_serializing_if = "Vec::is_empty")]
650 pub groups: Vec<SidebarGroup>,
651 #[serde(default, skip_serializing_if = "Vec::is_empty")]
652 pub fixed_bottom: Vec<SidebarNavItem>,
653}
654
655#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
657pub struct HeaderProps {
658 pub business_name: String,
659 #[serde(default, skip_serializing_if = "Option::is_none")]
661 pub notification_count: Option<u32>,
662 #[serde(default, skip_serializing_if = "Option::is_none")]
663 pub user_name: Option<String>,
664 #[serde(default, skip_serializing_if = "Option::is_none")]
665 pub user_avatar: Option<String>,
666 #[serde(default, skip_serializing_if = "Option::is_none")]
667 pub logout_url: Option<String>,
668}
669
670#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
672#[serde(rename_all = "snake_case")]
673pub enum GapSize {
674 None,
675 Sm,
676 #[default]
677 Md,
678 Lg,
679 Xl,
680}
681
682#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
685pub struct GridProps {
686 #[serde(default = "default_grid_columns")]
688 pub columns: u8,
689 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub md_columns: Option<u8>,
692 #[serde(default, skip_serializing_if = "Option::is_none")]
694 pub lg_columns: Option<u8>,
695 #[serde(default)]
697 pub gap: GapSize,
698 #[serde(default, skip_serializing_if = "Option::is_none")]
701 pub scrollable: Option<bool>,
702 #[serde(default, skip_serializing_if = "Vec::is_empty")]
703 pub children: Vec<ComponentNode>,
704}
705
706fn default_grid_columns() -> u8 {
707 2
708}
709
710#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
713pub struct CollapsibleProps {
714 pub title: String,
715 #[serde(default)]
716 pub expanded: bool,
717 #[serde(default, skip_serializing_if = "Vec::is_empty")]
718 pub children: Vec<ComponentNode>,
719}
720
721#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
723pub struct EmptyStateProps {
724 pub title: String,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub description: Option<String>,
727 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub action: Option<Action>,
729 #[serde(default, skip_serializing_if = "Option::is_none")]
730 pub action_label: Option<String>,
731}
732
733#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
735#[serde(rename_all = "snake_case")]
736pub enum FormSectionLayout {
737 #[default]
738 Stacked,
739 TwoColumn,
740}
741
742#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
745pub struct FormSectionProps {
746 pub title: String,
747 #[serde(default, skip_serializing_if = "Option::is_none")]
748 pub description: Option<String>,
749 #[serde(default, skip_serializing_if = "Vec::is_empty")]
750 pub children: Vec<ComponentNode>,
751 #[serde(default, skip_serializing_if = "Option::is_none")]
753 pub layout: Option<FormSectionLayout>,
754}
755
756#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
759pub struct PageHeaderProps {
760 pub title: String,
761 #[serde(default, skip_serializing_if = "Vec::is_empty")]
762 pub breadcrumb: Vec<BreadcrumbItem>,
763 #[serde(default, skip_serializing_if = "Vec::is_empty")]
764 pub actions: Vec<ComponentNode>,
765}
766
767#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
770pub struct ButtonGroupProps {
771 #[serde(default, skip_serializing_if = "Vec::is_empty")]
772 pub buttons: Vec<ComponentNode>,
773}
774
775#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
777pub struct DropdownMenuAction {
778 pub label: String,
779 pub action: Action,
780 #[serde(default)]
781 pub destructive: bool,
782}
783
784#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
786pub struct DropdownMenuProps {
787 pub menu_id: String,
788 pub trigger_label: String,
789 pub items: Vec<DropdownMenuAction>,
790 #[serde(default, skip_serializing_if = "Option::is_none")]
791 pub trigger_variant: Option<ButtonVariant>,
792}
793
794#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
797pub struct DataTableProps {
798 pub columns: Vec<Column>,
799 pub data_path: String,
800 #[serde(default, skip_serializing_if = "Option::is_none")]
801 pub row_actions: Option<Vec<DropdownMenuAction>>,
802 #[serde(default, skip_serializing_if = "Option::is_none")]
803 pub empty_message: Option<String>,
804 #[serde(default, skip_serializing_if = "Option::is_none")]
805 pub row_key: Option<String>,
806 #[serde(default, skip_serializing_if = "Option::is_none")]
808 pub row_href: Option<String>,
809}
810
811#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
814pub struct KanbanColumnProps {
815 pub id: String,
816 pub title: String,
817 pub count: u32,
818 #[serde(default, skip_serializing_if = "Vec::is_empty")]
819 pub children: Vec<ComponentNode>,
820}
821
822#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
825pub struct KanbanBoardProps {
826 pub columns: Vec<KanbanColumnProps>,
827 #[serde(default, skip_serializing_if = "Option::is_none")]
828 pub mobile_default_column: Option<String>,
829}
830
831#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
836pub struct CalendarCellProps {
837 pub day: u8,
838 #[serde(default)]
839 pub is_today: bool,
840 #[serde(default)]
841 pub is_current_month: bool,
842 #[serde(default)]
843 pub event_count: u32,
844 #[serde(default, skip_serializing_if = "Vec::is_empty")]
847 pub dot_colors: Vec<String>,
848}
849
850#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
852#[serde(rename_all = "snake_case")]
853pub enum ActionCardVariant {
854 #[default]
855 Default,
856 Setup,
857 Danger,
858}
859
860#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
865pub struct ActionCardProps {
866 pub title: String,
867 pub description: String,
868 #[serde(default, skip_serializing_if = "Option::is_none")]
869 pub icon: Option<String>,
870 #[serde(default)]
871 pub variant: ActionCardVariant,
872 #[serde(default, skip_serializing_if = "Option::is_none")]
874 pub href: Option<String>,
875}
876
877#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
882pub struct ProductTileProps {
883 pub product_id: String,
884 pub name: String,
885 pub price: String,
886 pub field: String,
887 #[serde(default, skip_serializing_if = "Option::is_none")]
888 pub default_quantity: Option<u32>,
889}
890
891#[derive(Debug, Clone, PartialEq)]
898pub struct PluginProps {
899 pub plugin_type: String,
901 pub props: serde_json::Value,
903}
904
905impl Serialize for PluginProps {
906 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
907 let obj = self.props.as_object();
909 let extra_len = obj.map_or(0, |m| m.len());
910 let mut map = serializer.serialize_map(Some(1 + extra_len))?;
911 map.serialize_entry("type", &self.plugin_type)?;
912 if let Some(obj) = obj {
913 for (k, v) in obj {
914 if k != "type" {
915 map.serialize_entry(k, v)?;
916 }
917 }
918 }
919 map.end()
920 }
921}
922
923impl<'de> Deserialize<'de> for PluginProps {
924 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
925 let mut value = serde_json::Value::deserialize(deserializer)?;
926 let plugin_type = value
927 .get("type")
928 .and_then(|v| v.as_str())
929 .map(|s| s.to_string())
930 .ok_or_else(|| de::Error::missing_field("type"))?;
931 if let Some(obj) = value.as_object_mut() {
933 obj.remove("type");
934 }
935 Ok(PluginProps {
936 plugin_type,
937 props: value,
938 })
939 }
940}
941
942#[derive(Debug, Clone, PartialEq)]
949pub enum Component {
950 Card(CardProps),
951 Table(TableProps),
952 Form(FormProps),
953 Button(ButtonProps),
954 Input(InputProps),
955 Select(SelectProps),
956 Alert(AlertProps),
957 Badge(BadgeProps),
958 Modal(ModalProps),
959 Text(TextProps),
960 Checkbox(CheckboxProps),
961 Switch(SwitchProps),
962 Separator(SeparatorProps),
963 DescriptionList(DescriptionListProps),
964 Tabs(TabsProps),
965 Breadcrumb(BreadcrumbProps),
966 Pagination(PaginationProps),
967 Progress(ProgressProps),
968 Avatar(AvatarProps),
969 Skeleton(SkeletonProps),
970 StatCard(StatCardProps),
971 Checklist(ChecklistProps),
972 Toast(ToastProps),
973 NotificationDropdown(NotificationDropdownProps),
974 Sidebar(SidebarProps),
975 Header(HeaderProps),
976 Grid(GridProps),
977 Collapsible(CollapsibleProps),
978 EmptyState(EmptyStateProps),
979 FormSection(FormSectionProps),
980 PageHeader(PageHeaderProps),
981 ButtonGroup(ButtonGroupProps),
982 DropdownMenu(DropdownMenuProps),
983 KanbanBoard(KanbanBoardProps),
984 CalendarCell(CalendarCellProps),
985 ActionCard(ActionCardProps),
986 ProductTile(ProductTileProps),
987 DataTable(DataTableProps),
988 Image(ImageProps),
989 KeyValueEditor(KeyValueEditorProps),
990 Plugin(PluginProps),
991}
992
993fn serialize_tagged<S: Serializer, T: Serialize>(
998 serializer: S,
999 type_name: &str,
1000 props: &T,
1001) -> Result<S::Ok, S::Error> {
1002 let mut value = serde_json::to_value(props).map_err(serde::ser::Error::custom)?;
1003 if let Some(obj) = value.as_object_mut() {
1004 obj.insert(
1005 "type".to_string(),
1006 serde_json::Value::String(type_name.to_string()),
1007 );
1008 }
1009 value.serialize(serializer)
1010}
1011
1012impl Serialize for Component {
1013 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1014 match self {
1015 Component::Card(p) => serialize_tagged(serializer, "Card", p),
1016 Component::Table(p) => serialize_tagged(serializer, "Table", p),
1017 Component::Form(p) => serialize_tagged(serializer, "Form", p),
1018 Component::Button(p) => serialize_tagged(serializer, "Button", p),
1019 Component::Input(p) => serialize_tagged(serializer, "Input", p),
1020 Component::Select(p) => serialize_tagged(serializer, "Select", p),
1021 Component::Alert(p) => serialize_tagged(serializer, "Alert", p),
1022 Component::Badge(p) => serialize_tagged(serializer, "Badge", p),
1023 Component::Modal(p) => serialize_tagged(serializer, "Modal", p),
1024 Component::Text(p) => serialize_tagged(serializer, "Text", p),
1025 Component::Checkbox(p) => serialize_tagged(serializer, "Checkbox", p),
1026 Component::Switch(p) => serialize_tagged(serializer, "Switch", p),
1027 Component::Separator(p) => serialize_tagged(serializer, "Separator", p),
1028 Component::DescriptionList(p) => serialize_tagged(serializer, "DescriptionList", p),
1029 Component::Tabs(p) => serialize_tagged(serializer, "Tabs", p),
1030 Component::Breadcrumb(p) => serialize_tagged(serializer, "Breadcrumb", p),
1031 Component::Pagination(p) => serialize_tagged(serializer, "Pagination", p),
1032 Component::Progress(p) => serialize_tagged(serializer, "Progress", p),
1033 Component::Avatar(p) => serialize_tagged(serializer, "Avatar", p),
1034 Component::Skeleton(p) => serialize_tagged(serializer, "Skeleton", p),
1035 Component::StatCard(p) => serialize_tagged(serializer, "StatCard", p),
1036 Component::Checklist(p) => serialize_tagged(serializer, "Checklist", p),
1037 Component::Toast(p) => serialize_tagged(serializer, "Toast", p),
1038 Component::NotificationDropdown(p) => {
1039 serialize_tagged(serializer, "NotificationDropdown", p)
1040 }
1041 Component::Sidebar(p) => serialize_tagged(serializer, "Sidebar", p),
1042 Component::Header(p) => serialize_tagged(serializer, "Header", p),
1043 Component::Grid(p) => serialize_tagged(serializer, "Grid", p),
1044 Component::Collapsible(p) => serialize_tagged(serializer, "Collapsible", p),
1045 Component::EmptyState(p) => serialize_tagged(serializer, "EmptyState", p),
1046 Component::FormSection(p) => serialize_tagged(serializer, "FormSection", p),
1047 Component::PageHeader(p) => serialize_tagged(serializer, "PageHeader", p),
1048 Component::ButtonGroup(p) => serialize_tagged(serializer, "ButtonGroup", p),
1049 Component::DropdownMenu(p) => serialize_tagged(serializer, "DropdownMenu", p),
1050 Component::KanbanBoard(p) => serialize_tagged(serializer, "KanbanBoard", p),
1051 Component::CalendarCell(p) => serialize_tagged(serializer, "CalendarCell", p),
1052 Component::ActionCard(p) => serialize_tagged(serializer, "ActionCard", p),
1053 Component::ProductTile(p) => serialize_tagged(serializer, "ProductTile", p),
1054 Component::DataTable(p) => serialize_tagged(serializer, "DataTable", p),
1055 Component::Image(p) => serialize_tagged(serializer, "Image", p),
1056 Component::KeyValueEditor(p) => serialize_tagged(serializer, "KeyValueEditor", p),
1057 Component::Plugin(p) => p.serialize(serializer),
1058 }
1059 }
1060}
1061
1062impl<'de> Deserialize<'de> for Component {
1065 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1066 let value = serde_json::Value::deserialize(deserializer)?;
1067 let type_str = value
1068 .get("type")
1069 .and_then(|v| v.as_str())
1070 .ok_or_else(|| de::Error::missing_field("type"))?;
1071
1072 match type_str {
1073 "Card" => serde_json::from_value::<CardProps>(value)
1074 .map(Component::Card)
1075 .map_err(de::Error::custom),
1076 "Table" => serde_json::from_value::<TableProps>(value)
1077 .map(Component::Table)
1078 .map_err(de::Error::custom),
1079 "Form" => serde_json::from_value::<FormProps>(value)
1080 .map(Component::Form)
1081 .map_err(de::Error::custom),
1082 "Button" => serde_json::from_value::<ButtonProps>(value)
1083 .map(Component::Button)
1084 .map_err(de::Error::custom),
1085 "Input" => serde_json::from_value::<InputProps>(value)
1086 .map(Component::Input)
1087 .map_err(de::Error::custom),
1088 "Select" => serde_json::from_value::<SelectProps>(value)
1089 .map(Component::Select)
1090 .map_err(de::Error::custom),
1091 "Alert" => serde_json::from_value::<AlertProps>(value)
1092 .map(Component::Alert)
1093 .map_err(de::Error::custom),
1094 "Badge" => serde_json::from_value::<BadgeProps>(value)
1095 .map(Component::Badge)
1096 .map_err(de::Error::custom),
1097 "Modal" => serde_json::from_value::<ModalProps>(value)
1098 .map(Component::Modal)
1099 .map_err(de::Error::custom),
1100 "Text" => serde_json::from_value::<TextProps>(value)
1101 .map(Component::Text)
1102 .map_err(de::Error::custom),
1103 "Checkbox" => serde_json::from_value::<CheckboxProps>(value)
1104 .map(Component::Checkbox)
1105 .map_err(de::Error::custom),
1106 "Switch" => serde_json::from_value::<SwitchProps>(value)
1107 .map(Component::Switch)
1108 .map_err(de::Error::custom),
1109 "Separator" => serde_json::from_value::<SeparatorProps>(value)
1110 .map(Component::Separator)
1111 .map_err(de::Error::custom),
1112 "DescriptionList" => serde_json::from_value::<DescriptionListProps>(value)
1113 .map(Component::DescriptionList)
1114 .map_err(de::Error::custom),
1115 "Tabs" => serde_json::from_value::<TabsProps>(value)
1116 .map(Component::Tabs)
1117 .map_err(de::Error::custom),
1118 "Breadcrumb" => serde_json::from_value::<BreadcrumbProps>(value)
1119 .map(Component::Breadcrumb)
1120 .map_err(de::Error::custom),
1121 "Pagination" => serde_json::from_value::<PaginationProps>(value)
1122 .map(Component::Pagination)
1123 .map_err(de::Error::custom),
1124 "Progress" => serde_json::from_value::<ProgressProps>(value)
1125 .map(Component::Progress)
1126 .map_err(de::Error::custom),
1127 "Avatar" => serde_json::from_value::<AvatarProps>(value)
1128 .map(Component::Avatar)
1129 .map_err(de::Error::custom),
1130 "Skeleton" => serde_json::from_value::<SkeletonProps>(value)
1131 .map(Component::Skeleton)
1132 .map_err(de::Error::custom),
1133 "StatCard" => serde_json::from_value::<StatCardProps>(value)
1134 .map(Component::StatCard)
1135 .map_err(de::Error::custom),
1136 "Checklist" => serde_json::from_value::<ChecklistProps>(value)
1137 .map(Component::Checklist)
1138 .map_err(de::Error::custom),
1139 "Toast" => serde_json::from_value::<ToastProps>(value)
1140 .map(Component::Toast)
1141 .map_err(de::Error::custom),
1142 "NotificationDropdown" => serde_json::from_value::<NotificationDropdownProps>(value)
1143 .map(Component::NotificationDropdown)
1144 .map_err(de::Error::custom),
1145 "Sidebar" => serde_json::from_value::<SidebarProps>(value)
1146 .map(Component::Sidebar)
1147 .map_err(de::Error::custom),
1148 "Header" => serde_json::from_value::<HeaderProps>(value)
1149 .map(Component::Header)
1150 .map_err(de::Error::custom),
1151 "Grid" => serde_json::from_value::<GridProps>(value)
1152 .map(Component::Grid)
1153 .map_err(de::Error::custom),
1154 "Collapsible" => serde_json::from_value::<CollapsibleProps>(value)
1155 .map(Component::Collapsible)
1156 .map_err(de::Error::custom),
1157 "EmptyState" => serde_json::from_value::<EmptyStateProps>(value)
1158 .map(Component::EmptyState)
1159 .map_err(de::Error::custom),
1160 "FormSection" => serde_json::from_value::<FormSectionProps>(value)
1161 .map(Component::FormSection)
1162 .map_err(de::Error::custom),
1163 "PageHeader" => serde_json::from_value::<PageHeaderProps>(value)
1164 .map(Component::PageHeader)
1165 .map_err(de::Error::custom),
1166 "ButtonGroup" => serde_json::from_value::<ButtonGroupProps>(value)
1167 .map(Component::ButtonGroup)
1168 .map_err(de::Error::custom),
1169 "DropdownMenu" => serde_json::from_value::<DropdownMenuProps>(value)
1170 .map(Component::DropdownMenu)
1171 .map_err(de::Error::custom),
1172 "KanbanBoard" => serde_json::from_value::<KanbanBoardProps>(value)
1173 .map(Component::KanbanBoard)
1174 .map_err(de::Error::custom),
1175 "CalendarCell" => serde_json::from_value::<CalendarCellProps>(value)
1176 .map(Component::CalendarCell)
1177 .map_err(de::Error::custom),
1178 "ActionCard" => serde_json::from_value::<ActionCardProps>(value)
1179 .map(Component::ActionCard)
1180 .map_err(de::Error::custom),
1181 "ProductTile" => serde_json::from_value::<ProductTileProps>(value)
1182 .map(Component::ProductTile)
1183 .map_err(de::Error::custom),
1184 "DataTable" => serde_json::from_value::<DataTableProps>(value)
1185 .map(Component::DataTable)
1186 .map_err(de::Error::custom),
1187 "Image" => serde_json::from_value::<ImageProps>(value)
1188 .map(Component::Image)
1189 .map_err(de::Error::custom),
1190 "KeyValueEditor" => serde_json::from_value::<KeyValueEditorProps>(value)
1191 .map(Component::KeyValueEditor)
1192 .map_err(de::Error::custom),
1193 _ => {
1194 let plugin_type = type_str.to_string();
1196 let mut props = value;
1197 if let Some(obj) = props.as_object_mut() {
1198 obj.remove("type");
1199 }
1200 Ok(Component::Plugin(PluginProps { plugin_type, props }))
1201 }
1202 }
1203 }
1204}
1205
1206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1213pub struct ComponentNode {
1214 pub key: String,
1215 #[serde(flatten)]
1216 pub component: Component,
1217 #[serde(default, skip_serializing_if = "Option::is_none")]
1218 pub action: Option<Action>,
1219 #[serde(default, skip_serializing_if = "Option::is_none")]
1220 pub visibility: Option<Visibility>,
1221}
1222
1223impl ComponentNode {
1224 pub fn card(key: impl Into<String>, props: CardProps) -> Self {
1226 Self {
1227 key: key.into(),
1228 component: Component::Card(props),
1229 action: None,
1230 visibility: None,
1231 }
1232 }
1233
1234 pub fn table(key: impl Into<String>, props: TableProps) -> Self {
1236 Self {
1237 key: key.into(),
1238 component: Component::Table(props),
1239 action: None,
1240 visibility: None,
1241 }
1242 }
1243
1244 pub fn form(key: impl Into<String>, props: FormProps) -> Self {
1246 Self {
1247 key: key.into(),
1248 component: Component::Form(props),
1249 action: None,
1250 visibility: None,
1251 }
1252 }
1253
1254 pub fn button(key: impl Into<String>, props: ButtonProps) -> Self {
1256 Self {
1257 key: key.into(),
1258 component: Component::Button(props),
1259 action: None,
1260 visibility: None,
1261 }
1262 }
1263
1264 pub fn input(key: impl Into<String>, props: InputProps) -> Self {
1266 Self {
1267 key: key.into(),
1268 component: Component::Input(props),
1269 action: None,
1270 visibility: None,
1271 }
1272 }
1273
1274 pub fn select(key: impl Into<String>, props: SelectProps) -> Self {
1276 Self {
1277 key: key.into(),
1278 component: Component::Select(props),
1279 action: None,
1280 visibility: None,
1281 }
1282 }
1283
1284 pub fn alert(key: impl Into<String>, props: AlertProps) -> Self {
1286 Self {
1287 key: key.into(),
1288 component: Component::Alert(props),
1289 action: None,
1290 visibility: None,
1291 }
1292 }
1293
1294 pub fn badge(key: impl Into<String>, props: BadgeProps) -> Self {
1296 Self {
1297 key: key.into(),
1298 component: Component::Badge(props),
1299 action: None,
1300 visibility: None,
1301 }
1302 }
1303
1304 pub fn modal(key: impl Into<String>, props: ModalProps) -> Self {
1306 Self {
1307 key: key.into(),
1308 component: Component::Modal(props),
1309 action: None,
1310 visibility: None,
1311 }
1312 }
1313
1314 pub fn text(key: impl Into<String>, props: TextProps) -> Self {
1316 Self {
1317 key: key.into(),
1318 component: Component::Text(props),
1319 action: None,
1320 visibility: None,
1321 }
1322 }
1323
1324 pub fn checkbox(key: impl Into<String>, props: CheckboxProps) -> Self {
1326 Self {
1327 key: key.into(),
1328 component: Component::Checkbox(props),
1329 action: None,
1330 visibility: None,
1331 }
1332 }
1333
1334 pub fn switch(key: impl Into<String>, props: SwitchProps) -> Self {
1336 Self {
1337 key: key.into(),
1338 component: Component::Switch(props),
1339 action: None,
1340 visibility: None,
1341 }
1342 }
1343
1344 pub fn separator(key: impl Into<String>, props: SeparatorProps) -> Self {
1346 Self {
1347 key: key.into(),
1348 component: Component::Separator(props),
1349 action: None,
1350 visibility: None,
1351 }
1352 }
1353
1354 pub fn description_list(key: impl Into<String>, props: DescriptionListProps) -> Self {
1356 Self {
1357 key: key.into(),
1358 component: Component::DescriptionList(props),
1359 action: None,
1360 visibility: None,
1361 }
1362 }
1363
1364 pub fn tabs(key: impl Into<String>, props: TabsProps) -> Self {
1366 Self {
1367 key: key.into(),
1368 component: Component::Tabs(props),
1369 action: None,
1370 visibility: None,
1371 }
1372 }
1373
1374 pub fn breadcrumb(key: impl Into<String>, props: BreadcrumbProps) -> Self {
1376 Self {
1377 key: key.into(),
1378 component: Component::Breadcrumb(props),
1379 action: None,
1380 visibility: None,
1381 }
1382 }
1383
1384 pub fn pagination(key: impl Into<String>, props: PaginationProps) -> Self {
1386 Self {
1387 key: key.into(),
1388 component: Component::Pagination(props),
1389 action: None,
1390 visibility: None,
1391 }
1392 }
1393
1394 pub fn progress(key: impl Into<String>, props: ProgressProps) -> Self {
1396 Self {
1397 key: key.into(),
1398 component: Component::Progress(props),
1399 action: None,
1400 visibility: None,
1401 }
1402 }
1403
1404 pub fn avatar(key: impl Into<String>, props: AvatarProps) -> Self {
1406 Self {
1407 key: key.into(),
1408 component: Component::Avatar(props),
1409 action: None,
1410 visibility: None,
1411 }
1412 }
1413
1414 pub fn skeleton(key: impl Into<String>, props: SkeletonProps) -> Self {
1416 Self {
1417 key: key.into(),
1418 component: Component::Skeleton(props),
1419 action: None,
1420 visibility: None,
1421 }
1422 }
1423
1424 pub fn stat_card(key: impl Into<String>, props: StatCardProps) -> Self {
1426 Self {
1427 key: key.into(),
1428 component: Component::StatCard(props),
1429 action: None,
1430 visibility: None,
1431 }
1432 }
1433
1434 pub fn checklist(key: impl Into<String>, props: ChecklistProps) -> Self {
1436 Self {
1437 key: key.into(),
1438 component: Component::Checklist(props),
1439 action: None,
1440 visibility: None,
1441 }
1442 }
1443
1444 pub fn toast(key: impl Into<String>, props: ToastProps) -> Self {
1446 Self {
1447 key: key.into(),
1448 component: Component::Toast(props),
1449 action: None,
1450 visibility: None,
1451 }
1452 }
1453
1454 pub fn notification_dropdown(key: impl Into<String>, props: NotificationDropdownProps) -> Self {
1456 Self {
1457 key: key.into(),
1458 component: Component::NotificationDropdown(props),
1459 action: None,
1460 visibility: None,
1461 }
1462 }
1463
1464 pub fn sidebar(key: impl Into<String>, props: SidebarProps) -> Self {
1466 Self {
1467 key: key.into(),
1468 component: Component::Sidebar(props),
1469 action: None,
1470 visibility: None,
1471 }
1472 }
1473
1474 pub fn header(key: impl Into<String>, props: HeaderProps) -> Self {
1476 Self {
1477 key: key.into(),
1478 component: Component::Header(props),
1479 action: None,
1480 visibility: None,
1481 }
1482 }
1483
1484 pub fn grid(key: impl Into<String>, props: GridProps) -> Self {
1486 Self {
1487 key: key.into(),
1488 component: Component::Grid(props),
1489 action: None,
1490 visibility: None,
1491 }
1492 }
1493
1494 pub fn collapsible(key: impl Into<String>, props: CollapsibleProps) -> Self {
1496 Self {
1497 key: key.into(),
1498 component: Component::Collapsible(props),
1499 action: None,
1500 visibility: None,
1501 }
1502 }
1503
1504 pub fn empty_state(key: impl Into<String>, props: EmptyStateProps) -> Self {
1506 Self {
1507 key: key.into(),
1508 component: Component::EmptyState(props),
1509 action: None,
1510 visibility: None,
1511 }
1512 }
1513
1514 pub fn form_section(key: impl Into<String>, props: FormSectionProps) -> Self {
1516 Self {
1517 key: key.into(),
1518 component: Component::FormSection(props),
1519 action: None,
1520 visibility: None,
1521 }
1522 }
1523
1524 pub fn dropdown_menu(key: impl Into<String>, props: DropdownMenuProps) -> Self {
1526 Self {
1527 key: key.into(),
1528 component: Component::DropdownMenu(props),
1529 action: None,
1530 visibility: None,
1531 }
1532 }
1533
1534 pub fn kanban_board(key: impl Into<String>, props: KanbanBoardProps) -> Self {
1536 Self {
1537 key: key.into(),
1538 component: Component::KanbanBoard(props),
1539 action: None,
1540 visibility: None,
1541 }
1542 }
1543
1544 pub fn calendar_cell(key: impl Into<String>, props: CalendarCellProps) -> Self {
1546 Self {
1547 key: key.into(),
1548 component: Component::CalendarCell(props),
1549 action: None,
1550 visibility: None,
1551 }
1552 }
1553
1554 pub fn action_card(key: impl Into<String>, props: ActionCardProps) -> Self {
1556 Self {
1557 key: key.into(),
1558 component: Component::ActionCard(props),
1559 action: None,
1560 visibility: None,
1561 }
1562 }
1563
1564 pub fn product_tile(key: impl Into<String>, props: ProductTileProps) -> Self {
1566 Self {
1567 key: key.into(),
1568 component: Component::ProductTile(props),
1569 action: None,
1570 visibility: None,
1571 }
1572 }
1573
1574 pub fn data_table(key: impl Into<String>, props: DataTableProps) -> Self {
1576 Self {
1577 key: key.into(),
1578 component: Component::DataTable(props),
1579 action: None,
1580 visibility: None,
1581 }
1582 }
1583
1584 pub fn image(key: impl Into<String>, props: ImageProps) -> Self {
1586 Self {
1587 key: key.into(),
1588 component: Component::Image(props),
1589 action: None,
1590 visibility: None,
1591 }
1592 }
1593
1594 pub fn plugin_component(key: impl Into<String>, props: PluginProps) -> Self {
1598 Self {
1599 key: key.into(),
1600 component: Component::Plugin(props),
1601 action: None,
1602 visibility: None,
1603 }
1604 }
1605}
1606
1607#[cfg(test)]
1608mod tests {
1609 use super::*;
1610 use crate::action::HttpMethod;
1611 use crate::visibility::{VisibilityCondition, VisibilityOperator};
1612
1613 #[test]
1614 fn card_component_tagged_serialization() {
1615 let card = Component::Card(CardProps {
1616 title: "Test Card".to_string(),
1617 description: Some("A description".to_string()),
1618 children: vec![],
1619 footer: vec![],
1620 max_width: None,
1621 });
1622 let json = serde_json::to_value(&card).unwrap();
1623 assert_eq!(json["type"], "Card");
1624 assert_eq!(json["title"], "Test Card");
1625 assert_eq!(json["description"], "A description");
1626 }
1627
1628 #[test]
1629 fn button_variant_defaults_to_default() {
1630 let json = r#"{"type": "Button", "label": "Click me"}"#;
1631 let component: Component = serde_json::from_str(json).unwrap();
1632 match component {
1633 Component::Button(props) => {
1634 assert_eq!(props.variant, ButtonVariant::Default);
1635 assert_eq!(props.label, "Click me");
1636 }
1637 _ => panic!("expected Button"),
1638 }
1639 }
1640
1641 #[test]
1642 fn input_type_defaults_to_text() {
1643 let json = r#"{"type": "Input", "field": "email", "label": "Email"}"#;
1644 let component: Component = serde_json::from_str(json).unwrap();
1645 match component {
1646 Component::Input(props) => {
1647 assert_eq!(props.input_type, InputType::Text);
1648 assert_eq!(props.field, "email");
1649 }
1650 _ => panic!("expected Input"),
1651 }
1652 }
1653
1654 #[test]
1655 fn alert_variant_defaults_to_info() {
1656 let json = r#"{"type": "Alert", "message": "Hello"}"#;
1657 let component: Component = serde_json::from_str(json).unwrap();
1658 match component {
1659 Component::Alert(props) => assert_eq!(props.variant, AlertVariant::Info),
1660 _ => panic!("expected Alert"),
1661 }
1662 }
1663
1664 #[test]
1665 fn badge_variant_defaults_to_default() {
1666 let json = r#"{"type": "Badge", "label": "New"}"#;
1667 let component: Component = serde_json::from_str(json).unwrap();
1668 match component {
1669 Component::Badge(props) => assert_eq!(props.variant, BadgeVariant::Default),
1670 _ => panic!("expected Badge"),
1671 }
1672 }
1673
1674 #[test]
1675 fn text_element_defaults_to_p() {
1676 let json = r#"{"type": "Text", "content": "Hello world"}"#;
1677 let component: Component = serde_json::from_str(json).unwrap();
1678 match component {
1679 Component::Text(props) => {
1680 assert_eq!(props.element, TextElement::P);
1681 assert_eq!(props.content, "Hello world");
1682 }
1683 _ => panic!("expected Text"),
1684 }
1685 }
1686
1687 #[test]
1688 fn table_component_round_trips() {
1689 let table = Component::Table(TableProps {
1690 columns: vec![
1691 Column {
1692 key: "name".to_string(),
1693 label: "Name".to_string(),
1694 format: None,
1695 },
1696 Column {
1697 key: "created_at".to_string(),
1698 label: "Created".to_string(),
1699 format: Some(ColumnFormat::Date),
1700 },
1701 ],
1702 data_path: "/data/users".to_string(),
1703 row_actions: None,
1704 empty_message: Some("No users found".to_string()),
1705 sortable: None,
1706 sort_column: None,
1707 sort_direction: None,
1708 });
1709 let json = serde_json::to_string(&table).unwrap();
1710 let parsed: Component = serde_json::from_str(&json).unwrap();
1711 assert_eq!(parsed, table);
1712 }
1713
1714 #[test]
1715 fn select_component_round_trips() {
1716 let select = Component::Select(SelectProps {
1717 field: "role".to_string(),
1718 label: "Role".to_string(),
1719 options: vec![
1720 SelectOption {
1721 value: "admin".to_string(),
1722 label: "Administrator".to_string(),
1723 },
1724 SelectOption {
1725 value: "user".to_string(),
1726 label: "User".to_string(),
1727 },
1728 ],
1729 placeholder: Some("Select a role".to_string()),
1730 required: Some(true),
1731 disabled: None,
1732 error: None,
1733 description: None,
1734 default_value: None,
1735 data_path: None,
1736 });
1737 let json = serde_json::to_string(&select).unwrap();
1738 let parsed: Component = serde_json::from_str(&json).unwrap();
1739 assert_eq!(parsed, select);
1740 }
1741
1742 #[test]
1743 fn modal_component_round_trips() {
1744 let modal = Component::Modal(ModalProps {
1745 id: "modal-confirm".to_string(),
1746 title: "Confirm".to_string(),
1747 description: None,
1748 children: vec![ComponentNode {
1749 key: "msg".to_string(),
1750 component: Component::Text(TextProps {
1751 content: "Are you sure?".to_string(),
1752 element: TextElement::P,
1753 }),
1754 action: None,
1755 visibility: None,
1756 }],
1757 footer: vec![],
1758 trigger_label: Some("Open".to_string()),
1759 });
1760 let json = serde_json::to_string(&modal).unwrap();
1761 let parsed: Component = serde_json::from_str(&json).unwrap();
1762 assert_eq!(parsed, modal);
1763 }
1764
1765 #[test]
1766 fn form_component_round_trips() {
1767 let form = Component::Form(FormProps {
1768 action: Action {
1769 handler: "users.store".to_string(),
1770 url: None,
1771 method: HttpMethod::Post,
1772 confirm: None,
1773 on_success: None,
1774 on_error: None,
1775 target: None,
1776 },
1777 fields: vec![ComponentNode {
1778 key: "email-input".to_string(),
1779 component: Component::Input(InputProps {
1780 field: "email".to_string(),
1781 label: "Email".to_string(),
1782 input_type: InputType::Email,
1783 placeholder: Some("user@example.com".to_string()),
1784 required: Some(true),
1785 disabled: None,
1786 error: None,
1787 description: None,
1788 default_value: None,
1789 data_path: None,
1790 step: None,
1791 list: None,
1792 }),
1793 action: None,
1794 visibility: None,
1795 }],
1796 method: None,
1797 guard: None,
1798 max_width: None,
1799 });
1800 let json = serde_json::to_string(&form).unwrap();
1801 let parsed: Component = serde_json::from_str(&json).unwrap();
1802 assert_eq!(parsed, form);
1803 }
1804
1805 #[test]
1806 fn component_node_with_action_and_visibility() {
1807 let node = ComponentNode {
1808 key: "create-btn".to_string(),
1809 component: Component::Button(ButtonProps {
1810 label: "Create User".to_string(),
1811 variant: ButtonVariant::Default,
1812 size: Size::Default,
1813 disabled: None,
1814 icon: None,
1815 icon_position: None,
1816 button_type: None,
1817 }),
1818 action: Some(Action {
1819 handler: "users.create".to_string(),
1820 url: None,
1821 method: HttpMethod::Post,
1822 confirm: None,
1823 on_success: None,
1824 on_error: None,
1825 target: None,
1826 }),
1827 visibility: Some(Visibility::Condition(VisibilityCondition {
1828 path: "/auth/user/role".to_string(),
1829 operator: VisibilityOperator::Eq,
1830 value: Some(serde_json::Value::String("admin".to_string())),
1831 })),
1832 };
1833 let json = serde_json::to_string(&node).unwrap();
1834 let parsed: ComponentNode = serde_json::from_str(&json).unwrap();
1835 assert_eq!(parsed, node);
1836
1837 let value = serde_json::to_value(&node).unwrap();
1839 assert_eq!(value["type"], "Button");
1840 assert_eq!(value["key"], "create-btn");
1841 assert!(value.get("action").is_some());
1842 assert!(value.get("visibility").is_some());
1843 }
1844
1845 #[test]
1846 fn all_component_variants_serialize() {
1847 let components: Vec<Component> = vec![
1848 Component::Card(CardProps {
1849 title: "t".to_string(),
1850 description: None,
1851 children: vec![],
1852 footer: vec![],
1853 max_width: None,
1854 }),
1855 Component::Table(TableProps {
1856 columns: vec![],
1857 data_path: "/d".to_string(),
1858 row_actions: None,
1859 empty_message: None,
1860 sortable: None,
1861 sort_column: None,
1862 sort_direction: None,
1863 }),
1864 Component::Form(FormProps {
1865 action: Action {
1866 handler: "h.m".to_string(),
1867 url: None,
1868 method: HttpMethod::Post,
1869 confirm: None,
1870 on_success: None,
1871 on_error: None,
1872 target: None,
1873 },
1874 fields: vec![],
1875 method: None,
1876 guard: None,
1877 max_width: None,
1878 }),
1879 Component::Button(ButtonProps {
1880 label: "b".to_string(),
1881 variant: ButtonVariant::Default,
1882 size: Size::Default,
1883 disabled: None,
1884 icon: None,
1885 icon_position: None,
1886 button_type: None,
1887 }),
1888 Component::Input(InputProps {
1889 field: "f".to_string(),
1890 label: "l".to_string(),
1891 input_type: InputType::Text,
1892 placeholder: None,
1893 required: None,
1894 disabled: None,
1895 error: None,
1896 description: None,
1897 default_value: None,
1898 data_path: None,
1899 step: None,
1900 list: None,
1901 }),
1902 Component::Select(SelectProps {
1903 field: "f".to_string(),
1904 label: "l".to_string(),
1905 options: vec![],
1906 placeholder: None,
1907 required: None,
1908 disabled: None,
1909 error: None,
1910 description: None,
1911 default_value: None,
1912 data_path: None,
1913 }),
1914 Component::Alert(AlertProps {
1915 message: "m".to_string(),
1916 variant: AlertVariant::Info,
1917 title: None,
1918 }),
1919 Component::Badge(BadgeProps {
1920 label: "b".to_string(),
1921 variant: BadgeVariant::Default,
1922 }),
1923 Component::Modal(ModalProps {
1924 id: "modal-t".to_string(),
1925 title: "t".to_string(),
1926 description: None,
1927 children: vec![],
1928 footer: vec![],
1929 trigger_label: None,
1930 }),
1931 Component::Text(TextProps {
1932 content: "c".to_string(),
1933 element: TextElement::P,
1934 }),
1935 Component::Checkbox(CheckboxProps {
1936 field: "f".to_string(),
1937 value: None,
1938 label: "l".to_string(),
1939 description: None,
1940 checked: None,
1941 data_path: None,
1942 required: None,
1943 disabled: None,
1944 error: None,
1945 }),
1946 Component::Switch(SwitchProps {
1947 field: "f".to_string(),
1948 label: "l".to_string(),
1949 description: None,
1950 checked: None,
1951 data_path: None,
1952 required: None,
1953 disabled: None,
1954 error: None,
1955 action: None,
1956 compact: false,
1957 }),
1958 Component::Separator(SeparatorProps { orientation: None }),
1959 Component::DescriptionList(DescriptionListProps {
1960 items: vec![DescriptionItem {
1961 label: "k".to_string(),
1962 value: "v".to_string(),
1963 format: None,
1964 }],
1965 columns: None,
1966 }),
1967 Component::Tabs(TabsProps {
1968 default_tab: "t1".to_string(),
1969 tabs: vec![Tab {
1970 value: "t1".to_string(),
1971 label: "Tab 1".to_string(),
1972 children: vec![],
1973 }],
1974 }),
1975 Component::Breadcrumb(BreadcrumbProps {
1976 items: vec![BreadcrumbItem {
1977 label: "Home".to_string(),
1978 url: Some("/".to_string()),
1979 }],
1980 }),
1981 Component::Pagination(PaginationProps {
1982 current_page: 1,
1983 per_page: 10,
1984 total: 100,
1985 base_url: None,
1986 }),
1987 Component::Progress(ProgressProps {
1988 value: 50,
1989 max: None,
1990 label: None,
1991 }),
1992 Component::Avatar(AvatarProps {
1993 src: None,
1994 alt: "User".to_string(),
1995 fallback: Some("U".to_string()),
1996 size: None,
1997 }),
1998 Component::Skeleton(SkeletonProps {
1999 width: None,
2000 height: None,
2001 rounded: None,
2002 }),
2003 Component::StatCard(StatCardProps {
2004 label: "Revenue".to_string(),
2005 value: "$1,234".to_string(),
2006 icon: None,
2007 subtitle: None,
2008 sse_target: None,
2009 }),
2010 Component::Checklist(ChecklistProps {
2011 title: "Tasks".to_string(),
2012 items: vec![],
2013 dismissible: true,
2014 dismiss_label: None,
2015 data_key: None,
2016 }),
2017 Component::Toast(ToastProps {
2018 message: "Saved!".to_string(),
2019 variant: ToastVariant::Success,
2020 timeout: None,
2021 dismissible: true,
2022 }),
2023 Component::NotificationDropdown(NotificationDropdownProps {
2024 notifications: vec![],
2025 empty_text: None,
2026 }),
2027 Component::Sidebar(SidebarProps {
2028 fixed_top: vec![],
2029 groups: vec![],
2030 fixed_bottom: vec![],
2031 }),
2032 Component::Header(HeaderProps {
2033 business_name: "Acme".to_string(),
2034 notification_count: None,
2035 user_name: None,
2036 user_avatar: None,
2037 logout_url: None,
2038 }),
2039 Component::Image(ImageProps {
2040 src: "/img/screenshot.png".to_string(),
2041 alt: "Page screenshot".to_string(),
2042 aspect_ratio: None,
2043 placeholder_label: None,
2044 }),
2045 ];
2046 assert_eq!(components.len(), 27, "should have 27 component variants");
2047 let expected_types = [
2048 "Card",
2049 "Table",
2050 "Form",
2051 "Button",
2052 "Input",
2053 "Select",
2054 "Alert",
2055 "Badge",
2056 "Modal",
2057 "Text",
2058 "Checkbox",
2059 "Switch",
2060 "Separator",
2061 "DescriptionList",
2062 "Tabs",
2063 "Breadcrumb",
2064 "Pagination",
2065 "Progress",
2066 "Avatar",
2067 "Skeleton",
2068 "StatCard",
2069 "Checklist",
2070 "Toast",
2071 "NotificationDropdown",
2072 "Sidebar",
2073 "Header",
2074 "Image",
2075 ];
2076 for (component, expected_type) in components.iter().zip(expected_types.iter()) {
2077 let json = serde_json::to_value(component).unwrap();
2078 assert_eq!(
2079 json["type"], *expected_type,
2080 "component should serialize with type={expected_type}"
2081 );
2082 let roundtripped: Component = serde_json::from_value(json).unwrap();
2083 assert_eq!(&roundtripped, component);
2084 }
2085 }
2086
2087 #[test]
2088 fn size_enum_serialization() {
2089 let cases = [
2090 (Size::Xs, "xs"),
2091 (Size::Sm, "sm"),
2092 (Size::Default, "default"),
2093 (Size::Lg, "lg"),
2094 ];
2095 for (size, expected) in &cases {
2096 let json = serde_json::to_value(size).unwrap();
2097 assert_eq!(json, *expected);
2098 let parsed: Size = serde_json::from_value(json).unwrap();
2099 assert_eq!(&parsed, size);
2100 }
2101 }
2102
2103 #[test]
2104 fn icon_position_serialization() {
2105 let cases = [(IconPosition::Left, "left"), (IconPosition::Right, "right")];
2106 for (pos, expected) in &cases {
2107 let json = serde_json::to_value(pos).unwrap();
2108 assert_eq!(json, *expected);
2109 let parsed: IconPosition = serde_json::from_value(json).unwrap();
2110 assert_eq!(&parsed, pos);
2111 }
2112 }
2113
2114 #[test]
2115 fn sort_direction_serialization() {
2116 let cases = [(SortDirection::Asc, "asc"), (SortDirection::Desc, "desc")];
2117 for (dir, expected) in &cases {
2118 let json = serde_json::to_value(dir).unwrap();
2119 assert_eq!(json, *expected);
2120 let parsed: SortDirection = serde_json::from_value(json).unwrap();
2121 assert_eq!(&parsed, dir);
2122 }
2123 }
2124
2125 #[test]
2126 fn button_with_size_and_icon() {
2127 let button = Component::Button(ButtonProps {
2128 label: "Save".to_string(),
2129 variant: ButtonVariant::Default,
2130 size: Size::Lg,
2131 disabled: None,
2132 icon: Some("save".to_string()),
2133 icon_position: Some(IconPosition::Left),
2134 button_type: None,
2135 });
2136 let json = serde_json::to_value(&button).unwrap();
2137 assert_eq!(json["size"], "lg");
2138 assert_eq!(json["icon"], "save");
2139 assert_eq!(json["icon_position"], "left");
2140 let parsed: Component = serde_json::from_value(json).unwrap();
2141 assert_eq!(parsed, button);
2142 }
2143
2144 #[test]
2145 fn card_with_footer() {
2146 let card = Component::Card(CardProps {
2147 title: "Actions".to_string(),
2148 description: None,
2149 children: vec![],
2150 max_width: None,
2151 footer: vec![ComponentNode {
2152 key: "cancel".to_string(),
2153 component: Component::Button(ButtonProps {
2154 label: "Cancel".to_string(),
2155 variant: ButtonVariant::Outline,
2156 size: Size::Default,
2157 disabled: None,
2158 icon: None,
2159 icon_position: None,
2160 button_type: None,
2161 }),
2162 action: None,
2163 visibility: None,
2164 }],
2165 });
2166 let json = serde_json::to_value(&card).unwrap();
2167 assert!(json["footer"].is_array());
2168 assert_eq!(json["footer"][0]["label"], "Cancel");
2169 let parsed: Component = serde_json::from_value(json).unwrap();
2170 assert_eq!(parsed, card);
2171 }
2172
2173 #[test]
2174 fn input_with_error_and_description() {
2175 let input = Component::Input(InputProps {
2176 field: "email".to_string(),
2177 label: "Email".to_string(),
2178 input_type: InputType::Email,
2179 placeholder: None,
2180 required: Some(true),
2181 disabled: Some(false),
2182 error: Some("Invalid email".to_string()),
2183 description: Some("Your work email".to_string()),
2184 default_value: Some("user@example.com".to_string()),
2185 data_path: None,
2186 step: None,
2187 list: None,
2188 });
2189 let json = serde_json::to_value(&input).unwrap();
2190 assert_eq!(json["error"], "Invalid email");
2191 assert_eq!(json["description"], "Your work email");
2192 assert_eq!(json["default_value"], "user@example.com");
2193 assert_eq!(json["disabled"], false);
2194 let parsed: Component = serde_json::from_value(json).unwrap();
2195 assert_eq!(parsed, input);
2196 }
2197
2198 #[test]
2199 fn select_with_default_value() {
2200 let select = Component::Select(SelectProps {
2201 field: "role".to_string(),
2202 label: "Role".to_string(),
2203 options: vec![SelectOption {
2204 value: "admin".to_string(),
2205 label: "Admin".to_string(),
2206 }],
2207 placeholder: None,
2208 required: None,
2209 disabled: Some(true),
2210 error: Some("Required field".to_string()),
2211 description: Some("User role".to_string()),
2212 default_value: Some("admin".to_string()),
2213 data_path: None,
2214 });
2215 let json = serde_json::to_value(&select).unwrap();
2216 assert_eq!(json["default_value"], "admin");
2217 assert_eq!(json["error"], "Required field");
2218 assert_eq!(json["description"], "User role");
2219 assert_eq!(json["disabled"], true);
2220 let parsed: Component = serde_json::from_value(json).unwrap();
2221 assert_eq!(parsed, select);
2222 }
2223
2224 #[test]
2225 fn alert_with_title() {
2226 let alert = Component::Alert(AlertProps {
2227 message: "Something happened".to_string(),
2228 variant: AlertVariant::Warning,
2229 title: Some("Warning".to_string()),
2230 });
2231 let json = serde_json::to_value(&alert).unwrap();
2232 assert_eq!(json["title"], "Warning");
2233 assert_eq!(json["message"], "Something happened");
2234 let parsed: Component = serde_json::from_value(json).unwrap();
2235 assert_eq!(parsed, alert);
2236 }
2237
2238 #[test]
2239 fn modal_with_footer_and_description() {
2240 let modal = Component::Modal(ModalProps {
2241 id: "modal-delete-item".to_string(),
2242 title: "Delete Item".to_string(),
2243 description: Some("This action cannot be undone.".to_string()),
2244 children: vec![],
2245 footer: vec![ComponentNode {
2246 key: "confirm".to_string(),
2247 component: Component::Button(ButtonProps {
2248 label: "Delete".to_string(),
2249 variant: ButtonVariant::Destructive,
2250 size: Size::Default,
2251 disabled: None,
2252 icon: None,
2253 icon_position: None,
2254 button_type: None,
2255 }),
2256 action: None,
2257 visibility: None,
2258 }],
2259 trigger_label: Some("Delete".to_string()),
2260 });
2261 let json = serde_json::to_value(&modal).unwrap();
2262 assert_eq!(json["description"], "This action cannot be undone.");
2263 assert!(json["footer"].is_array());
2264 assert_eq!(json["footer"][0]["label"], "Delete");
2265 let parsed: Component = serde_json::from_value(json).unwrap();
2266 assert_eq!(parsed, modal);
2267 }
2268
2269 #[test]
2270 fn table_with_sort_props() {
2271 let table = Component::Table(TableProps {
2272 columns: vec![Column {
2273 key: "name".to_string(),
2274 label: "Name".to_string(),
2275 format: None,
2276 }],
2277 data_path: "/data/users".to_string(),
2278 row_actions: None,
2279 empty_message: None,
2280 sortable: Some(true),
2281 sort_column: Some("name".to_string()),
2282 sort_direction: Some(SortDirection::Desc),
2283 });
2284 let json = serde_json::to_value(&table).unwrap();
2285 assert_eq!(json["sortable"], true);
2286 assert_eq!(json["sort_column"], "name");
2287 assert_eq!(json["sort_direction"], "desc");
2288 let parsed: Component = serde_json::from_value(json).unwrap();
2289 assert_eq!(parsed, table);
2290 }
2291
2292 #[test]
2293 fn aligned_button_variants_serialize() {
2294 let cases = [
2295 (ButtonVariant::Default, "default"),
2296 (ButtonVariant::Secondary, "secondary"),
2297 (ButtonVariant::Destructive, "destructive"),
2298 (ButtonVariant::Outline, "outline"),
2299 (ButtonVariant::Ghost, "ghost"),
2300 (ButtonVariant::Link, "link"),
2301 ];
2302 for (variant, expected) in &cases {
2303 let json = serde_json::to_value(variant).unwrap();
2304 assert_eq!(
2305 json, *expected,
2306 "ButtonVariant::{variant:?} should serialize as {expected}"
2307 );
2308 let parsed: ButtonVariant = serde_json::from_value(json).unwrap();
2309 assert_eq!(&parsed, variant);
2310 }
2311 }
2312
2313 #[test]
2314 fn aligned_badge_variants_serialize() {
2315 let cases = [
2316 (BadgeVariant::Default, "default"),
2317 (BadgeVariant::Secondary, "secondary"),
2318 (BadgeVariant::Destructive, "destructive"),
2319 (BadgeVariant::Outline, "outline"),
2320 ];
2321 for (variant, expected) in &cases {
2322 let json = serde_json::to_value(variant).unwrap();
2323 assert_eq!(
2324 json, *expected,
2325 "BadgeVariant::{variant:?} should serialize as {expected}"
2326 );
2327 let parsed: BadgeVariant = serde_json::from_value(json).unwrap();
2328 assert_eq!(&parsed, variant);
2329 }
2330 }
2331
2332 #[test]
2333 fn checkbox_round_trips() {
2334 let checkbox = Component::Checkbox(CheckboxProps {
2335 field: "terms".to_string(),
2336 value: None,
2337 label: "Accept Terms".to_string(),
2338 description: Some("You must accept the terms".to_string()),
2339 checked: Some(true),
2340 data_path: None,
2341 required: Some(true),
2342 disabled: Some(false),
2343 error: None,
2344 });
2345 let json = serde_json::to_value(&checkbox).unwrap();
2346 assert_eq!(json["type"], "Checkbox");
2347 assert_eq!(json["field"], "terms");
2348 assert_eq!(json["checked"], true);
2349 assert_eq!(json["description"], "You must accept the terms");
2350 let parsed: Component = serde_json::from_value(json).unwrap();
2351 assert_eq!(parsed, checkbox);
2352 }
2353
2354 #[test]
2355 fn switch_round_trips() {
2356 let switch = Component::Switch(SwitchProps {
2357 field: "notifications".to_string(),
2358 label: "Enable Notifications".to_string(),
2359 description: Some("Receive email notifications".to_string()),
2360 checked: Some(false),
2361 data_path: None,
2362 required: None,
2363 disabled: Some(false),
2364 error: None,
2365 action: None,
2366 compact: false,
2367 });
2368 let json = serde_json::to_value(&switch).unwrap();
2369 assert_eq!(json["type"], "Switch");
2370 assert_eq!(json["field"], "notifications");
2371 assert_eq!(json["checked"], false);
2372 let parsed: Component = serde_json::from_value(json).unwrap();
2373 assert_eq!(parsed, switch);
2374 }
2375
2376 #[test]
2377 fn separator_defaults_to_horizontal() {
2378 let json = r#"{"type": "Separator"}"#;
2379 let component: Component = serde_json::from_str(json).unwrap();
2380 match component {
2381 Component::Separator(props) => {
2382 assert_eq!(props.orientation, None);
2383 let explicit = Component::Separator(SeparatorProps {
2386 orientation: Some(Orientation::Horizontal),
2387 });
2388 let v = serde_json::to_value(&explicit).unwrap();
2389 assert_eq!(v["orientation"], "horizontal");
2390 let parsed: Component = serde_json::from_value(v).unwrap();
2391 assert_eq!(parsed, explicit);
2392 }
2393 _ => panic!("expected Separator"),
2394 }
2395 }
2396
2397 #[test]
2398 fn description_list_with_format() {
2399 let dl = Component::DescriptionList(DescriptionListProps {
2400 items: vec![
2401 DescriptionItem {
2402 label: "Created".to_string(),
2403 value: "2026-01-15".to_string(),
2404 format: Some(ColumnFormat::Date),
2405 },
2406 DescriptionItem {
2407 label: "Name".to_string(),
2408 value: "Alice".to_string(),
2409 format: None,
2410 },
2411 ],
2412 columns: Some(2),
2413 });
2414 let json = serde_json::to_value(&dl).unwrap();
2415 assert_eq!(json["type"], "DescriptionList");
2416 assert_eq!(json["columns"], 2);
2417 assert_eq!(json["items"][0]["format"], "date");
2418 assert!(json["items"][1].get("format").is_none());
2419 let parsed: Component = serde_json::from_value(json).unwrap();
2420 assert_eq!(parsed, dl);
2421 }
2422
2423 #[test]
2424 fn checkbox_with_error() {
2425 let checkbox = Component::Checkbox(CheckboxProps {
2426 field: "agree".to_string(),
2427 value: None,
2428 label: "I agree".to_string(),
2429 description: None,
2430 checked: None,
2431 data_path: None,
2432 required: Some(true),
2433 disabled: None,
2434 error: Some("You must agree".to_string()),
2435 });
2436 let json = serde_json::to_value(&checkbox).unwrap();
2437 assert_eq!(json["error"], "You must agree");
2438 assert!(json.get("description").is_none());
2439 assert!(json.get("checked").is_none());
2440 let parsed: Component = serde_json::from_value(json).unwrap();
2441 assert_eq!(parsed, checkbox);
2442 }
2443
2444 #[test]
2445 fn tabs_round_trips() {
2446 let tabs = Component::Tabs(TabsProps {
2447 default_tab: "general".to_string(),
2448 tabs: vec![
2449 Tab {
2450 value: "general".to_string(),
2451 label: "General".to_string(),
2452 children: vec![ComponentNode {
2453 key: "name-input".to_string(),
2454 component: Component::Input(InputProps {
2455 field: "name".to_string(),
2456 label: "Name".to_string(),
2457 input_type: InputType::Text,
2458 placeholder: None,
2459 required: None,
2460 disabled: None,
2461 error: None,
2462 description: None,
2463 default_value: None,
2464 data_path: None,
2465 step: None,
2466 list: None,
2467 }),
2468 action: None,
2469 visibility: None,
2470 }],
2471 },
2472 Tab {
2473 value: "security".to_string(),
2474 label: "Security".to_string(),
2475 children: vec![ComponentNode {
2476 key: "password-input".to_string(),
2477 component: Component::Input(InputProps {
2478 field: "password".to_string(),
2479 label: "Password".to_string(),
2480 input_type: InputType::Password,
2481 placeholder: None,
2482 required: None,
2483 disabled: None,
2484 error: None,
2485 description: None,
2486 default_value: None,
2487 data_path: None,
2488 step: None,
2489 list: None,
2490 }),
2491 action: None,
2492 visibility: None,
2493 }],
2494 },
2495 ],
2496 });
2497 let json = serde_json::to_string(&tabs).unwrap();
2498 let parsed: Component = serde_json::from_str(&json).unwrap();
2499 assert_eq!(parsed, tabs);
2500 }
2501
2502 #[test]
2503 fn breadcrumb_round_trips() {
2504 let breadcrumb = Component::Breadcrumb(BreadcrumbProps {
2505 items: vec![
2506 BreadcrumbItem {
2507 label: "Home".to_string(),
2508 url: Some("/".to_string()),
2509 },
2510 BreadcrumbItem {
2511 label: "Users".to_string(),
2512 url: Some("/users".to_string()),
2513 },
2514 BreadcrumbItem {
2515 label: "Edit User".to_string(),
2516 url: None,
2517 },
2518 ],
2519 });
2520 let json = serde_json::to_string(&breadcrumb).unwrap();
2521 let parsed: Component = serde_json::from_str(&json).unwrap();
2522 assert_eq!(parsed, breadcrumb);
2523
2524 let value = serde_json::to_value(&breadcrumb).unwrap();
2526 assert!(value["items"][2].get("url").is_none());
2527 }
2528
2529 #[test]
2530 fn pagination_round_trips() {
2531 let pagination = Component::Pagination(PaginationProps {
2532 current_page: 3,
2533 per_page: 25,
2534 total: 150,
2535 base_url: None,
2536 });
2537 let json = serde_json::to_string(&pagination).unwrap();
2538 let parsed: Component = serde_json::from_str(&json).unwrap();
2539 assert_eq!(parsed, pagination);
2540 }
2541
2542 #[test]
2543 fn progress_round_trips() {
2544 let progress = Component::Progress(ProgressProps {
2545 value: 75,
2546 max: Some(100),
2547 label: Some("Uploading...".to_string()),
2548 });
2549 let json = serde_json::to_string(&progress).unwrap();
2550 let parsed: Component = serde_json::from_str(&json).unwrap();
2551 assert_eq!(parsed, progress);
2552
2553 let value = serde_json::to_value(&progress).unwrap();
2554 assert_eq!(value["value"], 75);
2555 assert_eq!(value["max"], 100);
2556 assert_eq!(value["label"], "Uploading...");
2557 }
2558
2559 #[test]
2560 fn avatar_with_fallback() {
2561 let avatar = Component::Avatar(AvatarProps {
2562 src: None,
2563 alt: "John Doe".to_string(),
2564 fallback: Some("JD".to_string()),
2565 size: Some(Size::Lg),
2566 });
2567 let json = serde_json::to_string(&avatar).unwrap();
2568 let parsed: Component = serde_json::from_str(&json).unwrap();
2569 assert_eq!(parsed, avatar);
2570
2571 let value = serde_json::to_value(&avatar).unwrap();
2572 assert!(value.get("src").is_none());
2573 assert_eq!(value["fallback"], "JD");
2574 assert_eq!(value["size"], "lg");
2575 }
2576
2577 #[test]
2578 fn skeleton_round_trips() {
2579 let skeleton = Component::Skeleton(SkeletonProps {
2580 width: Some("100%".to_string()),
2581 height: Some("40px".to_string()),
2582 rounded: Some(true),
2583 });
2584 let json = serde_json::to_string(&skeleton).unwrap();
2585 let parsed: Component = serde_json::from_str(&json).unwrap();
2586 assert_eq!(parsed, skeleton);
2587
2588 let value = serde_json::to_value(&skeleton).unwrap();
2589 assert_eq!(value["width"], "100%");
2590 assert_eq!(value["height"], "40px");
2591 assert_eq!(value["rounded"], true);
2592 }
2593
2594 #[test]
2595 fn tabs_deserializes_from_json() {
2596 let json = r#"{
2597 "type": "Tabs",
2598 "default_tab": "general",
2599 "tabs": [
2600 {
2601 "value": "general",
2602 "label": "General",
2603 "children": [
2604 {
2605 "key": "name-input",
2606 "type": "Input",
2607 "field": "name",
2608 "label": "Name"
2609 }
2610 ]
2611 },
2612 {
2613 "value": "security",
2614 "label": "Security"
2615 }
2616 ]
2617 }"#;
2618 let component: Component = serde_json::from_str(json).unwrap();
2619 match component {
2620 Component::Tabs(props) => {
2621 assert_eq!(props.default_tab, "general");
2622 assert_eq!(props.tabs.len(), 2);
2623 assert_eq!(props.tabs[0].value, "general");
2624 assert_eq!(props.tabs[0].children.len(), 1);
2625 assert_eq!(props.tabs[1].value, "security");
2626 assert!(props.tabs[1].children.is_empty());
2627 }
2628 _ => panic!("expected Tabs"),
2629 }
2630 }
2631
2632 #[test]
2633 fn input_data_path_round_trips() {
2634 let input = Component::Input(InputProps {
2635 field: "name".to_string(),
2636 label: "Name".to_string(),
2637 input_type: InputType::Text,
2638 placeholder: None,
2639 required: None,
2640 disabled: None,
2641 error: None,
2642 description: None,
2643 default_value: None,
2644 data_path: Some("/data/user/name".to_string()),
2645 step: None,
2646 list: None,
2647 });
2648 let json = serde_json::to_value(&input).unwrap();
2649 assert_eq!(json["data_path"], "/data/user/name");
2650 let parsed: Component = serde_json::from_value(json).unwrap();
2651 assert_eq!(parsed, input);
2652 }
2653
2654 #[test]
2655 fn select_data_path_round_trips() {
2656 let select = Component::Select(SelectProps {
2657 field: "role".to_string(),
2658 label: "Role".to_string(),
2659 options: vec![SelectOption {
2660 value: "admin".to_string(),
2661 label: "Admin".to_string(),
2662 }],
2663 placeholder: None,
2664 required: None,
2665 disabled: None,
2666 error: None,
2667 description: None,
2668 default_value: None,
2669 data_path: Some("/data/user/role".to_string()),
2670 });
2671 let json = serde_json::to_value(&select).unwrap();
2672 assert_eq!(json["data_path"], "/data/user/role");
2673 let parsed: Component = serde_json::from_value(json).unwrap();
2674 assert_eq!(parsed, select);
2675 }
2676
2677 #[test]
2678 fn checkbox_data_path_round_trips() {
2679 let checkbox = Component::Checkbox(CheckboxProps {
2680 field: "terms".to_string(),
2681 value: None,
2682 label: "Accept Terms".to_string(),
2683 description: None,
2684 checked: None,
2685 data_path: Some("/data/user/accepted_terms".to_string()),
2686 required: None,
2687 disabled: None,
2688 error: None,
2689 });
2690 let json = serde_json::to_value(&checkbox).unwrap();
2691 assert_eq!(json["data_path"], "/data/user/accepted_terms");
2692 let parsed: Component = serde_json::from_value(json).unwrap();
2693 assert_eq!(parsed, checkbox);
2694 }
2695
2696 #[test]
2697 fn switch_data_path_round_trips() {
2698 let switch = Component::Switch(SwitchProps {
2699 field: "notifications".to_string(),
2700 label: "Enable Notifications".to_string(),
2701 description: None,
2702 checked: None,
2703 data_path: Some("/data/user/notifications_enabled".to_string()),
2704 required: None,
2705 disabled: None,
2706 error: None,
2707 action: None,
2708 compact: false,
2709 });
2710 let json = serde_json::to_value(&switch).unwrap();
2711 assert_eq!(json["data_path"], "/data/user/notifications_enabled");
2712 let parsed: Component = serde_json::from_value(json).unwrap();
2713 assert_eq!(parsed, switch);
2714 }
2715
2716 #[test]
2719 fn unknown_type_deserializes_as_plugin() {
2720 let json = r#"{"type": "Map", "center": [40.7, -74.0], "zoom": 12}"#;
2721 let component: Component = serde_json::from_str(json).unwrap();
2722 match component {
2723 Component::Plugin(props) => {
2724 assert_eq!(props.plugin_type, "Map");
2725 assert_eq!(props.props["center"][0], 40.7);
2726 assert_eq!(props.props["center"][1], -74.0);
2727 assert_eq!(props.props["zoom"], 12);
2728 assert!(props.props.get("type").is_none());
2730 }
2731 _ => panic!("expected Plugin"),
2732 }
2733 }
2734
2735 #[test]
2736 fn plugin_round_trips() {
2737 let plugin = Component::Plugin(PluginProps {
2738 plugin_type: "Chart".to_string(),
2739 props: serde_json::json!({"data": [1, 2, 3], "style": "bar"}),
2740 });
2741 let json = serde_json::to_value(&plugin).unwrap();
2742 assert_eq!(json["type"], "Chart");
2743 assert_eq!(json["data"], serde_json::json!([1, 2, 3]));
2744 assert_eq!(json["style"], "bar");
2745
2746 let parsed: Component = serde_json::from_value(json).unwrap();
2747 assert_eq!(parsed, plugin);
2748 }
2749
2750 #[test]
2751 fn plugin_serializes_with_type_field() {
2752 let plugin = Component::Plugin(PluginProps {
2753 plugin_type: "Map".to_string(),
2754 props: serde_json::json!({"lat": 51.5, "lng": -0.1}),
2755 });
2756 let json = serde_json::to_value(&plugin).unwrap();
2757 assert_eq!(json["type"], "Map");
2758 assert_eq!(json["lat"], 51.5);
2759 assert_eq!(json["lng"], -0.1);
2760 }
2761
2762 #[test]
2763 fn plugin_with_empty_props() {
2764 let json = r#"{"type": "CustomWidget"}"#;
2765 let component: Component = serde_json::from_str(json).unwrap();
2766 match component {
2767 Component::Plugin(props) => {
2768 assert_eq!(props.plugin_type, "CustomWidget");
2769 assert!(props.props.as_object().unwrap().is_empty());
2770 }
2771 _ => panic!("expected Plugin"),
2772 }
2773 }
2774
2775 #[test]
2776 fn plugin_in_component_node() {
2777 let node = ComponentNode {
2778 key: "map-1".to_string(),
2779 component: Component::Plugin(PluginProps {
2780 plugin_type: "Map".to_string(),
2781 props: serde_json::json!({"center": [0.0, 0.0]}),
2782 }),
2783 action: None,
2784 visibility: None,
2785 };
2786 let json = serde_json::to_string(&node).unwrap();
2787 let parsed: ComponentNode = serde_json::from_str(&json).unwrap();
2788 assert_eq!(parsed, node);
2789
2790 let value = serde_json::to_value(&node).unwrap();
2791 assert_eq!(value["type"], "Map");
2792 assert_eq!(value["key"], "map-1");
2793 }
2794
2795 #[test]
2796 fn known_types_not_treated_as_plugin() {
2797 let known_types = [
2799 "Card",
2800 "Table",
2801 "Form",
2802 "Button",
2803 "Input",
2804 "Select",
2805 "Alert",
2806 "Badge",
2807 "Modal",
2808 "Text",
2809 "Checkbox",
2810 "Switch",
2811 "Separator",
2812 "DescriptionList",
2813 "Tabs",
2814 "Breadcrumb",
2815 "Pagination",
2816 "Progress",
2817 "Avatar",
2818 "Skeleton",
2819 ];
2820 for type_name in &known_types {
2821 let json_str = match *type_name {
2824 "Card" => r#"{"type":"Card","title":"t"}"#,
2825 "Table" => r#"{"type":"Table","columns":[],"data_path":"/d"}"#,
2826 "Form" => r#"{"type":"Form","action":{"handler":"h","method":"POST"},"fields":[]}"#,
2827 "Button" => r#"{"type":"Button","label":"b"}"#,
2828 "Input" => r#"{"type":"Input","field":"f","label":"l"}"#,
2829 "Select" => r#"{"type":"Select","field":"f","label":"l","options":[]}"#,
2830 "Alert" => r#"{"type":"Alert","message":"m"}"#,
2831 "Badge" => r#"{"type":"Badge","label":"b"}"#,
2832 "Modal" => r#"{"type":"Modal","id":"modal-t","title":"t"}"#,
2833 "Text" => r#"{"type":"Text","content":"c"}"#,
2834 "Checkbox" => r#"{"type":"Checkbox","field":"f","label":"l"}"#,
2835 "Switch" => r#"{"type":"Switch","field":"f","label":"l"}"#,
2836 "Separator" => r#"{"type":"Separator"}"#,
2837 "DescriptionList" => r#"{"type":"DescriptionList","items":[]}"#,
2838 "Tabs" => r#"{"type":"Tabs","default_tab":"t","tabs":[]}"#,
2839 "Breadcrumb" => r#"{"type":"Breadcrumb","items":[]}"#,
2840 "Pagination" => r#"{"type":"Pagination","current_page":1,"per_page":10,"total":0}"#,
2841 "Progress" => r#"{"type":"Progress","value":0}"#,
2842 "Avatar" => r#"{"type":"Avatar","alt":"a"}"#,
2843 "Skeleton" => r#"{"type":"Skeleton"}"#,
2844 _ => unreachable!(),
2845 };
2846 let component: Component = serde_json::from_str(json_str).unwrap();
2847 assert!(
2848 !matches!(component, Component::Plugin(_)),
2849 "type {type_name} should not deserialize as Plugin"
2850 );
2851 }
2852 }
2853
2854 #[test]
2857 fn test_stat_card_serde_round_trip() {
2858 let component = Component::StatCard(StatCardProps {
2859 label: "Orders".into(),
2860 value: "42".into(),
2861 icon: Some("package".into()),
2862 subtitle: Some("today".into()),
2863 sse_target: Some("orders_today".into()),
2864 });
2865 let json = serde_json::to_string(&component).unwrap();
2866 assert!(json.contains("\"type\":\"StatCard\""));
2867 assert!(json.contains("\"sse_target\":\"orders_today\""));
2868 let deserialized: Component = serde_json::from_str(&json).unwrap();
2869 assert_eq!(component, deserialized);
2870 }
2871
2872 #[test]
2873 fn test_checklist_serde_round_trip() {
2874 let component = Component::Checklist(ChecklistProps {
2875 title: "Getting Started".into(),
2876 items: vec![
2877 ChecklistItem {
2878 label: "Install dependencies".into(),
2879 checked: true,
2880 href: None,
2881 },
2882 ChecklistItem {
2883 label: "Read the docs".into(),
2884 checked: false,
2885 href: Some("/docs".into()),
2886 },
2887 ],
2888 dismissible: true,
2889 dismiss_label: Some("Dismiss".into()),
2890 data_key: Some("onboarding".into()),
2891 });
2892 let json = serde_json::to_string(&component).unwrap();
2893 assert!(json.contains("\"type\":\"Checklist\""));
2894 assert!(json.contains("\"data_key\":\"onboarding\""));
2895 let deserialized: Component = serde_json::from_str(&json).unwrap();
2896 assert_eq!(component, deserialized);
2897 }
2898
2899 #[test]
2900 fn test_toast_serde_round_trip() {
2901 let component = Component::Toast(ToastProps {
2902 message: "Operation completed".into(),
2903 variant: ToastVariant::Success,
2904 timeout: Some(10),
2905 dismissible: true,
2906 });
2907 let json = serde_json::to_string(&component).unwrap();
2908 assert!(json.contains("\"type\":\"Toast\""));
2909 assert!(json.contains("\"timeout\":10"));
2910 let deserialized: Component = serde_json::from_str(&json).unwrap();
2911 assert_eq!(component, deserialized);
2912 }
2913
2914 #[test]
2915 fn test_notification_dropdown_serde_round_trip() {
2916 let component = Component::NotificationDropdown(NotificationDropdownProps {
2917 notifications: vec![
2918 NotificationItem {
2919 icon: Some("bell".into()),
2920 text: "New message".into(),
2921 timestamp: Some("2m ago".into()),
2922 read: false,
2923 action_url: Some("/messages/1".into()),
2924 },
2925 NotificationItem {
2926 icon: None,
2927 text: "Old notification".into(),
2928 timestamp: None,
2929 read: true,
2930 action_url: None,
2931 },
2932 ],
2933 empty_text: Some("No notifications".into()),
2934 });
2935 let json = serde_json::to_string(&component).unwrap();
2936 assert!(json.contains("\"type\":\"NotificationDropdown\""));
2937 assert!(json.contains("\"empty_text\":\"No notifications\""));
2938 let deserialized: Component = serde_json::from_str(&json).unwrap();
2939 assert_eq!(component, deserialized);
2940 }
2941
2942 #[test]
2943 fn test_sidebar_serde_round_trip() {
2944 let component = Component::Sidebar(SidebarProps {
2945 fixed_top: vec![SidebarNavItem {
2946 label: "Dashboard".into(),
2947 href: "/dashboard".into(),
2948 icon: Some("home".into()),
2949 active: true,
2950 }],
2951 groups: vec![SidebarGroup {
2952 label: "Management".into(),
2953 collapsed: false,
2954 items: vec![SidebarNavItem {
2955 label: "Users".into(),
2956 href: "/users".into(),
2957 icon: None,
2958 active: false,
2959 }],
2960 }],
2961 fixed_bottom: vec![SidebarNavItem {
2962 label: "Settings".into(),
2963 href: "/settings".into(),
2964 icon: Some("gear".into()),
2965 active: false,
2966 }],
2967 });
2968 let json = serde_json::to_string(&component).unwrap();
2969 assert!(json.contains("\"type\":\"Sidebar\""));
2970 assert!(json.contains("\"fixed_top\""));
2971 let deserialized: Component = serde_json::from_str(&json).unwrap();
2972 assert_eq!(component, deserialized);
2973 }
2974
2975 #[test]
2976 fn test_header_serde_round_trip() {
2977 let component = Component::Header(HeaderProps {
2978 business_name: "Acme Corp".into(),
2979 notification_count: Some(5),
2980 user_name: Some("Jane Doe".into()),
2981 user_avatar: Some("/avatar.jpg".into()),
2982 logout_url: Some("/logout".into()),
2983 });
2984 let json = serde_json::to_string(&component).unwrap();
2985 assert!(json.contains("\"type\":\"Header\""));
2986 assert!(json.contains("\"business_name\":\"Acme Corp\""));
2987 assert!(json.contains("\"notification_count\":5"));
2988 let deserialized: Component = serde_json::from_str(&json).unwrap();
2989 assert_eq!(component, deserialized);
2990 }
2991
2992 #[test]
2995 fn test_stat_card_constructor() {
2996 let props = StatCardProps {
2997 label: "Revenue".into(),
2998 value: "$1,000".into(),
2999 icon: None,
3000 subtitle: None,
3001 sse_target: None,
3002 };
3003 let node = ComponentNode::stat_card("revenue-card", props.clone());
3004 assert_eq!(node.key, "revenue-card");
3005 assert!(node.action.is_none());
3006 assert!(node.visibility.is_none());
3007 assert_eq!(node.component, Component::StatCard(props));
3008 }
3009
3010 #[test]
3011 fn test_checklist_constructor() {
3012 let props = ChecklistProps {
3013 title: "Tasks".into(),
3014 items: vec![],
3015 dismissible: true,
3016 dismiss_label: None,
3017 data_key: None,
3018 };
3019 let node = ComponentNode::checklist("task-list", props.clone());
3020 assert_eq!(node.key, "task-list");
3021 assert!(node.action.is_none());
3022 assert!(node.visibility.is_none());
3023 assert_eq!(node.component, Component::Checklist(props));
3024 }
3025
3026 #[test]
3027 fn test_toast_constructor() {
3028 let props = ToastProps {
3029 message: "Done!".into(),
3030 variant: ToastVariant::Success,
3031 timeout: None,
3032 dismissible: true,
3033 };
3034 let node = ComponentNode::toast("success-toast", props.clone());
3035 assert_eq!(node.key, "success-toast");
3036 assert!(node.action.is_none());
3037 assert!(node.visibility.is_none());
3038 assert_eq!(node.component, Component::Toast(props));
3039 }
3040
3041 #[test]
3042 fn test_notification_dropdown_constructor() {
3043 let props = NotificationDropdownProps {
3044 notifications: vec![],
3045 empty_text: Some("All caught up!".into()),
3046 };
3047 let node = ComponentNode::notification_dropdown("notifs", props.clone());
3048 assert_eq!(node.key, "notifs");
3049 assert!(node.action.is_none());
3050 assert!(node.visibility.is_none());
3051 assert_eq!(node.component, Component::NotificationDropdown(props));
3052 }
3053
3054 #[test]
3055 fn test_sidebar_constructor() {
3056 let props = SidebarProps {
3057 fixed_top: vec![],
3058 groups: vec![],
3059 fixed_bottom: vec![],
3060 };
3061 let node = ComponentNode::sidebar("main-nav", props.clone());
3062 assert_eq!(node.key, "main-nav");
3063 assert!(node.action.is_none());
3064 assert!(node.visibility.is_none());
3065 assert_eq!(node.component, Component::Sidebar(props));
3066 }
3067
3068 #[test]
3069 fn test_header_constructor() {
3070 let props = HeaderProps {
3071 business_name: "MyApp".into(),
3072 notification_count: None,
3073 user_name: None,
3074 user_avatar: None,
3075 logout_url: None,
3076 };
3077 let node = ComponentNode::header("page-header", props.clone());
3078 assert_eq!(node.key, "page-header");
3079 assert!(node.action.is_none());
3080 assert!(node.visibility.is_none());
3081 assert_eq!(node.component, Component::Header(props));
3082 }
3083
3084 #[test]
3087 fn test_checklist_item_round_trip() {
3088 let checked_item = ChecklistItem {
3089 label: "Completed task".into(),
3090 checked: true,
3091 href: Some("/task/1".into()),
3092 };
3093 let json = serde_json::to_string(&checked_item).unwrap();
3094 let parsed: ChecklistItem = serde_json::from_str(&json).unwrap();
3095 assert_eq!(parsed, checked_item);
3096
3097 let unchecked_item = ChecklistItem {
3098 label: "Pending task".into(),
3099 checked: false,
3100 href: None,
3101 };
3102 let json = serde_json::to_string(&unchecked_item).unwrap();
3103 let parsed: ChecklistItem = serde_json::from_str(&json).unwrap();
3104 assert_eq!(parsed, unchecked_item);
3105 assert!(!json.contains("href"));
3107 }
3108
3109 #[test]
3110 fn test_sidebar_group_round_trip() {
3111 let expanded = SidebarGroup {
3112 label: "Main".into(),
3113 collapsed: false,
3114 items: vec![
3115 SidebarNavItem {
3116 label: "Home".into(),
3117 href: "/".into(),
3118 icon: Some("home".into()),
3119 active: true,
3120 },
3121 SidebarNavItem {
3122 label: "About".into(),
3123 href: "/about".into(),
3124 icon: None,
3125 active: false,
3126 },
3127 ],
3128 };
3129 let json = serde_json::to_string(&expanded).unwrap();
3130 let parsed: SidebarGroup = serde_json::from_str(&json).unwrap();
3131 assert_eq!(parsed, expanded);
3132 assert_eq!(parsed.items.len(), 2);
3133
3134 let collapsed = SidebarGroup {
3135 label: "Advanced".into(),
3136 collapsed: true,
3137 items: vec![],
3138 };
3139 let json = serde_json::to_string(&collapsed).unwrap();
3140 let parsed: SidebarGroup = serde_json::from_str(&json).unwrap();
3141 assert_eq!(parsed, collapsed);
3142 assert!(parsed.collapsed);
3143 }
3144
3145 #[test]
3146 fn test_notification_item_round_trip() {
3147 let unread = NotificationItem {
3148 icon: Some("mail".into()),
3149 text: "You have a new message".into(),
3150 timestamp: Some("5m ago".into()),
3151 read: false,
3152 action_url: Some("/messages/42".into()),
3153 };
3154 let json = serde_json::to_string(&unread).unwrap();
3155 let parsed: NotificationItem = serde_json::from_str(&json).unwrap();
3156 assert_eq!(parsed, unread);
3157 assert!(!parsed.read);
3158
3159 let read_notif = NotificationItem {
3160 icon: None,
3161 text: "Welcome to the platform".into(),
3162 timestamp: None,
3163 read: true,
3164 action_url: None,
3165 };
3166 let json = serde_json::to_string(&read_notif).unwrap();
3167 let parsed: NotificationItem = serde_json::from_str(&json).unwrap();
3168 assert_eq!(parsed, read_notif);
3169 assert!(parsed.read);
3170 assert!(!json.contains("\"icon\""));
3172 assert!(!json.contains("\"action_url\""));
3173 }
3174
3175 #[test]
3178 fn test_stat_card_all_optionals_none() {
3179 let component = Component::StatCard(StatCardProps {
3180 label: "Count".into(),
3181 value: "0".into(),
3182 icon: None,
3183 subtitle: None,
3184 sse_target: None,
3185 });
3186 let json = serde_json::to_string(&component).unwrap();
3187 assert!(json.contains("\"type\":\"StatCard\""));
3188 assert!(!json.contains("\"icon\""));
3189 assert!(!json.contains("\"subtitle\""));
3190 assert!(!json.contains("\"sse_target\""));
3191 let deserialized: Component = serde_json::from_str(&json).unwrap();
3192 assert_eq!(component, deserialized);
3193 }
3194
3195 #[test]
3196 fn test_checklist_empty_items() {
3197 let component = Component::Checklist(ChecklistProps {
3198 title: "Empty List".into(),
3199 items: vec![],
3200 dismissible: true,
3201 dismiss_label: None,
3202 data_key: None,
3203 });
3204 let json = serde_json::to_string(&component).unwrap();
3205 assert!(json.contains("\"type\":\"Checklist\""));
3206 let deserialized: Component = serde_json::from_str(&json).unwrap();
3207 assert_eq!(component, deserialized);
3208 match &deserialized {
3209 Component::Checklist(props) => assert!(props.items.is_empty()),
3210 _ => panic!("expected Checklist"),
3211 }
3212 }
3213
3214 #[test]
3215 fn test_sidebar_empty_groups_and_fixed() {
3216 let component = Component::Sidebar(SidebarProps {
3217 fixed_top: vec![],
3218 groups: vec![],
3219 fixed_bottom: vec![],
3220 });
3221 let json = serde_json::to_string(&component).unwrap();
3222 assert!(json.contains("\"type\":\"Sidebar\""));
3223 assert!(!json.contains("\"fixed_top\""));
3225 assert!(!json.contains("\"groups\""));
3226 assert!(!json.contains("\"fixed_bottom\""));
3227 let deserialized: Component = serde_json::from_str(&json).unwrap();
3228 assert_eq!(component, deserialized);
3229 }
3230
3231 #[test]
3232 fn test_notification_dropdown_empty_uses_empty_text() {
3233 let component = Component::NotificationDropdown(NotificationDropdownProps {
3234 notifications: vec![],
3235 empty_text: Some("Nothing here!".into()),
3236 });
3237 let json = serde_json::to_string(&component).unwrap();
3238 assert!(json.contains("\"type\":\"NotificationDropdown\""));
3239 assert!(json.contains("\"empty_text\":\"Nothing here!\""));
3240 let deserialized: Component = serde_json::from_str(&json).unwrap();
3241 assert_eq!(component, deserialized);
3242 }
3243
3244 #[test]
3247 fn test_stat_card_omits_sse_target_when_none() {
3248 let component = Component::StatCard(StatCardProps {
3249 label: "Revenue".into(),
3250 value: "$500".into(),
3251 icon: None,
3252 subtitle: None,
3253 sse_target: None,
3254 });
3255 let json = serde_json::to_string(&component).unwrap();
3256 assert!(
3257 !json.contains("sse_target"),
3258 "sse_target must be omitted when None"
3259 );
3260 }
3261
3262 #[test]
3265 fn grid_round_trips() {
3266 let grid = Component::Grid(GridProps {
3267 columns: 3,
3268 md_columns: None,
3269 lg_columns: None,
3270 gap: GapSize::Lg,
3271 scrollable: None,
3272 children: vec![ComponentNode::text(
3273 "t",
3274 TextProps {
3275 content: "cell".into(),
3276 element: TextElement::P,
3277 },
3278 )],
3279 });
3280 let json = serde_json::to_value(&grid).unwrap();
3281 assert_eq!(json["type"], "Grid");
3282 assert_eq!(json["columns"], 3);
3283 assert_eq!(json["gap"], "lg");
3284 let parsed: Component = serde_json::from_value(json).unwrap();
3285 assert_eq!(parsed, grid);
3286 }
3287
3288 #[test]
3289 fn grid_defaults() {
3290 let json = serde_json::json!({"type": "Grid"});
3291 let parsed: Component = serde_json::from_value(json).unwrap();
3292 match parsed {
3293 Component::Grid(props) => {
3294 assert_eq!(props.columns, 2);
3295 assert_eq!(props.gap, GapSize::Md);
3296 assert!(props.children.is_empty());
3297 }
3298 _ => panic!("expected Grid"),
3299 }
3300 }
3301
3302 #[test]
3305 fn collapsible_round_trips() {
3306 let c = Component::Collapsible(CollapsibleProps {
3307 title: "Details".into(),
3308 expanded: true,
3309 children: vec![],
3310 });
3311 let json = serde_json::to_value(&c).unwrap();
3312 assert_eq!(json["type"], "Collapsible");
3313 assert_eq!(json["title"], "Details");
3314 assert_eq!(json["expanded"], true);
3315 let parsed: Component = serde_json::from_value(json).unwrap();
3316 assert_eq!(parsed, c);
3317 }
3318
3319 #[test]
3322 fn empty_state_round_trips() {
3323 let es = Component::EmptyState(EmptyStateProps {
3324 title: "No items".into(),
3325 description: Some("Create one".into()),
3326 action: Some(Action::get("items.create")),
3327 action_label: Some("New item".into()),
3328 });
3329 let json = serde_json::to_value(&es).unwrap();
3330 assert_eq!(json["type"], "EmptyState");
3331 assert_eq!(json["title"], "No items");
3332 let parsed: Component = serde_json::from_value(json).unwrap();
3333 assert_eq!(parsed, es);
3334 }
3335
3336 #[test]
3337 fn empty_state_minimal() {
3338 let json = serde_json::json!({"type": "EmptyState", "title": "Nothing"});
3339 let parsed: Component = serde_json::from_value(json).unwrap();
3340 match parsed {
3341 Component::EmptyState(props) => {
3342 assert_eq!(props.title, "Nothing");
3343 assert!(props.description.is_none());
3344 assert!(props.action.is_none());
3345 assert!(props.action_label.is_none());
3346 }
3347 _ => panic!("expected EmptyState"),
3348 }
3349 }
3350
3351 #[test]
3354 fn form_section_round_trips() {
3355 let fs = Component::FormSection(FormSectionProps {
3356 title: "Contact".into(),
3357 description: Some("Your details".into()),
3358 children: vec![],
3359 layout: None,
3360 });
3361 let json = serde_json::to_value(&fs).unwrap();
3362 assert_eq!(json["type"], "FormSection");
3363 assert_eq!(json["title"], "Contact");
3364 let parsed: Component = serde_json::from_value(json).unwrap();
3365 assert_eq!(parsed, fs);
3366 }
3367
3368 #[test]
3371 fn switch_with_action_round_trips() {
3372 let sw = Component::Switch(SwitchProps {
3373 field: "active".into(),
3374 label: "Active".into(),
3375 description: None,
3376 checked: Some(true),
3377 data_path: None,
3378 required: None,
3379 disabled: None,
3380 error: None,
3381 action: Some(Action::new("settings.toggle")),
3382 compact: false,
3383 });
3384 let json = serde_json::to_value(&sw).unwrap();
3385 assert!(json["action"].is_object());
3386 assert_eq!(json["action"]["handler"], "settings.toggle");
3387 let parsed: Component = serde_json::from_value(json).unwrap();
3388 assert_eq!(parsed, sw);
3389 }
3390
3391 #[test]
3392 fn switch_without_action_omits_field() {
3393 let sw = Component::Switch(SwitchProps {
3394 field: "f".into(),
3395 label: "l".into(),
3396 description: None,
3397 checked: None,
3398 data_path: None,
3399 required: None,
3400 disabled: None,
3401 error: None,
3402 action: None,
3403 compact: false,
3404 });
3405 let json = serde_json::to_string(&sw).unwrap();
3406 assert!(!json.contains("\"action\""));
3407 }
3408
3409 #[test]
3410 fn test_toast_omits_timeout_when_none() {
3411 let component = Component::Toast(ToastProps {
3412 message: "Hello".into(),
3413 variant: ToastVariant::Info,
3414 timeout: None,
3415 dismissible: false,
3416 });
3417 let json = serde_json::to_string(&component).unwrap();
3418 assert!(
3419 !json.contains("\"timeout\""),
3420 "timeout must be omitted when None"
3421 );
3422 }
3423
3424 #[test]
3425 fn page_header_round_trip_title_only() {
3426 let component = Component::PageHeader(PageHeaderProps {
3427 title: "Test Title".to_string(),
3428 breadcrumb: vec![],
3429 actions: vec![],
3430 });
3431 let json = serde_json::to_value(&component).unwrap();
3432 assert_eq!(json["type"], "PageHeader");
3433 assert_eq!(json["title"], "Test Title");
3434 assert!(json.get("breadcrumb").is_none());
3436 assert!(json.get("actions").is_none());
3437 let parsed: Component = serde_json::from_value(json).unwrap();
3438 assert_eq!(parsed, component);
3439 }
3440
3441 #[test]
3442 fn page_header_round_trip_with_breadcrumb_and_actions() {
3443 let component = Component::PageHeader(PageHeaderProps {
3444 title: "Users".to_string(),
3445 breadcrumb: vec![
3446 BreadcrumbItem {
3447 label: "Home".to_string(),
3448 url: Some("/".to_string()),
3449 },
3450 BreadcrumbItem {
3451 label: "Users".to_string(),
3452 url: None,
3453 },
3454 ],
3455 actions: vec![ComponentNode {
3456 key: "add-btn".to_string(),
3457 component: Component::Button(ButtonProps {
3458 label: "Add User".to_string(),
3459 variant: ButtonVariant::Default,
3460 size: Size::Default,
3461 disabled: None,
3462 icon: None,
3463 icon_position: None,
3464 button_type: None,
3465 }),
3466 action: None,
3467 visibility: None,
3468 }],
3469 });
3470 let json = serde_json::to_string(&component).unwrap();
3471 let parsed: Component = serde_json::from_str(&json).unwrap();
3472 assert_eq!(parsed, component);
3473 let value = serde_json::to_value(&component).unwrap();
3475 assert_eq!(value["type"], "PageHeader");
3476 assert_eq!(value["title"], "Users");
3477 assert!(value["breadcrumb"].is_array());
3478 assert!(value["actions"].is_array());
3479 }
3480
3481 #[test]
3482 fn page_header_deserialize_from_json() {
3483 let json = r#"{"type":"PageHeader","title":"Test"}"#;
3484 let component: Component = serde_json::from_str(json).unwrap();
3485 match component {
3486 Component::PageHeader(props) => {
3487 assert_eq!(props.title, "Test");
3488 assert!(props.breadcrumb.is_empty());
3489 assert!(props.actions.is_empty());
3490 }
3491 _ => panic!("expected PageHeader"),
3492 }
3493 }
3494
3495 #[test]
3496 fn button_group_round_trip_empty() {
3497 let component = Component::ButtonGroup(ButtonGroupProps { buttons: vec![] });
3498 let json = serde_json::to_value(&component).unwrap();
3499 assert_eq!(json["type"], "ButtonGroup");
3500 assert!(json.get("buttons").is_none());
3502 let parsed: Component = serde_json::from_value(json).unwrap();
3503 assert_eq!(parsed, component);
3504 }
3505
3506 #[test]
3507 fn button_group_round_trip_with_buttons() {
3508 let component = Component::ButtonGroup(ButtonGroupProps {
3509 buttons: vec![
3510 ComponentNode {
3511 key: "save".to_string(),
3512 component: Component::Button(ButtonProps {
3513 label: "Save".to_string(),
3514 variant: ButtonVariant::Default,
3515 size: Size::Default,
3516 disabled: None,
3517 icon: None,
3518 icon_position: None,
3519 button_type: None,
3520 }),
3521 action: None,
3522 visibility: None,
3523 },
3524 ComponentNode {
3525 key: "cancel".to_string(),
3526 component: Component::Button(ButtonProps {
3527 label: "Cancel".to_string(),
3528 variant: ButtonVariant::Outline,
3529 size: Size::Default,
3530 disabled: None,
3531 icon: None,
3532 icon_position: None,
3533 button_type: None,
3534 }),
3535 action: None,
3536 visibility: None,
3537 },
3538 ],
3539 });
3540 let json = serde_json::to_string(&component).unwrap();
3541 let parsed: Component = serde_json::from_str(&json).unwrap();
3542 assert_eq!(parsed, component);
3543 let value = serde_json::to_value(&component).unwrap();
3544 assert_eq!(value["type"], "ButtonGroup");
3545 assert!(value["buttons"].is_array());
3546 assert_eq!(value["buttons"].as_array().unwrap().len(), 2);
3547 }
3548
3549 #[test]
3550 fn button_group_deserialize_from_json() {
3551 let json = r#"{"type":"ButtonGroup","buttons":[]}"#;
3552 let component: Component = serde_json::from_str(json).unwrap();
3553 match component {
3554 Component::ButtonGroup(props) => {
3555 assert!(props.buttons.is_empty());
3556 }
3557 _ => panic!("expected ButtonGroup"),
3558 }
3559 }
3560
3561 #[test]
3562 fn image_round_trips() {
3563 let json = r#"{"type": "Image", "src": "/img/s.png", "alt": "Screenshot"}"#;
3564 let component: Component = serde_json::from_str(json).unwrap();
3565 match component {
3566 Component::Image(props) => {
3567 assert_eq!(props.src, "/img/s.png");
3568 assert_eq!(props.alt, "Screenshot");
3569 assert!(props.aspect_ratio.is_none());
3570 }
3571 _ => panic!("expected Image"),
3572 }
3573 }
3574
3575 #[test]
3576 fn all_known_types_round_trip() {
3577 let known_types: &[(&str, &str)] = &[
3578 ("Alert", r#"{"type":"Alert","message":"m"}"#),
3579 ("Avatar", r#"{"type":"Avatar","alt":"a"}"#),
3580 ("Badge", r#"{"type":"Badge","label":"b"}"#),
3581 ("Breadcrumb", r#"{"type":"Breadcrumb","items":[]}"#),
3582 ("Button", r#"{"type":"Button","label":"b"}"#),
3583 ("CalendarCell", r#"{"type":"CalendarCell","day":1}"#),
3584 ("Checkbox", r#"{"type":"Checkbox","field":"f","label":"l"}"#),
3585 ("Image", r#"{"type":"Image","src":"/img/s.png","alt":"a"}"#),
3586 ("Input", r#"{"type":"Input","field":"f","label":"l"}"#),
3587 (
3588 "Pagination",
3589 r#"{"type":"Pagination","current_page":1,"per_page":10,"total":0}"#,
3590 ),
3591 ("Progress", r#"{"type":"Progress","value":50}"#),
3592 (
3593 "Select",
3594 r#"{"type":"Select","field":"f","label":"l","options":[]}"#,
3595 ),
3596 ("Separator", r#"{"type":"Separator"}"#),
3597 ("Skeleton", r#"{"type":"Skeleton"}"#),
3598 ("Text", r#"{"type":"Text","content":"c"}"#),
3599 ];
3600 for (type_name, json_str) in known_types {
3601 let component: Component = serde_json::from_str(json_str)
3602 .unwrap_or_else(|e| panic!("failed to parse {type_name}: {e}"));
3603 let serialized = serde_json::to_value(&component).unwrap();
3604 assert_eq!(
3605 serialized["type"], *type_name,
3606 "type mismatch for {type_name}"
3607 );
3608 let reparsed: Component = serde_json::from_value(serialized)
3609 .unwrap_or_else(|e| panic!("failed to reparse {type_name}: {e}"));
3610 assert_eq!(
3611 serde_json::to_value(&reparsed).unwrap()["type"],
3612 *type_name,
3613 "round-trip type mismatch for {type_name}"
3614 );
3615 }
3616 }
3617}
3618
3619#[cfg(test)]
3620mod key_value_editor_tests {
3621 use super::*;
3622 use serde_json::json;
3623
3624 #[test]
3625 fn key_value_editor_serde_roundtrip() {
3626 let original = Component::KeyValueEditor(KeyValueEditorProps {
3627 field: "metadata".to_string(),
3628 label: Some("Metadata".to_string()),
3629 suggested_keys: vec!["env".to_string(), "region".to_string()],
3630 allow_custom_keys: false,
3631 data_path: Some("/meta".to_string()),
3632 error: Some("required".to_string()),
3633 });
3634
3635 let serialized =
3636 serde_json::to_value(&original).expect("serialize KeyValueEditor component");
3637
3638 assert_eq!(
3640 serialized.get("type").and_then(|v| v.as_str()),
3641 Some("KeyValueEditor"),
3642 "serialized form must have type=KeyValueEditor: {serialized}"
3643 );
3644 assert_eq!(
3645 serialized.get("field").and_then(|v| v.as_str()),
3646 Some("metadata")
3647 );
3648 assert_eq!(
3649 serialized
3650 .get("allow_custom_keys")
3651 .and_then(|v| v.as_bool()),
3652 Some(false)
3653 );
3654
3655 let deserialized: Component =
3657 serde_json::from_value(serialized).expect("deserialize KeyValueEditor component");
3658 match deserialized {
3659 Component::KeyValueEditor(ref p) => {
3660 assert_eq!(p.field, "metadata");
3661 assert_eq!(p.label.as_deref(), Some("Metadata"));
3662 assert_eq!(
3663 p.suggested_keys,
3664 vec!["env".to_string(), "region".to_string()]
3665 );
3666 assert!(!p.allow_custom_keys);
3667 assert_eq!(p.data_path.as_deref(), Some("/meta"));
3668 assert_eq!(p.error.as_deref(), Some("required"));
3669 }
3670 other => panic!("expected KeyValueEditor, got {other:?}"),
3671 }
3672 assert_eq!(original, deserialized, "PartialEq round-trip failed");
3673 }
3674
3675 #[test]
3676 fn key_value_editor_allow_custom_keys_defaults_to_true() {
3677 let json_input = json!({
3679 "type": "KeyValueEditor",
3680 "field": "meta",
3681 });
3682 let parsed: Component =
3683 serde_json::from_value(json_input).expect("deserialize minimal KeyValueEditor");
3684 match parsed {
3685 Component::KeyValueEditor(p) => {
3686 assert!(
3687 p.allow_custom_keys,
3688 "allow_custom_keys default must be true"
3689 );
3690 assert!(
3691 p.suggested_keys.is_empty(),
3692 "suggested_keys default must be empty"
3693 );
3694 assert!(p.label.is_none());
3695 assert!(p.data_path.is_none());
3696 assert!(p.error.is_none());
3697 }
3698 other => panic!("expected KeyValueEditor, got {other:?}"),
3699 }
3700 }
3701}