From c86d9399acfc4be3a1ae02479905aa3451de5313 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 9 Mar 2026 18:16:20 +0300 Subject: [PATCH] pinakes-plugin-api: initial UI schema types; manifest extension for GUI plugins Signed-off-by: NotAShelf Change-Id: I508f94798a6eaa800672bd95fa8127d86a6a6964 --- crates/pinakes-core/src/plugin/registry.rs | 2 +- crates/pinakes-core/src/plugin/runtime.rs | 9 +- crates/pinakes-plugin-api/src/lib.rs | 2 + crates/pinakes-plugin-api/src/manifest.rs | 305 ++++ crates/pinakes-plugin-api/src/ui_schema.rs | 1742 ++++++++++++++++++++ 5 files changed, 2053 insertions(+), 7 deletions(-) create mode 100644 crates/pinakes-plugin-api/src/ui_schema.rs diff --git a/crates/pinakes-core/src/plugin/registry.rs b/crates/pinakes-core/src/plugin/registry.rs index 76ef7ad..afa09b1 100644 --- a/crates/pinakes-core/src/plugin/registry.rs +++ b/crates/pinakes-core/src/plugin/registry.rs @@ -158,7 +158,7 @@ impl Default for PluginRegistry { mod tests { use std::collections::HashMap; - use pinakes_plugin_api::Capabilities; + use pinakes_plugin_api::{Capabilities, manifest::ManifestCapabilities}; use super::*; diff --git a/crates/pinakes-core/src/plugin/runtime.rs b/crates/pinakes-core/src/plugin/runtime.rs index 8ad22e3..cb93d73 100644 --- a/crates/pinakes-core/src/plugin/runtime.rs +++ b/crates/pinakes-core/src/plugin/runtime.rs @@ -493,12 +493,9 @@ impl HostFunctions { if let Some(ref allowed) = caller.data().context.capabilities.network.allowed_domains { - let parsed = match url::Url::parse(&url_str) { - Ok(u) => u, - _ => { - tracing::warn!(url = %url_str, "plugin provided invalid URL"); - return -1; - }, + let parsed = if let Ok(u) = url::Url::parse(&url_str) { u } else { + tracing::warn!(url = %url_str, "plugin provided invalid URL"); + return -1; }; let domain = parsed.host_str().unwrap_or(""); diff --git a/crates/pinakes-plugin-api/src/lib.rs b/crates/pinakes-plugin-api/src/lib.rs index 2fb96c6..34e806f 100644 --- a/crates/pinakes-plugin-api/src/lib.rs +++ b/crates/pinakes-plugin-api/src/lib.rs @@ -15,10 +15,12 @@ use thiserror::Error; pub mod manifest; pub mod types; +pub mod ui_schema; pub mod wasm; pub use manifest::PluginManifest; pub use types::*; +pub use ui_schema::*; pub use wasm::host_functions; /// Plugin API version - plugins must match this version diff --git a/crates/pinakes-plugin-api/src/manifest.rs b/crates/pinakes-plugin-api/src/manifest.rs index a334fba..c611546 100644 --- a/crates/pinakes-plugin-api/src/manifest.rs +++ b/crates/pinakes-plugin-api/src/manifest.rs @@ -10,6 +10,7 @@ use crate::{ EnvironmentCapability, FilesystemCapability, NetworkCapability, + UiPage, }; /// Plugin manifest file format (TOML) @@ -22,6 +23,73 @@ pub struct PluginManifest { #[serde(default)] pub config: HashMap, + + /// UI pages provided by this plugin + #[serde(default)] + pub ui: UiSection, +} + +/// UI section of the plugin manifest +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct UiSection { + /// UI pages defined by this plugin + #[serde(default)] + pub pages: Vec, +} + +/// Entry for a UI page in the manifest - can be inline or file reference +#[derive(Debug, Clone)] +pub enum UiPageEntry { + /// Inline UI page definition (boxed to reduce enum size) + Inline(Box), + /// Reference to a JSON file containing the page definition + File { file: String }, +} + +impl<'de> Deserialize<'de> for UiPageEntry { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + + // First try to deserialize as a file reference (has "file" key) + // We use toml::Value since the manifest is TOML + let value = toml::Value::deserialize(deserializer)?; + + if let Some(file) = value.get("file").and_then(|v| v.as_str()) { + if file.is_empty() { + return Err(D::Error::custom("file path cannot be empty")); + } + return Ok(Self::File { + file: file.to_string(), + }); + } + + // Otherwise try to deserialize as inline UiPage + // Convert toml::Value back to a deserializer for UiPage + let page: UiPage = UiPage::deserialize(value) + .map_err(|e| D::Error::custom(format!("invalid inline UI page: {e}")))?; + + Ok(Self::Inline(Box::new(page))) + } +} + +impl Serialize for UiPageEntry { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Inline(page) => page.serialize(serializer), + Self::File { file } => { + use serde::ser::SerializeStruct; + let mut state = serializer.serialize_struct("UiPageEntry", 1)?; + state.serialize_field("file", file)?; + state.end() + }, + } + } } const fn default_priority() -> u16 { @@ -168,6 +236,7 @@ impl PluginManifest { "search_backend", "event_handler", "theme_provider", + "ui_page", ]; for kind in &self.plugin.kind { @@ -193,9 +262,77 @@ impl PluginManifest { )); } + // Validate UI pages + for (idx, page_entry) in self.ui.pages.iter().enumerate() { + match page_entry { + UiPageEntry::Inline(page) => { + if let Err(e) = page.validate() { + return Err(ManifestError::ValidationError(format!( + "UI page {} validation failed: {e}", + idx + 1 + ))); + } + }, + UiPageEntry::File { file } => { + if file.is_empty() { + return Err(ManifestError::ValidationError(format!( + "UI page {} file path cannot be empty", + idx + 1 + ))); + } + }, + } + } + Ok(()) } + /// Load and resolve all UI page definitions + /// + /// For inline pages, returns them directly. For file references, loads + /// and parses the JSON file. + /// + /// # Arguments + /// + /// * `base_path` - Base directory for resolving relative file paths + /// + /// # Errors + /// + /// Returns [`ManifestError::IoError`] if a file cannot be read, or + /// [`ManifestError::ValidationError`] if JSON parsing fails. + pub fn load_ui_pages( + &self, + base_path: &Path, + ) -> Result, ManifestError> { + let mut pages = Vec::with_capacity(self.ui.pages.len()); + + for entry in &self.ui.pages { + let page = match entry { + UiPageEntry::Inline(page) => (**page).clone(), + UiPageEntry::File { file } => { + let file_path = base_path.join(file); + let content = std::fs::read_to_string(&file_path)?; + let page: UiPage = serde_json::from_str(&content).map_err(|e| { + ManifestError::ValidationError(format!( + "Failed to parse UI page from file '{}': {e}", + file_path.display() + )) + })?; + if let Err(e) = page.validate() { + return Err(ManifestError::ValidationError(format!( + "UI page validation failed for file '{}': {e}", + file_path.display() + ))); + } + page + }, + }; + pages.push(page); + } + + Ok(pages) + } + /// Convert manifest capabilities to API capabilities #[must_use] pub fn to_capabilities(&self) -> Capabilities { @@ -353,4 +490,172 @@ wasm = "plugin.wasm" assert!(PluginManifest::parse_str(toml).is_err()); } + + #[test] + fn test_ui_page_inline() { + let toml = r#" +[plugin] +name = "ui-demo" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +[plugin.binary] +wasm = "plugin.wasm" + +[[ui.pages]] +id = "demo" +title = "Demo Page" +route = "/plugins/demo" +icon = "star" + +[ui.pages.layout] +type = "container" +children = [] +gap = 16 +"#; + + let manifest = PluginManifest::parse_str(toml).unwrap(); + assert_eq!(manifest.ui.pages.len(), 1); + + match &manifest.ui.pages[0] { + UiPageEntry::Inline(page) => { + assert_eq!(page.id, "demo"); + assert_eq!(page.title, "Demo Page"); + assert_eq!(page.route, "/plugins/demo"); + assert_eq!(page.icon, Some("star".to_string())); + }, + _ => panic!("Expected inline page"), + } + } + + #[test] + fn test_ui_page_file_reference() { + let toml = r#" +[plugin] +name = "ui-demo" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +[plugin.binary] +wasm = "plugin.wasm" + +[[ui.pages]] +file = "pages/demo.json" +"#; + + let manifest = PluginManifest::parse_str(toml).unwrap(); + assert_eq!(manifest.ui.pages.len(), 1); + + match &manifest.ui.pages[0] { + UiPageEntry::File { file } => { + assert_eq!(file, "pages/demo.json"); + }, + _ => panic!("Expected file reference"), + } + } + + #[test] + fn test_ui_page_invalid_kind() { + // ui_page must be in valid_kinds list + let toml = r#" +[plugin] +name = "test" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +[plugin.binary] +wasm = "plugin.wasm" +"#; + + // Should succeed now that ui_page is in valid_kinds + let manifest = PluginManifest::parse_str(toml); + assert!(manifest.is_ok()); + } + + #[test] + fn test_ui_page_validation_failure() { + let toml = r#" +[plugin] +name = "ui-demo" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +[plugin.binary] +wasm = "plugin.wasm" + +[[ui.pages]] +id = "" +title = "Demo" +route = "/plugins/demo" + +[ui.pages.layout] +type = "container" +children = [] +gap = 16 +"#; + + // Empty ID should fail validation + assert!(PluginManifest::parse_str(toml).is_err()); + } + + #[test] + fn test_ui_page_empty_file() { + let toml = r#" +[plugin] +name = "ui-demo" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +[plugin.binary] +wasm = "plugin.wasm" + +[[ui.pages]] +file = "" +"#; + + // Empty file path should fail validation + assert!(PluginManifest::parse_str(toml).is_err()); + } + + #[test] + fn test_ui_page_multiple_pages() { + let toml = r#" +[plugin] +name = "ui-demo" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +[plugin.binary] +wasm = "plugin.wasm" + +[[ui.pages]] +id = "page1" +title = "Page 1" +route = "/plugins/page1" + +[ui.pages.layout] +type = "container" +children = [] +gap = 16 + +[[ui.pages]] +id = "page2" +title = "Page 2" +route = "/plugins/page2" + +[ui.pages.layout] +type = "container" +children = [] +gap = 16 +"#; + + let manifest = PluginManifest::parse_str(toml).unwrap(); + assert_eq!(manifest.ui.pages.len(), 2); + } } diff --git a/crates/pinakes-plugin-api/src/ui_schema.rs b/crates/pinakes-plugin-api/src/ui_schema.rs new file mode 100644 index 0000000..f6c1e8d --- /dev/null +++ b/crates/pinakes-plugin-api/src/ui_schema.rs @@ -0,0 +1,1742 @@ +//! UI Schema Types for GUI Plugin System +//! +//! This module defines the declarative UI schema that plugins use to define +//! their user interfaces. The schema is JSON-based and rendered by the host UI +//! into Dioxus components. +//! +//! ## Security Model +//! +//! - No arbitrary code execution in UI - only declarative schema +//! - All text content is sanitized through ammonia before rendering +//! - URLs are validated against whitelist +//! - Component tree depth limited to prevent abuse +//! - Action execution goes through typed API layer +//! +//! ## Example Schema +//! +//! ```json +//! { +//! "id": "music-analyzer", +//! "title": "Music Analysis", +//! "route": "/plugins/music-analyzer", +//! "icon": "music", +//! "layout": { +//! "type": "split", +//! "sidebar": { +//! "type": "list", +//! "data": "playlists", +//! "item_template": { "type": "text", "content": "{{title}}" } +//! }, +//! "main": { +//! "type": "data_table", +//! "columns": [ +//! { "key": "title", "header": "Title" }, +//! { "key": "duration", "header": "Duration" } +//! ], +//! "data": "tracks" +//! } +//! }, +//! "data_sources": { +//! "playlists": { "type": "endpoint", "path": "/api/v1/collections" } +//! } +//! } +//! ``` + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Maximum allowed depth for nested UI elements +pub const MAX_ELEMENT_DEPTH: usize = 10; + +/// Maximum allowed width percentage +pub const MAX_WIDTH_PERCENT: u32 = 100; + +/// Maximum allowed height in pixels +pub const MAX_HEIGHT_PX: u32 = 10000; + +/// Schema validation and rendering errors +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum SchemaError { + #[error("validation failed: {0}")] + ValidationError(String), + + #[error("invalid data source: {0}")] + InvalidDataSource(String), + + #[error("invalid expression: {0}")] + InvalidExpression(String), + + #[error("depth limit exceeded: max is {MAX_ELEMENT_DEPTH}")] + DepthLimitExceeded, + + #[error("unknown field: {0}")] + UnknownField(String), + + #[error("required field missing: {0}")] + MissingField(String), +} + +/// Result type for schema operations +pub type SchemaResult = Result; + +/// Root page definition for a plugin UI page +/// +/// This is the top-level structure that defines a complete plugin page. +/// Each page has a unique route, title, and layout. +/// +/// # Example +/// +/// ```rust +/// use pinakes_plugin_api::ui_schema::{DataSource, UiElement, UiPage}; +/// +/// let page = UiPage { +/// id: "demo".to_string(), +/// title: "Demo Page".to_string(), +/// route: "/plugins/demo".to_string(), +/// icon: Some("star".to_string()), +/// root_element: UiElement::Container { +/// children: vec![], +/// gap: 16, +/// padding: None, +/// }, +/// data_sources: Default::default(), +/// }; +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct UiPage { + /// Unique identifier for this page (must be unique across all plugins) + pub id: String, + + /// Display title for the page + pub title: String, + + /// URL route for this page (e.g., "/plugins/my-plugin/page1") + /// Must be unique across all pages + pub route: String, + + /// Optional icon identifier (matches icon names in dioxus-free-icons) + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + + /// Root layout element defining the page structure + #[serde(rename = "layout")] + pub root_element: UiElement, + + /// Named data sources available to this page + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub data_sources: HashMap, +} + +impl UiPage { + /// Validates the complete page definition + /// + /// Checks: + /// - ID format (alphanumeric with dashes/underscores) + /// - Route format (must start with "/") + /// - Element tree depth + /// - Data source validity + /// - All data references in elements exist in `data_sources` + /// + /// # Errors + /// + /// Returns `SchemaError::ValidationError` if validation fails + pub fn validate(&self) -> SchemaResult<()> { + validate_id(&self.id)?; + + if !self.route.starts_with('/') { + return Err(SchemaError::ValidationError( + "Route must start with '/'".to_string(), + )); + } + + let depth = self.root_element.depth(); + if depth > MAX_ELEMENT_DEPTH { + return Err(SchemaError::DepthLimitExceeded); + } + + self.root_element.validate(self)?; + + for (name, source) in &self.data_sources { + validate_id(name)?; + source.validate()?; + + // Check that Transform sources reference existing data sources + if let DataSource::Transform { source_name, .. } = source + && !self.data_sources.contains_key(source_name) + { + return Err(SchemaError::ValidationError(format!( + "DataSource '{name}' references unknown source: {source_name}" + ))); + } + } + + // Detect cycles in Transform dependencies using DFS + self.validate_no_cycles()?; + + Ok(()) + } + + /// Validates that there are no cycles in Transform data source dependencies + fn validate_no_cycles(&self) -> SchemaResult<()> { + let mut visited = std::collections::HashSet::new(); + let mut stack = std::collections::HashSet::new(); + + for name in self.data_sources.keys() { + Self::dfs_check_cycles(self, name, &mut visited, &mut stack)?; + } + + Ok(()) + } + + /// Helper function for cycle detection using DFS + fn dfs_check_cycles( + &self, + name: &str, + visited: &mut std::collections::HashSet, + stack: &mut std::collections::HashSet, + ) -> SchemaResult<()> { + if stack.contains(name) { + return Err(SchemaError::ValidationError(format!( + "Cycle detected in data source dependencies: {name}" + ))); + } + if visited.contains(name) { + return Ok(()); + } + + stack.insert(name.to_string()); + + if let Some(DataSource::Transform { source_name, .. }) = + self.data_sources.get(name) + { + Self::dfs_check_cycles(self, source_name, visited, stack)?; + } + + stack.remove(name); + visited.insert(name.to_string()); + Ok(()) + } + + /// Returns all data source names referenced by the layout + #[must_use] + pub fn referenced_data_sources(&self) -> Vec { + let mut refs = Vec::new(); + self.root_element.collect_data_refs(&mut refs); + refs.sort_unstable(); + refs.dedup(); + refs + } +} + +/// Core UI element enum - the building block of all plugin UIs +/// +/// Elements are categorized into groups: +/// - **Layout containers**: Structure the page (Container, Grid, Flex, Split, +/// Tabs) +/// - **Typography**: Text display (Heading, Text, Code) +/// - **Data display**: Show data (`DataTable`, Card, `MediaGrid`, List, +/// `DescriptionList`) +/// - **Interactive**: User input (Button, Form, Link, Progress, Badge) +/// - **Visualization**: Charts and graphs (Chart) +/// - **Conditional**: Dynamic rendering (Conditional, Loop) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum UiElement { + /// Generic container with configurable layout + /// + /// Use for grouping elements with gap and padding control. + Container { + /// Child elements + children: Vec, + + /// Gap between children in pixels + #[serde(default = "default_gap")] + gap: u32, + + /// Padding inside container (top, right, bottom, left) + #[serde(skip_serializing_if = "Option::is_none")] + padding: Option<[u32; 4]>, + }, + + /// Grid layout with fixed columns + /// + /// Arranges children in a grid with configurable column count. + Grid { + /// Child elements + children: Vec, + + /// Number of columns (1-12) + #[serde(default = "default_grid_columns")] + columns: u8, + + /// Gap between grid items in pixels + #[serde(default = "default_gap")] + gap: u32, + }, + + /// Horizontal or vertical flex layout + /// + /// Use for linear arrangements with alignment control. + Flex { + /// Child elements + children: Vec, + + /// Direction: "row" (horizontal) or "column" (vertical) + #[serde(default)] + direction: FlexDirection, + + /// Main axis alignment + #[serde(default)] + justify: JustifyContent, + + /// Cross axis alignment + #[serde(default)] + align: AlignItems, + + /// Gap between items in pixels + #[serde(default = "default_gap")] + gap: u32, + + /// Whether items should wrap to next line + #[serde(default)] + wrap: bool, + }, + + /// Split view with sidebar and main content + /// + /// Classic sidebar + content layout with resizable divider. + Split { + /// Sidebar content + sidebar: Box, + + /// Sidebar width in pixels + #[serde(default = "default_sidebar_width")] + sidebar_width: u32, + + /// Main content area + main: Box, + }, + + /// Tabbed interface + /// + /// Displays content in tabbed panels. + Tabs { + /// Tab definitions + tabs: Vec, + + /// Initially active tab index + #[serde(default)] + default_tab: usize, + }, + + /// Section heading (h1-h6) + /// + /// Semantic heading for page structure. + Heading { + /// Heading level 1-6 + level: u8, + + /// Heading text (can be static or expression) + #[serde(default)] + content: TextContent, + + /// Optional ID for linking + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + }, + + /// Paragraph text + /// + /// Multi-line text with optional formatting. + Text { + /// Text content (can be static or expression) + #[serde(default)] + content: TextContent, + + /// Text style variant + #[serde(default)] + variant: TextVariant, + + /// Whether to allow HTML (sanitized) in content + #[serde(default)] + allow_html: bool, + }, + + /// Monospaced code block + /// + /// Displays code with syntax highlighting support. + Code { + /// Code content + content: String, + + /// Language for syntax highlighting (e.g., "rust", "json") + #[serde(skip_serializing_if = "Option::is_none")] + language: Option, + + /// Whether to show line numbers + #[serde(default)] + show_line_numbers: bool, + }, + + /// Data table with sortable columns + /// + /// Displays tabular data with sorting, filtering, and pagination. + DataTable { + /// Column definitions + columns: Vec, + + /// Data source reference (key into `page.data_sources`) + data: String, + + /// Whether to enable sorting + #[serde(default = "default_true")] + sortable: bool, + + /// Whether to enable filtering + #[serde(default)] + filterable: bool, + + /// Items per page (0 = no pagination) + #[serde(default)] + page_size: usize, + + /// Row actions (displayed as buttons per row) + #[serde(default, skip_serializing_if = "Vec::is_empty")] + row_actions: Vec, + }, + + /// Card component for content grouping + /// + /// Displays content in a card with optional header and footer. + Card { + /// Card title + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + + /// Main content elements + content: Vec, + + /// Footer elements + #[serde(default, skip_serializing_if = "Vec::is_empty")] + footer: Vec, + }, + + /// Media grid for images/videos + /// + /// Displays media items in a responsive grid. + MediaGrid { + /// Data source reference containing media items + data: String, + + /// Number of columns (1-12) + #[serde(default = "default_grid_columns")] + columns: u8, + + /// Gap between items in pixels + #[serde(default = "default_gap")] + gap: u32, + }, + + /// List component with item template + /// + /// Renders a list by applying a template to each item. + List { + /// Data source reference + data: String, + + /// Template for each list item + item_template: Box, + + /// Whether to show dividers between items + #[serde(default = "default_true")] + dividers: bool, + }, + + /// Description list (key-value pairs) + /// + /// Displays key-value pairs, ideal for metadata. + DescriptionList { + /// Data source reference containing key-value pairs + data: String, + + /// Whether to display horizontally + #[serde(default)] + horizontal: bool, + }, + + /// Button component + /// + /// Triggers actions when clicked. + Button { + /// Button label + label: String, + + /// Button variant/style + #[serde(default)] + variant: ButtonVariant, + + /// Action to execute on click + action: ActionRef, + + /// Whether button is disabled + #[serde(default)] + disabled: bool, + }, + + /// Form with fields + /// + /// Input form with validation and submission. + Form { + /// Form field definitions + fields: Vec, + + /// Submit button label + #[serde(default = "default_submit_label")] + submit_label: String, + + /// Action to execute on submit + submit_action: ActionRef, + + /// Cancel button label (None = no cancel button) + #[serde(skip_serializing_if = "Option::is_none")] + cancel_label: Option, + }, + + /// Link component + /// + /// Navigates to internal or external URLs. + Link { + /// Link text + text: String, + + /// Target URL + href: String, + + /// Whether to open in new tab + #[serde(default)] + external: bool, + }, + + /// Progress bar or indicator + /// + /// Shows progress of an operation. + Progress { + /// Current value expression + value: Expression, + + /// Maximum value + #[serde(default = "default_progress_max")] + max: f64, + + /// Whether to show percentage text + #[serde(default = "default_true")] + show_percentage: bool, + }, + + /// Badge/chip component + /// + /// Small label for status, tags, etc. + Badge { + /// Badge text + text: String, + + /// Badge color variant + #[serde(default)] + variant: BadgeVariant, + }, + + /// Chart visualization + /// + /// Displays data as charts (bar, line, pie, etc.). + Chart { + /// Chart type + chart_type: ChartType, + + /// Data source reference + data: String, + + /// Chart title + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + + /// X-axis label + #[serde(skip_serializing_if = "Option::is_none")] + x_axis_label: Option, + + /// Y-axis label + #[serde(skip_serializing_if = "Option::is_none")] + y_axis_label: Option, + + /// Chart height in pixels + #[serde(default = "default_chart_height")] + height: u32, + }, + + /// Conditional rendering + /// + /// Shows different content based on a condition. + Conditional { + /// Condition expression (evaluates to boolean) + condition: Expression, + + /// Element to render when condition is true + then: Box, + + /// Optional element to render when condition is false + #[serde(rename = "else", skip_serializing_if = "Option::is_none")] + else_element: Option>, + }, + + /// Loop/repeat over data + /// + /// Renders a template for each item in a collection. + Loop { + /// Data source reference (must be an array) + data: String, + + /// Template for each iteration + template: Box, + + /// Content to show when data is empty + #[serde(skip_serializing_if = "Option::is_none")] + empty: Option>, + }, +} + +impl UiElement { + /// Calculates the depth of this element tree + pub fn depth(&self) -> usize { + 1 + match self { + Self::Container { children, .. } + | Self::Grid { children, .. } + | Self::Flex { children, .. } => { + children.iter().map(Self::depth).max().unwrap_or(0) + }, + Self::Split { sidebar, main, .. } => sidebar.depth().max(main.depth()), + Self::Tabs { tabs, .. } => { + tabs.iter().map(|t| t.content.depth()).max().unwrap_or(0) + }, + Self::Card { + content, footer, .. + } => { + content + .iter() + .chain(footer.iter()) + .map(Self::depth) + .max() + .unwrap_or(0) + }, + Self::List { item_template, .. } => item_template.depth(), + Self::Conditional { + then, else_element, .. + } => { + let else_depth = else_element.as_ref().map_or(0, |e| e.depth()); + then.depth().max(else_depth) + }, + Self::Loop { + template, empty, .. + } => { + let empty_depth = empty.as_ref().map_or(0, |e| e.depth()); + template.depth().max(empty_depth) + }, + _ => 0, + } + } + + /// Validates this element and its children + /// + /// # Errors + /// + /// Returns `SchemaError` if validation fails + pub fn validate(&self, page: &UiPage) -> SchemaResult<()> { + match self { + Self::Container { children, .. } | Self::Flex { children, .. } => { + for child in children { + child.validate(page)?; + } + }, + Self::Grid { + children, columns, .. + } => { + if *columns == 0 || *columns > 12 { + return Err(SchemaError::ValidationError(format!( + "Grid columns must be between 1 and 12, got {columns}" + ))); + } + for child in children { + child.validate(page)?; + } + }, + Self::Split { sidebar, main, .. } => { + sidebar.validate(page)?; + main.validate(page)?; + }, + Self::Tabs { + tabs, default_tab, .. + } => { + if tabs.is_empty() { + return Err(SchemaError::ValidationError( + "Tabs array must contain at least one tab".to_string(), + )); + } + if *default_tab >= tabs.len() { + return Err(SchemaError::ValidationError(format!( + "Default tab index {default_tab} out of range (max: {})", + tabs.len().saturating_sub(1) + ))); + } + for tab in tabs { + tab.content.validate(page)?; + } + }, + Self::Heading { level, .. } if *level == 0 || *level > 6 => { + return Err(SchemaError::ValidationError(format!( + "Heading level must be between 1 and 6, got {level}" + ))); + }, + Self::DataTable { columns, data, .. } => { + if columns.is_empty() { + return Err(SchemaError::ValidationError( + "DataTable must have at least one column".to_string(), + )); + } + if !page.data_sources.contains_key(data) { + return Err(SchemaError::ValidationError(format!( + "DataTable references unknown data source: {data}" + ))); + } + }, + Self::Card { + content, footer, .. + } => { + for child in content { + child.validate(page)?; + } + for child in footer { + child.validate(page)?; + } + }, + Self::MediaGrid { columns, data, .. } => { + if *columns == 0 || *columns > 12 { + return Err(SchemaError::ValidationError(format!( + "MediaGrid columns must be between 1 and 12, got {columns}" + ))); + } + if !page.data_sources.contains_key(data) { + return Err(SchemaError::ValidationError(format!( + "MediaGrid references unknown data source: {data}" + ))); + } + }, + Self::Form { fields, .. } if fields.is_empty() => { + return Err(SchemaError::ValidationError( + "Form must have at least one field".to_string(), + )); + }, + Self::Chart { data, .. } if !page.data_sources.contains_key(data) => { + return Err(SchemaError::ValidationError(format!( + "Chart references unknown data source: {data}" + ))); + }, + Self::Conditional { + then, else_element, .. + } => { + then.validate(page)?; + if let Some(else_el) = else_element { + else_el.validate(page)?; + } + }, + Self::List { + data, + item_template, + .. + } => { + if !page.data_sources.contains_key(data) { + return Err(SchemaError::ValidationError(format!( + "List references unknown data source: {data}" + ))); + } + item_template.validate(page)?; + }, + Self::DescriptionList { data, .. } + if !page.data_sources.contains_key(data) => + { + return Err(SchemaError::ValidationError(format!( + "DescriptionList references unknown data source: {data}" + ))); + }, + Self::Loop { + template, + empty, + data, + .. + } => { + if !page.data_sources.contains_key(data) { + return Err(SchemaError::ValidationError(format!( + "Loop references unknown data source: {data}" + ))); + } + template.validate(page)?; + if let Some(empty_el) = empty { + empty_el.validate(page)?; + } + }, + Self::Button { action, .. } => { + action.validate()?; + }, + Self::Form { + fields, + submit_action, + .. + } => { + for field in fields { + validate_id(&field.id)?; + if field.label.is_empty() { + return Err(SchemaError::ValidationError(format!( + "Form field '{}' must have a label", + field.id + ))); + } + } + submit_action.validate()?; + }, + _ => {}, + } + Ok(()) + } + + /// Collects all data source references from this element + fn collect_data_refs(&self, refs: &mut Vec) { + match self { + Self::Container { children, .. } + | Self::Grid { children, .. } + | Self::Flex { children, .. } => { + for child in children { + child.collect_data_refs(refs); + } + }, + Self::Split { sidebar, main, .. } => { + sidebar.collect_data_refs(refs); + main.collect_data_refs(refs); + }, + Self::Tabs { tabs, .. } => { + for tab in tabs { + tab.content.collect_data_refs(refs); + } + }, + Self::Card { + content, footer, .. + } => { + for child in content { + child.collect_data_refs(refs); + } + for child in footer { + child.collect_data_refs(refs); + } + }, + Self::DataTable { data, .. } + | Self::MediaGrid { data, .. } + | Self::DescriptionList { data, .. } + | Self::Chart { data, .. } => { + refs.push(data.clone()); + }, + Self::List { + data, + item_template, + .. + } => { + refs.push(data.clone()); + item_template.collect_data_refs(refs); + }, + Self::Loop { + data, + template, + empty, + .. + } => { + refs.push(data.clone()); + template.collect_data_refs(refs); + if let Some(empty_el) = empty { + empty_el.collect_data_refs(refs); + } + }, + Self::Conditional { + then, else_element, .. + } => { + then.collect_data_refs(refs); + if let Some(else_el) = else_element { + else_el.collect_data_refs(refs); + } + }, + _ => {}, + } + } +} + +/// Flex direction for Flex layout +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum FlexDirection { + /// Horizontal layout (left to right) + #[default] + Row, + /// Vertical layout (top to bottom) + Column, +} + +/// Justify content alignment for Flex layout +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum JustifyContent { + /// Items packed to start + #[default] + FlexStart, + /// Items packed to end + FlexEnd, + /// Items centered + Center, + /// Items evenly distributed with space between + SpaceBetween, + /// Items evenly distributed with space around + SpaceAround, + /// Items evenly distributed with equal space + SpaceEvenly, +} + +/// Align items for Flex layout (cross-axis alignment) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum AlignItems { + /// Items aligned to start + #[default] + FlexStart, + /// Items aligned to end + FlexEnd, + /// Items centered + Center, + /// Items stretched to fill + Stretch, + /// Items aligned to baseline + Baseline, +} + +/// Text content can be static or dynamic +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(untagged)] +pub enum TextContent { + /// Static text string + Static(String), + + /// Dynamic expression that evaluates to text + Expression(Expression), + + /// Default (empty text) + #[default] + Empty, +} + +impl TextContent { + /// Returns true if content is empty + #[must_use] + pub const fn is_empty(&self) -> bool { + matches!(self, Self::Empty) + } +} + +impl From for TextContent { + fn from(s: String) -> Self { + Self::Static(s) + } +} + +impl From<&str> for TextContent { + fn from(s: &str) -> Self { + Self::Static(s.to_string()) + } +} + +impl From for TextContent { + fn from(expr: Expression) -> Self { + Self::Expression(expr) + } +} + +/// Text style variants +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum TextVariant { + /// Default body text + #[default] + Body, + /// Secondary/muted text + Secondary, + /// Success text (green) + Success, + /// Warning text (yellow/orange) + Warning, + /// Error text (red) + Error, + /// Bold text + Bold, + /// Italic text + Italic, + /// Small/caption text + Small, + /// Large/lead text + Large, +} + +/// Column definition for `DataTable` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ColumnDef { + /// Data key for this column + pub key: String, + + /// Display header text + pub header: String, + + /// Column width (e.g., "100px", "20%", or "auto") + #[serde(skip_serializing_if = "Option::is_none")] + pub width: Option, + + /// Whether column is sortable + #[serde(default = "default_true")] + pub sortable: bool, + + /// Data type for formatting (affects sorting and display) + #[serde(default)] + pub data_type: ColumnDataType, + + /// Custom format string (e.g., for dates: "YYYY-MM-DD") + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, +} + +/// Row action for `DataTable` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RowAction { + /// Action identifier (unique within this table) + pub id: String, + + /// Display label + pub label: String, + + /// Action to execute + pub action: ActionRef, + + /// Button variant + #[serde(default)] + pub variant: ButtonVariant, +} + +/// Data type for column formatting +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum ColumnDataType { + /// Generic text + #[default] + Text, + /// Numeric value + Number, + /// Date/time + DateTime, + /// Boolean (shows as checkmark/x) + Boolean, + /// File size (auto-formatted) + FileSize, + /// Duration in seconds (formatted as HH:MM:SS) + Duration, +} + +/// Tab definition for Tabs component +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TabDefinition { + /// Tab label + pub label: String, + + /// Tab icon (optional) + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + + /// Tab content + pub content: UiElement, +} + +/// Button style variants +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum ButtonVariant { + /// Primary action button (filled) + #[default] + Primary, + /// Secondary button (outlined) + Secondary, + /// Tertiary button (text only) + Tertiary, + /// Danger/destructive action + Danger, + /// Success/confirm action + Success, + /// Ghost/invisible button + Ghost, +} + +/// Form field definition +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FormField { + /// Field identifier (form data key) + pub id: String, + + /// Field label + pub label: String, + + /// Field type + #[serde(rename = "type")] + pub field_type: FieldType, + + /// Whether field is required + #[serde(default)] + pub required: bool, + + /// Placeholder text (for text inputs) + #[serde(skip_serializing_if = "Option::is_none")] + pub placeholder: Option, + + /// Default value + #[serde(skip_serializing_if = "Option::is_none")] + pub default_value: Option, + + /// Validation rules + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub validation: Vec, + + /// Help text displayed below field + #[serde(skip_serializing_if = "Option::is_none")] + pub help_text: Option, +} + +/// Form field types +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum FieldType { + /// Single-line text input + Text { + /// Maximum length + #[serde(skip_serializing_if = "Option::is_none")] + max_length: Option, + }, + + /// Multi-line text area + Textarea { + /// Number of rows + #[serde(default = "default_textarea_rows")] + rows: u32, + }, + + /// Email input with validation + Email, + + /// URL input with validation + Url, + + /// Number input + Number { + /// Minimum value + #[serde(skip_serializing_if = "Option::is_none")] + min: Option, + /// Maximum value + #[serde(skip_serializing_if = "Option::is_none")] + max: Option, + /// Step increment + #[serde(skip_serializing_if = "Option::is_none")] + step: Option, + }, + + /// Toggle switch + Switch, + + /// Checkbox + Checkbox { + /// Checkbox label (separate from field label) + #[serde(skip_serializing_if = "Option::is_none")] + checkbox_label: Option, + }, + + /// Select dropdown + Select { + /// Available options + options: Vec, + /// Whether multiple selection is allowed + #[serde(default)] + multiple: bool, + }, + + /// Radio button group + Radio { + /// Available options + options: Vec, + }, + + /// Date picker + Date, + + /// `DateTime` picker + DateTime, + + /// File upload + File { + /// Accepted MIME types (e.g., [`"image/*"`, `"application/pdf"`]) + #[serde(skip_serializing_if = "Option::is_none")] + accept: Option>, + /// Maximum file size in bytes + #[serde(skip_serializing_if = "Option::is_none")] + max_size: Option, + /// Whether multiple files allowed + #[serde(default)] + multiple: bool, + }, +} + +/// Select/Radio option +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SelectOption { + /// Option value (stored in form data) + pub value: String, + /// Display label + pub label: String, +} + +/// Validation rules for form fields +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ValidationRule { + /// Minimum length (for text) + MinLength { value: usize }, + /// Maximum length (for text) + MaxLength { value: usize }, + /// Pattern match (regex) + Pattern { regex: String }, + /// Custom validation message + Custom { message: String }, +} + +/// Badge color variants +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum BadgeVariant { + /// Default/neutral + #[default] + Default, + /// Primary color + Primary, + /// Secondary color + Secondary, + /// Success/green + Success, + /// Warning/yellow + Warning, + /// Error/red + Error, + /// Info/blue + Info, +} + +/// Chart types +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ChartType { + /// Bar chart + Bar, + /// Line chart + Line, + /// Pie/donut chart + Pie, + /// Area chart + Area, + /// Scatter plot + Scatter, +} + +/// Action reference - identifies an action to execute +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum ActionRef { + /// Simple action name (references page.actions) + Name(String), + + /// Inline action definition + Inline(ActionDefinition), +} + +impl ActionRef { + /// Validates the action reference + /// + /// For inline actions, validates the `ActionDefinition`. + /// For named actions, validates the name is a valid identifier. + /// + /// # Errors + /// + /// Returns `SchemaError::ValidationError` if validation fails. + pub fn validate(&self) -> SchemaResult<()> { + match self { + Self::Name(name) => { + if name.is_empty() { + return Err(SchemaError::ValidationError( + "Action name cannot be empty".to_string(), + )); + } + validate_id(name)?; + }, + Self::Inline(action) => { + action.validate()?; + }, + } + Ok(()) + } +} + +/// Action definition +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ActionDefinition { + /// HTTP method + #[serde(default = "default_http_method")] + pub method: HttpMethod, + + /// API endpoint path (must start with '/') + pub path: String, + + /// Action parameters (merged with form data on submit) + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub params: HashMap, + + /// Success message + #[serde(skip_serializing_if = "Option::is_none")] + pub success_message: Option, + + /// Error message + #[serde(skip_serializing_if = "Option::is_none")] + pub error_message: Option, + + /// Navigation after success (route to navigate to) + #[serde(skip_serializing_if = "Option::is_none")] + pub navigate_to: Option, +} + +impl ActionDefinition { + /// Validates the action definition + /// + /// Checks: + /// - Path is non-empty and starts with '/' + /// + /// # Errors + /// + /// Returns `SchemaError::ValidationError` if validation fails. + pub fn validate(&self) -> SchemaResult<()> { + if self.path.is_empty() { + return Err(SchemaError::ValidationError( + "Action path cannot be empty".to_string(), + )); + } + if !self.path.starts_with('/') { + return Err(SchemaError::ValidationError(format!( + "Action path must start with '/': {}", + self.path + ))); + } + Ok(()) + } +} + +impl Default for ActionDefinition { + fn default() -> Self { + Self { + method: default_http_method(), + path: String::new(), + params: HashMap::new(), + success_message: None, + error_message: None, + navigate_to: None, + } + } +} + +/// HTTP methods for actions +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "UPPERCASE")] +pub enum HttpMethod { + #[default] + Get, + Post, + Put, + Patch, + Delete, +} + +/// Data source for dynamic content +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum DataSource { + /// API endpoint + Endpoint { + /// HTTP method + #[serde(default = "default_http_method")] + method: HttpMethod, + + /// API path (relative to base URL) + path: String, + + /// Query parameters + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + params: HashMap, + + /// Polling interval in seconds (0 = no polling) + #[serde(default)] + poll_interval: u64, + + /// Transform expression applied to response + #[serde(skip_serializing_if = "Option::is_none")] + transform: Option, + }, + + /// Transform of another data source + Transform { + /// Source data reference + #[serde(rename = "source")] + source_name: String, + + /// Transform expression + expression: Expression, + }, + + /// Static data + Static { + /// Static JSON value + value: serde_json::Value, + }, +} + +impl DataSource { + /// Validates this data source + /// + /// # Errors + /// + /// Returns `SchemaError::InvalidDataSource` if validation fails + pub fn validate(&self) -> SchemaResult<()> { + match self { + Self::Endpoint { path, .. } => { + if !path.starts_with('/') { + return Err(SchemaError::InvalidDataSource(format!( + "Endpoint path must start with '/': {path}" + ))); + } + }, + Self::Transform { source_name, .. } => { + validate_id(source_name)?; + }, + Self::Static { .. } => {}, + } + Ok(()) + } +} + +/// Expression for dynamic value evaluation +/// +/// Expressions use JSONPath-like syntax for data access. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum Expression { + /// Literal JSON value + Literal(serde_json::Value), + + /// Data path reference (e.g., "$.users[0].name") + Path(String), + + /// Binary operation + Operation { + /// Left operand + left: Box, + /// Operator + op: Operator, + /// Right operand + right: Box, + }, + + /// Function call + Call { + /// Function name + function: String, + /// Function arguments + args: Vec, + }, +} + +impl Default for Expression { + fn default() -> Self { + Self::Literal(serde_json::Value::Null) + } +} + +/// Binary operators +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Operator { + /// Equal + Eq, + /// Not equal + Ne, + /// Greater than + Gt, + /// Greater than or equal + Gte, + /// Less than + Lt, + /// Less than or equal + Lte, + /// Logical and + And, + /// Logical or + Or, + /// String concatenation + Concat, + /// Addition + Add, + /// Subtraction + Sub, + /// Multiplication + Mul, + /// Division + Div, +} + +// Helper functions for defaults + +const fn default_gap() -> u32 { + 16 +} + +const fn default_sidebar_width() -> u32 { + 280 +} + +const fn default_grid_columns() -> u8 { + 4 +} + +const fn default_true() -> bool { + true +} + +const fn default_textarea_rows() -> u32 { + 3 +} + +const fn default_chart_height() -> u32 { + 300 +} + +const fn default_progress_max() -> f64 { + 100.0 +} + +fn default_submit_label() -> String { + "Submit".to_string() +} + +const fn default_http_method() -> HttpMethod { + HttpMethod::Get +} + +/// Validates an identifier string +/// +/// IDs must: +/// - Start with a letter +/// - Contain only alphanumeric characters, underscores, and dashes +/// - Be between 1 and 64 characters +/// +/// # Errors +/// +/// Returns `SchemaError::ValidationError` if the ID is invalid +fn validate_id(id: &str) -> SchemaResult<()> { + if id.is_empty() { + return Err(SchemaError::ValidationError( + "ID cannot be empty".to_string(), + )); + } + + if id.len() > 64 { + return Err(SchemaError::ValidationError(format!( + "ID exceeds maximum length of 64: {id}" + ))); + } + + let first_char = id.chars().next(); + if first_char.is_none_or(|c| !c.is_ascii_alphabetic()) { + return Err(SchemaError::ValidationError(format!( + "ID must start with a letter: {id}" + ))); + } + + if !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(SchemaError::ValidationError(format!( + "ID contains invalid characters: {id}" + ))); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ui_element_depth() { + let element = UiElement::Container { + children: vec![UiElement::Container { + children: vec![UiElement::Text { + content: TextContent::Static("test".to_string()), + variant: TextVariant::default(), + allow_html: false, + }], + gap: 16, + padding: None, + }], + gap: 16, + padding: None, + }; + + assert_eq!(element.depth(), 3); + } + + #[test] + fn test_validate_id() { + assert!(validate_id("valid-id").is_ok()); + assert!(validate_id("valid_id_123").is_ok()); + assert!(validate_id("ValidId").is_ok()); + + assert!(validate_id("").is_err()); + assert!(validate_id("123-start").is_err()); + assert!(validate_id("invalid.id").is_err()); + assert!(validate_id("a".repeat(65).as_str()).is_err()); + } + + #[test] + fn test_column_data_type_default() { + let column: ColumnDef = + serde_json::from_str(r#"{"key": "name", "header": "Name"}"#).unwrap(); + assert_eq!(column.data_type, ColumnDataType::Text); + assert!(column.sortable); + } + + #[test] + fn test_data_source_endpoint_validation() { + let valid = DataSource::Endpoint { + method: HttpMethod::Get, + path: "/api/test".to_string(), + params: HashMap::new(), + poll_interval: 0, + transform: None, + }; + assert!(valid.validate().is_ok()); + + let invalid = DataSource::Endpoint { + method: HttpMethod::Get, + path: "api/test".to_string(), + params: HashMap::new(), + poll_interval: 0, + transform: None, + }; + assert!(invalid.validate().is_err()); + } + + #[test] + fn test_text_content_from_string() { + let content: TextContent = "hello".into(); + assert_eq!(content, TextContent::Static("hello".to_string())); + } + + #[test] + fn test_expression_default() { + let expr = Expression::default(); + assert_eq!(expr, Expression::Literal(serde_json::Value::Null)); + } + + #[test] + fn test_action_definition_default() { + let action = ActionDefinition::default(); + assert_eq!(action.method, HttpMethod::Get); + assert!(action.path.is_empty()); + } + + #[test] + fn test_data_source_serialization() { + let source = DataSource::Static { + value: serde_json::json!({"key": "value"}), + }; + let json = serde_json::to_string(&source).unwrap(); + assert!(json.contains("static")); + } + + #[test] + fn test_ui_page_referenced_data_sources() { + let page = UiPage { + id: "test".to_string(), + title: "Test".to_string(), + route: "/test".to_string(), + icon: None, + root_element: UiElement::DataTable { + columns: vec![], + data: "items".to_string(), + sortable: true, + filterable: false, + page_size: 0, + row_actions: vec![], + }, + data_sources: HashMap::new(), + }; + + let refs = page.referenced_data_sources(); + assert_eq!(refs, vec!["items"]); + } + + #[test] + fn test_grid_validation() { + let page = UiPage { + id: "test".to_string(), + title: "Test".to_string(), + route: "/test".to_string(), + icon: None, + root_element: UiElement::Grid { + children: vec![], + columns: 13, + gap: 16, + }, + data_sources: HashMap::new(), + }; + + assert!(page.validate().is_err()); + } + + #[test] + fn test_heading_validation() { + let page = UiPage { + id: "test".to_string(), + title: "Test".to_string(), + route: "/test".to_string(), + icon: None, + root_element: UiElement::Heading { + level: 7, + content: TextContent::Static("Title".to_string()), + id: None, + }, + data_sources: HashMap::new(), + }; + + assert!(page.validate().is_err()); + } +}