pinakes-ui: rewrite renderer with interactive tabs; correct data context; per-item loop binding
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Iebe4945e1fcb7c28ff74a76e9d0717276a6a6964
This commit is contained in:
parent
188f9a7b8d
commit
de913e54bc
1 changed files with 548 additions and 74 deletions
|
|
@ -14,6 +14,7 @@ use pinakes_plugin_api::{
|
||||||
FieldType,
|
FieldType,
|
||||||
FlexDirection,
|
FlexDirection,
|
||||||
JustifyContent,
|
JustifyContent,
|
||||||
|
TabDefinition,
|
||||||
TextContent,
|
TextContent,
|
||||||
TextVariant,
|
TextVariant,
|
||||||
UiElement,
|
UiElement,
|
||||||
|
|
@ -21,7 +22,7 @@ use pinakes_plugin_api::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
actions::{ActionResult, execute_action},
|
actions::execute_action,
|
||||||
data::{PluginPageData, use_plugin_data},
|
data::{PluginPageData, use_plugin_data},
|
||||||
};
|
};
|
||||||
use crate::client::ApiClient;
|
use crate::client::ApiClient;
|
||||||
|
|
@ -52,13 +53,78 @@ pub fn PluginViewRenderer(props: PluginViewProps) -> Element {
|
||||||
class: "plugin-page",
|
class: "plugin-page",
|
||||||
"data-plugin-id": props.plugin_id.clone(),
|
"data-plugin-id": props.plugin_id.clone(),
|
||||||
h2 { class: "plugin-page-title", "{page.title}" }
|
h2 { class: "plugin-page-title", "{page.title}" }
|
||||||
{ render_element(&page.root_element, &page_data.read()) }
|
{ render_element(&page.root_element, &page_data.read(), props.client) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Props for the interactive [`PluginTabs`] component.
|
||||||
|
#[derive(Props, PartialEq, Clone)]
|
||||||
|
struct PluginTabsProps {
|
||||||
|
tabs: Vec<TabDefinition>,
|
||||||
|
default_tab: usize,
|
||||||
|
data: PluginPageData,
|
||||||
|
client: Signal<ApiClient>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
rsx! {
|
||||||
|
div {
|
||||||
|
class: if is_active {
|
||||||
|
"plugin-tab-panel active"
|
||||||
|
} else {
|
||||||
|
"plugin-tab-panel"
|
||||||
|
},
|
||||||
|
hidden: !is_active,
|
||||||
|
{ render_element(&content, &props.data, props.client) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a single [`UiElement`] with the provided data context.
|
/// Render a single [`UiElement`] with the provided data context.
|
||||||
fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
pub(crate) fn render_element(
|
||||||
|
element: &UiElement,
|
||||||
|
data: &PluginPageData,
|
||||||
|
client: Signal<ApiClient>,
|
||||||
|
) -> Element {
|
||||||
match element {
|
match element {
|
||||||
// Layout containers
|
// Layout containers
|
||||||
UiElement::Container {
|
UiElement::Container {
|
||||||
|
|
@ -78,7 +144,7 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
||||||
class: "plugin-container",
|
class: "plugin-container",
|
||||||
style: "{style}",
|
style: "{style}",
|
||||||
for child in children {
|
for child in children {
|
||||||
{ render_element(child, data) }
|
{ render_element(child, data, client) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -97,7 +163,7 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
||||||
class: "plugin-grid",
|
class: "plugin-grid",
|
||||||
style: "{style}",
|
style: "{style}",
|
||||||
for child in children {
|
for child in children {
|
||||||
{ render_element(child, data) }
|
{ render_element(child, data, client) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -124,7 +190,7 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
||||||
class: "plugin-flex",
|
class: "plugin-flex",
|
||||||
style: "{style}",
|
style: "{style}",
|
||||||
for child in children {
|
for child in children {
|
||||||
{ render_element(child, data) }
|
{ render_element(child, data, client) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -142,12 +208,12 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
||||||
aside {
|
aside {
|
||||||
class: "plugin-split-sidebar",
|
class: "plugin-split-sidebar",
|
||||||
style: "width:{sidebar_width}px;flex-shrink:0;",
|
style: "width:{sidebar_width}px;flex-shrink:0;",
|
||||||
{ render_element(sidebar, data) }
|
{ render_element(sidebar, data, client) }
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
class: "plugin-split-main",
|
class: "plugin-split-main",
|
||||||
style: "flex:1;min-width:0;",
|
style: "flex:1;min-width:0;",
|
||||||
{ render_element(main, data) }
|
{ render_element(main, data, client) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -155,40 +221,19 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
||||||
|
|
||||||
UiElement::Tabs { tabs, default_tab } => {
|
UiElement::Tabs { tabs, default_tab } => {
|
||||||
rsx! {
|
rsx! {
|
||||||
div {
|
PluginTabs {
|
||||||
class: "plugin-tabs",
|
tabs: tabs.clone(),
|
||||||
div {
|
default_tab: *default_tab,
|
||||||
class: "plugin-tab-list",
|
data: data.clone(),
|
||||||
role: "tablist",
|
client,
|
||||||
for (idx, tab) in tabs.iter().enumerate() {
|
|
||||||
button {
|
|
||||||
class: if idx == *default_tab { "plugin-tab active" } else { "plugin-tab" },
|
|
||||||
role: "tab",
|
|
||||||
aria_selected: if idx == *default_tab { "true" } else { "false" },
|
|
||||||
if let Some(icon) = &tab.icon {
|
|
||||||
span { class: "tab-icon", "{icon}" }
|
|
||||||
}
|
|
||||||
"{tab.label}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
class: "plugin-tab-panels",
|
|
||||||
for (idx, tab) in tabs.iter().enumerate() {
|
|
||||||
div {
|
|
||||||
class: if idx == *default_tab { "plugin-tab-panel active" } else { "plugin-tab-panel" },
|
|
||||||
hidden: idx != *default_tab,
|
|
||||||
{ render_element(&tab.content, data) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Typography
|
// Typography
|
||||||
UiElement::Heading { level, content, id } => {
|
UiElement::Heading { level, content, id } => {
|
||||||
let text = resolve_text_content(content, &serde_json::json!({}));
|
let ctx = data.as_json();
|
||||||
|
let text = resolve_text_content(content, &ctx);
|
||||||
let class = format!("plugin-heading level-{level}");
|
let class = format!("plugin-heading level-{level}");
|
||||||
let anchor = id.clone().unwrap_or_default();
|
let anchor = id.clone().unwrap_or_default();
|
||||||
match level.min(&6) {
|
match level.min(&6) {
|
||||||
|
|
@ -206,7 +251,8 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
||||||
variant,
|
variant,
|
||||||
allow_html,
|
allow_html,
|
||||||
} => {
|
} => {
|
||||||
let text = resolve_text_content(content, &serde_json::json!({}));
|
let ctx = data.as_json();
|
||||||
|
let text = resolve_text_content(content, &ctx);
|
||||||
let variant_class = text_variant_class(variant);
|
let variant_class = text_variant_class(variant);
|
||||||
if *allow_html {
|
if *allow_html {
|
||||||
let sanitized = ammonia::clean(&text);
|
let sanitized = ammonia::clean(&text);
|
||||||
|
|
@ -300,9 +346,9 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
let a = action.clone();
|
let a = action.clone();
|
||||||
let fd = row_data.clone();
|
let fd = row_data.clone();
|
||||||
let client = ApiClient::default();
|
let c = client.read().clone();
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
let _ = execute_action(&client, &a, Some(&fd)).await;
|
let _ = execute_action(&c, &a, Some(&fd)).await;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
"{act.label}"
|
"{act.label}"
|
||||||
|
|
@ -336,14 +382,14 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
||||||
div {
|
div {
|
||||||
class: "plugin-card-content",
|
class: "plugin-card-content",
|
||||||
for child in content {
|
for child in content {
|
||||||
{ render_element(child, data) }
|
{ render_element(child, data, client) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !footer.is_empty() {
|
if !footer.is_empty() {
|
||||||
div {
|
div {
|
||||||
class: "plugin-card-footer",
|
class: "plugin-card-footer",
|
||||||
for child in footer {
|
for child in footer {
|
||||||
{ render_element(child, data) }
|
{ render_element(child, data, client) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -394,16 +440,25 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
||||||
if arr.is_empty() {
|
if arr.is_empty() {
|
||||||
div { class: "plugin-list-empty", "No items" }
|
div { class: "plugin-list-empty", "No items" }
|
||||||
} else {
|
} else {
|
||||||
ul { class: "plugin-list",
|
{
|
||||||
for _item in arr {
|
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 {
|
li {
|
||||||
class: "plugin-list-item",
|
class: "plugin-list-item",
|
||||||
{ render_element(item_template, data) }
|
{ render_element(item_template, &item_data, client) }
|
||||||
if *dividers {
|
if dividers {
|
||||||
hr { class: "plugin-list-divider" }
|
hr { class: "plugin-list-divider" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
rsx! { ul { class: "plugin-list", for el in elements { {el} } } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -454,9 +509,9 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
||||||
disabled: *disabled,
|
disabled: *disabled,
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
let a = action_ref.clone();
|
let a = action_ref.clone();
|
||||||
let client = ApiClient::default();
|
let c = client.read().clone();
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
let _ = execute_action(&client, &a, None).await;
|
let _ = execute_action(&c, &a, None).await;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
"{label}"
|
"{label}"
|
||||||
|
|
@ -482,19 +537,25 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
||||||
let vals = event.data().values();
|
let vals = event.data().values();
|
||||||
let map: serde_json::Map<String, serde_json::Value> = vals
|
let map: serde_json::Map<String, serde_json::Value> = vals
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(k, v)| {
|
.filter_map(|(k, v)| {
|
||||||
let s = match v {
|
let s = match v {
|
||||||
FormValue::Text(s) => s,
|
FormValue::Text(s) => s,
|
||||||
FormValue::File(_) => String::new(),
|
FormValue::File(_) => {
|
||||||
|
tracing::warn!(
|
||||||
|
field = %k,
|
||||||
|
"file upload not supported in plugin forms"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
(k, serde_json::Value::String(s))
|
Some((k, serde_json::Value::String(s)))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
serde_json::Value::Object(map)
|
serde_json::Value::Object(map)
|
||||||
};
|
};
|
||||||
let client = ApiClient::default();
|
let c = client.read().clone();
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
let _ = execute_action(&client, &a, Some(&form_values)).await;
|
let _ = execute_action(&c, &a, Some(&form_values)).await;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
for field in fields {
|
for field in fields {
|
||||||
|
|
@ -521,7 +582,7 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
||||||
}
|
}
|
||||||
if let Some(cancel) = cancel_label {
|
if let Some(cancel) = cancel_label {
|
||||||
button {
|
button {
|
||||||
r#type: "button",
|
r#type: "reset",
|
||||||
class: "plugin-button btn-secondary",
|
class: "plugin-button btn-secondary",
|
||||||
"{cancel}"
|
"{cancel}"
|
||||||
}
|
}
|
||||||
|
|
@ -554,7 +615,8 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
||||||
max,
|
max,
|
||||||
show_percentage,
|
show_percentage,
|
||||||
} => {
|
} => {
|
||||||
let pct = evaluate_expression_as_f64(value);
|
let ctx = data.as_json();
|
||||||
|
let pct = evaluate_expression_as_f64(value, &ctx);
|
||||||
let fraction = (pct / max).clamp(0.0, 1.0);
|
let fraction = (pct / max).clamp(0.0, 1.0);
|
||||||
let pct_int = (fraction * 100.0).round() as u32;
|
let pct_int = (fraction * 100.0).round() as u32;
|
||||||
rsx! {
|
rsx! {
|
||||||
|
|
@ -618,11 +680,11 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
||||||
then,
|
then,
|
||||||
else_element,
|
else_element,
|
||||||
} => {
|
} => {
|
||||||
let ctx = serde_json::json!({});
|
let ctx = data.as_json();
|
||||||
if evaluate_expression_as_bool(condition, &ctx) {
|
if evaluate_expression_as_bool(condition, &ctx) {
|
||||||
render_element(then, data)
|
render_element(then, data, client)
|
||||||
} else if let Some(else_el) = else_element {
|
} else if let Some(else_el) = else_element {
|
||||||
render_element(else_el, data)
|
render_element(else_el, data, client)
|
||||||
} else {
|
} else {
|
||||||
rsx! {}
|
rsx! {}
|
||||||
}
|
}
|
||||||
|
|
@ -643,15 +705,19 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
||||||
if let Some(arr) = items.and_then(|v| v.as_array()) {
|
if let Some(arr) = items.and_then(|v| v.as_array()) {
|
||||||
if arr.is_empty() {
|
if arr.is_empty() {
|
||||||
if let Some(empty_el) = empty {
|
if let Some(empty_el) = empty {
|
||||||
return render_element(empty_el, data);
|
return render_element(empty_el, data, client);
|
||||||
}
|
}
|
||||||
return rsx! {};
|
return rsx! {};
|
||||||
}
|
}
|
||||||
rsx! {
|
let elements: Vec<Element> = arr
|
||||||
for _item in arr {
|
.iter()
|
||||||
{ render_element(template, data) }
|
.map(|item| {
|
||||||
}
|
let mut item_data = data.clone();
|
||||||
}
|
item_data.set_data("item".to_string(), item.clone());
|
||||||
|
render_element(template, &item_data, client)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
rsx! { for el in elements { {el} } }
|
||||||
} else {
|
} else {
|
||||||
rsx! {}
|
rsx! {}
|
||||||
}
|
}
|
||||||
|
|
@ -737,7 +803,7 @@ fn render_form_field(field: &pinakes_plugin_api::FormField) -> Element {
|
||||||
name: "{field.id}",
|
name: "{field.id}",
|
||||||
multiple: *multiple,
|
multiple: *multiple,
|
||||||
required: field.required,
|
required: field.required,
|
||||||
option { value: "", disabled: true, "Select…" }
|
option { value: "", disabled: true, selected: true, "Select…" }
|
||||||
for opt in options {
|
for opt in options {
|
||||||
option { value: "{opt.value}", "{opt.label}" }
|
option { value: "{opt.value}", "{opt.label}" }
|
||||||
}
|
}
|
||||||
|
|
@ -969,10 +1035,11 @@ fn evaluate_expression_as_bool(
|
||||||
evaluate_expression(expr, ctx).as_bool().unwrap_or(false)
|
evaluate_expression(expr, ctx).as_bool().unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn evaluate_expression_as_f64(expr: &Expression) -> f64 {
|
fn evaluate_expression_as_f64(
|
||||||
evaluate_expression(expr, &serde_json::json!({}))
|
expr: &Expression,
|
||||||
.as_f64()
|
ctx: &serde_json::Value,
|
||||||
.unwrap_or(0.0)
|
) -> f64 {
|
||||||
|
evaluate_expression(expr, ctx).as_f64().unwrap_or(0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_cell(row: &serde_json::Value, key: &str) -> String {
|
fn extract_cell(row: &serde_json::Value, key: &str) -> String {
|
||||||
|
|
@ -993,10 +1060,26 @@ fn extract_cell(row: &serde_json::Value, key: &str) -> String {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use pinakes_plugin_api::Expression;
|
use pinakes_plugin_api::{Expression, Operator};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn lit(v: serde_json::Value) -> Box<Expression> {
|
||||||
|
Box::new(Expression::Literal(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn op_expr(
|
||||||
|
left: serde_json::Value,
|
||||||
|
op: Operator,
|
||||||
|
right: serde_json::Value,
|
||||||
|
) -> Expression {
|
||||||
|
Expression::Operation {
|
||||||
|
left: lit(left),
|
||||||
|
op,
|
||||||
|
right: lit(right),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_evaluate_expression_literal() {
|
fn test_evaluate_expression_literal() {
|
||||||
let expr = Expression::Literal(serde_json::json!("hello"));
|
let expr = Expression::Literal(serde_json::json!("hello"));
|
||||||
|
|
@ -1004,6 +1087,13 @@ mod tests {
|
||||||
assert_eq!(result, serde_json::json!("hello"));
|
assert_eq!(result, serde_json::json!("hello"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_literal_number() {
|
||||||
|
let expr = Expression::Literal(serde_json::json!(3.14));
|
||||||
|
let result = evaluate_expression(&expr, &serde_json::json!({}));
|
||||||
|
assert_eq!(result, serde_json::json!(3.14));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_evaluate_expression_path() {
|
fn test_evaluate_expression_path() {
|
||||||
let expr = Expression::Path("foo.bar".to_string());
|
let expr = Expression::Path("foo.bar".to_string());
|
||||||
|
|
@ -1012,6 +1102,250 @@ mod tests {
|
||||||
assert_eq!(result, serde_json::json!(42));
|
assert_eq!(result, serde_json::json!(42));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_path_missing_key() {
|
||||||
|
let expr = Expression::Path("foo.missing".to_string());
|
||||||
|
let ctx = serde_json::json!({ "foo": { "bar": 1 } });
|
||||||
|
let result = evaluate_expression(&expr, &ctx);
|
||||||
|
assert_eq!(result, serde_json::Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_path_array_index() {
|
||||||
|
let expr = Expression::Path("items.1".to_string());
|
||||||
|
let ctx = serde_json::json!({ "items": ["a", "b", "c"] });
|
||||||
|
let result = evaluate_expression(&expr, &ctx);
|
||||||
|
assert_eq!(result, serde_json::json!("b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_path_array_out_of_bounds() {
|
||||||
|
let expr = Expression::Path("items.9".to_string());
|
||||||
|
let ctx = serde_json::json!({ "items": ["a"] });
|
||||||
|
let result = evaluate_expression(&expr, &ctx);
|
||||||
|
assert_eq!(result, serde_json::Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_path_on_scalar() {
|
||||||
|
let expr = Expression::Path("x.y".to_string());
|
||||||
|
let ctx = serde_json::json!({ "x": 42 });
|
||||||
|
let result = evaluate_expression(&expr, &ctx);
|
||||||
|
assert_eq!(result, serde_json::Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_eq_true() {
|
||||||
|
let expr =
|
||||||
|
op_expr(serde_json::json!(1), Operator::Eq, serde_json::json!(1));
|
||||||
|
let result = evaluate_expression(&expr, &serde_json::json!({}));
|
||||||
|
assert_eq!(result, serde_json::json!(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_eq_false() {
|
||||||
|
let expr =
|
||||||
|
op_expr(serde_json::json!(1), Operator::Eq, serde_json::json!(2));
|
||||||
|
let result = evaluate_expression(&expr, &serde_json::json!({}));
|
||||||
|
assert_eq!(result, serde_json::json!(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_ne() {
|
||||||
|
let expr =
|
||||||
|
op_expr(serde_json::json!("a"), Operator::Ne, serde_json::json!("b"));
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_expression(&expr, &serde_json::json!({})),
|
||||||
|
serde_json::json!(true)
|
||||||
|
);
|
||||||
|
let expr2 =
|
||||||
|
op_expr(serde_json::json!("a"), Operator::Ne, serde_json::json!("a"));
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_expression(&expr2, &serde_json::json!({})),
|
||||||
|
serde_json::json!(false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_gt() {
|
||||||
|
let expr =
|
||||||
|
op_expr(serde_json::json!(5), Operator::Gt, serde_json::json!(3));
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_expression(&expr, &serde_json::json!({})),
|
||||||
|
serde_json::json!(true)
|
||||||
|
);
|
||||||
|
let expr2 =
|
||||||
|
op_expr(serde_json::json!(3), Operator::Gt, serde_json::json!(5));
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_expression(&expr2, &serde_json::json!({})),
|
||||||
|
serde_json::json!(false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_gte() {
|
||||||
|
let expr =
|
||||||
|
op_expr(serde_json::json!(5), Operator::Gte, serde_json::json!(5));
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_expression(&expr, &serde_json::json!({})),
|
||||||
|
serde_json::json!(true)
|
||||||
|
);
|
||||||
|
let expr2 =
|
||||||
|
op_expr(serde_json::json!(4), Operator::Gte, serde_json::json!(5));
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_expression(&expr2, &serde_json::json!({})),
|
||||||
|
serde_json::json!(false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_lt() {
|
||||||
|
let expr =
|
||||||
|
op_expr(serde_json::json!(3), Operator::Lt, serde_json::json!(5));
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_expression(&expr, &serde_json::json!({})),
|
||||||
|
serde_json::json!(true)
|
||||||
|
);
|
||||||
|
let expr2 =
|
||||||
|
op_expr(serde_json::json!(5), Operator::Lt, serde_json::json!(3));
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_expression(&expr2, &serde_json::json!({})),
|
||||||
|
serde_json::json!(false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_lte() {
|
||||||
|
let expr =
|
||||||
|
op_expr(serde_json::json!(5), Operator::Lte, serde_json::json!(5));
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_expression(&expr, &serde_json::json!({})),
|
||||||
|
serde_json::json!(true)
|
||||||
|
);
|
||||||
|
let expr2 =
|
||||||
|
op_expr(serde_json::json!(6), Operator::Lte, serde_json::json!(5));
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_expression(&expr2, &serde_json::json!({})),
|
||||||
|
serde_json::json!(false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_and() {
|
||||||
|
let expr = op_expr(
|
||||||
|
serde_json::json!(true),
|
||||||
|
Operator::And,
|
||||||
|
serde_json::json!(true),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_expression(&expr, &serde_json::json!({})),
|
||||||
|
serde_json::json!(true)
|
||||||
|
);
|
||||||
|
let expr2 = op_expr(
|
||||||
|
serde_json::json!(true),
|
||||||
|
Operator::And,
|
||||||
|
serde_json::json!(false),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_expression(&expr2, &serde_json::json!({})),
|
||||||
|
serde_json::json!(false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_or() {
|
||||||
|
let expr = op_expr(
|
||||||
|
serde_json::json!(false),
|
||||||
|
Operator::Or,
|
||||||
|
serde_json::json!(true),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_expression(&expr, &serde_json::json!({})),
|
||||||
|
serde_json::json!(true)
|
||||||
|
);
|
||||||
|
let expr2 = op_expr(
|
||||||
|
serde_json::json!(false),
|
||||||
|
Operator::Or,
|
||||||
|
serde_json::json!(false),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_expression(&expr2, &serde_json::json!({})),
|
||||||
|
serde_json::json!(false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_unimplemented_ops_return_null() {
|
||||||
|
for op in [
|
||||||
|
Operator::Concat,
|
||||||
|
Operator::Add,
|
||||||
|
Operator::Sub,
|
||||||
|
Operator::Mul,
|
||||||
|
Operator::Div,
|
||||||
|
] {
|
||||||
|
let expr = op_expr(serde_json::json!(1), op, serde_json::json!(2));
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_expression(&expr, &serde_json::json!({})),
|
||||||
|
serde_json::Value::Null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_call_returns_null() {
|
||||||
|
let expr = Expression::Call {
|
||||||
|
function: "unknown".to_string(),
|
||||||
|
args: vec![],
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_expression(&expr, &serde_json::json!({})),
|
||||||
|
serde_json::Value::Null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_as_bool_true() {
|
||||||
|
let expr = Expression::Literal(serde_json::json!(true));
|
||||||
|
assert!(evaluate_expression_as_bool(&expr, &serde_json::json!({})));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_as_bool_false() {
|
||||||
|
let expr = Expression::Literal(serde_json::json!(false));
|
||||||
|
assert!(!evaluate_expression_as_bool(&expr, &serde_json::json!({})));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_as_bool_non_bool_returns_false() {
|
||||||
|
let expr = Expression::Literal(serde_json::json!("yes"));
|
||||||
|
assert!(!evaluate_expression_as_bool(&expr, &serde_json::json!({})));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_as_f64() {
|
||||||
|
let expr = Expression::Literal(serde_json::json!(7.5));
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_expression_as_f64(&expr, &serde_json::json!({})),
|
||||||
|
7.5
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_as_f64_non_numeric_returns_zero() {
|
||||||
|
let expr = Expression::Literal(serde_json::json!("text"));
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_expression_as_f64(&expr, &serde_json::json!({})),
|
||||||
|
0.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evaluate_expression_as_f64_from_path() {
|
||||||
|
let expr = Expression::Path("score".to_string());
|
||||||
|
let ctx = serde_json::json!({ "score": 42 });
|
||||||
|
assert_eq!(evaluate_expression_as_f64(&expr, &ctx), 42.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_cell_string() {
|
fn test_extract_cell_string() {
|
||||||
let row = serde_json::json!({ "name": "Alice", "count": 5 });
|
let row = serde_json::json!({ "name": "Alice", "count": 5 });
|
||||||
|
|
@ -1020,6 +1354,32 @@ mod tests {
|
||||||
assert_eq!(extract_cell(&row, "missing"), "");
|
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]
|
#[test]
|
||||||
fn test_resolve_text_content_static() {
|
fn test_resolve_text_content_static() {
|
||||||
let tc = TextContent::Static("Hello".to_string());
|
let tc = TextContent::Static("Hello".to_string());
|
||||||
|
|
@ -1031,4 +1391,118 @@ mod tests {
|
||||||
let tc = TextContent::Empty;
|
let tc = TextContent::Empty;
|
||||||
assert_eq!(resolve_text_content(&tc, &serde_json::json!({})), "");
|
assert_eq!(resolve_text_content(&tc, &serde_json::json!({})), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_text_content_expression() {
|
||||||
|
let tc = TextContent::Expression(Expression::Path("user.name".to_string()));
|
||||||
|
let ctx = serde_json::json!({ "user": { "name": "Bob" } });
|
||||||
|
// evaluate_expression returns a serde_json::Value; .to_string()
|
||||||
|
// JSON-encodes it
|
||||||
|
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() {
|
||||||
|
let tc = TextContent::Expression(Expression::Path("missing".to_string()));
|
||||||
|
assert_eq!(resolve_text_content(&tc, &serde_json::json!({})), "null");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue