1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum StateFieldType {
17 String,
18 Number,
19 Boolean,
20 Object,
21 Array,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub struct StateFieldDefinition {
28 #[serde(rename = "type")]
30 pub field_type: StateFieldType,
31
32 #[serde(default)]
34 pub default: Option<serde_json::Value>,
35
36 #[serde(default)]
38 pub nullable: bool,
39
40 #[serde(default)]
42 pub description: Option<String>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(tag = "type", rename_all = "snake_case")]
52pub enum Action {
53 UpdateState {
55 path: String,
56 #[serde(default)]
57 value: Option<serde_json::Value>,
58 #[serde(default)]
59 from: Option<String>,
60 #[serde(default)]
61 merge: bool,
62 },
63
64 CallApi {
66 #[serde(default)]
67 name: Option<String>,
68 api: String,
69 #[serde(default)]
70 method: Option<String>,
71 #[serde(default)]
72 args_from_state: Vec<String>,
73 #[serde(default)]
74 map_args: Vec<ArgMapping>,
75 #[serde(default)]
76 body: Option<serde_json::Value>,
77 #[serde(default)]
78 on_success: Vec<Action>,
79 #[serde(default)]
80 on_error: Vec<Action>,
81 #[serde(default)]
82 on_finally: Vec<Action>,
83 },
84
85 Navigate {
87 to: String,
88 #[serde(default)]
89 replace: bool,
90 #[serde(default)]
91 params: HashMap<String, String>,
92 },
93
94 ShowToast {
96 level: ToastLevel,
97 message: String,
98 #[serde(default)]
99 title: Option<String>,
100 #[serde(default)]
101 duration: Option<u32>,
102 },
103
104 ShowDialog {
106 dialog_id: String,
107 #[serde(default)]
108 data: HashMap<String, String>,
109 },
110
111 CloseDialog {
113 #[serde(default)]
114 dialog_id: Option<String>,
115 },
116
117 DebouncedAction {
119 delay_ms: u32,
120 action: Box<Action>,
121 #[serde(default)]
122 key: Option<String>,
123 },
124
125 SetLoading {
127 loading: bool,
128 #[serde(default)]
129 target: Option<String>,
130 },
131
132 Conditional {
134 condition: String,
135 then: Vec<Action>,
136 #[serde(default)]
137 else_actions: Vec<Action>,
138 },
139
140 Sequence {
142 actions: Vec<Action>,
143 #[serde(default)]
144 stop_on_error: bool,
145 },
146
147 Copy {
149 text: String,
150 #[serde(default)]
151 show_notification: bool,
152 },
153
154 OpenUrl {
156 url: String,
157 #[serde(default)]
158 new_tab: bool,
159 },
160
161 Emit {
163 event: String,
164 #[serde(default)]
165 payload: HashMap<String, serde_json::Value>,
166 },
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct ArgMapping {
172 pub from: String,
173 pub to: String,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum ToastLevel {
180 Info,
181 Success,
182 Warning,
183 Error,
184}
185
186#[derive(Debug, Clone, Default, Serialize, Deserialize)]
192#[serde(rename_all = "snake_case")]
193pub struct EventHandlers {
194 #[serde(default)]
195 pub on_click: Vec<Action>,
196 #[serde(default)]
197 pub on_change: Vec<Action>,
198 #[serde(default)]
199 pub on_submit: Vec<Action>,
200 #[serde(default)]
201 pub on_focus: Vec<Action>,
202 #[serde(default)]
203 pub on_blur: Vec<Action>,
204 #[serde(default)]
205 pub on_row_click: Vec<Action>,
206 #[serde(default)]
207 pub on_select: Vec<Action>,
208 #[serde(default)]
209 pub on_page_change: Vec<Action>,
210 #[serde(default)]
211 pub on_sort_change: Vec<Action>,
212 #[serde(default)]
213 pub on_close: Vec<Action>,
214 #[serde(default)]
215 pub on_open: Vec<Action>,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(rename_all = "snake_case")]
225pub struct ComponentSchema {
226 #[serde(rename = "type")]
228 pub component_type: String,
229
230 #[serde(default)]
232 pub id: Option<String>,
233
234 #[serde(default, rename = "className")]
236 pub class_name: Option<String>,
237
238 #[serde(default)]
240 pub style: Option<serde_json::Value>,
241
242 #[serde(default)]
244 pub visible: Option<serde_json::Value>,
245
246 #[serde(default)]
248 pub children: Vec<ComponentSchema>,
249
250 #[serde(default)]
252 pub events: Option<EventHandlers>,
253
254 #[serde(flatten)]
256 pub props: HashMap<String, serde_json::Value>,
257}
258
259impl ComponentSchema {
260 #[must_use]
262 pub fn new(component_type: &str) -> Self {
263 Self {
264 component_type: component_type.to_string(),
265 id: None,
266 class_name: None,
267 style: None,
268 visible: None,
269 children: Vec::new(),
270 events: None,
271 props: HashMap::new(),
272 }
273 }
274
275 #[must_use]
277 pub fn with_id(mut self, id: &str) -> Self {
278 self.id = Some(id.to_string());
279 self
280 }
281
282 #[must_use]
284 pub fn with_child(mut self, child: ComponentSchema) -> Self {
285 self.children.push(child);
286 self
287 }
288
289 #[must_use]
291 pub fn with_prop(mut self, key: &str, value: serde_json::Value) -> Self {
292 self.props.insert(key.to_string(), value);
293 self
294 }
295
296 pub fn validate(&self) -> crate::Result<()> {
302 if self.component_type.is_empty() {
303 return Err(crate::Error::schema("Component type is required"));
304 }
305
306 for child in &self.children {
307 child.validate()?;
308 }
309
310 Ok(())
311 }
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
320#[serde(rename_all = "snake_case")]
321pub struct DialogDefinition {
322 pub id: String,
323 #[serde(default)]
324 pub title: Option<String>,
325 #[serde(default)]
326 pub description: Option<String>,
327 pub content: ComponentSchema,
328 #[serde(default)]
329 pub footer: Option<ComponentSchema>,
330 #[serde(default)]
331 pub size: Option<String>,
332}
333
334#[derive(Debug, Clone, Default, Serialize, Deserialize)]
336#[serde(rename_all = "snake_case")]
337pub struct PageLifecycleHooks {
338 #[serde(default)]
339 pub on_mount: Vec<Action>,
340 #[serde(default)]
341 pub on_unmount: Vec<Action>,
342 #[serde(default)]
343 pub on_params_change: Vec<Action>,
344 #[serde(default)]
345 pub on_query_change: Vec<Action>,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
350#[serde(rename_all = "snake_case")]
351pub struct PageDefinition {
352 pub route: String,
354
355 pub title: String,
357
358 #[serde(default)]
360 pub icon: Option<String>,
361
362 #[serde(default)]
364 pub description: Option<String>,
365
366 #[serde(default = "default_true")]
368 pub show_in_menu: bool,
369
370 #[serde(default)]
372 pub menu_order: i32,
373
374 #[serde(default)]
376 pub parent_route: Option<String>,
377
378 #[serde(default = "default_true")]
380 pub requires_auth: bool,
381
382 #[serde(default)]
384 pub permissions: Vec<String>,
385
386 #[serde(default)]
388 pub roles: Vec<String>,
389
390 #[serde(default)]
392 pub state: HashMap<String, StateFieldDefinition>,
393
394 #[serde(default)]
396 pub computed: HashMap<String, String>,
397
398 pub sections: Vec<ComponentSchema>,
400
401 #[serde(default)]
403 pub actions: HashMap<String, Action>,
404
405 #[serde(default)]
407 pub hooks: Option<PageLifecycleHooks>,
408
409 #[serde(default)]
411 pub dialogs: Vec<DialogDefinition>,
412}
413
414fn default_true() -> bool {
415 true
416}
417
418impl PageDefinition {
419 pub fn validate(&self) -> crate::Result<()> {
425 if self.route.is_empty() {
426 return Err(crate::Error::schema("Page route is required"));
427 }
428
429 if !self.route.starts_with('/') {
430 return Err(crate::Error::schema("Page route must start with '/'"));
431 }
432
433 if self.title.is_empty() {
434 return Err(crate::Error::schema("Page title is required"));
435 }
436
437 for section in &self.sections {
438 section.validate()?;
439 }
440
441 Ok(())
442 }
443
444 #[must_use]
446 pub fn full_route(&self, plugin_name: &str) -> String {
447 format!("/plugins/{}{}", plugin_name, self.route)
448 }
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize)]
457#[serde(rename_all = "snake_case")]
458pub struct NavigationItem {
459 pub id: String,
460 pub label: String,
461 #[serde(default)]
462 pub icon: Option<String>,
463 #[serde(default)]
464 pub href: Option<String>,
465 #[serde(default)]
466 pub external: bool,
467 #[serde(default)]
468 pub children: Vec<NavigationItem>,
469 #[serde(default)]
470 pub badge: Option<String>,
471 #[serde(default)]
472 pub badge_variant: Option<String>,
473 #[serde(default)]
474 pub visible: Option<serde_json::Value>,
475 #[serde(default)]
476 pub disabled: Option<serde_json::Value>,
477}
478
479#[derive(Debug, Clone, Default, Serialize, Deserialize)]
481#[serde(rename_all = "snake_case")]
482pub struct NavigationConfig {
483 #[serde(default)]
484 pub primary: Vec<NavigationItem>,
485 #[serde(default)]
486 pub secondary: Vec<NavigationItem>,
487 #[serde(default)]
488 pub user: Vec<NavigationItem>,
489 #[serde(default)]
490 pub footer: Vec<NavigationItem>,
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize)]
499#[serde(rename_all = "snake_case")]
500pub struct TableColumn {
501 pub key: String,
502 pub label: String,
503 #[serde(default)]
504 pub sortable: bool,
505 #[serde(default)]
506 pub width: Option<String>,
507 #[serde(default)]
508 pub align: Option<String>,
509 #[serde(default)]
510 pub render: Option<ComponentSchema>,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize)]
515#[serde(rename_all = "snake_case")]
516pub struct FormField {
517 pub id: String,
518 pub name: String,
519 pub field_type: String,
520 #[serde(default)]
521 pub label: Option<String>,
522 #[serde(default)]
523 pub placeholder: Option<String>,
524 #[serde(default)]
525 pub description: Option<String>,
526 #[serde(default)]
527 pub default_value: Option<serde_json::Value>,
528 #[serde(default)]
529 pub bind_to: Option<String>,
530 #[serde(default)]
531 pub required: bool,
532 #[serde(default)]
533 pub disabled: Option<serde_json::Value>,
534 #[serde(default)]
535 pub options: Vec<SelectOption>,
536 #[serde(default)]
537 pub validation: Option<ValidationRule>,
538 #[serde(default)]
539 pub events: Option<EventHandlers>,
540}
541
542#[derive(Debug, Clone, Serialize, Deserialize)]
544#[serde(rename_all = "snake_case")]
545pub struct SelectOption {
546 pub value: String,
547 pub label: String,
548 #[serde(default)]
549 pub disabled: bool,
550}
551
552#[derive(Debug, Clone, Serialize, Deserialize)]
554#[serde(rename_all = "snake_case")]
555pub struct ValidationRule {
556 #[serde(default)]
557 pub required: Option<serde_json::Value>,
558 #[serde(default)]
559 pub min: Option<serde_json::Value>,
560 #[serde(default)]
561 pub max: Option<serde_json::Value>,
562 #[serde(default)]
563 pub min_length: Option<serde_json::Value>,
564 #[serde(default)]
565 pub max_length: Option<serde_json::Value>,
566 #[serde(default)]
567 pub pattern: Option<serde_json::Value>,
568 #[serde(default)]
569 pub email: Option<serde_json::Value>,
570 #[serde(default)]
571 pub url: Option<serde_json::Value>,
572 #[serde(default)]
573 pub custom: Option<CustomValidation>,
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize)]
578#[serde(rename_all = "snake_case")]
579pub struct CustomValidation {
580 pub expression: String,
581 pub message: String,
582}
583
584#[derive(Debug, Clone, Serialize, Deserialize)]
586#[serde(rename_all = "snake_case")]
587pub struct TabItem {
588 pub key: String,
589 pub label: String,
590 #[serde(default)]
591 pub icon: Option<String>,
592 #[serde(default)]
593 pub disabled: Option<serde_json::Value>,
594 pub content: ComponentSchema,
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize)]
599#[serde(rename_all = "snake_case")]
600pub struct AccordionItem {
601 pub key: String,
602 pub title: String,
603 pub content: ComponentSchema,
604 #[serde(default)]
605 pub disabled: Option<serde_json::Value>,
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize)]
610#[serde(rename_all = "snake_case")]
611pub struct BreadcrumbItem {
612 pub label: String,
613 #[serde(default)]
614 pub href: Option<String>,
615 #[serde(default)]
616 pub icon: Option<String>,
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622
623 #[test]
624 fn test_page_definition_serialization() {
625 let page = PageDefinition {
626 route: "/users".to_string(),
627 title: "User Management".to_string(),
628 icon: Some("Users".to_string()),
629 description: Some("Manage system users".to_string()),
630 show_in_menu: true,
631 menu_order: 1,
632 parent_route: None,
633 requires_auth: true,
634 permissions: vec!["users.read".to_string()],
635 roles: vec![],
636 state: {
637 let mut map = HashMap::new();
638 map.insert(
639 "users".to_string(),
640 StateFieldDefinition {
641 field_type: StateFieldType::Array,
642 default: Some(serde_json::json!([])),
643 nullable: false,
644 description: None,
645 },
646 );
647 map.insert(
648 "loading".to_string(),
649 StateFieldDefinition {
650 field_type: StateFieldType::Boolean,
651 default: Some(serde_json::json!(false)),
652 nullable: false,
653 description: None,
654 },
655 );
656 map
657 },
658 computed: HashMap::new(),
659 sections: vec![ComponentSchema::new("Container").with_id("main")],
660 actions: HashMap::new(),
661 hooks: None,
662 dialogs: vec![],
663 };
664
665 let json = serde_json::to_string_pretty(&page).unwrap();
666 println!("{}", json);
667
668 let parsed: PageDefinition = serde_json::from_str(&json).unwrap();
669 assert_eq!(parsed.route, "/users");
670 assert_eq!(parsed.title, "User Management");
671 }
672
673 #[test]
674 fn test_complex_page_deserialization() {
675 let json = r#"{
676 "route": "/users",
677 "title": "User Management",
678 "state": {
679 "filters": { "type": "object", "default": { "search": "" } },
680 "users": { "type": "array", "default": [] },
681 "loading": { "type": "boolean", "default": false }
682 },
683 "sections": [
684 {
685 "type": "Form",
686 "id": "filterForm",
687 "fields": [
688 {
689 "id": "search",
690 "label": "Search",
691 "field_type": "text",
692 "bind_to": "filters.search"
693 }
694 ]
695 },
696 {
697 "type": "Table",
698 "id": "userTable",
699 "columns": [
700 { "key": "id", "label": "ID" },
701 { "key": "email", "label": "Email" }
702 ],
703 "dataSource": "state:users"
704 }
705 ]
706 }"#;
707
708 let page: PageDefinition = serde_json::from_str(json).unwrap();
709 assert_eq!(page.route, "/users");
710 assert_eq!(page.sections.len(), 2);
711 assert!(page.state.contains_key("users"));
712 }
713}