GUI plugins #9

Merged
NotAShelf merged 46 commits from notashelf/push-mytsqvppsvxu into main 2026-03-12 16:53:43 +00:00
Showing only changes of commit de913e54bc - Show all commits

pinakes-ui: rewrite renderer with interactive tabs; correct data context; per-item loop binding

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Iebe4945e1fcb7c28ff74a76e9d0717276a6a6964
raf 2026-03-10 00:02:16 +03:00
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF

View file

@ -14,6 +14,7 @@ use pinakes_plugin_api::{
FieldType,
FlexDirection,
JustifyContent,
TabDefinition,
TextContent,
TextVariant,
UiElement,
@ -21,7 +22,7 @@ use pinakes_plugin_api::{
};
use super::{
actions::{ActionResult, execute_action},
actions::execute_action,
data::{PluginPageData, use_plugin_data},
};
use crate::client::ApiClient;
@ -52,13 +53,78 @@ pub fn PluginViewRenderer(props: PluginViewProps) -> Element {
class: "plugin-page",
"data-plugin-id": props.plugin_id.clone(),
h2 { class: "plugin-page-title", "{page.title}" }
{ render_element(&page.root_element, &page_data.read()) }
{ render_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.
fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
pub(crate) fn render_element(
element: &UiElement,
data: &PluginPageData,
client: Signal<ApiClient>,
) -> Element {
match element {
// Layout containers
UiElement::Container {
@ -78,7 +144,7 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
class: "plugin-container",
style: "{style}",
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",
style: "{style}",
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",
style: "{style}",
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 {
class: "plugin-split-sidebar",
style: "width:{sidebar_width}px;flex-shrink:0;",
{ render_element(sidebar, data) }
{ render_element(sidebar, data, client) }
}
main {
class: "plugin-split-main",
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 } => {
rsx! {
div {
class: "plugin-tabs",
div {
class: "plugin-tab-list",
role: "tablist",
for (idx, tab) in tabs.iter().enumerate() {
button {
class: if idx == *default_tab { "plugin-tab active" } else { "plugin-tab" },
role: "tab",
aria_selected: if idx == *default_tab { "true" } else { "false" },
if let Some(icon) = &tab.icon {
span { class: "tab-icon", "{icon}" }
}
"{tab.label}"
}
}
}
div {
class: "plugin-tab-panels",
for (idx, tab) in tabs.iter().enumerate() {
div {
class: if idx == *default_tab { "plugin-tab-panel active" } else { "plugin-tab-panel" },
hidden: idx != *default_tab,
{ render_element(&tab.content, data) }
}
}
}
PluginTabs {
tabs: tabs.clone(),
default_tab: *default_tab,
data: data.clone(),
client,
}
}
},
// Typography
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 anchor = id.clone().unwrap_or_default();
match level.min(&6) {
@ -206,7 +251,8 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
variant,
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);
if *allow_html {
let sanitized = ammonia::clean(&text);
@ -300,9 +346,9 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
onclick: move |_| {
let a = action.clone();
let fd = row_data.clone();
let client = ApiClient::default();
let c = client.read().clone();
spawn(async move {
let _ = execute_action(&client, &a, Some(&fd)).await;
let _ = execute_action(&c, &a, Some(&fd)).await;
});
},
"{act.label}"
@ -336,14 +382,14 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
div {
class: "plugin-card-content",
for child in content {
{ render_element(child, data) }
{ render_element(child, data, client) }
}
}
if !footer.is_empty() {
div {
class: "plugin-card-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() {
div { class: "plugin-list-empty", "No items" }
} else {
ul { class: "plugin-list",
for _item in arr {
li {
class: "plugin-list-item",
{ render_element(item_template, data) }
if *dividers {
hr { class: "plugin-list-divider" }
{
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, client) }
if dividers {
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,
onclick: move |_| {
let a = action_ref.clone();
let client = ApiClient::default();
let c = client.read().clone();
spawn(async move {
let _ = execute_action(&client, &a, None).await;
let _ = execute_action(&c, &a, None).await;
});
},
"{label}"
@ -482,19 +537,25 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
let vals = event.data().values();
let map: serde_json::Map<String, serde_json::Value> = vals
.into_iter()
.map(|(k, v)| {
.filter_map(|(k, v)| {
let s = match v {
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();
serde_json::Value::Object(map)
};
let client = ApiClient::default();
let c = client.read().clone();
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 {
@ -521,7 +582,7 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
}
if let Some(cancel) = cancel_label {
button {
r#type: "button",
r#type: "reset",
class: "plugin-button btn-secondary",
"{cancel}"
}
@ -554,7 +615,8 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
max,
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 pct_int = (fraction * 100.0).round() as u32;
rsx! {
@ -618,11 +680,11 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
then,
else_element,
} => {
let ctx = serde_json::json!({});
let ctx = data.as_json();
if evaluate_expression_as_bool(condition, &ctx) {
render_element(then, data)
render_element(then, data, client)
} else if let Some(else_el) = else_element {
render_element(else_el, data)
render_element(else_el, data, client)
} else {
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 arr.is_empty() {
if let Some(empty_el) = empty {
return render_element(empty_el, data);
return render_element(empty_el, data, client);
}
return rsx! {};
}
rsx! {
for _item in arr {
{ render_element(template, data) }
}
}
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, client)
})
.collect();
rsx! { for el in elements { {el} } }
} else {
rsx! {}
}
@ -737,7 +803,7 @@ fn render_form_field(field: &pinakes_plugin_api::FormField) -> Element {
name: "{field.id}",
multiple: *multiple,
required: field.required,
option { value: "", disabled: true, "Select…" }
option { value: "", disabled: true, selected: true, "Select…" }
for opt in options {
option { value: "{opt.value}", "{opt.label}" }
}
@ -969,10 +1035,11 @@ fn evaluate_expression_as_bool(
evaluate_expression(expr, ctx).as_bool().unwrap_or(false)
}
fn evaluate_expression_as_f64(expr: &Expression) -> f64 {
evaluate_expression(expr, &serde_json::json!({}))
.as_f64()
.unwrap_or(0.0)
fn evaluate_expression_as_f64(
expr: &Expression,
ctx: &serde_json::Value,
) -> f64 {
evaluate_expression(expr, ctx).as_f64().unwrap_or(0.0)
}
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)]
mod tests {
use pinakes_plugin_api::Expression;
use pinakes_plugin_api::{Expression, Operator};
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]
fn test_evaluate_expression_literal() {
let expr = Expression::Literal(serde_json::json!("hello"));
@ -1004,6 +1087,13 @@ mod tests {
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]
fn test_evaluate_expression_path() {
let expr = Expression::Path("foo.bar".to_string());
@ -1012,6 +1102,250 @@ mod tests {
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]
fn test_extract_cell_string() {
let row = serde_json::json!({ "name": "Alice", "count": 5 });
@ -1020,6 +1354,32 @@ mod tests {
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());
@ -1031,4 +1391,118 @@ mod tests {
let tc = TextContent::Empty;
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");
}
}