pinakes/crates/pinakes-ui/src/plugin_ui/renderer.rs
NotAShelf df1c46fa5c
treewide: cleanup
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ia01590cdeed872cc8ebd16f6ca95f3cc6a6a6964
2026-03-11 21:30:58 +03:00

1802 lines
64 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<T>` (which is `Copy`), so `RenderContext` is `Copy`.
/// `Eq` is not derived because `Signal<Option<UiElement>>` cannot implement it
/// (`UiElement` contains `f64` fields).
#[derive(Clone, Copy, PartialEq)]
#[allow(clippy::derive_partial_eq_without_eq)]
pub struct RenderContext {
pub client: Signal<ApiClient>,
pub feedback: Signal<Option<(String, bool)>>,
pub navigate: Signal<Option<String>>,
pub refresh: Signal<u32>,
pub modal: Signal<Option<UiElement>>,
pub local_state: Signal<HashMap<String, serde_json::Value>>,
}
/// Build the expression evaluation context from page data and local state.
fn build_ctx(
data: &PluginPageData,
local_state: &HashMap<String, serde_json::Value>,
) -> 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<ApiClient>,
/// Called when a plugin action requests navigation to a route
pub on_navigate: EventHandler<String>,
/// Endpoint paths this plugin is allowed to fetch (empty means no
/// restriction)
pub allowed_endpoints: Vec<String>,
}
/// 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::<String>);
let refresh = use_signal(|| 0u32);
let mut modal = use_signal(|| None::<UiElement>);
let local_state = use_signal(HashMap::<String, serde_json::Value>::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<TabDefinition>,
default_tab: usize,
data: PluginPageData,
actions: HashMap<String, ActionDefinition>,
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<pinakes_plugin_api::ColumnDef>,
source_key: String,
sortable: bool,
filterable: bool,
page_size: usize,
row_actions: Vec<pinakes_plugin_api::RowAction>,
data: PluginPageData,
actions: HashMap<String, ActionDefinition>,
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<serde_json::Value> = 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<String>,
Option<String>,
) = 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<String, ActionDefinition>,
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<Element> = 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<String>, Option<String>) =
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<String>, Option<String>) =
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<String, serde_json::Value> = 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<Element> = 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<String> = 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<String> {
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<String> {
if w == "auto" {
return Some("auto".to_string());
}
if let Ok(n) = w.parse::<u32>() {
return Some(format!("{n}px"));
}
if let Some(num) = w.strip_suffix("px").and_then(|n| n.parse::<u32>().ok()) {
return Some(format!("{num}px"));
}
if let Some(num) = w.strip_suffix('%').and_then(|n| n.parse::<u32>().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);
}
}