diff --git a/crates/pinakes-ui/src/plugin_ui/renderer.rs b/crates/pinakes-ui/src/plugin_ui/renderer.rs new file mode 100644 index 0000000..b8e3586 --- /dev/null +++ b/crates/pinakes-ui/src/plugin_ui/renderer.rs @@ -0,0 +1,1034 @@ +//! Schema-to-Dioxus renderer for plugin UI pages +//! +//! Converts `UiElement` schemas from plugin manifests into rendered Dioxus +//! elements. Data-driven elements resolve their data from a [`PluginPageData`] +//! context that is populated by the `use_plugin_data` hook. + +use dioxus::prelude::*; +use pinakes_plugin_api::{ + AlignItems, + BadgeVariant, + ButtonVariant, + ChartType, + Expression, + FieldType, + FlexDirection, + JustifyContent, + TextContent, + TextVariant, + UiElement, + UiPage, +}; + +use super::{ + actions::{ActionResult, execute_action}, + data::{PluginPageData, use_plugin_data}, +}; +use crate::client::ApiClient; + +/// Props for [`PluginViewRenderer`] +#[derive(Props, PartialEq, Clone)] +pub struct PluginViewProps { + /// Plugin ID that owns this page + pub plugin_id: String, + /// Page schema to render + pub page: UiPage, + /// API client signal + pub client: Signal, +} + +/// Main component for rendering a plugin page. +/// +/// Fetches all declared data sources and then renders the page layout using +/// the resolved data. +#[component] +pub fn PluginViewRenderer(props: PluginViewProps) -> Element { + let page = props.page.clone(); + let data_sources = page.data_sources.clone(); + let page_data = use_plugin_data(props.client, data_sources); + + rsx! { + div { + class: "plugin-page", + "data-plugin-id": props.plugin_id.clone(), + h2 { class: "plugin-page-title", "{page.title}" } + { render_element(&page.root_element, &page_data.read()) } + } + } +} + +/// Render a single [`UiElement`] with the provided data context. +fn render_element(element: &UiElement, data: &PluginPageData) -> Element { + match element { + // Layout containers + UiElement::Container { + children, + gap, + padding, + } => { + let padding_css = padding.map_or_else( + || "0".to_string(), + |p| format!("{}px {}px {}px {}px", p[0], p[1], p[2], p[3]), + ); + let style = format!( + "display:flex;flex-direction:column;gap:{gap}px;padding:{padding_css};" + ); + rsx! { + div { + class: "plugin-container", + style: "{style}", + for child in children { + { render_element(child, data) } + } + } + } + }, + + UiElement::Grid { + children, + columns, + gap, + } => { + let style = format!( + "display:grid;grid-template-columns:repeat({columns},1fr);gap:{gap}px;" + ); + rsx! { + div { + class: "plugin-grid", + style: "{style}", + for child in children { + { render_element(child, data) } + } + } + } + }, + + UiElement::Flex { + children, + direction, + justify, + align, + gap, + wrap, + } => { + let dir = flex_direction_css(direction); + let jc = justify_content_css(justify); + let ai = align_items_css(align); + let fw = if *wrap { "wrap" } else { "nowrap" }; + let style = format!( + "display:flex;flex-direction:{dir};justify-content:{jc};align-items:\ + {ai};flex-wrap:{fw};gap:{gap}px;" + ); + rsx! { + div { + class: "plugin-flex", + style: "{style}", + for child in children { + { render_element(child, data) } + } + } + } + }, + + UiElement::Split { + sidebar, + sidebar_width, + main, + } => { + rsx! { + div { + class: "plugin-split", + style: "display:flex;", + aside { + class: "plugin-split-sidebar", + style: "width:{sidebar_width}px;flex-shrink:0;", + { render_element(sidebar, data) } + } + main { + class: "plugin-split-main", + style: "flex:1;min-width:0;", + { render_element(main, data) } + } + } + } + }, + + UiElement::Tabs { tabs, default_tab } => { + rsx! { + div { + class: "plugin-tabs", + div { + class: "plugin-tab-list", + role: "tablist", + for (idx, tab) in tabs.iter().enumerate() { + button { + class: if idx == *default_tab { "plugin-tab active" } else { "plugin-tab" }, + role: "tab", + aria_selected: if idx == *default_tab { "true" } else { "false" }, + if let Some(icon) = &tab.icon { + span { class: "tab-icon", "{icon}" } + } + "{tab.label}" + } + } + } + div { + class: "plugin-tab-panels", + for (idx, tab) in tabs.iter().enumerate() { + div { + class: if idx == *default_tab { "plugin-tab-panel active" } else { "plugin-tab-panel" }, + hidden: idx != *default_tab, + { render_element(&tab.content, data) } + } + } + } + } + } + }, + + // Typography + UiElement::Heading { level, content, id } => { + let text = resolve_text_content(content, &serde_json::json!({})); + let class = format!("plugin-heading level-{level}"); + let anchor = id.clone().unwrap_or_default(); + match level.min(&6) { + 1 => rsx! { h1 { class: "{class}", id: "{anchor}", "{text}" } }, + 2 => rsx! { h2 { class: "{class}", id: "{anchor}", "{text}" } }, + 3 => rsx! { h3 { class: "{class}", id: "{anchor}", "{text}" } }, + 4 => rsx! { h4 { class: "{class}", id: "{anchor}", "{text}" } }, + 5 => rsx! { h5 { class: "{class}", id: "{anchor}", "{text}" } }, + _ => rsx! { h6 { class: "{class}", id: "{anchor}", "{text}" } }, + } + }, + + UiElement::Text { + content, + variant, + allow_html, + } => { + let text = resolve_text_content(content, &serde_json::json!({})); + let variant_class = text_variant_class(variant); + if *allow_html { + let sanitized = ammonia::clean(&text); + rsx! { + p { + class: "plugin-text {variant_class}", + dangerous_inner_html: "{sanitized}", + } + } + } else { + rsx! { + p { + class: "plugin-text {variant_class}", + "{text}" + } + } + } + }, + + UiElement::Code { + content, + language, + show_line_numbers, + } => { + let lang = language.as_deref().unwrap_or("plaintext"); + let line_class = if *show_line_numbers { + "show-line-numbers" + } else { + "" + }; + rsx! { + pre { + class: "plugin-code {line_class}", + "data-language": "{lang}", + code { "{content}" } + } + } + }, + + // Data display + UiElement::DataTable { + columns, + data: source_key, + sortable, + filterable: _, + page_size: _, + row_actions, + } => { + let rows = data.get(source_key); + rsx! { + div { class: "plugin-data-table-wrapper", + if data.is_loading(source_key) { + div { class: "plugin-loading", "Loading…" } + } else if let Some(err) = data.error(source_key) { + div { class: "plugin-error", "Error: {err}" } + } else { + table { + class: "plugin-data-table", + "data-sortable": if *sortable { "true" } else { "false" }, + thead { + tr { + for col in columns { + th { + style: col.width.as_ref().map(|w| format!("width:{w};")).unwrap_or_default(), + "{col.header}" + } + } + if !row_actions.is_empty() { + th { "Actions" } + } + } + } + tbody { + if let Some(arr) = rows.and_then(|v| v.as_array()) { + for row in arr { + tr { + for col in columns { + td { "{extract_cell(row, &col.key)}" } + } + if !row_actions.is_empty() { + td { + class: "row-actions", + for act in row_actions { + { + let action = act.action.clone(); + let row_data = row.clone(); + let variant_class = button_variant_class(&act.variant); + rsx! { + button { + class: "plugin-button {variant_class}", + onclick: move |_| { + let a = action.clone(); + let fd = row_data.clone(); + let client = ApiClient::default(); + spawn(async move { + let _ = execute_action(&client, &a, Some(&fd)).await; + }); + }, + "{act.label}" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + + UiElement::Card { + title, + content, + footer, + } => { + rsx! { + div { + class: "plugin-card", + if let Some(t) = title { + div { class: "plugin-card-header", "{t}" } + } + div { + class: "plugin-card-content", + for child in content { + { render_element(child, data) } + } + } + if !footer.is_empty() { + div { + class: "plugin-card-footer", + for child in footer { + { render_element(child, data) } + } + } + } + } + } + }, + + UiElement::MediaGrid { + data: source_key, + columns, + gap, + } => { + let items = data.get(source_key); + let style = format!( + "display:grid;grid-template-columns:repeat({columns},1fr);gap:{gap}px;" + ); + rsx! { + div { class: "plugin-media-grid", style: "{style}", + if data.is_loading(source_key) { + div { class: "plugin-loading", "Loading…" } + } else if let Some(err) = data.error(source_key) { + div { class: "plugin-error", "Error: {err}" } + } else if let Some(arr) = items.and_then(|v| v.as_array()) { + for item in arr { + div { + class: "media-grid-item", + "{extract_cell(item, \"thumbnail\")}" + } + } + } + } + } + }, + + UiElement::List { + data: source_key, + item_template, + dividers, + } => { + let items = data.get(source_key); + rsx! { + div { class: "plugin-list-wrapper", + if data.is_loading(source_key) { + div { class: "plugin-loading", "Loading…" } + } else if let Some(err) = data.error(source_key) { + div { class: "plugin-error", "Error: {err}" } + } else if let Some(arr) = items.and_then(|v| v.as_array()) { + if arr.is_empty() { + div { class: "plugin-list-empty", "No items" } + } else { + ul { class: "plugin-list", + for _item in arr { + li { + class: "plugin-list-item", + { render_element(item_template, data) } + if *dividers { + hr { class: "plugin-list-divider" } + } + } + } + } + } + } + } + } + }, + + UiElement::DescriptionList { + data: source_key, + horizontal, + } => { + let resolved = data.get(source_key); + let class = if *horizontal { + "plugin-description-list horizontal" + } else { + "plugin-description-list" + }; + rsx! { + div { class: "plugin-description-list-wrapper", + if data.is_loading(source_key) { + div { class: "plugin-loading", "Loading…" } + } else if let Some(err) = data.error(source_key) { + div { class: "plugin-error", "Error: {err}" } + } else if let Some(obj) = resolved.and_then(|v| v.as_object()) { + dl { class: "{class}", + for (key, val) in obj { + dt { "{key}" } + dd { "{val}" } + } + } + } + } + } + }, + + // Interactive + UiElement::Button { + label, + variant, + action, + disabled, + } => { + let variant_class = button_variant_class(variant); + let action_ref = action.clone(); + rsx! { + button { + class: "plugin-button {variant_class}", + disabled: *disabled, + onclick: move |_| { + let a = action_ref.clone(); + let client = ApiClient::default(); + spawn(async move { + let _ = execute_action(&client, &a, None).await; + }); + }, + "{label}" + } + } + }, + + UiElement::Form { + fields, + submit_label, + submit_action, + cancel_label, + } => { + let action_ref = submit_action.clone(); + rsx! { + form { + class: "plugin-form", + onsubmit: move |event| { + event.prevent_default(); + let a = action_ref.clone(); + let form_values: serde_json::Value = { + use dioxus::html::FormValue; + let vals = event.data().values(); + let map: serde_json::Map = vals + .into_iter() + .map(|(k, v)| { + let s = match v { + FormValue::Text(s) => s, + FormValue::File(_) => String::new(), + }; + (k, serde_json::Value::String(s)) + }) + .collect(); + serde_json::Value::Object(map) + }; + let client = ApiClient::default(); + spawn(async move { + let _ = execute_action(&client, &a, Some(&form_values)).await; + }); + }, + for field in fields { + div { + class: "form-field", + label { + r#for: "{field.id}", + "{field.label}" + if field.required { + span { class: "required", " *" } + } + } + { render_form_field(field) } + if let Some(help) = &field.help_text { + p { class: "form-help", "{help}" } + } + } + } + div { class: "form-actions", + button { + r#type: "submit", + class: "plugin-button btn-primary", + "{submit_label}" + } + if let Some(cancel) = cancel_label { + button { + r#type: "button", + class: "plugin-button btn-secondary", + "{cancel}" + } + } + } + } + } + }, + + UiElement::Link { + text, + href, + external, + } => { + let target = if *external { "_blank" } else { "_self" }; + let rel = if *external { "noopener noreferrer" } else { "" }; + rsx! { + a { + class: "plugin-link", + href: "{href}", + target: "{target}", + rel: "{rel}", + "{text}" + } + } + }, + + UiElement::Progress { + value, + max, + show_percentage, + } => { + let pct = evaluate_expression_as_f64(value); + let fraction = (pct / max).clamp(0.0, 1.0); + let pct_int = (fraction * 100.0).round() as u32; + rsx! { + div { class: "plugin-progress", + div { + class: "plugin-progress-bar", + role: "progressbar", + aria_valuenow: "{pct_int}", + aria_valuemin: "0", + aria_valuemax: "100", + style: "width:{pct_int}%;", + } + if *show_percentage { + span { class: "plugin-progress-label", "{pct_int}%" } + } + } + } + }, + + UiElement::Badge { text, variant } => { + let variant_class = badge_variant_class(variant); + rsx! { + span { + class: "plugin-badge {variant_class}", + "{text}" + } + } + }, + + // Visualization + UiElement::Chart { + chart_type, + data: source_key, + title, + x_axis_label, + y_axis_label, + height, + } => { + let chart_class = chart_type_class(chart_type); + rsx! { + div { + class: "plugin-chart {chart_class}", + style: "height:{height}px;", + if data.is_loading(source_key) { + div { class: "plugin-loading", "Loading…" } + } else if let Some(err) = data.error(source_key) { + div { class: "plugin-error", "Error: {err}" } + } else { + if let Some(t) = title { div { class: "chart-title", "{t}" } } + if let Some(x) = x_axis_label { div { class: "chart-x-label", "{x}" } } + if let Some(y) = y_axis_label { div { class: "chart-y-label", "{y}" } } + div { class: "chart-canvas", "Chart rendering requires JavaScript" } + } + } + } + }, + + // Conditional & loop + UiElement::Conditional { + condition, + then, + else_element, + } => { + let ctx = serde_json::json!({}); + if evaluate_expression_as_bool(condition, &ctx) { + render_element(then, data) + } else if let Some(else_el) = else_element { + render_element(else_el, data) + } else { + rsx! {} + } + }, + + UiElement::Loop { + data: source_key, + template, + empty, + } => { + let items = data.get(source_key); + if data.is_loading(source_key) { + return rsx! { div { class: "plugin-loading", "Loading…" } }; + } + if let Some(err) = data.error(source_key) { + return rsx! { div { class: "plugin-error", "Error: {err}" } }; + } + if let Some(arr) = items.and_then(|v| v.as_array()) { + if arr.is_empty() { + if let Some(empty_el) = empty { + return render_element(empty_el, data); + } + return rsx! {}; + } + rsx! { + for _item in arr { + { render_element(template, data) } + } + } + } else { + rsx! {} + } + }, + } +} + +// Form field helper + +fn render_form_field(field: &pinakes_plugin_api::FormField) -> Element { + match &field.field_type { + FieldType::Text { .. } => { + rsx! { + input { + r#type: "text", + id: "{field.id}", + name: "{field.id}", + placeholder: field.placeholder.as_deref().unwrap_or(""), + required: field.required, + } + } + }, + FieldType::Textarea { rows } => { + rsx! { + textarea { + id: "{field.id}", + name: "{field.id}", + rows: *rows, + placeholder: field.placeholder.as_deref().unwrap_or(""), + required: field.required, + } + } + }, + FieldType::Number { min, max, step } => { + rsx! { + input { + r#type: "number", + id: "{field.id}", + name: "{field.id}", + min: min.map(|m| m.to_string()), + max: max.map(|m| m.to_string()), + step: step.map(|s| s.to_string()), + required: field.required, + } + } + }, + FieldType::Email => { + rsx! { + input { + r#type: "email", + id: "{field.id}", + name: "{field.id}", + placeholder: field.placeholder.as_deref().unwrap_or(""), + required: field.required, + } + } + }, + FieldType::Url => { + rsx! { + input { + r#type: "url", + id: "{field.id}", + name: "{field.id}", + placeholder: field.placeholder.as_deref().unwrap_or(""), + required: field.required, + } + } + }, + FieldType::Switch | FieldType::Checkbox { .. } => { + rsx! { + input { + r#type: "checkbox", + id: "{field.id}", + name: "{field.id}", + required: field.required, + } + } + }, + FieldType::Select { options, multiple } => { + rsx! { + select { + id: "{field.id}", + name: "{field.id}", + multiple: *multiple, + required: field.required, + option { value: "", disabled: true, "Select…" } + for opt in options { + option { value: "{opt.value}", "{opt.label}" } + } + } + } + }, + FieldType::Radio { options } => { + rsx! { + fieldset { + id: "{field.id}", + for opt in options { + label { + input { + r#type: "radio", + name: "{field.id}", + value: "{opt.value}", + required: field.required, + } + " {opt.label}" + } + } + } + } + }, + FieldType::Date => { + rsx! { + input { + r#type: "date", + id: "{field.id}", + name: "{field.id}", + required: field.required, + } + } + }, + FieldType::DateTime => { + rsx! { + input { + r#type: "datetime-local", + id: "{field.id}", + name: "{field.id}", + required: field.required, + } + } + }, + FieldType::File { + accept, multiple, .. + } => { + rsx! { + input { + r#type: "file", + id: "{field.id}", + name: "{field.id}", + accept: accept.as_ref().map(|a| a.join(",")).unwrap_or_default(), + multiple: *multiple, + required: field.required, + } + } + }, + } +} + +fn flex_direction_css(d: &FlexDirection) -> &'static str { + match d { + FlexDirection::Row => "row", + FlexDirection::Column => "column", + } +} + +fn justify_content_css(j: &JustifyContent) -> &'static str { + match j { + JustifyContent::FlexStart => "flex-start", + JustifyContent::FlexEnd => "flex-end", + JustifyContent::Center => "center", + JustifyContent::SpaceBetween => "space-between", + JustifyContent::SpaceAround => "space-around", + JustifyContent::SpaceEvenly => "space-evenly", + } +} + +fn align_items_css(a: &AlignItems) -> &'static str { + match a { + AlignItems::FlexStart => "flex-start", + AlignItems::FlexEnd => "flex-end", + AlignItems::Center => "center", + AlignItems::Stretch => "stretch", + AlignItems::Baseline => "baseline", + } +} + +fn button_variant_class(v: &ButtonVariant) -> &'static str { + match v { + ButtonVariant::Primary => "btn-primary", + ButtonVariant::Secondary => "btn-secondary", + ButtonVariant::Tertiary => "btn-tertiary", + ButtonVariant::Danger => "btn-danger", + ButtonVariant::Success => "btn-success", + ButtonVariant::Ghost => "btn-ghost", + } +} + +fn badge_variant_class(v: &BadgeVariant) -> &'static str { + match v { + BadgeVariant::Default => "badge-default", + BadgeVariant::Primary => "badge-primary", + BadgeVariant::Secondary => "badge-secondary", + BadgeVariant::Success => "badge-success", + BadgeVariant::Warning => "badge-warning", + BadgeVariant::Error => "badge-error", + BadgeVariant::Info => "badge-info", + } +} + +fn chart_type_class(t: &ChartType) -> &'static str { + match t { + ChartType::Bar => "chart-bar", + ChartType::Line => "chart-line", + ChartType::Pie => "chart-pie", + ChartType::Area => "chart-area", + ChartType::Scatter => "chart-scatter", + } +} + +fn text_variant_class(v: &TextVariant) -> &'static str { + match v { + TextVariant::Body => "", + TextVariant::Secondary => "text-secondary", + TextVariant::Success => "text-success", + TextVariant::Warning => "text-warning", + TextVariant::Error => "text-error", + TextVariant::Bold => "text-bold", + TextVariant::Italic => "text-italic", + TextVariant::Small => "text-small", + TextVariant::Large => "text-large", + } +} + +fn resolve_text_content( + content: &TextContent, + ctx: &serde_json::Value, +) -> String { + match content { + TextContent::Static(s) => s.clone(), + TextContent::Expression(expr) => evaluate_expression(expr, ctx).to_string(), + TextContent::Empty => String::new(), + } +} + +fn evaluate_expression( + expr: &Expression, + ctx: &serde_json::Value, +) -> serde_json::Value { + match expr { + Expression::Literal(v) => v.clone(), + Expression::Path(path) => { + let mut current = ctx; + for key in path.split('.') { + match current { + serde_json::Value::Object(map) => { + if let Some(next) = map.get(key) { + current = next; + } else { + return serde_json::Value::Null; + } + }, + serde_json::Value::Array(arr) => { + if let Ok(idx) = key.parse::() { + if let Some(item) = arr.get(idx) { + current = item; + } else { + return serde_json::Value::Null; + } + } else { + return serde_json::Value::Null; + } + }, + _ => return serde_json::Value::Null, + } + } + current.clone() + }, + Expression::Operation { left, op, right } => { + use pinakes_plugin_api::Operator; + let l = evaluate_expression(left, ctx); + let r = evaluate_expression(right, ctx); + match op { + Operator::Eq => serde_json::Value::Bool(l == r), + Operator::Ne => serde_json::Value::Bool(l != r), + Operator::And => { + serde_json::Value::Bool( + l.as_bool().unwrap_or(false) && r.as_bool().unwrap_or(false), + ) + }, + Operator::Or => { + serde_json::Value::Bool( + l.as_bool().unwrap_or(false) || r.as_bool().unwrap_or(false), + ) + }, + Operator::Gt => { + let lf = l.as_f64().unwrap_or(0.0); + let rf = r.as_f64().unwrap_or(0.0); + serde_json::Value::Bool(lf > rf) + }, + Operator::Gte => { + let lf = l.as_f64().unwrap_or(0.0); + let rf = r.as_f64().unwrap_or(0.0); + serde_json::Value::Bool(lf >= rf) + }, + Operator::Lt => { + let lf = l.as_f64().unwrap_or(0.0); + let rf = r.as_f64().unwrap_or(0.0); + serde_json::Value::Bool(lf < rf) + }, + Operator::Lte => { + let lf = l.as_f64().unwrap_or(0.0); + let rf = r.as_f64().unwrap_or(0.0); + serde_json::Value::Bool(lf <= rf) + }, + _ => serde_json::Value::Null, + } + }, + Expression::Call { .. } => serde_json::Value::Null, + } +} + +fn evaluate_expression_as_bool( + expr: &Expression, + ctx: &serde_json::Value, +) -> bool { + evaluate_expression(expr, ctx).as_bool().unwrap_or(false) +} + +fn evaluate_expression_as_f64(expr: &Expression) -> f64 { + evaluate_expression(expr, &serde_json::json!({})) + .as_f64() + .unwrap_or(0.0) +} + +fn extract_cell(row: &serde_json::Value, key: &str) -> String { + row + .as_object() + .and_then(|obj| obj.get(key)) + .map(|v| { + match v { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Null => String::new(), + other => other.to_string(), + } + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use pinakes_plugin_api::Expression; + + use super::*; + + #[test] + fn test_evaluate_expression_literal() { + let expr = Expression::Literal(serde_json::json!("hello")); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result, serde_json::json!("hello")); + } + + #[test] + fn test_evaluate_expression_path() { + let expr = Expression::Path("foo.bar".to_string()); + let ctx = serde_json::json!({ "foo": { "bar": 42 } }); + let result = evaluate_expression(&expr, &ctx); + assert_eq!(result, serde_json::json!(42)); + } + + #[test] + fn test_extract_cell_string() { + let row = serde_json::json!({ "name": "Alice", "count": 5 }); + assert_eq!(extract_cell(&row, "name"), "Alice"); + assert_eq!(extract_cell(&row, "count"), "5"); + assert_eq!(extract_cell(&row, "missing"), ""); + } + + #[test] + fn test_resolve_text_content_static() { + let tc = TextContent::Static("Hello".to_string()); + assert_eq!(resolve_text_content(&tc, &serde_json::json!({})), "Hello"); + } + + #[test] + fn test_resolve_text_content_empty() { + let tc = TextContent::Empty; + assert_eq!(resolve_text_content(&tc, &serde_json::json!({})), ""); + } +}