orbis_plugin_api/
ui.rs

1//! Enhanced UI component and page definitions for JSON-described GUI.
2//!
3//! This module provides comprehensive types for defining dynamic UIs from JSON schemas,
4//! supporting state management, event handling, and complex component compositions.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9// =============================================================================
10// State Definition Types
11// =============================================================================
12
13/// State field type enumeration.
14#[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/// A single state field definition.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub struct StateFieldDefinition {
28    /// The type of the state field.
29    #[serde(rename = "type")]
30    pub field_type: StateFieldType,
31
32    /// Default value for the field.
33    #[serde(default)]
34    pub default: Option<serde_json::Value>,
35
36    /// Whether the field is nullable.
37    #[serde(default)]
38    pub nullable: bool,
39
40    /// Description for documentation.
41    #[serde(default)]
42    pub description: Option<String>,
43}
44
45// =============================================================================
46// Action Types
47// =============================================================================
48
49/// Action that can be executed in response to events.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(tag = "type", rename_all = "snake_case")]
52pub enum Action {
53    /// Update state at a path.
54    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    /// Call a backend API.
65    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 to a route.
86    Navigate {
87        to: String,
88        #[serde(default)]
89        replace: bool,
90        #[serde(default)]
91        params: HashMap<String, String>,
92    },
93
94    /// Show a toast notification.
95    ShowToast {
96        level: ToastLevel,
97        message: String,
98        #[serde(default)]
99        title: Option<String>,
100        #[serde(default)]
101        duration: Option<u32>,
102    },
103
104    /// Show a dialog.
105    ShowDialog {
106        dialog_id: String,
107        #[serde(default)]
108        data: HashMap<String, String>,
109    },
110
111    /// Close a dialog.
112    CloseDialog {
113        #[serde(default)]
114        dialog_id: Option<String>,
115    },
116
117    /// Debounced action execution.
118    DebouncedAction {
119        delay_ms: u32,
120        action: Box<Action>,
121        #[serde(default)]
122        key: Option<String>,
123    },
124
125    /// Set loading state.
126    SetLoading {
127        loading: bool,
128        #[serde(default)]
129        target: Option<String>,
130    },
131
132    /// Conditional action.
133    Conditional {
134        condition: String,
135        then: Vec<Action>,
136        #[serde(default)]
137        else_actions: Vec<Action>,
138    },
139
140    /// Sequence of actions.
141    Sequence {
142        actions: Vec<Action>,
143        #[serde(default)]
144        stop_on_error: bool,
145    },
146
147    /// Copy text to clipboard.
148    Copy {
149        text: String,
150        #[serde(default)]
151        show_notification: bool,
152    },
153
154    /// Open external URL.
155    OpenUrl {
156        url: String,
157        #[serde(default)]
158        new_tab: bool,
159    },
160
161    /// Emit custom event.
162    Emit {
163        event: String,
164        #[serde(default)]
165        payload: HashMap<String, serde_json::Value>,
166    },
167}
168
169/// Argument mapping for API calls.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct ArgMapping {
172    pub from: String,
173    pub to: String,
174}
175
176/// Toast notification level.
177#[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// =============================================================================
187// Event Handler Types
188// =============================================================================
189
190/// Event handlers that can be attached to components.
191#[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// =============================================================================
219// Component Schema Types
220// =============================================================================
221
222/// Enhanced component schema for JSON-described UI.
223#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(rename_all = "snake_case")]
225pub struct ComponentSchema {
226    /// Component type (e.g., "Container", "Button", "Form").
227    #[serde(rename = "type")]
228    pub component_type: String,
229
230    /// Unique identifier for the component.
231    #[serde(default)]
232    pub id: Option<String>,
233
234    /// CSS class names.
235    #[serde(default, rename = "className")]
236    pub class_name: Option<String>,
237
238    /// Inline CSS styles.
239    #[serde(default)]
240    pub style: Option<serde_json::Value>,
241
242    /// Visibility condition (boolean or expression).
243    #[serde(default)]
244    pub visible: Option<serde_json::Value>,
245
246    /// Child components.
247    #[serde(default)]
248    pub children: Vec<ComponentSchema>,
249
250    /// Event handlers.
251    #[serde(default)]
252    pub events: Option<EventHandlers>,
253
254    /// All other component-specific properties.
255    #[serde(flatten)]
256    pub props: HashMap<String, serde_json::Value>,
257}
258
259impl ComponentSchema {
260    /// Create a new component schema.
261    #[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    /// Set the component ID.
276    #[must_use]
277    pub fn with_id(mut self, id: &str) -> Self {
278        self.id = Some(id.to_string());
279        self
280    }
281
282    /// Add a child component.
283    #[must_use]
284    pub fn with_child(mut self, child: ComponentSchema) -> Self {
285        self.children.push(child);
286        self
287    }
288
289    /// Set a property.
290    #[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    /// Validate the component schema.
297    ///
298    /// # Errors
299    ///
300    /// Returns an error if the schema is invalid.
301    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// =============================================================================
315// Page Definition Types
316// =============================================================================
317
318/// Dialog definition for modals.
319#[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/// Page lifecycle hooks.
335#[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/// Enhanced page definition for plugin UI.
349#[derive(Debug, Clone, Serialize, Deserialize)]
350#[serde(rename_all = "snake_case")]
351pub struct PageDefinition {
352    /// Route path for the page.
353    pub route: String,
354
355    /// Page title.
356    pub title: String,
357
358    /// Icon name (from icon library).
359    #[serde(default)]
360    pub icon: Option<String>,
361
362    /// Page description.
363    #[serde(default)]
364    pub description: Option<String>,
365
366    /// Whether to show in navigation menu.
367    #[serde(default = "default_true")]
368    pub show_in_menu: bool,
369
370    /// Menu order (lower = higher priority).
371    #[serde(default)]
372    pub menu_order: i32,
373
374    /// Parent route (for nested pages).
375    #[serde(default)]
376    pub parent_route: Option<String>,
377
378    /// Whether authentication is required.
379    #[serde(default = "default_true")]
380    pub requires_auth: bool,
381
382    /// Required permissions to view page.
383    #[serde(default)]
384    pub permissions: Vec<String>,
385
386    /// Required roles to view page.
387    #[serde(default)]
388    pub roles: Vec<String>,
389
390    /// Page-level state definition.
391    #[serde(default)]
392    pub state: HashMap<String, StateFieldDefinition>,
393
394    /// Computed values derived from state.
395    #[serde(default)]
396    pub computed: HashMap<String, String>,
397
398    /// Page sections/content.
399    pub sections: Vec<ComponentSchema>,
400
401    /// Page-level action definitions.
402    #[serde(default)]
403    pub actions: HashMap<String, Action>,
404
405    /// Page lifecycle hooks.
406    #[serde(default)]
407    pub hooks: Option<PageLifecycleHooks>,
408
409    /// Dialog definitions.
410    #[serde(default)]
411    pub dialogs: Vec<DialogDefinition>,
412}
413
414fn default_true() -> bool {
415    true
416}
417
418impl PageDefinition {
419    /// Validate the page definition.
420    ///
421    /// # Errors
422    ///
423    /// Returns an error if the page is invalid.
424    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    /// Get the full route path with plugin prefix.
445    #[must_use]
446    pub fn full_route(&self, plugin_name: &str) -> String {
447        format!("/plugins/{}{}", plugin_name, self.route)
448    }
449}
450
451// =============================================================================
452// Navigation Types
453// =============================================================================
454
455/// Navigation menu item.
456#[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/// Navigation configuration.
480#[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// =============================================================================
494// Helper Types for Common Patterns
495// =============================================================================
496
497/// Table column definition.
498#[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/// Form field definition.
514#[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/// Select option.
543#[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/// Validation rule.
553#[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/// Custom validation rule.
577#[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/// Tab item.
585#[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/// Accordion item.
598#[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/// Breadcrumb item.
609#[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}