//! 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 std::collections::HashMap; use dioxus::prelude::*; use pinakes_plugin_api::{ ActionDefinition, ActionRef, AlignItems, BadgeVariant, ButtonVariant, ChartType, FieldType, FlexDirection, JustifyContent, TabDefinition, TextContent, TextVariant, UiElement, UiPage, }; use super::{ actions::execute_action, data::{PluginPageData, use_plugin_data}, expr::{ evaluate_expression, evaluate_expression_as_bool, evaluate_expression_as_f64, value_to_display_string, }, }; use crate::client::ApiClient; /// Mutable signals threaded through the element tree. /// /// All fields are `Signal` (which is `Copy`), so `RenderContext` is `Copy`. /// `Eq` is not derived because `Signal>` cannot implement it /// (`UiElement` contains `f64` fields). #[derive(Clone, Copy, PartialEq)] #[allow(clippy::derive_partial_eq_without_eq)] pub struct RenderContext { pub client: Signal, pub feedback: Signal>, pub navigate: Signal>, pub refresh: Signal, pub modal: Signal>, pub local_state: Signal>, } /// Build the expression evaluation context from page data and local state. fn build_ctx( data: &PluginPageData, local_state: &HashMap, ) -> serde_json::Value { let mut base = data.as_json(); if let serde_json::Value::Object(ref mut obj) = base { obj.insert( "local".to_string(), serde_json::Value::Object( local_state .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(), ), ); } base } /// 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, /// Called when a plugin action requests navigation to a route pub on_navigate: EventHandler, /// Endpoint paths this plugin is allowed to fetch (empty means no /// restriction) pub allowed_endpoints: Vec, } /// 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 actions = page.actions.clone(); let mut feedback = use_signal(|| None::<(String, bool)>); let mut navigate = use_signal(|| None::); let refresh = use_signal(|| 0u32); let mut modal = use_signal(|| None::); let local_state = use_signal(HashMap::::new); let ctx = RenderContext { client: props.client, feedback, navigate, refresh, modal, local_state, }; let page_data = use_plugin_data( props.client, data_sources, refresh, props.allowed_endpoints, ); // Consume pending navigation requests and forward to the parent use_effect(move || { let pending = navigate.read().clone(); if let Some(route) = pending { props.on_navigate.call(route); navigate.set(None); } }); rsx! { div { class: "plugin-page", "data-plugin-id": props.plugin_id, h2 { class: "plugin-page-title", "{page.title}" } { render_element(&page.root_element, &page_data.read(), &actions, ctx) } if let Some((msg, is_error)) = feedback.read().as_ref().cloned() { div { class: if is_error { "plugin-feedback error" } else { "plugin-feedback success" }, "{msg}" button { class: "plugin-feedback-dismiss", onclick: move |_| feedback.set(None), "×" } } } if let Some(elem) = modal.read().as_ref().cloned() { div { class: "plugin-modal-overlay", onclick: move |_| modal.set(None), div { class: "plugin-modal", onclick: |e| e.stop_propagation(), button { class: "plugin-modal-close", onclick: move |_| modal.set(None), "×" } { render_element(&elem, &page_data.read(), &actions, ctx) } } } } } } } /// Props for the interactive [`PluginTabs`] component. #[derive(Props, PartialEq, Clone)] struct PluginTabsProps { tabs: Vec, default_tab: usize, data: PluginPageData, actions: HashMap, ctx: RenderContext, } /// Renders a tabbed interface with interactive tab switching. #[component] fn PluginTabs(props: PluginTabsProps) -> Element { let mut active = use_signal(|| props.default_tab); let active_idx = *active.read(); rsx! { div { class: "plugin-tabs", div { class: "plugin-tab-list", role: "tablist", for (idx, tab) in props.tabs.iter().enumerate() { { let tab_label = tab.label.clone(); let tab_icon = tab.icon.clone(); let is_active = idx == active_idx; rsx! { button { class: if is_active { "plugin-tab active" } else { "plugin-tab" }, role: "tab", aria_selected: if is_active { "true" } else { "false" }, onclick: move |_| active.set(idx), if let Some(icon) = tab_icon { span { class: "tab-icon", "{icon}" } } "{tab_label}" } } } } } div { class: "plugin-tab-panels", for (idx, tab) in props.tabs.iter().enumerate() { { let is_active = idx == active_idx; let content = tab.content.clone(); let actions = props.actions.clone(); rsx! { div { class: if is_active { "plugin-tab-panel active" } else { "plugin-tab-panel" }, hidden: !is_active, { render_element(&content, &props.data, &actions, props.ctx) } } } } } } } } } /// Props for the stateful [`PluginDataTable`] component. #[derive(Props, PartialEq, Clone)] struct PluginDataTableProps { columns: Vec, source_key: String, sortable: bool, filterable: bool, page_size: usize, row_actions: Vec, data: PluginPageData, actions: HashMap, ctx: RenderContext, } /// Stateful data table with optional client-side filtering and pagination. #[component] fn PluginDataTable(props: PluginDataTableProps) -> Element { let mut filter_text = use_signal(String::new); let mut current_page = use_signal(|| 0usize); let all_rows = props .data .get(&props.source_key) .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); let filter = filter_text.read().to_lowercase(); let filtered: Vec = if filter.is_empty() { all_rows } else { all_rows .into_iter() .filter(|row| { props.columns.iter().any(|col| { extract_cell(row, &col.key).to_lowercase().contains(&filter) }) }) .collect() }; let total = filtered.len(); let (page_rows, total_pages) = if props.page_size > 0 && total > 0 { let total_pages = total.div_ceil(props.page_size); let page = (*current_page.read()).min(total_pages.saturating_sub(1)); let start = page * props.page_size; let end = (start + props.page_size).min(total); (filtered[start..end].to_vec(), total_pages) } else { (filtered, 1usize) }; let page = *current_page.read(); rsx! { div { class: "plugin-data-table-wrapper", if props.data.is_loading(&props.source_key) { div { class: "plugin-loading", "Loading…" } } else if let Some(err) = props.data.error(&props.source_key) { div { class: "plugin-error", "Error: {err}" } } else { if props.filterable { div { class: "table-filter", input { r#type: "text", placeholder: "Filter…", value: "{filter_text}", oninput: move |e| { filter_text.set(e.value()); current_page.set(0); }, } } } table { class: "plugin-data-table", "data-sortable": if props.sortable { "true" } else { "false" }, thead { tr { {props.columns.iter().map(|col| { let col_width = col.width.as_deref().and_then(safe_col_width_css); rsx! { th { style: col_width.as_deref().map(|v| format!("--plugin-col-width:{v};")).unwrap_or_default(), class: if col_width.is_some() { "plugin-col-constrained" } else { "" }, "{col.header}" } } })} if !props.row_actions.is_empty() { th { "Actions" } } } } tbody { for row in page_rows { { let row_val = row; rsx! { tr { for col in &props.columns { td { "{extract_cell(&row_val, &col.key)}" } } if !props.row_actions.is_empty() { td { class: "row-actions", for act in &props.row_actions { { let action = act.action.clone(); let row_data = row_val.clone(); let variant_class = button_variant_class(&act.variant); let page_actions = props.actions.clone(); let (success_msg, error_msg): ( Option, Option, ) = match &act.action { ActionRef::Special(_) => (None, None), ActionRef::Name(name) => props .actions .get(name) .map_or((None, None), |a| { ( a.success_message.clone(), a.error_message.clone(), ) }), ActionRef::Inline(a) => ( a.success_message.clone(), a.error_message.clone(), ), }; let ctx = props.ctx; // Pre-compute data JSON at render time to // avoid moving props.data into closures. let data_json = props.data.as_json(); rsx! { button { class: "plugin-button {variant_class}", onclick: move |_| { let a = action.clone(); let fd = row_data.clone(); let c = ctx.client.read().clone(); let pa = page_actions.clone(); let sm = success_msg.clone(); let em = error_msg.clone(); // Combine pre-rendered data JSON // with current local_state. let mut data_snapshot = data_json.clone(); if let serde_json::Value::Object( ref mut m, ) = data_snapshot { m.insert( "local".to_string(), serde_json::Value::Object( ctx.local_state.read().iter().map(|(k, v)| (k.clone(), v.clone())).collect(), ), ); } spawn(async move { let mut ctx = ctx; match execute_action( &c, &a, &pa, Some(&fd), ) .await { Ok(super::actions::ActionResult::Success(body)) => { tracing::debug!(response = ?body, "plugin action succeeded"); if let Some(msg) = sm { ctx.feedback.set(Some((msg, false))); } }, Ok(super::actions::ActionResult::Error(msg)) => { ctx.feedback.set(Some(( em.unwrap_or(msg), true, ))); }, Ok(super::actions::ActionResult::Navigate(route)) => { ctx.navigate.set(Some(route)); }, Ok(super::actions::ActionResult::None) => {}, Ok(super::actions::ActionResult::Refresh) => { *ctx.refresh.write() += 1; }, Ok(super::actions::ActionResult::UpdateState { key, value_expr }) => { let evaluated = evaluate_expression(&value_expr, &data_snapshot); ctx.local_state.write().insert(key, evaluated); }, Ok(super::actions::ActionResult::OpenModal(element)) => { ctx.modal.set(Some(element)); }, Ok(super::actions::ActionResult::CloseModal) => { ctx.modal.set(None); }, Err(e) => { ctx.feedback.set(Some((e, true))); }, } }); }, "{act.label}" } } } } } } } } } } } } if props.page_size > 0 && total_pages > 1 { div { class: "table-pagination", button { class: "plugin-button", disabled: page == 0, onclick: move |_| { let p = *current_page.read(); if p > 0 { current_page.set(p - 1); } }, "←" } span { "Page {page + 1} of {total_pages} ({total} items)" } button { class: "plugin-button", disabled: page + 1 >= total_pages, onclick: move |_| { let p = *current_page.read(); current_page.set(p + 1); }, "→" } } } } } } } /// Render a single [`UiElement`] with the provided data context. pub fn render_element( element: &UiElement, data: &PluginPageData, actions: &HashMap, ctx: RenderContext, ) -> 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!("--plugin-gap:{gap}px;--plugin-padding:{padding_css};"); rsx! { div { class: "plugin-container", style: "{style}", for child in children { { render_element(child, data, actions, ctx) } } } } }, UiElement::Grid { children, columns, gap, } => { let style = format!("--plugin-columns:{columns};--plugin-gap:{gap}px;"); rsx! { div { class: "plugin-grid", style: "{style}", for child in children { { render_element(child, data, actions, ctx) } } } } }, 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!("--plugin-gap:{gap}px;"); rsx! { div { class: "plugin-flex", style: "{style}", "data-direction": "{dir}", "data-justify": "{jc}", "data-align": "{ai}", "data-wrap": "{fw}", for child in children { { render_element(child, data, actions, ctx) } } } } }, UiElement::Split { sidebar, sidebar_width, main, } => { rsx! { div { class: "plugin-split", aside { class: "plugin-split-sidebar", style: "--plugin-sidebar-width:{sidebar_width}px;", { render_element(sidebar, data, actions, ctx) } } main { class: "plugin-split-main", { render_element(main, data, actions, ctx) } } } } }, UiElement::Tabs { tabs, default_tab } => { rsx! { PluginTabs { tabs: tabs.clone(), default_tab: *default_tab, data: data.clone(), actions: actions.clone(), ctx, } } }, // Typography UiElement::Heading { level, content, id } => { let eval_ctx = data.as_json(); let text = resolve_text_content(content, &eval_ctx); 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 eval_ctx = data.as_json(); let text = resolve_text_content(content, &eval_ctx); 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, } => { rsx! { PluginDataTable { columns: columns.clone(), source_key: source_key.clone(), sortable: *sortable, filterable: *filterable, page_size: *page_size, row_actions: row_actions.clone(), data: data.clone(), actions: actions.clone(), ctx, } } }, 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, actions, ctx) } } } if !footer.is_empty() { div { class: "plugin-card-footer", for child in footer { { render_element(child, data, actions, ctx) } } } } } } }, UiElement::MediaGrid { data: source_key, columns, gap, } => { let items = data.get(source_key); let style = format!("--plugin-columns:{columns};--plugin-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 { { let url_opt = media_grid_image_url(item); let label = media_grid_label(item); rsx! { div { class: "media-grid-item", if let Some(url) = url_opt { if pinakes_plugin_api::ui_schema::is_safe_href(&url) { img { class: "media-grid-img", src: "{url}", alt: "{label}", loading: "lazy", } } else { div { class: "media-grid-no-img", "{label}" } } } else { div { class: "media-grid-no-img", "No image" } } if !label.is_empty() { div { class: "media-grid-caption", "{label}" } } } } } } } } } }, 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 { { let dividers = *dividers; let elements: Vec = arr .iter() .map(|item| { let mut item_data = data.clone(); item_data.set_data("item".to_string(), item.clone()); rsx! { li { class: "plugin-list-item", { render_element(item_template, &item_data, actions, ctx) } if dividers { hr { class: "plugin-list-divider" } } } } }) .collect(); rsx! { ul { class: "plugin-list", for el in elements { {el} } } } } } } } } }, UiElement::DescriptionList { data: source_key, horizontal, } => { let resolved = data.get(source_key); let class = if *horizontal { "plugin-description-list horizontal" } else { "plugin-description-list" }; let pairs: Vec<(String, String)> = resolved .and_then(|v| v.as_object()) .map(|obj| { obj .iter() .map(|(k, v)| (k.clone(), value_to_display_string(v))) .collect() }) .unwrap_or_default(); 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 !pairs.is_empty() { dl { class: "{class}", for (key, val) in &pairs { dt { "{key}" } dd { "{val}" } } } } } } }, // Interactive UiElement::Button { label, variant, action, disabled, } => { let variant_class = button_variant_class(variant); let action_ref = action.clone(); let page_actions = actions.clone(); let (success_msg, error_msg): (Option, Option) = match action { ActionRef::Special(_) => (None, None), ActionRef::Name(name) => { actions.get(name).map_or((None, None), |a| { (a.success_message.clone(), a.error_message.clone()) }) }, ActionRef::Inline(a) => { (a.success_message.clone(), a.error_message.clone()) }, }; let data_snapshot = build_ctx(data, &ctx.local_state.read()); rsx! { button { class: "plugin-button {variant_class}", disabled: *disabled, onclick: move |_| { let a = action_ref.clone(); let c = ctx.client.read().clone(); let pa = page_actions.clone(); let success_msg = success_msg.clone(); let error_msg = error_msg.clone(); let data_snapshot = data_snapshot.clone(); spawn(async move { let mut ctx = ctx; match execute_action(&c, &a, &pa, None).await { Ok(super::actions::ActionResult::Success(body)) => { tracing::debug!(response = ?body, "plugin action succeeded"); if let Some(msg) = success_msg { ctx.feedback.set(Some((msg, false))); } }, Ok(super::actions::ActionResult::Error(msg)) => { let display = error_msg.unwrap_or(msg); ctx.feedback.set(Some((display, true))); }, Ok(super::actions::ActionResult::Navigate(route)) => { ctx.navigate.set(Some(route)); }, Ok(super::actions::ActionResult::None) => {}, Ok(super::actions::ActionResult::Refresh) => { *ctx.refresh.write() += 1; }, Ok(super::actions::ActionResult::UpdateState { key, value_expr }) => { let evaluated = evaluate_expression(&value_expr, &data_snapshot); ctx.local_state.write().insert(key, evaluated); }, Ok(super::actions::ActionResult::OpenModal(element)) => { ctx.modal.set(Some(element)); }, Ok(super::actions::ActionResult::CloseModal) => { ctx.modal.set(None); }, Err(e) => { ctx.feedback.set(Some((e, true))); }, } }); }, "{label}" } } }, UiElement::Form { fields, submit_label, submit_action, cancel_label, } => { let action_ref = submit_action.clone(); let page_actions = actions.clone(); let (success_msg, error_msg): (Option, Option) = match submit_action { ActionRef::Special(_) => (None, None), ActionRef::Name(name) => { actions.get(name).map_or((None, None), |a| { (a.success_message.clone(), a.error_message.clone()) }) }, ActionRef::Inline(a) => { (a.success_message.clone(), a.error_message.clone()) }, }; let data_snapshot = build_ctx(data, &ctx.local_state.read()); rsx! { form { class: "plugin-form", onsubmit: move |event| { event.prevent_default(); let a = action_ref.clone(); let pa = page_actions.clone(); let form_values: serde_json::Value = { use dioxus::html::FormValue; let vals = event.data().values(); let map: serde_json::Map = vals .into_iter() .filter_map(|(k, v)| { let s = match v { FormValue::Text(s) => s, FormValue::File(_) => { tracing::warn!( field = %k, "file upload not supported in plugin forms" ); return None; }, }; Some((k, serde_json::Value::String(s))) }) .collect(); serde_json::Value::Object(map) }; let c = ctx.client.read().clone(); let success_msg = success_msg.clone(); let error_msg = error_msg.clone(); let data_snapshot = data_snapshot.clone(); spawn(async move { let mut ctx = ctx; match execute_action(&c, &a, &pa, Some(&form_values)).await { Ok(super::actions::ActionResult::Success(body)) => { tracing::debug!(response = ?body, "plugin action succeeded"); if let Some(msg) = success_msg { ctx.feedback.set(Some((msg, false))); } }, Ok(super::actions::ActionResult::Error(msg)) => { let display = error_msg.unwrap_or(msg); ctx.feedback.set(Some((display, true))); }, Ok(super::actions::ActionResult::Navigate(route)) => { ctx.navigate.set(Some(route)); }, Ok(super::actions::ActionResult::None) => {}, Ok(super::actions::ActionResult::Refresh) => { *ctx.refresh.write() += 1; }, Ok(super::actions::ActionResult::UpdateState { key, value_expr }) => { let evaluated = evaluate_expression(&value_expr, &data_snapshot); ctx.local_state.write().insert(key, evaluated); }, Ok(super::actions::ActionResult::OpenModal(element)) => { ctx.modal.set(Some(element)); }, Ok(super::actions::ActionResult::CloseModal) => { ctx.modal.set(None); }, Err(e) => { ctx.feedback.set(Some((e, true))); }, } }); }, 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: "reset", class: "plugin-button btn-secondary", "{cancel}" } } } } } }, UiElement::Link { text, href, external, } => { if !pinakes_plugin_api::ui_schema::is_safe_href(href) { // Refuse to render unsafe schemes (javascript:, data:, etc.) return rsx! { span { class: "plugin-link-blocked", title: "Blocked: unsafe URL scheme", "{text}" } }; } 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 eval_ctx = data.as_json(); let pct = evaluate_expression_as_f64(value, &eval_ctx); let fraction = if *max > 0.0 { (pct / max).clamp(0.0, 1.0) } else { 0.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: "--plugin-progress:{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); let chart_data = data.get(source_key).cloned(); rsx! { div { class: "plugin-chart {chart_class}", style: "--plugin-chart-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-data-table", { render_chart_data(chart_data.as_ref(), x_axis_label.as_deref().unwrap_or(""), y_axis_label.as_deref().unwrap_or("")) } } } } } }, // Conditional & loop UiElement::Conditional { condition, then, else_element, } => { let eval_ctx = data.as_json(); if evaluate_expression_as_bool(condition, &eval_ctx) { render_element(then, data, actions, ctx) } else if let Some(else_el) = else_element { render_element(else_el, data, actions, ctx) } 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, actions, ctx); } return rsx! {}; } let elements: Vec = arr .iter() .map(|item| { let mut item_data = data.clone(); item_data.set_data("item".to_string(), item.clone()); render_element(template, &item_data, actions, ctx) }) .collect(); rsx! { for el in elements { {el} } } } else { rsx! {} } }, } } // Chart data renderer /// Render chart data as an HTML table (best available without a JS chart /// library). /// /// - Array of objects: table with one column per unique key /// - Array of primitives: two-column table (index, value) /// - Object: two-column key/value table fn render_chart_data( data: Option<&serde_json::Value>, x_label: &str, y_label: &str, ) -> Element { match data { Some(serde_json::Value::Array(arr)) if !arr.is_empty() => { if arr.first().map(|v| v.is_object()).unwrap_or(false) { // Object rows: collect unique keys preserving insertion order let mut seen = std::collections::HashSet::new(); let cols: Vec = arr .iter() .filter_map(|r| r.as_object()) .flat_map(|o| o.keys().cloned()) .filter(|k| seen.insert(k.clone())) .collect(); rsx! { table { class: "plugin-data-table", thead { tr { for c in &cols { th { "{c}" } } } } tbody { for row in arr { tr { for c in &cols { td { "{value_to_display_string(row.get(c).unwrap_or(&serde_json::Value::Null))}" } } } } } } } } else { // Primitive array: index vs value let x = if x_label.is_empty() { "Index" } else { x_label }; let y = if y_label.is_empty() { "Value" } else { y_label }; rsx! { table { class: "plugin-data-table", thead { tr { th { "{x}" } th { "{y}" } } } tbody { for (i, v) in arr.iter().enumerate() { tr { td { "{i}" } td { "{value_to_display_string(v)}" } } } } } } } }, Some(serde_json::Value::Object(map)) if !map.is_empty() => { let x = if x_label.is_empty() { "Key" } else { x_label }; let y = if y_label.is_empty() { "Value" } else { y_label }; rsx! { table { class: "plugin-data-table", thead { tr { th { "{x}" } th { "{y}" } } } tbody { for (k, v) in map.iter() { tr { td { "{k}" } td { "{value_to_display_string(v)}" } } } } } } }, _ => rsx! { div { class: "chart-no-data", "No data available" } }, } } // MediaGrid helpers /// Probe a JSON object for common image URL fields. fn media_grid_image_url(item: &serde_json::Value) -> Option { for key in &[ "thumbnail_url", "thumbnail", "url", "image", "cover", "src", "poster", ] { if let Some(url) = item.get(*key).and_then(|v| v.as_str()) { if !url.is_empty() { return Some(url.to_string()); } } } None } /// Probe a JSON object for a human-readable label. fn media_grid_label(item: &serde_json::Value) -> String { for key in &["title", "name", "label", "caption"] { if let Some(s) = item.get(*key).and_then(|v| v.as_str()) { if !s.is_empty() { return s.to_string(); } } } String::new() } // Form field helper fn render_form_field(field: &pinakes_plugin_api::FormField) -> Element { match &field.field_type { FieldType::Text { .. } => { let default = field .default_value .as_ref() .and_then(|v| v.as_str()) .unwrap_or(""); rsx! { input { r#type: "text", id: "{field.id}", name: "{field.id}", value: "{default}", placeholder: field.placeholder.as_deref().unwrap_or(""), required: field.required, } } }, FieldType::Textarea { rows } => { let default = field .default_value .as_ref() .and_then(|v| v.as_str()) .unwrap_or(""); rsx! { textarea { id: "{field.id}", name: "{field.id}", rows: *rows, placeholder: field.placeholder.as_deref().unwrap_or(""), required: field.required, "{default}" } } }, FieldType::Number { min, max, step } => { let default = field .default_value .as_ref() .and_then(|v| v.as_f64()) .map(|n| n.to_string()) .unwrap_or_default(); rsx! { input { r#type: "number", id: "{field.id}", name: "{field.id}", value: "{default}", 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 => { let default = field .default_value .as_ref() .and_then(|v| v.as_str()) .unwrap_or(""); rsx! { input { r#type: "email", id: "{field.id}", name: "{field.id}", value: "{default}", placeholder: field.placeholder.as_deref().unwrap_or(""), required: field.required, } } }, FieldType::Url => { let default = field .default_value .as_ref() .and_then(|v| v.as_str()) .unwrap_or(""); rsx! { input { r#type: "url", id: "{field.id}", name: "{field.id}", value: "{default}", placeholder: field.placeholder.as_deref().unwrap_or(""), required: field.required, } } }, FieldType::Switch | FieldType::Checkbox { .. } => { let checked = field .default_value .as_ref() .and_then(|v| v.as_bool()) .unwrap_or(false); rsx! { input { r#type: "checkbox", id: "{field.id}", name: "{field.id}", checked, required: field.required, } } }, FieldType::Select { options, multiple } => { let default = field .default_value .as_ref() .and_then(|v| v.as_str()) .unwrap_or(""); rsx! { select { id: "{field.id}", name: "{field.id}", multiple: *multiple, required: field.required, option { value: "", disabled: true, selected: default.is_empty(), "Select…" } for opt in options { option { value: "{opt.value}", selected: opt.value == default, "{opt.label}" } } } } }, FieldType::Radio { options } => { let default = field .default_value .as_ref() .and_then(|v| v.as_str()) .unwrap_or(""); rsx! { fieldset { id: "{field.id}", for opt in options { label { input { r#type: "radio", name: "{field.id}", value: "{opt.value}", checked: opt.value == default, required: field.required, } " {opt.label}" } } } } }, FieldType::Date => { let default = field .default_value .as_ref() .and_then(|v| v.as_str()) .unwrap_or(""); rsx! { input { r#type: "date", id: "{field.id}", name: "{field.id}", value: "{default}", required: field.required, } } }, FieldType::DateTime => { let default = field .default_value .as_ref() .and_then(|v| v.as_str()) .unwrap_or(""); rsx! { input { r#type: "datetime-local", id: "{field.id}", name: "{field.id}", value: "{default}", required: field.required, } } }, FieldType::File { accept, multiple, .. } => { // File inputs cannot carry a default value (browser security // restriction). 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 { use super::expr::evaluate_expression; match content { TextContent::Static(s) => s.clone(), TextContent::Expression(expr) => { value_to_display_string(&evaluate_expression(expr, ctx)) }, TextContent::Empty => String::new(), } } fn extract_cell(row: &serde_json::Value, key: &str) -> String { // Use get_json_path so column keys support dot-notation (e.g. "author.name") super::expr::get_json_path(row, key) .map(value_to_display_string) .unwrap_or_default() } /// Validate and normalize a plugin-supplied column width value. /// Accepts: bare integer (adds px), `{n}px`, `{n}%`, or `"auto"`. /// Rejects anything else to prevent CSS injection. fn safe_col_width_css(w: &str) -> Option { if w == "auto" { return Some("auto".to_string()); } if let Ok(n) = w.parse::() { return Some(format!("{n}px")); } if let Some(num) = w.strip_suffix("px").and_then(|n| n.parse::().ok()) { return Some(format!("{num}px")); } if let Some(num) = w.strip_suffix('%').and_then(|n| n.parse::().ok()) { return Some(format!("{num}%")); } None } #[cfg(test)] mod tests { use pinakes_plugin_api::Expression; use super::*; #[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_extract_cell_bool() { let row = serde_json::json!({ "active": true, "deleted": false }); assert_eq!(extract_cell(&row, "active"), "true"); assert_eq!(extract_cell(&row, "deleted"), "false"); } #[test] fn test_extract_cell_null() { let row = serde_json::json!({ "value": null }); assert_eq!(extract_cell(&row, "value"), ""); } #[test] fn test_extract_cell_nested_object() { let row = serde_json::json!({ "meta": { "x": 1 } }); let result = extract_cell(&row, "meta"); assert!(result.contains("x")); } #[test] fn test_extract_cell_non_object_row() { let row = serde_json::json!("not an object"); assert_eq!(extract_cell(&row, "key"), ""); } #[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!({})), ""); } #[test] fn test_resolve_text_content_expression_string() { // String values are returned raw (no JSON quoting) let tc = TextContent::Expression(Expression::Path("user.name".to_string())); let ctx = serde_json::json!({ "user": { "name": "Bob" } }); assert_eq!(resolve_text_content(&tc, &ctx), "Bob"); } #[test] fn test_resolve_text_content_expression_number() { let tc = TextContent::Expression(Expression::Literal(serde_json::json!(42))); assert_eq!(resolve_text_content(&tc, &serde_json::json!({})), "42"); } #[test] fn test_resolve_text_content_expression_missing_returns_empty() { // Null values resolve to empty string (not "null") let tc = TextContent::Expression(Expression::Path("missing".to_string())); assert_eq!(resolve_text_content(&tc, &serde_json::json!({})), ""); } #[test] fn test_flex_direction_css() { assert_eq!(flex_direction_css(&FlexDirection::Row), "row"); assert_eq!(flex_direction_css(&FlexDirection::Column), "column"); } #[test] fn test_justify_content_css() { assert_eq!( justify_content_css(&JustifyContent::FlexStart), "flex-start" ); assert_eq!(justify_content_css(&JustifyContent::FlexEnd), "flex-end"); assert_eq!(justify_content_css(&JustifyContent::Center), "center"); assert_eq!( justify_content_css(&JustifyContent::SpaceBetween), "space-between" ); assert_eq!( justify_content_css(&JustifyContent::SpaceAround), "space-around" ); assert_eq!( justify_content_css(&JustifyContent::SpaceEvenly), "space-evenly" ); } #[test] fn test_align_items_css() { assert_eq!(align_items_css(&AlignItems::FlexStart), "flex-start"); assert_eq!(align_items_css(&AlignItems::FlexEnd), "flex-end"); assert_eq!(align_items_css(&AlignItems::Center), "center"); assert_eq!(align_items_css(&AlignItems::Stretch), "stretch"); assert_eq!(align_items_css(&AlignItems::Baseline), "baseline"); } #[test] fn test_button_variant_class() { assert_eq!(button_variant_class(&ButtonVariant::Primary), "btn-primary"); assert_eq!( button_variant_class(&ButtonVariant::Secondary), "btn-secondary" ); assert_eq!( button_variant_class(&ButtonVariant::Tertiary), "btn-tertiary" ); assert_eq!(button_variant_class(&ButtonVariant::Danger), "btn-danger"); assert_eq!(button_variant_class(&ButtonVariant::Success), "btn-success"); assert_eq!(button_variant_class(&ButtonVariant::Ghost), "btn-ghost"); } #[test] fn test_badge_variant_class() { assert_eq!(badge_variant_class(&BadgeVariant::Default), "badge-default"); assert_eq!(badge_variant_class(&BadgeVariant::Primary), "badge-primary"); assert_eq!( badge_variant_class(&BadgeVariant::Secondary), "badge-secondary" ); assert_eq!(badge_variant_class(&BadgeVariant::Success), "badge-success"); assert_eq!(badge_variant_class(&BadgeVariant::Warning), "badge-warning"); assert_eq!(badge_variant_class(&BadgeVariant::Error), "badge-error"); assert_eq!(badge_variant_class(&BadgeVariant::Info), "badge-info"); } #[test] fn test_chart_type_class() { assert_eq!(chart_type_class(&ChartType::Bar), "chart-bar"); assert_eq!(chart_type_class(&ChartType::Line), "chart-line"); assert_eq!(chart_type_class(&ChartType::Pie), "chart-pie"); assert_eq!(chart_type_class(&ChartType::Area), "chart-area"); assert_eq!(chart_type_class(&ChartType::Scatter), "chart-scatter"); } #[test] fn test_text_variant_class() { assert_eq!(text_variant_class(&TextVariant::Body), ""); assert_eq!( text_variant_class(&TextVariant::Secondary), "text-secondary" ); assert_eq!(text_variant_class(&TextVariant::Success), "text-success"); assert_eq!(text_variant_class(&TextVariant::Warning), "text-warning"); assert_eq!(text_variant_class(&TextVariant::Error), "text-error"); assert_eq!(text_variant_class(&TextVariant::Bold), "text-bold"); assert_eq!(text_variant_class(&TextVariant::Italic), "text-italic"); assert_eq!(text_variant_class(&TextVariant::Small), "text-small"); assert_eq!(text_variant_class(&TextVariant::Large), "text-large"); } #[test] fn test_safe_col_width_css_auto() { assert_eq!(safe_col_width_css("auto"), Some("auto".to_string())); } #[test] fn test_safe_col_width_css_bare_integer() { assert_eq!(safe_col_width_css("100"), Some("100px".to_string())); assert_eq!(safe_col_width_css("0"), Some("0px".to_string())); } #[test] fn test_safe_col_width_css_px_suffix() { assert_eq!(safe_col_width_css("150px"), Some("150px".to_string())); assert_eq!(safe_col_width_css("0px"), Some("0px".to_string())); } #[test] fn test_safe_col_width_css_percent_suffix() { assert_eq!(safe_col_width_css("20%"), Some("20%".to_string())); assert_eq!(safe_col_width_css("100%"), Some("100%".to_string())); } #[test] fn test_safe_col_width_css_rejects_unsafe() { assert_eq!(safe_col_width_css(""), None); assert_eq!(safe_col_width_css("1em"), None); assert_eq!(safe_col_width_css("1rem"), None); assert_eq!(safe_col_width_css("expression(alert(1))"), None); assert_eq!(safe_col_width_css("-1px"), None); assert_eq!(safe_col_width_css("100px; color: red"), None); } }