Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ia01590cdeed872cc8ebd16f6ca95f3cc6a6a6964
1802 lines
64 KiB
Rust
1802 lines
64 KiB
Rust
//! 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);
|
||
}
|
||
}
|