1use std::collections::HashSet;
9
10use serde_json::Value;
11
12use crate::action::HttpMethod;
13use crate::component::{
14 ActionCardProps, ActionCardVariant, AlertProps, AlertVariant, AvatarProps, BadgeProps,
15 BadgeVariant, BreadcrumbProps, ButtonGroupProps, ButtonProps, ButtonType, ButtonVariant,
16 CalendarCellProps, CardProps, CheckboxProps, ChecklistProps, CollapsibleProps, Component,
17 ComponentNode, DataTableProps, DescriptionListProps, DropdownMenuAction, DropdownMenuProps,
18 EmptyStateProps, FormMaxWidth, FormProps, FormSectionLayout, FormSectionProps, GapSize,
19 GridProps, HeaderProps, IconPosition, ImageProps, InputProps, InputType, KanbanBoardProps,
20 ModalProps, NotificationDropdownProps, Orientation, PageHeaderProps, PaginationProps,
21 PluginProps, ProductTileProps, ProgressProps, SelectProps, SeparatorProps, SidebarProps, Size,
22 SkeletonProps, StatCardProps, SwitchProps, TableProps, TabsProps, TextElement, TextProps,
23 ToastProps, ToastVariant,
24};
25use crate::data::{resolve_path, resolve_path_string};
26use crate::plugin::{collect_plugin_assets, Asset};
27use crate::view::JsonUiView;
28
29pub fn render_to_html(view: &JsonUiView, data: &Value) -> String {
38 let mut html = String::from(
39 "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">",
40 );
41 for node in &view.components {
42 html.push_str(&render_node(node, data));
43 }
44 html.push_str("</div>");
45 html
46}
47
48pub struct RenderResult {
53 pub html: String,
55 pub css_head: String,
57 pub scripts: String,
59}
60
61pub fn render_to_html_with_plugins(view: &JsonUiView, data: &Value) -> RenderResult {
67 let html = render_to_html(view, data);
68
69 let plugin_types = collect_plugin_types(view);
70 if plugin_types.is_empty() {
71 return RenderResult {
72 html,
73 css_head: String::new(),
74 scripts: String::new(),
75 };
76 }
77
78 let type_names: Vec<String> = plugin_types.into_iter().collect();
79 let assets = collect_plugin_assets(&type_names);
80
81 let css_head = render_css_tags(&assets.css);
82 let scripts = render_js_tags(&assets.js, &assets.init_scripts);
83
84 RenderResult {
85 html,
86 css_head,
87 scripts,
88 }
89}
90
91pub(crate) fn collect_plugin_types(view: &JsonUiView) -> HashSet<String> {
93 let mut types = HashSet::new();
94 for node in &view.components {
95 collect_plugin_types_node(node, &mut types);
96 }
97 types
98}
99
100fn collect_plugin_types_node(node: &ComponentNode, types: &mut HashSet<String>) {
102 match &node.component {
103 Component::Plugin(props) => {
104 types.insert(props.plugin_type.clone());
105 }
106 Component::Card(props) => {
107 for child in &props.children {
108 collect_plugin_types_node(child, types);
109 }
110 for child in &props.footer {
111 collect_plugin_types_node(child, types);
112 }
113 }
114 Component::Form(props) => {
115 for field in &props.fields {
116 collect_plugin_types_node(field, types);
117 }
118 }
119 Component::Modal(props) => {
120 for child in &props.children {
121 collect_plugin_types_node(child, types);
122 }
123 for child in &props.footer {
124 collect_plugin_types_node(child, types);
125 }
126 }
127 Component::Tabs(props) => {
128 for tab in &props.tabs {
129 for child in &tab.children {
130 collect_plugin_types_node(child, types);
131 }
132 }
133 }
134 Component::Grid(props) => {
135 for child in &props.children {
136 collect_plugin_types_node(child, types);
137 }
138 }
139 Component::Collapsible(props) => {
140 for child in &props.children {
141 collect_plugin_types_node(child, types);
142 }
143 }
144 Component::FormSection(props) => {
145 for child in &props.children {
146 collect_plugin_types_node(child, types);
147 }
148 }
149 Component::PageHeader(props) => {
150 for child in &props.actions {
151 collect_plugin_types_node(child, types);
152 }
153 }
154 Component::ButtonGroup(props) => {
155 for child in &props.buttons {
156 collect_plugin_types_node(child, types);
157 }
158 }
159 Component::Table(_)
161 | Component::Button(_)
162 | Component::Input(_)
163 | Component::Select(_)
164 | Component::Alert(_)
165 | Component::Badge(_)
166 | Component::Text(_)
167 | Component::Checkbox(_)
168 | Component::Switch(_)
169 | Component::Separator(_)
170 | Component::DescriptionList(_)
171 | Component::Breadcrumb(_)
172 | Component::Pagination(_)
173 | Component::Progress(_)
174 | Component::Avatar(_)
175 | Component::Skeleton(_)
176 | Component::StatCard(_)
177 | Component::Checklist(_)
178 | Component::Toast(_)
179 | Component::NotificationDropdown(_)
180 | Component::Sidebar(_)
181 | Component::Header(_)
182 | Component::EmptyState(_)
183 | Component::DropdownMenu(_)
184 | Component::CalendarCell(_)
185 | Component::ActionCard(_)
186 | Component::ProductTile(_)
187 | Component::DataTable(_)
188 | Component::Image(_) => {}
189 Component::KanbanBoard(props) => {
190 for col in &props.columns {
191 for child in &col.children {
192 collect_plugin_types_node(child, types);
193 }
194 }
195 }
196 }
197}
198
199fn render_css_tags(assets: &[Asset]) -> String {
201 let mut out = String::new();
202 for asset in assets {
203 out.push_str("<link rel=\"stylesheet\" href=\"");
204 out.push_str(&html_escape(&asset.url));
205 out.push('"');
206 if let Some(ref integrity) = asset.integrity {
207 out.push_str(" integrity=\"");
208 out.push_str(&html_escape(integrity));
209 out.push('"');
210 }
211 if let Some(ref co) = asset.crossorigin {
212 out.push_str(" crossorigin=\"");
213 out.push_str(&html_escape(co));
214 out.push('"');
215 }
216 out.push('>');
217 }
218 out
219}
220
221fn render_js_tags(assets: &[Asset], init_scripts: &[String]) -> String {
223 let mut out = String::new();
224 for asset in assets {
225 out.push_str("<script src=\"");
226 out.push_str(&html_escape(&asset.url));
227 out.push('"');
228 if let Some(ref integrity) = asset.integrity {
229 out.push_str(" integrity=\"");
230 out.push_str(&html_escape(integrity));
231 out.push('"');
232 }
233 if let Some(ref co) = asset.crossorigin {
234 out.push_str(" crossorigin=\"");
235 out.push_str(&html_escape(co));
236 out.push('"');
237 }
238 out.push_str("></script>");
239 }
240 if !init_scripts.is_empty() {
241 out.push_str("<script>");
242 for script in init_scripts {
243 out.push_str(script);
244 }
245 out.push_str("</script>");
246 }
247 out
248}
249
250fn render_node(node: &ComponentNode, data: &Value) -> String {
252 let component_html = render_component(&node.component, data);
253
254 if let Some(ref action) = node.action {
256 if action.method == HttpMethod::Get {
257 if let Some(ref url) = action.url {
258 let target_attr = match action.target.as_deref() {
259 Some(t) => {
260 format!(" target=\"{}\" rel=\"noopener noreferrer\"", html_escape(t))
261 }
262 None => String::new(),
263 };
264 let style_attr = if matches!(node.component, Component::Image(_)) {
269 " style=\"width:100%\""
270 } else {
271 ""
272 };
273 return format!(
274 "<a href=\"{}\" class=\"block\"{}{}>{}</a>",
275 html_escape(url),
276 style_attr,
277 target_attr,
278 component_html
279 );
280 }
281 }
282 }
283
284 component_html
285}
286
287fn render_component(component: &Component, data: &Value) -> String {
289 match component {
290 Component::Text(props) => render_text(props),
291 Component::Button(props) => render_button(props),
292 Component::Badge(props) => render_badge(props),
293 Component::Alert(props) => render_alert(props),
294 Component::Separator(props) => render_separator(props),
295 Component::Progress(props) => render_progress(props),
296 Component::Avatar(props) => render_avatar(props),
297 Component::Skeleton(props) => render_skeleton(props),
298 Component::Breadcrumb(props) => render_breadcrumb(props),
299 Component::Pagination(props) => render_pagination(props),
300 Component::DescriptionList(props) => render_description_list(props),
301
302 Component::Card(props) => render_card(props, data),
304 Component::Form(props) => render_form(props, data),
305 Component::Modal(props) => render_modal(props, data),
306 Component::Tabs(props) => render_tabs(props, data),
307 Component::Table(props) => render_table(props, data),
308
309 Component::Input(props) => render_input(props, data),
311 Component::Select(props) => render_select(props, data),
312 Component::Checkbox(props) => render_checkbox(props, data),
313 Component::Switch(props) => render_switch(props, data),
314
315 Component::Grid(props) => render_grid(props, data),
317 Component::Collapsible(props) => render_collapsible(props, data),
318 Component::FormSection(props) => render_form_section(props, data),
319
320 Component::EmptyState(props) => render_empty_state(props),
322 Component::DropdownMenu(props) => render_dropdown_menu(props),
323
324 Component::StatCard(props) => render_stat_card(props),
326 Component::Checklist(props) => render_checklist(props),
327 Component::Toast(props) => render_toast(props),
328 Component::NotificationDropdown(props) => render_notification_dropdown(props),
329 Component::Sidebar(props) => render_sidebar(props),
330 Component::Header(props) => render_header(props),
331
332 Component::PageHeader(props) => render_page_header(props, data),
334 Component::ButtonGroup(props) => render_button_group(props, data),
335
336 Component::CalendarCell(props) => render_calendar_cell(props),
338 Component::ActionCard(props) => render_action_card(props),
339 Component::ProductTile(props) => render_product_tile(props),
340 Component::DataTable(props) => render_data_table(props, data),
341
342 Component::KanbanBoard(props) => render_kanban_board(props, data),
344
345 Component::Image(props) => render_image(props),
347
348 Component::Plugin(props) => render_plugin(props, data),
350 }
351}
352
353fn render_calendar_cell(props: &CalendarCellProps) -> String {
356 let opacity = if props.is_current_month {
357 ""
358 } else {
359 " opacity-40"
360 };
361 let hover = if props.is_current_month {
362 " hover:bg-surface/60 transition-colors cursor-pointer"
363 } else {
364 " cursor-pointer"
365 };
366
367 let mut html = format!(
368 "<div class=\"flex flex-col min-h-[5rem] p-2 border border-border -mt-px -ml-px{opacity}{hover}\">",
369 );
370
371 if props.is_today {
373 html.push_str(&format!(
374 "<span class=\"w-7 h-7 flex items-center justify-center text-sm font-semibold bg-primary text-primary-foreground rounded-full\">{}</span>",
375 props.day
376 ));
377 } else {
378 html.push_str(&format!(
379 "<span class=\"text-sm text-text\">{}</span>",
380 props.day
381 ));
382 }
383
384 let total = if !props.dot_colors.is_empty() {
388 props.dot_colors.len() as u32
389 } else {
390 props.event_count
391 };
392 if total > 0 && total <= 3 {
393 html.push_str("<div class=\"flex gap-1 mt-auto pt-1\">");
394 if props.dot_colors.is_empty() {
395 for _ in 0..total {
396 html.push_str("<span class=\"w-1.5 h-1.5 rounded-full bg-primary\"></span>");
397 }
398 } else {
399 for color in props.dot_colors.iter().take(3) {
400 html.push_str(&format!(
401 "<span class=\"w-1.5 h-1.5 rounded-full {}\"></span>",
402 html_escape(color)
403 ));
404 }
405 }
406 html.push_str("</div>");
407 } else if total > 3 {
408 html.push_str(&format!(
409 "<span class=\"mt-auto pt-1 text-xs font-medium text-primary\">{total} prenot.</span>"
410 ));
411 }
412
413 html.push_str("</div>");
414 html
415}
416
417fn render_action_card(props: &ActionCardProps) -> String {
420 let border_class = match props.variant {
421 ActionCardVariant::Default => "border-l-primary",
422 ActionCardVariant::Setup => "border-l-warning",
423 ActionCardVariant::Danger => "border-l-destructive",
424 };
425
426 let (open_tag, close_tag) = if let Some(ref href) = props.href {
428 (
429 format!(
430 "<a href=\"{}\" aria-label=\"{}\" class=\"rounded-lg border-l-4 {} border border-border bg-card shadow-sm p-4 flex items-center gap-4 hover:bg-surface transition-colors duration-150 no-underline\">",
431 html_escape(href),
432 html_escape(&props.title),
433 border_class,
434 ),
435 "</a>".to_string(),
436 )
437 } else {
438 (
439 format!(
440 "<div class=\"rounded-lg border-l-4 {border_class} border border-border bg-card shadow-sm p-4 flex items-center gap-4 cursor-pointer hover:bg-surface transition-colors duration-150\">"
441 ),
442 "</div>".to_string(),
443 )
444 };
445
446 let mut html = open_tag;
447
448 if let Some(ref icon) = props.icon {
451 html.push_str(&format!(
452 "<div class=\"w-10 h-10 flex-shrink-0 rounded-md bg-surface flex items-center justify-center text-text-muted\">{icon}</div>",
453 ));
454 }
455
456 html.push_str(&format!(
458 "<div class=\"flex-1 min-w-0\"><p class=\"text-sm font-semibold text-text\">{}</p><p class=\"text-sm text-text-muted mt-0.5\">{}</p></div>",
459 html_escape(&props.title),
460 html_escape(&props.description)
461 ));
462
463 html.push_str("<span class=\"text-text-muted flex-shrink-0 text-lg\">›</span>");
465
466 html.push_str(&close_tag);
467 html
468}
469
470fn render_product_tile(props: &ProductTileProps) -> String {
473 let name = html_escape(&props.name);
474 let price = html_escape(&props.price);
475 let field = html_escape(&props.field);
476 let qty = props.default_quantity.unwrap_or(0);
477
478 format!(
479 "<div class=\"rounded-lg border border-border bg-card p-4 flex flex-col gap-3 touch-manipulation\">\
480 <div class=\"flex items-start justify-between gap-2\">\
481 <span class=\"text-sm font-semibold text-text\">{name}</span>\
482 <span class=\"text-sm font-semibold text-text-muted\">{price}</span>\
483 </div>\
484 <div class=\"flex items-center justify-between gap-2\">\
485 <button type=\"button\" data-qty-dec=\"{field}\" \
486 class=\"min-h-[44px] min-w-[44px] flex items-center justify-center rounded-md border border-border bg-surface text-text text-lg font-semibold hover:bg-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\" \
487 aria-label=\"Diminuisci quantit\u{00E0} {name}\">\u{2212}</button>\
488 <span data-qty-display=\"{field}\" class=\"text-sm font-semibold text-text min-w-[2ch] text-center\">{qty}</span>\
489 <button type=\"button\" data-qty-inc=\"{field}\" \
490 class=\"min-h-[44px] min-w-[44px] flex items-center justify-center rounded-md border border-border bg-surface text-text text-lg font-semibold hover:bg-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\" \
491 aria-label=\"Aumenta quantit\u{00E0} {name}\">+</button>\
492 </div>\
493 <input type=\"hidden\" name=\"{field}\" data-qty-input=\"{field}\" value=\"{qty}\">\
494 </div>"
495 )
496}
497
498fn render_kanban_board(props: &KanbanBoardProps, data: &Value) -> String {
501 if props.columns.is_empty() {
502 return String::new();
503 }
504
505 let default_id = props
506 .mobile_default_column
507 .as_deref()
508 .unwrap_or_else(|| &props.columns[0].id);
509
510 let mut html = String::new();
511
512 html.push_str("<div class=\"hidden md:block overflow-x-auto pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">");
514 html.push_str("<div class=\"flex gap-4\" style=\"min-width: min-content;\">");
515
516 for col in &props.columns {
517 html.push_str("<div class=\"min-w-[260px] flex-1 flex-shrink-0 rounded-lg border border-border bg-card/50 p-3\">");
518 html.push_str("<div class=\"flex items-center justify-between mb-3\">");
519 html.push_str(&format!(
520 "<h3 class=\"text-sm font-semibold text-text\">{}</h3>",
521 html_escape(&col.title),
522 ));
523 let badge_class = if col.count > 0 {
524 "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold bg-primary text-primary-foreground"
525 } else {
526 "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium text-text-muted bg-surface"
527 };
528 html.push_str(&format!(
529 "<span class=\"{}\">{}</span>",
530 badge_class, col.count,
531 ));
532 html.push_str("</div>");
533 html.push_str("<div class=\"space-y-2\">");
534 for child in &col.children {
535 html.push_str("<div data-kanban-card class=\"cursor-pointer\">");
536 html.push_str(&render_node(child, data));
537 html.push_str("</div>");
538 }
539 html.push_str("</div>");
540 html.push_str("</div>");
541 }
542
543 html.push_str("</div>");
544 html.push_str("</div>");
545
546 html.push_str("<div class=\"block md:hidden\" data-tabs>");
548 html.push_str("<div class=\"flex border-b border-border mb-4\">");
549
550 for col in &props.columns {
551 let is_default = col.id == default_id;
552 let (border, text) = if is_default {
553 ("border-primary", "text-primary font-semibold")
554 } else {
555 ("border-transparent", "text-text-muted hover:text-text")
556 };
557 html.push_str(&format!(
558 "<button type=\"button\" data-tab=\"{}\" class=\"flex-1 px-3 py-2 text-sm border-b-2 {} {}\" aria-selected=\"{}\">{} <span class=\"ml-1 text-xs text-text-muted\">({})</span></button>",
559 html_escape(&col.id),
560 border,
561 text,
562 is_default,
563 html_escape(&col.title),
564 col.count,
565 ));
566 }
567
568 html.push_str("</div>");
569
570 for col in &props.columns {
571 let is_default = col.id == default_id;
572 let hidden = if is_default { "" } else { " hidden" };
573 html.push_str(&format!(
574 "<div data-tab-panel=\"{}\" class=\"space-y-3{hidden}\">",
575 html_escape(&col.id),
576 ));
577 for child in &col.children {
578 html.push_str("<div data-kanban-card class=\"cursor-pointer\">");
579 html.push_str(&render_node(child, data));
580 html.push_str("</div>");
581 }
582 html.push_str("</div>");
583 }
584
585 html.push_str("</div>");
586
587 html
588}
589
590fn render_dropdown_menu(props: &DropdownMenuProps) -> String {
593 let mut html = String::from("<div class=\"relative\">");
594
595 let trigger_icon = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"5\" r=\"1\"/><circle cx=\"12\" cy=\"12\" r=\"1\"/><circle cx=\"12\" cy=\"19\" r=\"1\"/></svg>";
597 html.push_str(&format!(
598 "<button type=\"button\" data-dropdown-toggle=\"{}\" aria-label=\"{}\" \
599 class=\"inline-flex items-center justify-center rounded-md p-1.5 \
600 text-text-muted hover:text-text hover:bg-surface transition-colors duration-150 \
601 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary \
602 focus-visible:ring-offset-2\">{}</button>",
603 html_escape(&props.menu_id),
604 html_escape(&props.trigger_label),
605 trigger_icon,
606 ));
607
608 html.push_str(&format!(
610 "<div data-dropdown=\"{}\" \
611 class=\"absolute right-0 z-50 mt-1 w-48 rounded-md border border-border bg-card shadow-md hidden\">",
612 html_escape(&props.menu_id),
613 ));
614
615 for item in &props.items {
616 let url = item.action.url.as_deref().unwrap_or("#");
617 let base_class = if item.destructive {
618 "block w-full text-left px-4 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors duration-150"
619 } else {
620 "block w-full text-left px-4 py-2 text-sm text-text hover:bg-surface transition-colors duration-150"
621 };
622
623 let confirm_attrs = if let Some(ref confirm) = item.action.confirm {
625 let mut attrs = format!(" data-confirm-title=\"{}\"", html_escape(&confirm.title));
626 if let Some(ref message) = confirm.message {
627 attrs.push_str(&format!(
628 " data-confirm-message=\"{}\"",
629 html_escape(message)
630 ));
631 }
632 attrs
633 } else {
634 String::new()
635 };
636
637 let onclick = if item.action.confirm.is_some() {
638 " onclick=\"return confirm(this.dataset.confirmMessage || this.dataset.confirmTitle)\""
639 } else {
640 ""
641 };
642
643 match item.action.method {
644 HttpMethod::Get => {
645 html.push_str(&format!(
646 "<a href=\"{}\" class=\"{}\"{}{}>{}</a>",
647 html_escape(url),
648 base_class,
649 confirm_attrs,
650 onclick,
651 html_escape(&item.label),
652 ));
653 }
654 HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => {
655 let (form_method, needs_spoofing) = match item.action.method {
656 HttpMethod::Post => ("post", false),
657 HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => ("post", true),
658 _ => unreachable!(),
659 };
660 html.push_str(&format!(
661 "<form action=\"{}\" method=\"{}\">",
662 html_escape(url),
663 form_method,
664 ));
665 if needs_spoofing {
666 let method_value = match item.action.method {
667 HttpMethod::Put => "PUT",
668 HttpMethod::Patch => "PATCH",
669 HttpMethod::Delete => "DELETE",
670 _ => unreachable!(),
671 };
672 html.push_str(&format!(
673 "<input type=\"hidden\" name=\"_method\" value=\"{method_value}\">"
674 ));
675 }
676 html.push_str(&format!(
677 "<button type=\"submit\" class=\"{}\"{}{}>{}</button>",
678 base_class,
679 confirm_attrs,
680 onclick,
681 html_escape(&item.label),
682 ));
683 html.push_str("</form>");
684 }
685 }
686 }
687
688 html.push_str("</div>"); html.push_str("</div>"); html
691}
692
693fn render_plugin(props: &PluginProps, data: &Value) -> String {
696 crate::plugin::with_plugin(&props.plugin_type, |plugin| {
697 plugin.render(&props.props, data)
698 })
699 .unwrap_or_else(|| {
700 format!(
701 "<div class=\"p-4 bg-destructive/10 text-destructive rounded-md\">Unknown plugin component: {}</div>",
702 html_escape(&props.plugin_type)
703 )
704 })
705}
706
707fn render_page_header(props: &PageHeaderProps, data: &Value) -> String {
710 let mut html =
711 String::from("<div class=\"flex flex-wrap items-center justify-between gap-3 pb-4\">");
712
713 html.push_str("<div class=\"flex items-center gap-2 min-w-0\">");
715
716 if !props.breadcrumb.is_empty() {
717 for item in &props.breadcrumb {
718 if let Some(ref url) = item.url {
719 html.push_str(&format!(
720 "<a href=\"{}\" class=\"text-sm text-text-muted hover:text-text whitespace-nowrap\">{}</a>",
721 html_escape(url),
722 html_escape(&item.label)
723 ));
724 } else {
725 html.push_str(&format!(
726 "<span class=\"text-sm text-text-muted whitespace-nowrap\">{}</span>",
727 html_escape(&item.label)
728 ));
729 }
730 html.push_str(
732 "<span aria-hidden=\"true\" class=\"text-text-muted flex-shrink-0\">\
733 <svg class=\"h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\
734 <path fill-rule=\"evenodd\" d=\"M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z\" clip-rule=\"evenodd\"/>\
735 </svg></span>"
736 );
737 }
738 }
739
740 html.push_str(&format!(
741 "<h2 class=\"text-2xl font-semibold leading-tight tracking-tight text-text truncate\">{}</h2>",
742 html_escape(&props.title)
743 ));
744 html.push_str("</div>");
745
746 if !props.actions.is_empty() {
748 html.push_str("<div class=\"flex flex-wrap items-center gap-2\">");
749 for action in &props.actions {
750 html.push_str(&render_node(action, data));
751 }
752 html.push_str("</div>");
753 }
754
755 html.push_str("</div>");
756 html
757}
758
759fn render_button_group(props: &ButtonGroupProps, data: &Value) -> String {
760 let mut html = String::from("<div class=\"flex items-center gap-2 flex-wrap\">");
761 for button in &props.buttons {
762 html.push_str(&render_node(button, data));
763 }
764 html.push_str("</div>");
765 html
766}
767
768fn render_card(props: &CardProps, data: &Value) -> String {
771 let mut html = String::from(
772 "<div class=\"rounded-lg border border-border bg-card shadow-sm overflow-visible\"><div class=\"p-4\">",
773 );
774 html.push_str(&format!(
775 "<h3 class=\"text-base font-semibold leading-snug text-text\">{}</h3>",
776 html_escape(&props.title)
777 ));
778 if let Some(ref desc) = props.description {
779 html.push_str(&format!(
780 "<p class=\"mt-1 text-sm text-text-muted\">{}</p>",
781 html_escape(desc)
782 ));
783 }
784 if !props.children.is_empty() {
785 html.push_str(
786 "<div class=\"mt-3 flex flex-wrap gap-3 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto overflow-visible\">",
787 );
788 for child in &props.children {
789 html.push_str(&render_node(child, data));
790 }
791 html.push_str("</div>");
792 }
793 html.push_str("</div>"); if !props.footer.is_empty() {
795 html.push_str("<div class=\"border-t border-border px-6 py-4 flex items-center justify-between gap-2\">");
796 for child in &props.footer {
797 html.push_str(&render_node(child, data));
798 }
799 html.push_str("</div>");
800 }
801 html.push_str("</div>"); match props.max_width.as_ref().unwrap_or(&FormMaxWidth::Default) {
807 FormMaxWidth::Default => {}
808 FormMaxWidth::Narrow => {
809 html = format!(
810 "<div class=\"w-full\"><div class=\"max-w-2xl mx-auto\">{html}</div></div>"
811 );
812 }
813 FormMaxWidth::Wide => {
814 html = format!(
815 "<div class=\"w-full\"><div class=\"max-w-4xl mx-auto\">{html}</div></div>"
816 );
817 }
818 }
819
820 html
821}
822
823fn render_modal(props: &ModalProps, data: &Value) -> String {
824 let trigger = props.trigger_label.as_deref().unwrap_or("Open");
825 let mut html = String::new();
826 html.push_str(&format!(
828 "<button type=\"button\" class=\"inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium cursor-pointer\" data-modal-open=\"{}\">{}</button>",
829 html_escape(&props.id),
830 html_escape(trigger)
831 ));
832 html.push_str(&format!(
834 "<dialog id=\"{}\" aria-modal=\"true\" aria-labelledby=\"{}-title\" class=\"bg-card rounded-lg shadow-lg max-w-lg w-full mx-4 p-6 backdrop:bg-black/50\">",
835 html_escape(&props.id),
836 html_escape(&props.id)
837 ));
838 html.push_str("<div class=\"flex items-center justify-between mb-4\">");
840 html.push_str(&format!(
841 "<h3 id=\"{}-title\" class=\"text-lg font-semibold leading-snug text-text\">{}</h3>",
842 html_escape(&props.id),
843 html_escape(&props.title)
844 ));
845 html.push_str(
846 "<button type=\"button\" data-modal-close aria-label=\"Chiudi\" class=\"text-text-muted hover:text-text p-2 rounded transition-colors duration-150\">\u{00d7}</button>",
847 );
848 html.push_str("</div>");
849 if let Some(ref desc) = props.description {
850 html.push_str(&format!(
851 "<p class=\"text-sm text-text-muted mb-4\">{}</p>",
852 html_escape(desc)
853 ));
854 }
855 html.push_str(
856 "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">",
857 );
858 for child in &props.children {
859 html.push_str(&render_node(child, data));
860 }
861 html.push_str("</div>");
862 if !props.footer.is_empty() {
863 html.push_str("<div class=\"mt-6 flex items-center justify-end gap-2\">");
864 for child in &props.footer {
865 html.push_str(&render_node(child, data));
866 }
867 html.push_str("</div>");
868 }
869 html.push_str("</dialog>");
870 html
871}
872
873fn render_tabs(props: &TabsProps, data: &Value) -> String {
874 if props.tabs.len() == 1 {
876 let tab = &props.tabs[0];
877 let mut html = String::from(
878 "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">",
879 );
880 for child in &tab.children {
881 html.push_str(&render_node(child, data));
882 }
883 html.push_str("</div>");
884 return html;
885 }
886
887 let has_any_content = props.tabs.iter().any(|t| !t.children.is_empty());
891
892 let mut html = String::from("<div data-tabs>");
893 html.push_str("<div class=\"border-b border-border\">");
894 html.push_str("<nav class=\"flex -mb-px space-x-4\" role=\"tablist\">");
895
896 for tab in &props.tabs {
897 let is_active = tab.value == props.default_tab;
898 let border = if is_active {
899 "border-primary"
900 } else {
901 "border-transparent"
902 };
903 let text = if is_active {
904 "text-primary font-semibold"
905 } else {
906 "text-text-muted hover:text-text"
907 };
908
909 if has_any_content && (is_active || !tab.children.is_empty()) {
910 html.push_str(&format!(
912 "<button type=\"button\" role=\"tab\" id=\"tab-btn-{}\" aria-controls=\"tab-panel-{}\" data-tab=\"{}\" \
913 class=\"border-b-2 {} {} px-3 py-2 text-sm font-medium cursor-pointer transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\" \
914 aria-selected=\"{}\">{}</button>",
915 html_escape(&tab.value),
916 html_escape(&tab.value),
917 html_escape(&tab.value),
918 border,
919 text,
920 is_active,
921 html_escape(&tab.label),
922 ));
923 } else {
924 html.push_str(&format!(
926 "<a href=\"?tab={}\" role=\"tab\" id=\"tab-btn-{}\" aria-controls=\"tab-panel-{}\" \
927 class=\"border-b-2 {} {} px-3 py-2 text-sm font-medium transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\" \
928 aria-selected=\"{}\">{}</a>",
929 html_escape(&tab.value),
930 html_escape(&tab.value),
931 html_escape(&tab.value),
932 border,
933 text,
934 is_active,
935 html_escape(&tab.label),
936 ));
937 }
938 }
939
940 html.push_str("</nav></div>");
941
942 for tab in &props.tabs {
944 if tab.children.is_empty() && tab.value != props.default_tab {
945 continue;
946 }
947 let hidden = if tab.value != props.default_tab {
948 " hidden"
949 } else {
950 ""
951 };
952 html.push_str(&format!(
953 "<div role=\"tabpanel\" id=\"tab-panel-{}\" aria-labelledby=\"tab-btn-{}\" data-tab-panel=\"{}\" class=\"pt-4 flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto{}\">",
954 html_escape(&tab.value),
955 html_escape(&tab.value),
956 html_escape(&tab.value),
957 hidden,
958 ));
959 for child in &tab.children {
960 html.push_str(&render_node(child, data));
961 }
962 html.push_str("</div>");
963 }
964
965 html.push_str("</div>");
966 html
967}
968
969fn render_form(props: &FormProps, data: &Value) -> String {
970 let effective_method = props
972 .method
973 .as_ref()
974 .unwrap_or(&props.action.method)
975 .clone();
976
977 let (form_method, needs_spoofing) = match effective_method {
979 HttpMethod::Get => ("get", false),
980 HttpMethod::Post => ("post", false),
981 HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => ("post", true),
982 };
983
984 let action_url = props.action.url.as_deref().unwrap_or("#");
985 let mut html = match &props.guard {
986 Some(g) => format!(
987 "<form action=\"{}\" method=\"{}\" data-form-guard=\"{}\" class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">",
988 html_escape(action_url),
989 form_method,
990 html_escape(g)
991 ),
992 None => format!(
993 "<form action=\"{}\" method=\"{}\" class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">",
994 html_escape(action_url),
995 form_method
996 ),
997 };
998
999 if needs_spoofing {
1000 let method_value = match effective_method {
1001 HttpMethod::Put => "PUT",
1002 HttpMethod::Patch => "PATCH",
1003 HttpMethod::Delete => "DELETE",
1004 _ => unreachable!(),
1005 };
1006 html.push_str(&format!(
1007 "<input type=\"hidden\" name=\"_method\" value=\"{method_value}\">"
1008 ));
1009 }
1010
1011 for field in &props.fields {
1012 html.push_str(&render_node(field, data));
1013 }
1014 html.push_str("</form>");
1015
1016 let html = match props.max_width.as_ref().unwrap_or(&FormMaxWidth::Default) {
1020 FormMaxWidth::Default => html,
1021 FormMaxWidth::Narrow => {
1022 format!("<div class=\"w-full\"><div class=\"max-w-2xl mx-auto\">{html}</div></div>")
1023 }
1024 FormMaxWidth::Wide => {
1025 format!("<div class=\"w-full\"><div class=\"max-w-4xl mx-auto\">{html}</div></div>")
1026 }
1027 };
1028 html
1029}
1030
1031fn render_table(props: &TableProps, data: &Value) -> String {
1032 let mut html = String::from(
1033 "<div class=\"overflow-x-auto\"><table class=\"min-w-full divide-y divide-border\">",
1034 );
1035
1036 html.push_str("<thead class=\"bg-surface\"><tr>");
1038 for col in &props.columns {
1039 html.push_str(&format!(
1040 "<th class=\"px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-text-muted\">{}</th>",
1041 html_escape(&col.label)
1042 ));
1043 }
1044 if props.row_actions.is_some() {
1045 html.push_str("<th class=\"px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-text-muted\">Azioni</th>");
1046 }
1047 html.push_str("</tr></thead>");
1048
1049 html.push_str("<tbody class=\"divide-y divide-border bg-background\">");
1051
1052 let rows = resolve_path(data, &props.data_path);
1053 let row_array = rows.and_then(|v| v.as_array());
1054
1055 if let Some(items) = row_array {
1056 if items.is_empty() {
1057 if let Some(ref msg) = props.empty_message {
1058 let col_count =
1059 props.columns.len() + if props.row_actions.is_some() { 1 } else { 0 };
1060 html.push_str(&format!(
1061 "<tr><td colspan=\"{}\" class=\"px-6 py-8 text-center text-sm text-text-muted\">{}</td></tr>",
1062 col_count,
1063 html_escape(msg)
1064 ));
1065 }
1066 } else {
1067 for row in items {
1068 html.push_str("<tr class=\"hover:bg-surface\">");
1069 for col in &props.columns {
1070 let cell_value = row.get(&col.key);
1071 let cell_text = match cell_value {
1072 Some(Value::String(s)) => s.clone(),
1073 Some(Value::Number(n)) => n.to_string(),
1074 Some(Value::Bool(b)) => b.to_string(),
1075 Some(Value::Null) | None => String::new(),
1076 Some(v @ Value::Array(_)) | Some(v @ Value::Object(_)) => {
1077 serde_json::to_string(v).unwrap_or_default()
1078 }
1079 };
1080 html.push_str(&format!(
1081 "<td class=\"px-6 py-4 text-sm text-text whitespace-nowrap\">{}</td>",
1082 html_escape(&cell_text)
1083 ));
1084 }
1085 if let Some(ref actions) = props.row_actions {
1086 html.push_str("<td class=\"px-6 py-4 text-right text-sm space-x-2\">");
1087 for action in actions {
1088 let url = action.url.as_deref().unwrap_or("#");
1089 let label = action
1090 .handler
1091 .split('.')
1092 .next_back()
1093 .unwrap_or(&action.handler);
1094 html.push_str(&format!(
1095 "<a href=\"{}\" class=\"text-primary hover:text-primary/80\">{}</a>",
1096 html_escape(url),
1097 html_escape(label)
1098 ));
1099 }
1100 html.push_str("</td>");
1101 }
1102 html.push_str("</tr>");
1103 }
1104 }
1105 } else if let Some(ref msg) = props.empty_message {
1106 let col_count = props.columns.len() + if props.row_actions.is_some() { 1 } else { 0 };
1107 html.push_str(&format!(
1108 "<tr><td colspan=\"{}\" class=\"px-6 py-8 text-center text-sm text-text-muted\">{}</td></tr>",
1109 col_count,
1110 html_escape(msg)
1111 ));
1112 }
1113
1114 html.push_str("</tbody></table></div>");
1115 html
1116}
1117
1118fn render_data_table(props: &DataTableProps, data: &Value) -> String {
1119 let rows = resolve_path(data, &props.data_path);
1120 let row_array = rows.and_then(|v| v.as_array().cloned());
1121 let items = row_array.unwrap_or_default();
1122 let has_actions = props.row_actions.is_some();
1123 let col_count = props.columns.len() + if has_actions { 1 } else { 0 };
1124 let empty_msg = props
1125 .empty_message
1126 .as_deref()
1127 .unwrap_or("Nessun elemento trovato");
1128
1129 let mut html = String::new();
1130
1131 html.push_str(
1133 "<div class=\"hidden md:block rounded-lg border border-border overflow-visible\">",
1134 );
1135
1136 if items.is_empty() {
1137 html.push_str("<table class=\"w-full\"><tbody>");
1138 html.push_str(&format!(
1139 "<tr><td colspan=\"{}\" class=\"px-6 py-8 text-center text-sm text-text-muted\">{}</td></tr>",
1140 col_count,
1141 html_escape(empty_msg)
1142 ));
1143 html.push_str("</tbody></table>");
1144 } else {
1145 html.push_str("<table class=\"w-full\">");
1146
1147 html.push_str("<thead><tr class=\"bg-surface\">");
1149 for col in &props.columns {
1150 html.push_str(&format!(
1151 "<th class=\"px-6 py-4 text-left text-xs font-semibold uppercase tracking-wider text-text-muted\">{}</th>",
1152 html_escape(&col.label)
1153 ));
1154 }
1155 if has_actions {
1156 html.push_str(
1157 "<th class=\"px-6 py-4 text-right text-xs font-semibold uppercase tracking-wider text-text-muted\">Azioni</th>"
1158 );
1159 }
1160 html.push_str("</tr></thead>");
1161
1162 html.push_str("<tbody>");
1164 for (index, row) in items.iter().enumerate() {
1165 let row_key_value = if let Some(ref rk) = props.row_key {
1166 row.get(rk)
1167 .and_then(|v| match v {
1168 Value::String(s) => Some(s.clone()),
1169 Value::Number(n) => Some(n.to_string()),
1170 _ => None,
1171 })
1172 .unwrap_or_else(|| index.to_string())
1173 } else {
1174 index.to_string()
1175 };
1176 let href = props
1177 .row_href
1178 .as_ref()
1179 .map(|p| p.replace("{row_key}", &row_key_value));
1180 if let Some(ref url) = href {
1181 html.push_str(&format!(
1182 "<tr class=\"even:bg-surface hover:bg-surface/80 transition-colors duration-150 border-t border-border cursor-pointer\" onclick=\"window.location='{}'\">",
1183 html_escape(url)
1184 ));
1185 } else {
1186 html.push_str(
1187 "<tr class=\"even:bg-surface hover:bg-surface/80 transition-colors duration-150 border-t border-border\">"
1188 );
1189 }
1190 for col in &props.columns {
1191 let cell_value = row.get(&col.key);
1192 let cell_text = match cell_value {
1193 Some(Value::String(s)) => s.clone(),
1194 Some(Value::Number(n)) => n.to_string(),
1195 Some(Value::Bool(b)) => b.to_string(),
1196 Some(Value::Null) | None => String::new(),
1197 Some(v @ Value::Array(_)) | Some(v @ Value::Object(_)) => {
1198 serde_json::to_string(v).unwrap_or_default()
1199 }
1200 };
1201 html.push_str(&format!(
1202 "<td class=\"px-6 py-4 text-sm text-text\">{}</td>",
1203 html_escape(&cell_text)
1204 ));
1205 }
1206 if let Some(ref actions) = props.row_actions {
1207 let row_key_value = if let Some(ref rk) = props.row_key {
1208 row.get(rk)
1209 .and_then(|v| match v {
1210 Value::String(s) => Some(s.clone()),
1211 Value::Number(n) => Some(n.to_string()),
1212 _ => None,
1213 })
1214 .unwrap_or_else(|| index.to_string())
1215 } else {
1216 index.to_string()
1217 };
1218 let templated_items: Vec<DropdownMenuAction> = actions
1219 .iter()
1220 .map(|a| {
1221 let mut cloned = a.clone();
1222 let base_url = cloned
1224 .action
1225 .url
1226 .clone()
1227 .or_else(|| Some(cloned.action.handler.clone()));
1228 if let Some(url) = base_url {
1229 cloned.action.url = Some(url.replace("{row_key}", &row_key_value));
1230 }
1231 cloned
1232 })
1233 .collect();
1234 let dropdown_props = DropdownMenuProps {
1235 menu_id: format!("dt-{row_key_value}"),
1236 trigger_label: "\u{22EE}".to_string(),
1237 items: templated_items,
1238 trigger_variant: None,
1239 };
1240 html.push_str("<td class=\"px-6 py-4 text-right\">");
1241 html.push_str(&render_dropdown_menu(&dropdown_props));
1242 html.push_str("</td>");
1243 }
1244 html.push_str("</tr>");
1245 }
1246 html.push_str("</tbody></table>");
1247 }
1248 html.push_str("</div>");
1249
1250 html.push_str("<div class=\"block md:hidden space-y-3\">");
1252 if items.is_empty() {
1253 html.push_str(&format!(
1254 "<div class=\"text-center text-sm text-text-muted py-8\">{}</div>",
1255 html_escape(empty_msg)
1256 ));
1257 } else {
1258 for (index, row) in items.iter().enumerate() {
1259 let row_key_value = if let Some(ref rk) = props.row_key {
1260 row.get(rk)
1261 .and_then(|v| match v {
1262 Value::String(s) => Some(s.clone()),
1263 Value::Number(n) => Some(n.to_string()),
1264 _ => None,
1265 })
1266 .unwrap_or_else(|| index.to_string())
1267 } else {
1268 index.to_string()
1269 };
1270 let mobile_href = props
1271 .row_href
1272 .as_ref()
1273 .map(|p| p.replace("{row_key}", &row_key_value));
1274 let has_actions = props.row_actions.is_some();
1275 let use_outer_wrapper = mobile_href.is_some() && has_actions;
1279 if use_outer_wrapper {
1280 html.push_str(
1281 "<div class=\"rounded-lg border border-border bg-card overflow-visible\">",
1282 );
1283 html.push_str(&format!(
1284 "<a href=\"{}\" class=\"block p-4 space-y-2 hover:bg-surface transition-colors\">",
1285 html_escape(mobile_href.as_ref().unwrap())
1286 ));
1287 } else if let Some(ref url) = mobile_href {
1288 html.push_str(&format!(
1289 "<a href=\"{}\" class=\"block rounded-lg border border-border bg-card p-4 space-y-2 hover:bg-surface transition-colors\">",
1290 html_escape(url)
1291 ));
1292 } else {
1293 html.push_str(
1294 "<div class=\"rounded-lg border border-border bg-card p-4 space-y-2\">",
1295 );
1296 }
1297 for col in &props.columns {
1298 let cell_value = row.get(&col.key);
1299 let cell_text = match cell_value {
1300 Some(Value::String(s)) => s.clone(),
1301 Some(Value::Number(n)) => n.to_string(),
1302 Some(Value::Bool(b)) => b.to_string(),
1303 Some(Value::Null) | None => String::new(),
1304 Some(v @ Value::Array(_)) | Some(v @ Value::Object(_)) => {
1305 serde_json::to_string(v).unwrap_or_default()
1306 }
1307 };
1308 html.push_str(&format!(
1309 "<div class=\"flex justify-between\"><span class=\"text-xs font-semibold text-text-muted uppercase\">{}</span><span class=\"text-sm text-text\">{}</span></div>",
1310 html_escape(&col.label),
1311 html_escape(&cell_text)
1312 ));
1313 }
1314 if use_outer_wrapper {
1315 html.push_str("</a>");
1316 }
1317 if let Some(ref actions) = props.row_actions {
1318 let row_key_value = if let Some(ref rk) = props.row_key {
1319 row.get(rk)
1320 .and_then(|v| match v {
1321 Value::String(s) => Some(s.clone()),
1322 Value::Number(n) => Some(n.to_string()),
1323 _ => None,
1324 })
1325 .unwrap_or_else(|| index.to_string())
1326 } else {
1327 index.to_string()
1328 };
1329 let templated_items: Vec<DropdownMenuAction> = actions
1330 .iter()
1331 .map(|a| {
1332 let mut cloned = a.clone();
1333 let base_url = cloned
1334 .action
1335 .url
1336 .clone()
1337 .or_else(|| Some(cloned.action.handler.clone()));
1338 if let Some(url) = base_url {
1339 cloned.action.url = Some(url.replace("{row_key}", &row_key_value));
1340 }
1341 cloned
1342 })
1343 .collect();
1344 let dropdown_props = DropdownMenuProps {
1345 menu_id: format!("dt-m-{row_key_value}"),
1346 trigger_label: "\u{22EE}".to_string(),
1347 items: templated_items,
1348 trigger_variant: None,
1349 };
1350 html.push_str(
1351 "<div class=\"px-4 pb-3 pt-2 border-t border-border flex justify-end\">",
1352 );
1353 html.push_str(&render_dropdown_menu(&dropdown_props));
1354 html.push_str("</div>");
1355 }
1356 if use_outer_wrapper {
1357 html.push_str("</div>");
1358 } else if mobile_href.is_some() {
1359 html.push_str("</a>");
1360 } else {
1361 html.push_str("</div>");
1362 }
1363 }
1364 }
1365 html.push_str("</div>");
1366
1367 html
1368}
1369
1370fn render_input(props: &InputProps, data: &Value) -> String {
1373 let resolved_value = if let Some(ref dv) = props.default_value {
1375 Some(dv.clone())
1376 } else if let Some(ref dp) = props.data_path {
1377 resolve_path_string(data, dp)
1378 } else {
1379 None
1380 };
1381
1382 if matches!(props.input_type, InputType::Hidden) {
1384 let val = resolved_value.as_deref().unwrap_or("");
1385 return format!(
1386 "<input type=\"hidden\" id=\"{}\" name=\"{}\" value=\"{}\">",
1387 html_escape(&props.field),
1388 html_escape(&props.field),
1389 html_escape(val)
1390 );
1391 }
1392
1393 let has_error = props.error.is_some();
1394 let border_class = if has_error {
1395 "border-destructive"
1396 } else {
1397 "border-border"
1398 };
1399 let focus_ring_class = if has_error {
1400 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
1401 } else {
1402 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
1403 };
1404
1405 let mut html = String::from("<div class=\"space-y-1\">");
1406 html.push_str(&format!(
1407 "<label class=\"block text-sm font-medium text-text\" for=\"{}\">{}</label>",
1408 html_escape(&props.field),
1409 html_escape(&props.label)
1410 ));
1411
1412 match props.input_type {
1413 InputType::Hidden => unreachable!("handled by early return above"),
1414 InputType::Textarea => {
1415 let val = resolved_value.as_deref().unwrap_or("");
1416 html.push_str(&format!(
1417 "<textarea id=\"{}\" name=\"{}\" class=\"block w-full rounded-md border {} px-3 py-2 text-base shadow-sm transition-colors duration-150 motion-reduce:transition-none disabled:opacity-50 disabled:cursor-not-allowed {}\"",
1418 html_escape(&props.field),
1419 html_escape(&props.field),
1420 border_class,
1421 focus_ring_class
1422 ));
1423 if let Some(ref placeholder) = props.placeholder {
1424 html.push_str(&format!(" placeholder=\"{}\"", html_escape(placeholder)));
1425 }
1426 if props.required == Some(true) {
1427 html.push_str(" required");
1428 }
1429 if props.disabled == Some(true) {
1430 html.push_str(" disabled");
1431 }
1432 if has_error {
1434 html.push_str(&format!(
1435 " aria-invalid=\"true\" aria-describedby=\"err-{}\"",
1436 html_escape(&props.field)
1437 ));
1438 }
1439 html.push_str(&format!(">{}</textarea>", html_escape(val)));
1440 }
1441 _ => {
1442 let input_type = match props.input_type {
1443 InputType::Text => "text",
1444 InputType::Email => "email",
1445 InputType::Password => "password",
1446 InputType::Number => "number",
1447 InputType::Date => "date",
1448 InputType::Time => "time",
1449 InputType::Url => "url",
1450 InputType::Tel => "tel",
1451 InputType::Search => "search",
1452 InputType::Textarea | InputType::Hidden => unreachable!(),
1453 };
1454 html.push_str(&format!(
1455 "<input type=\"{}\" id=\"{}\" name=\"{}\" class=\"block w-full rounded-md border {} px-3 py-2 text-base shadow-sm transition-colors duration-150 motion-reduce:transition-none disabled:opacity-50 disabled:cursor-not-allowed {}\"",
1456 input_type,
1457 html_escape(&props.field),
1458 html_escape(&props.field),
1459 border_class,
1460 focus_ring_class
1461 ));
1462 if let Some(ref placeholder) = props.placeholder {
1463 html.push_str(&format!(" placeholder=\"{}\"", html_escape(placeholder)));
1464 }
1465 if let Some(ref val) = resolved_value {
1466 html.push_str(&format!(" value=\"{}\"", html_escape(val)));
1467 }
1468 if let Some(ref step) = props.step {
1469 html.push_str(&format!(" step=\"{}\"", html_escape(step)));
1470 }
1471 if let Some(ref list_id) = props.list {
1472 html.push_str(&format!(" list=\"{}\"", html_escape(list_id)));
1473 }
1474 if props.required == Some(true) {
1475 html.push_str(" required");
1476 }
1477 if props.disabled == Some(true) {
1478 html.push_str(" disabled");
1479 }
1480 if has_error {
1482 html.push_str(&format!(
1483 " aria-invalid=\"true\" aria-describedby=\"err-{}\"",
1484 html_escape(&props.field)
1485 ));
1486 }
1487 html.push('>');
1488 if let Some(ref list_id) = props.list {
1489 if let Some(arr) = data.get(list_id).and_then(|v| v.as_array()) {
1490 html.push_str(&format!("<datalist id=\"{}\">", html_escape(list_id)));
1491 for opt in arr {
1492 if let Some(s) = opt.as_str() {
1493 html.push_str(&format!("<option value=\"{}\">", html_escape(s)));
1494 }
1495 }
1496 html.push_str("</datalist>");
1497 }
1498 }
1499 }
1500 }
1501
1502 if let Some(ref desc) = props.description {
1503 html.push_str(&format!(
1504 "<p class=\"text-sm text-text-muted\">{}</p>",
1505 html_escape(desc)
1506 ));
1507 }
1508
1509 if let Some(ref error) = props.error {
1510 html.push_str(&format!(
1512 "<p id=\"err-{}\" class=\"text-sm text-destructive\">{}</p>",
1513 html_escape(&props.field),
1514 html_escape(error)
1515 ));
1516 }
1517 html.push_str("</div>");
1518 html
1519}
1520
1521fn render_select(props: &SelectProps, data: &Value) -> String {
1522 let selected_value = if let Some(ref dv) = props.default_value {
1524 Some(dv.clone())
1525 } else if let Some(ref dp) = props.data_path {
1526 resolve_path_string(data, dp)
1527 } else {
1528 None
1529 };
1530
1531 let has_error = props.error.is_some();
1532 let border_class = if has_error {
1533 "border-destructive"
1534 } else {
1535 "border-border"
1536 };
1537 let focus_ring_class = if has_error {
1538 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
1539 } else {
1540 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
1541 };
1542
1543 let mut html = String::from("<div class=\"space-y-1\">");
1544 html.push_str(&format!(
1545 "<label class=\"block text-sm font-medium text-text\" for=\"{}\">{}</label>",
1546 html_escape(&props.field),
1547 html_escape(&props.label)
1548 ));
1549
1550 html.push_str("<div class=\"relative\">");
1551 html.push_str(&format!(
1552 "<select id=\"{}\" name=\"{}\" class=\"block w-full appearance-none bg-background rounded-md border {} pr-10 px-3 py-2 text-base shadow-sm transition-colors duration-150 motion-reduce:transition-none disabled:opacity-50 disabled:cursor-not-allowed {}\"",
1553 html_escape(&props.field),
1554 html_escape(&props.field),
1555 border_class,
1556 focus_ring_class
1557 ));
1558 if props.required == Some(true) {
1559 html.push_str(" required");
1560 }
1561 if props.disabled == Some(true) {
1562 html.push_str(" disabled");
1563 }
1564 if has_error {
1566 html.push_str(&format!(
1567 " aria-invalid=\"true\" aria-describedby=\"err-{}\"",
1568 html_escape(&props.field)
1569 ));
1570 }
1571 html.push('>');
1572
1573 if let Some(ref placeholder) = props.placeholder {
1574 html.push_str(&format!(
1575 "<option value=\"\">{}</option>",
1576 html_escape(placeholder)
1577 ));
1578 }
1579
1580 for opt in &props.options {
1581 let is_selected = selected_value.as_deref() == Some(&opt.value);
1582 let selected_attr = if is_selected { " selected" } else { "" };
1583 html.push_str(&format!(
1584 "<option value=\"{}\"{}>{}</option>",
1585 html_escape(&opt.value),
1586 selected_attr,
1587 html_escape(&opt.label)
1588 ));
1589 }
1590
1591 html.push_str("</select>");
1592 html.push_str(concat!(
1593 "<span class=\"pointer-events-none absolute inset-y-0 right-3 flex items-center\" aria-hidden=\"true\">",
1594 "<svg class=\"h-4 w-4 text-text-muted\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
1595 "<path fill-rule=\"evenodd\" d=\"M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z\" clip-rule=\"evenodd\"/>",
1596 "</svg></span>"
1597 ));
1598 html.push_str("</div>");
1599
1600 if let Some(ref desc) = props.description {
1601 html.push_str(&format!(
1602 "<p class=\"text-sm text-text-muted\">{}</p>",
1603 html_escape(desc)
1604 ));
1605 }
1606
1607 if let Some(ref error) = props.error {
1608 html.push_str(&format!(
1610 "<p id=\"err-{}\" class=\"text-sm text-destructive\">{}</p>",
1611 html_escape(&props.field),
1612 html_escape(error)
1613 ));
1614 }
1615 html.push_str("</div>");
1616 html
1617}
1618
1619fn render_checkbox(props: &CheckboxProps, data: &Value) -> String {
1620 let is_checked = if let Some(c) = props.checked {
1622 c
1623 } else if let Some(ref dp) = props.data_path {
1624 resolve_path(data, dp)
1625 .map(|v| match v {
1626 Value::Bool(b) => *b,
1627 Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
1628 Value::String(s) => !s.is_empty() && s != "false" && s != "0",
1629 Value::Null => false,
1630 _ => true,
1631 })
1632 .unwrap_or(false)
1633 } else {
1634 false
1635 };
1636
1637 let value_attr = props.value.as_deref().unwrap_or("1");
1638 let checkbox_id = match &props.value {
1640 Some(v) => format!("{}_{}", props.field, v),
1641 None => props.field.clone(),
1642 };
1643
1644 let mut html = String::from("<div class=\"space-y-1\">");
1645 html.push_str("<div class=\"flex items-center gap-2\">");
1646 html.push_str(&format!(
1647 "<input type=\"checkbox\" id=\"{}\" name=\"{}\" value=\"{}\" class=\"h-4 w-4 rounded-sm border-border text-primary transition-colors duration-150 motion-reduce:transition-none disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\"",
1648 html_escape(&checkbox_id),
1649 html_escape(&props.field),
1650 html_escape(value_attr)
1651 ));
1652 if is_checked {
1653 html.push_str(" checked");
1654 }
1655 if props.required == Some(true) {
1656 html.push_str(" required");
1657 }
1658 if props.disabled == Some(true) {
1659 html.push_str(" disabled");
1660 }
1661 html.push('>');
1662 html.push_str(&format!(
1663 "<label class=\"text-sm font-medium text-text\" for=\"{}\">{}</label>",
1664 html_escape(&checkbox_id),
1665 html_escape(&props.label)
1666 ));
1667 html.push_str("</div>");
1668
1669 if let Some(ref desc) = props.description {
1670 html.push_str(&format!(
1671 "<p class=\"ml-6 text-sm text-text-muted\">{}</p>",
1672 html_escape(desc)
1673 ));
1674 }
1675
1676 if let Some(ref error) = props.error {
1677 html.push_str(&format!(
1678 "<p class=\"ml-6 text-sm text-destructive\">{}</p>",
1679 html_escape(error)
1680 ));
1681 }
1682 html.push_str("</div>");
1683 html
1684}
1685
1686fn render_switch(props: &SwitchProps, data: &Value) -> String {
1687 let is_checked = if let Some(c) = props.checked {
1689 c
1690 } else if let Some(ref dp) = props.data_path {
1691 resolve_path(data, dp)
1692 .map(|v| match v {
1693 Value::Bool(b) => *b,
1694 Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
1695 Value::String(s) => !s.is_empty() && s != "false" && s != "0",
1696 Value::Null => false,
1697 _ => true,
1698 })
1699 .unwrap_or(false)
1700 } else {
1701 false
1702 };
1703
1704 let auto_submit = props.action.is_some();
1705 let onchange = if auto_submit {
1706 " onchange=\"this.closest('form').submit()\""
1707 } else {
1708 ""
1709 };
1710
1711 let mut html = String::new();
1712
1713 if let Some(ref action) = props.action {
1715 let action_url = action.url.as_deref().unwrap_or("#");
1716 let (form_method, needs_spoofing) = match action.method {
1717 HttpMethod::Get => ("get", false),
1718 HttpMethod::Post => ("post", false),
1719 HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => ("post", true),
1720 };
1721 html.push_str(&format!(
1722 "<form action=\"{}\" method=\"{}\">",
1723 html_escape(action_url),
1724 form_method
1725 ));
1726 if needs_spoofing {
1727 let method_value = match action.method {
1728 HttpMethod::Put => "PUT",
1729 HttpMethod::Patch => "PATCH",
1730 HttpMethod::Delete => "DELETE",
1731 _ => unreachable!(),
1732 };
1733 html.push_str(&format!(
1734 "<input type=\"hidden\" name=\"_method\" value=\"{method_value}\">"
1735 ));
1736 }
1737 }
1738
1739 html.push_str("<div class=\"space-y-1\">");
1740 html.push_str("<div class=\"flex items-center justify-between\">");
1741
1742 html.push_str("<div>");
1744 html.push_str(&format!(
1745 "<label class=\"text-sm font-medium text-text\" for=\"{}\">{}</label>",
1746 html_escape(&props.field),
1747 html_escape(&props.label)
1748 ));
1749 if let Some(ref desc) = props.description {
1750 html.push_str(&format!(
1751 "<p class=\"text-sm text-text-muted\">{}</p>",
1752 html_escape(desc)
1753 ));
1754 }
1755 html.push_str("</div>");
1756
1757 html.push_str("<label class=\"relative inline-flex items-center cursor-pointer\">");
1759 let aria_checked = if is_checked { "true" } else { "false" };
1760 html.push_str(&format!(
1761 "<input type=\"checkbox\" id=\"{}\" name=\"{}\" value=\"1\" role=\"switch\" aria-checked=\"{}\" class=\"sr-only peer\"{}",
1762 html_escape(&props.field),
1763 html_escape(&props.field),
1764 aria_checked,
1765 onchange,
1766 ));
1767 if is_checked {
1768 html.push_str(" checked");
1769 }
1770 if props.required == Some(true) {
1771 html.push_str(" required");
1772 }
1773 if props.disabled == Some(true) {
1774 html.push_str(" disabled");
1775 }
1776 html.push('>');
1777 html.push_str("<div class=\"w-11 h-6 bg-border rounded-full peer peer-checked:bg-primary peer-focus:ring-2 peer-focus:ring-primary/30 after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-background after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-full\"></div>");
1778 html.push_str("</label>");
1779 html.push_str("</div>");
1780
1781 if let Some(ref error) = props.error {
1782 html.push_str(&format!(
1783 "<p class=\"text-sm text-destructive\">{}</p>",
1784 html_escape(error)
1785 ));
1786 }
1787 html.push_str("</div>");
1788
1789 if props.action.is_some() {
1790 html.push_str("</form>");
1791 }
1792
1793 html
1794}
1795
1796fn render_text(props: &TextProps) -> String {
1799 let content = html_escape(&props.content);
1800 match props.element {
1801 TextElement::P => format!("<p class=\"text-base leading-relaxed text-text\">{content}</p>"),
1802 TextElement::H1 => format!("<h1 class=\"text-3xl font-bold leading-tight tracking-tight text-text\">{content}</h1>"),
1803 TextElement::H2 => {
1804 format!("<h2 class=\"text-2xl font-semibold leading-tight tracking-tight text-text\">{content}</h2>")
1805 }
1806 TextElement::H3 => {
1807 format!("<h3 class=\"text-xl font-semibold leading-snug text-text\">{content}</h3>")
1808 }
1809 TextElement::Span => format!("<span class=\"text-base text-text\">{content}</span>"),
1810 TextElement::Div => format!("<div class=\"text-base leading-relaxed text-text\">{content}</div>"),
1811 TextElement::Section => {
1812 format!("<section class=\"text-base leading-relaxed text-text\">{content}</section>")
1813 }
1814 }
1815}
1816
1817fn render_button(props: &ButtonProps) -> String {
1818 let base = "inline-flex items-center justify-center rounded-md font-medium transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2";
1819
1820 let variant_classes = match props.variant {
1821 ButtonVariant::Default => "bg-primary text-primary-foreground hover:bg-primary/90",
1822 ButtonVariant::Secondary => "bg-secondary text-secondary-foreground hover:bg-secondary/90",
1823 ButtonVariant::Destructive => {
1824 "bg-destructive text-primary-foreground hover:bg-destructive/90"
1825 }
1826 ButtonVariant::Outline => "border border-border bg-background text-text hover:bg-surface",
1827 ButtonVariant::Ghost => "text-text hover:bg-surface",
1828 ButtonVariant::Link => "text-primary underline hover:text-primary/80",
1829 };
1830
1831 let is_ghost = matches!(props.variant, ButtonVariant::Ghost);
1832 let size_classes = match props.size {
1833 Size::Xs => {
1834 if is_ghost {
1835 "py-1 text-xs"
1836 } else {
1837 "px-2 py-1 text-xs"
1838 }
1839 }
1840 Size::Sm => {
1841 if is_ghost {
1842 "py-1.5 text-sm"
1843 } else {
1844 "px-3 py-1.5 text-sm"
1845 }
1846 }
1847 Size::Default => {
1848 if is_ghost {
1849 "py-2 text-sm"
1850 } else {
1851 "px-4 py-2 text-sm"
1852 }
1853 }
1854 Size::Lg => {
1855 if is_ghost {
1856 "py-3 text-base"
1857 } else {
1858 "px-6 py-3 text-base"
1859 }
1860 }
1861 };
1862
1863 let disabled_classes = if props.disabled == Some(true) {
1864 " opacity-50 cursor-not-allowed"
1865 } else {
1866 ""
1867 };
1868
1869 let disabled_attr = if props.disabled == Some(true) {
1870 " disabled"
1871 } else {
1872 ""
1873 };
1874
1875 let label = html_escape(&props.label);
1876
1877 let content = if let Some(ref icon) = props.icon {
1879 let icon_span = format!(
1880 "<span class=\"icon\" data-icon=\"{}\">{}</span>",
1881 html_escape(icon),
1882 html_escape(icon)
1883 );
1884 let position = props.icon_position.as_ref().cloned().unwrap_or_default();
1885 match position {
1886 IconPosition::Left => format!("{icon_span} {label}"),
1887 IconPosition::Right => format!("{label} {icon_span}"),
1888 }
1889 } else {
1890 label
1891 };
1892
1893 let type_attr = match props.button_type.as_ref() {
1897 Some(ButtonType::Button) => " type=\"button\"",
1898 Some(ButtonType::Submit) => " type=\"submit\"",
1899 None => "",
1900 };
1901
1902 format!(
1903 "<button{type_attr} class=\"{base} {variant_classes} {size_classes}{disabled_classes}\"{disabled_attr}>{content}</button>"
1904 )
1905}
1906
1907fn render_badge(props: &BadgeProps) -> String {
1908 let base = "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium";
1909 let variant_classes = match props.variant {
1910 BadgeVariant::Default => "bg-primary/10 text-primary",
1911 BadgeVariant::Secondary => "bg-secondary/10 text-secondary-foreground",
1912 BadgeVariant::Destructive => "bg-destructive/10 text-destructive",
1913 BadgeVariant::Outline => "border border-border text-text",
1914 };
1915 format!(
1916 "<span class=\"{} {}\">{}</span>",
1917 base,
1918 variant_classes,
1919 html_escape(&props.label)
1920 )
1921}
1922
1923const ICON_INFO: &str = concat!(
1926 "<span aria-hidden=\"true\" class=\"shrink-0\">",
1927 "<svg class=\"h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
1928 "<path fill-rule=\"evenodd\" d=\"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z\" clip-rule=\"evenodd\"/>",
1929 "</svg></span>"
1930);
1931
1932const ICON_SUCCESS: &str = concat!(
1933 "<span aria-hidden=\"true\" class=\"shrink-0\">",
1934 "<svg class=\"h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
1935 "<path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z\" clip-rule=\"evenodd\"/>",
1936 "</svg></span>"
1937);
1938
1939const ICON_WARNING: &str = concat!(
1940 "<span aria-hidden=\"true\" class=\"shrink-0\">",
1941 "<svg class=\"h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
1942 "<path fill-rule=\"evenodd\" d=\"M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z\" clip-rule=\"evenodd\"/>",
1943 "</svg></span>"
1944);
1945
1946const ICON_ERROR: &str = concat!(
1947 "<span aria-hidden=\"true\" class=\"shrink-0\">",
1948 "<svg class=\"h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
1949 "<path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z\" clip-rule=\"evenodd\"/>",
1950 "</svg></span>"
1951);
1952
1953fn render_alert(props: &AlertProps) -> String {
1954 let variant_classes = match props.variant {
1955 AlertVariant::Info => "bg-primary/10 border-primary text-primary",
1956 AlertVariant::Success => "bg-success/10 border-success text-success",
1957 AlertVariant::Warning => "bg-warning/10 border-warning text-warning",
1958 AlertVariant::Error => "bg-destructive/10 border-destructive text-destructive",
1959 };
1960 let icon = match props.variant {
1961 AlertVariant::Info => ICON_INFO,
1962 AlertVariant::Success => ICON_SUCCESS,
1963 AlertVariant::Warning => ICON_WARNING,
1964 AlertVariant::Error => ICON_ERROR,
1965 };
1966 let mut html = format!(
1967 "<div role=\"alert\" class=\"rounded-md border p-4 flex items-start gap-3 {variant_classes}\">"
1968 );
1969 html.push_str(icon);
1970 html.push_str("<div>");
1971 if let Some(ref title) = props.title {
1972 html.push_str(&format!(
1973 "<h4 class=\"font-semibold mb-1\">{}</h4>",
1974 html_escape(title)
1975 ));
1976 }
1977 html.push_str(&format!("<p>{}</p>", html_escape(&props.message)));
1978 html.push_str("</div>");
1979 html.push_str("</div>");
1980 html
1981}
1982
1983fn render_separator(props: &SeparatorProps) -> String {
1984 let orientation = props.orientation.as_ref().cloned().unwrap_or_default();
1985 match orientation {
1986 Orientation::Horizontal => "<hr class=\"my-4 border-border\">".to_string(),
1987 Orientation::Vertical => "<div class=\"mx-4 h-full w-px bg-border\"></div>".to_string(),
1988 }
1989}
1990
1991fn render_progress(props: &ProgressProps) -> String {
1992 let max = props.max.unwrap_or(100) as f64;
1993 let pct = if max > 0.0 {
1994 ((props.value as f64 * 100.0 / max).round() as u8).min(100)
1995 } else {
1996 0
1997 };
1998
1999 let mut html = String::from("<div class=\"w-full\">");
2000 if let Some(ref label) = props.label {
2001 html.push_str(&format!(
2002 "<div class=\"mb-1 text-sm text-text-muted\">{}</div>",
2003 html_escape(label)
2004 ));
2005 }
2006 html.push_str(&format!(
2007 "<div class=\"w-full rounded-full bg-border h-2.5\"><div class=\"rounded-full bg-primary h-2.5\" style=\"width: {pct}%\"></div></div>"
2008 ));
2009 html.push_str("</div>");
2010 html
2011}
2012
2013fn render_avatar(props: &AvatarProps) -> String {
2014 let size = props.size.as_ref().cloned().unwrap_or_default();
2015 let size_classes = match size {
2016 Size::Xs => "h-6 w-6 text-xs",
2017 Size::Sm => "h-8 w-8 text-sm",
2018 Size::Default => "h-10 w-10 text-sm",
2019 Size::Lg => "h-12 w-12 text-base",
2020 };
2021
2022 if let Some(ref src) = props.src {
2023 format!(
2024 "<img src=\"{}\" alt=\"{}\" class=\"rounded-full object-cover {}\">",
2025 html_escape(src),
2026 html_escape(&props.alt),
2027 size_classes
2028 )
2029 } else {
2030 let fallback_text = props.fallback.as_deref().unwrap_or_else(|| {
2031 &props.alt
2033 });
2034 let initials: String = fallback_text.chars().take(2).collect();
2036 format!(
2037 "<span class=\"inline-flex items-center justify-center rounded-full bg-card text-text-muted {}\">{}</span>",
2038 size_classes,
2039 html_escape(&initials)
2040 )
2041 }
2042}
2043
2044fn render_image(props: &ImageProps) -> String {
2045 let container_style = match &props.aspect_ratio {
2046 Some(ratio) => format!(" style=\"aspect-ratio: {}\"", html_escape(ratio)),
2047 None => String::new(),
2048 };
2049
2050 let placeholder = match &props.placeholder_label {
2053 Some(label) => format!(
2054 "<div class=\"absolute inset-0 flex items-center justify-center \
2055 rounded-md bg-surface text-xs text-text-muted\">{}</div>",
2056 html_escape(label)
2057 ),
2058 None => String::from("<div class=\"absolute inset-0 rounded-md bg-surface\"></div>"),
2059 };
2060
2061 format!(
2062 "<div class=\"relative w-full\"{container_style}>\
2063 {placeholder}\
2064 <img src=\"{src}\" alt=\"{alt}\" \
2065 class=\"relative w-full h-full rounded-md object-cover object-top\" \
2066 loading=\"lazy\" onerror=\"this.style.display='none'\">\
2067 </div>",
2068 src = html_escape(&props.src),
2069 alt = html_escape(&props.alt),
2070 )
2071}
2072
2073const SHIMMER_CSS: &str = concat!(
2076 "<style>",
2077 "@keyframes ferro-shimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}",
2078 ".ferro-shimmer{",
2079 "background:linear-gradient(90deg,var(--color-card,#f1f5f9) 25%,var(--color-border,#e2e8f0) 50%,var(--color-card,#f1f5f9) 75%);",
2080 "background-size:200% 100%;",
2081 "animation:ferro-shimmer 1.5s ease-in-out infinite;",
2082 "}",
2083 "</style>"
2084);
2085
2086fn render_skeleton(props: &SkeletonProps) -> String {
2087 let width = props.width.as_deref().unwrap_or("100%");
2088 let height = props.height.as_deref().unwrap_or("1rem");
2089 let rounded = if props.rounded == Some(true) {
2090 "rounded-full"
2091 } else {
2092 "rounded-md"
2093 };
2094 format!("{SHIMMER_CSS}<div class=\"ferro-shimmer {rounded}\" style=\"width: {width}; height: {height}\"></div>")
2095}
2096
2097const BREADCRUMB_SEP: &str = concat!(
2100 "<span aria-hidden=\"true\" class=\"text-text-muted\">",
2101 "<svg class=\"h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
2102 "<path fill-rule=\"evenodd\" d=\"M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z\" clip-rule=\"evenodd\"/>",
2103 "</svg></span>"
2104);
2105
2106fn render_breadcrumb(props: &BreadcrumbProps) -> String {
2107 let mut html =
2108 String::from("<nav class=\"flex items-center space-x-2 text-sm text-text-muted\">");
2109 let len = props.items.len();
2110 for (i, item) in props.items.iter().enumerate() {
2111 let is_last = i == len - 1;
2112 if is_last {
2113 html.push_str(&format!(
2114 "<span class=\"text-text font-medium\">{}</span>",
2115 html_escape(&item.label)
2116 ));
2117 } else if let Some(ref url) = item.url {
2118 html.push_str(&format!(
2119 "<a href=\"{}\" class=\"hover:text-text transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\">{}</a>",
2120 html_escape(url),
2121 html_escape(&item.label)
2122 ));
2123 } else {
2124 html.push_str(&format!("<span>{}</span>", html_escape(&item.label)));
2125 }
2126 if !is_last {
2127 html.push_str(BREADCRUMB_SEP);
2128 }
2129 }
2130 html.push_str("</nav>");
2131 html
2132}
2133
2134fn render_pagination(props: &PaginationProps) -> String {
2135 if props.total == 0 || props.per_page == 0 {
2136 return String::new();
2137 }
2138
2139 let total_pages = props.total.div_ceil(props.per_page);
2140 if total_pages <= 1 {
2141 return String::new();
2142 }
2143
2144 let base_url = props.base_url.as_deref().unwrap_or("?");
2145 let current = props.current_page;
2146
2147 let mut html = String::from("<nav class=\"flex items-center space-x-1\">");
2148
2149 if current > 1 {
2151 html.push_str(&format!(
2152 "<a href=\"{}page={}\" class=\"px-3 py-1 rounded-md bg-background text-text hover:bg-surface transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\">«</a>",
2153 html_escape(base_url),
2154 current - 1
2155 ));
2156 }
2157
2158 let pages = compute_page_range(current, total_pages);
2160 let mut prev_page = 0u32;
2161 for page in pages {
2162 if prev_page > 0 && page > prev_page + 1 {
2163 html.push_str("<span class=\"px-2 text-text-muted\">…</span>");
2164 }
2165 if page == current {
2166 html.push_str(&format!(
2167 "<span class=\"px-3 py-1 rounded-md bg-primary text-primary-foreground\">{page}</span>"
2168 ));
2169 } else {
2170 html.push_str(&format!(
2171 "<a href=\"{}page={}\" class=\"px-3 py-1 rounded-md bg-background text-text hover:bg-surface transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\">{}</a>",
2172 html_escape(base_url),
2173 page,
2174 page
2175 ));
2176 }
2177 prev_page = page;
2178 }
2179
2180 if current < total_pages {
2182 html.push_str(&format!(
2183 "<a href=\"{}page={}\" class=\"px-3 py-1 rounded-md bg-background text-text hover:bg-surface transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\">»</a>",
2184 html_escape(base_url),
2185 current + 1
2186 ));
2187 }
2188
2189 html.push_str("</nav>");
2190 html
2191}
2192
2193fn compute_page_range(current: u32, total: u32) -> Vec<u32> {
2195 if total <= 7 {
2196 return (1..=total).collect();
2197 }
2198 let mut pages = Vec::new();
2199 pages.push(1);
2200 let start = current.saturating_sub(1).max(2);
2201 let end = (current + 1).min(total - 1);
2202 for p in start..=end {
2203 if !pages.contains(&p) {
2204 pages.push(p);
2205 }
2206 }
2207 if !pages.contains(&total) {
2208 pages.push(total);
2209 }
2210 pages.sort();
2211 pages.dedup();
2212 pages
2213}
2214
2215fn render_description_list(props: &DescriptionListProps) -> String {
2216 let columns = props.columns.unwrap_or(1);
2217 let mut html = format!("<dl class=\"grid grid-cols-{columns} gap-4\">");
2218 for item in &props.items {
2219 html.push_str(&format!(
2220 "<div><dt class=\"text-sm font-medium text-text-muted\">{}</dt><dd class=\"mt-1 text-sm text-text\">{}</dd></div>",
2221 html_escape(&item.label),
2222 html_escape(&item.value)
2223 ));
2224 }
2225 html.push_str("</dl>");
2226 html
2227}
2228
2229fn render_grid(props: &GridProps, data: &Value) -> String {
2232 let gap = match props.gap {
2233 GapSize::None => "gap-0",
2234 GapSize::Sm => "gap-2",
2235 GapSize::Md => "gap-4",
2236 GapSize::Lg => "gap-6",
2237 GapSize::Xl => "gap-8",
2238 };
2239
2240 if props.scrollable == Some(true) {
2241 let mut html = format!("<div class=\"overflow-x-auto\"><div class=\"grid grid-flow-col auto-cols-[minmax(280px,1fr)] {gap}\">");
2242 for child in &props.children {
2243 html.push_str(&render_node(child, data));
2244 }
2245 html.push_str("</div></div>");
2246 return html;
2247 }
2248
2249 let cols = props.columns.clamp(1, 12);
2250 let mut col_classes = format!("grid-cols-{cols}");
2251 if let Some(md) = props.md_columns {
2252 col_classes.push_str(&format!(" md:grid-cols-{}", md.clamp(1, 12)));
2253 }
2254 if let Some(lg) = props.lg_columns {
2255 col_classes.push_str(&format!(" lg:grid-cols-{}", lg.clamp(1, 12)));
2256 }
2257 let mut html = format!("<div class=\"grid w-full {col_classes} {gap}\">");
2258 for child in &props.children {
2259 html.push_str(&render_node(child, data));
2260 }
2261 html.push_str("</div>");
2262 html
2263}
2264
2265const CHEVRON_DOWN: &str = concat!(
2268 "<svg class=\"h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
2269 "<path fill-rule=\"evenodd\" d=\"M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z\" clip-rule=\"evenodd\"/>",
2270 "</svg>"
2271);
2272
2273fn render_collapsible(props: &CollapsibleProps, data: &Value) -> String {
2274 let mut html = String::from("<details class=\"group\"");
2275 if props.expanded {
2276 html.push_str(" open");
2277 }
2278 html.push('>');
2279 let aria_expanded = if props.expanded { "true" } else { "false" };
2280 html.push_str(&format!(
2281 "<summary class=\"flex items-center justify-between cursor-pointer px-4 py-3 text-sm font-medium text-text bg-surface rounded-lg hover:bg-card\" aria-expanded=\"{}\">{}<span class=\"text-text-muted group-open:rotate-180 transition-transform\">{CHEVRON_DOWN}</span></summary>",
2282 aria_expanded,
2283 html_escape(&props.title)
2284 ));
2285 html.push_str("<div class=\"px-4 py-3 flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">");
2286 for child in &props.children {
2287 html.push_str(&render_node(child, data));
2288 }
2289 html.push_str("</div></details>");
2290 html
2291}
2292
2293fn render_empty_state(props: &EmptyStateProps) -> String {
2294 let mut html = String::from(
2295 "<div class=\"flex flex-col items-center justify-center py-8 px-6 text-center\">",
2296 );
2297 html.push_str(&format!(
2298 "<p class=\"text-sm text-text-muted\">{}</p>",
2299 html_escape(&props.title)
2300 ));
2301 if let Some(ref desc) = props.description {
2302 html.push_str(&format!(
2303 "<p class=\"mt-1 text-sm text-text-muted\">{}</p>",
2304 html_escape(desc)
2305 ));
2306 }
2307 if let Some(ref action) = props.action {
2308 let label = props.action_label.as_deref().unwrap_or("Action");
2309 let url = action.url.as_deref().unwrap_or("#");
2310 html.push_str(&format!(
2311 "<a href=\"{}\" class=\"mt-4 inline-flex items-center justify-center rounded-md \
2312 border border-border bg-card text-text px-4 py-2 text-sm font-medium \
2313 hover:bg-surface transition-colors\">{}</a>",
2314 html_escape(url),
2315 html_escape(label)
2316 ));
2317 }
2318 html.push_str("</div>");
2319 html
2320}
2321
2322fn render_form_section(props: &FormSectionProps, data: &Value) -> String {
2323 let is_two_column = matches!(props.layout.as_ref(), Some(FormSectionLayout::TwoColumn));
2324
2325 if is_two_column {
2326 let mut html = String::from("<fieldset class=\"md:grid md:grid-cols-5 md:gap-8\">");
2328 html.push_str(&format!(
2329 "<div class=\"md:col-span-2\"><legend class=\"text-base font-semibold text-text\">{}</legend>",
2330 html_escape(&props.title)
2331 ));
2332 if let Some(ref desc) = props.description {
2333 html.push_str(&format!(
2334 "<p class=\"text-sm text-text-muted mt-1\">{}</p>",
2335 html_escape(desc)
2336 ));
2337 }
2338 html.push_str("</div>");
2339 html.push_str("<div class=\"md:col-span-3 space-y-4 mt-4 md:mt-0\">");
2340 for child in &props.children {
2341 html.push_str(&render_node(child, data));
2342 }
2343 html.push_str("</div></fieldset>");
2344 html
2345 } else {
2346 let mut html = String::from(
2348 "<fieldset class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">",
2349 );
2350 html.push_str(&format!(
2351 "<legend class=\"text-base font-semibold text-text\">{}</legend>",
2352 html_escape(&props.title)
2353 ));
2354 if let Some(ref desc) = props.description {
2355 html.push_str(&format!(
2356 "<p class=\"text-sm text-text-muted\">{}</p>",
2357 html_escape(desc)
2358 ));
2359 }
2360 html.push_str("<div class=\"space-y-4\">");
2361 for child in &props.children {
2362 html.push_str(&render_node(child, data));
2363 }
2364 html.push_str("</div></fieldset>");
2365 html
2366 }
2367}
2368
2369fn render_stat_card(props: &StatCardProps) -> String {
2372 let mut html =
2373 String::from("<div class=\"bg-card rounded-lg shadow-sm p-4 border border-border\">");
2374 if let Some(ref icon) = props.icon {
2375 html.push_str(&format!(
2376 "<span class=\"inline-block mb-2 w-6 h-6\">{icon}</span>"
2377 ));
2378 }
2380 html.push_str(&format!(
2381 "<p class=\"text-sm text-text-muted\">{}</p>",
2382 html_escape(&props.label)
2383 ));
2384 if let Some(ref sse) = props.sse_target {
2385 html.push_str(&format!(
2386 "<p class=\"text-2xl font-bold text-text\" data-sse-target=\"{}\" data-live-value>{}</p>",
2387 html_escape(sse),
2388 html_escape(&props.value)
2389 ));
2390 } else {
2391 html.push_str(&format!(
2392 "<p class=\"text-2xl font-bold text-text\">{}</p>",
2393 html_escape(&props.value)
2394 ));
2395 }
2396 if let Some(ref subtitle) = props.subtitle {
2397 html.push_str(&format!(
2398 "<p class=\"text-xs text-text-muted mt-1\">{}</p>",
2399 html_escape(subtitle)
2400 ));
2401 }
2402 html.push_str("</div>");
2403 html
2404}
2405
2406fn render_checklist(props: &ChecklistProps) -> String {
2407 let mut html =
2408 String::from("<div class=\"bg-card rounded-lg shadow-sm p-4 border border-border\">");
2409 html.push_str("<div class=\"flex items-center justify-between mb-3\">");
2410 html.push_str(&format!(
2411 "<h3 class=\"text-sm font-semibold leading-snug text-text\">{}</h3>",
2412 html_escape(&props.title)
2413 ));
2414 if props.dismissible {
2415 let dismiss_label = props.dismiss_label.as_deref().unwrap_or("Dismiss");
2416 html.push_str(&format!(
2417 "<button type=\"button\" class=\"text-xs font-medium text-text hover:text-primary\" data-dismissible>{}</button>",
2418 html_escape(dismiss_label)
2419 ));
2420 }
2421 html.push_str("</div>");
2422 if let Some(ref key) = props.data_key {
2423 html.push_str(&format!(
2424 "<div data-checklist-key=\"{}\">",
2425 html_escape(key)
2426 ));
2427 } else {
2428 html.push_str("<div>");
2429 }
2430 if props.dismissible {
2431 html.push_str("<ul data-dismissible class=\"space-y-2\">");
2432 } else {
2433 html.push_str("<ul class=\"space-y-2\">");
2434 }
2435 for item in &props.items {
2436 html.push_str("<li class=\"flex items-center gap-2\">");
2437 if item.checked {
2438 html.push_str("<input type=\"checkbox\" checked class=\"h-4 w-4 rounded-sm border-border text-primary\">");
2439 } else {
2440 html.push_str(
2441 "<input type=\"checkbox\" class=\"h-4 w-4 rounded-sm border-border text-primary\">",
2442 );
2443 }
2444 let label_class = if item.checked {
2445 "text-sm line-through text-text-muted"
2446 } else {
2447 "text-sm text-text"
2448 };
2449 if let Some(ref href) = item.href {
2450 html.push_str(&format!(
2451 "<a href=\"{}\" class=\"{}\">{}</a>",
2452 html_escape(href),
2453 label_class,
2454 html_escape(&item.label)
2455 ));
2456 } else {
2457 html.push_str(&format!(
2458 "<span class=\"{}\">{}</span>",
2459 label_class,
2460 html_escape(&item.label)
2461 ));
2462 }
2463 html.push_str("</li>");
2464 }
2465 html.push_str("</ul></div></div>");
2466 html
2467}
2468
2469fn render_toast(props: &ToastProps) -> String {
2470 let variant_classes = match props.variant {
2471 ToastVariant::Info => "bg-primary/10 border-primary text-primary",
2472 ToastVariant::Success => "bg-success/10 border-success text-success",
2473 ToastVariant::Warning => "bg-warning/10 border-warning text-warning",
2474 ToastVariant::Error => "bg-destructive/10 border-destructive text-destructive",
2475 };
2476 let variant_str = match props.variant {
2477 ToastVariant::Info => "info",
2478 ToastVariant::Success => "success",
2479 ToastVariant::Warning => "warning",
2480 ToastVariant::Error => "error",
2481 };
2482 let timeout = props.timeout.unwrap_or(5);
2483 let mut html = format!(
2484 "<div class=\"fixed top-4 right-4 z-50 rounded-md border p-4 shadow-lg {variant_classes}\" data-toast-variant=\"{variant_str}\" data-toast-timeout=\"{timeout}\"",
2485 );
2486 if props.dismissible {
2487 html.push_str(" data-toast-dismissible");
2488 }
2489 html.push('>');
2490 html.push_str("<div class=\"flex items-start gap-3\">");
2491 html.push_str(&format!(
2492 "<p class=\"text-sm\">{}</p>",
2493 html_escape(&props.message)
2494 ));
2495 if props.dismissible {
2496 html.push_str(
2497 "<button type=\"button\" class=\"ml-auto text-current opacity-70 hover:opacity-100\">×</button>",
2498 );
2499 }
2500 html.push_str("</div></div>");
2501 html
2502}
2503
2504const BELL_SVG: &str = concat!(
2507 "<svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">",
2508 "<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" ",
2509 "d=\"M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9\"/>",
2510 "</svg>"
2511);
2512
2513fn render_notification_dropdown(props: &NotificationDropdownProps) -> String {
2514 let unread_count = props.notifications.iter().filter(|n| !n.read).count();
2515 let mut html = String::from("<div class=\"relative\" data-notification-dropdown>");
2516 html.push_str(&format!(
2518 "<button type=\"button\" class=\"relative p-2 text-text-muted hover:text-text\" data-notification-count=\"{unread_count}\">"
2519 ));
2520 html.push_str(BELL_SVG);
2521 if unread_count > 0 {
2522 html.push_str(&format!(
2523 "<span class=\"absolute top-0 right-0 inline-flex items-center justify-center h-4 w-4 text-xs font-bold text-primary-foreground bg-destructive rounded-full\">{unread_count}</span>"
2524 ));
2525 }
2526 html.push_str("</button>");
2527 html.push_str(
2529 "<div class=\"hidden absolute right-0 mt-2 w-80 bg-card rounded-lg shadow-lg border border-border z-50\" data-notification-panel>",
2530 );
2531 if props.notifications.is_empty() {
2532 let empty = props.empty_text.as_deref().unwrap_or("No notifications");
2533 html.push_str(&format!(
2534 "<p class=\"p-4 text-sm text-text-muted\">{}</p>",
2535 html_escape(empty)
2536 ));
2537 } else {
2538 html.push_str("<ul class=\"divide-y divide-border\">");
2539 for item in &props.notifications {
2540 html.push_str("<li class=\"flex items-start gap-3 p-3\">");
2541 if let Some(ref icon) = item.icon {
2542 html.push_str(&format!(
2543 "<span class=\"text-lg shrink-0\">{}</span>",
2544 html_escape(icon)
2545 ));
2546 }
2547 html.push_str("<div class=\"flex-1 min-w-0\">");
2548 if let Some(ref url) = item.action_url {
2549 html.push_str(&format!(
2550 "<a href=\"{}\" class=\"text-sm text-text hover:underline\">{}</a>",
2551 html_escape(url),
2552 html_escape(&item.text)
2553 ));
2554 } else {
2555 html.push_str(&format!(
2556 "<p class=\"text-sm text-text\">{}</p>",
2557 html_escape(&item.text)
2558 ));
2559 }
2560 if let Some(ref ts) = item.timestamp {
2561 html.push_str(&format!(
2562 "<p class=\"text-xs text-text-muted mt-0.5\">{}</p>",
2563 html_escape(ts)
2564 ));
2565 }
2566 html.push_str("</div>");
2567 if !item.read {
2568 html.push_str(
2569 "<span class=\"h-2 w-2 mt-1 shrink-0 rounded-full bg-primary\"></span>",
2570 );
2571 }
2572 html.push_str("</li>");
2573 }
2574 html.push_str("</ul>");
2575 }
2576 html.push_str("</div></div>");
2577 html
2578}
2579
2580fn render_sidebar(props: &SidebarProps) -> String {
2581 let mut html =
2582 String::from("<aside class=\"flex flex-col h-full bg-background border-r border-border\">");
2583 if !props.fixed_top.is_empty() {
2585 html.push_str("<nav class=\"p-4 space-y-1\">");
2586 for item in &props.fixed_top {
2587 html.push_str(&render_sidebar_nav_item(item));
2588 }
2589 html.push_str("</nav>");
2590 }
2591 if !props.groups.is_empty() {
2593 html.push_str("<div class=\"flex-1 overflow-y-auto p-4 flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">");
2594 for group in &props.groups {
2595 html.push_str("<div data-sidebar-group");
2596 if group.collapsed {
2597 html.push_str(" data-collapsed");
2598 }
2599 html.push('>');
2600 html.push_str(&format!(
2601 "<p class=\"px-2 py-1 text-xs font-semibold text-text-muted uppercase tracking-wider\">{}</p>",
2602 html_escape(&group.label)
2603 ));
2604 html.push_str("<nav class=\"space-y-1\">");
2605 for item in &group.items {
2606 html.push_str(&render_sidebar_nav_item(item));
2607 }
2608 html.push_str("</nav></div>");
2609 }
2610 html.push_str("</div>");
2611 }
2612 if !props.fixed_bottom.is_empty() {
2614 html.push_str("<nav class=\"p-4 space-y-1 border-t border-border\">");
2615 for item in &props.fixed_bottom {
2616 html.push_str(&render_sidebar_nav_item(item));
2617 }
2618 html.push_str("</nav>");
2619 }
2620 html.push_str("</aside>");
2621 html
2622}
2623
2624fn render_sidebar_nav_item(item: &crate::component::SidebarNavItem) -> String {
2625 let classes = if item.active {
2626 "flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium bg-card text-primary transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
2627 } else {
2628 "flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-text-muted hover:text-text hover:bg-surface transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
2629 };
2630 let mut html = format!(
2631 "<a href=\"{}\" class=\"{}\">",
2632 html_escape(&item.href),
2633 classes
2634 );
2635 if let Some(ref icon) = item.icon {
2636 html.push_str(&format!(
2637 "<span class=\"inline-flex items-center justify-center w-5 h-5 shrink-0\">{icon}</span>" ));
2639 }
2640 html.push_str(&format!("{}</a>", html_escape(&item.label)));
2641 html
2642}
2643
2644fn render_header(props: &HeaderProps) -> String {
2645 let mut html = String::from(
2646 "<header class=\"relative flex items-center justify-between px-6 py-4 bg-background border-b border-border\">",
2647 );
2648 html.push_str("<div></div>");
2650 html.push_str(&format!(
2653 "<span class=\"absolute left-1/2 -translate-x-1/2 text-lg font-semibold text-text pointer-events-none\">{}</span>",
2654 html_escape(&props.business_name)
2655 ));
2656 html.push_str("<div class=\"flex items-center gap-4\">");
2657 if let Some(count) = props.notification_count {
2659 if count > 0 {
2660 html.push_str(&format!(
2661 "<div class=\"relative\"><span class=\"text-text-muted\">{BELL_SVG}</span><span class=\"absolute top-0 right-0 inline-flex items-center justify-center h-4 w-4 text-xs font-bold text-primary-foreground bg-destructive rounded-full\" data-notification-count=\"{count}\">{count}</span></div>"
2662 ));
2663 } else {
2664 html.push_str(&format!(
2665 "<span class=\"text-text-muted\" data-notification-count=\"{count}\">{BELL_SVG}</span>"
2666 ));
2667 }
2668 }
2669 html.push_str("<div class=\"flex items-center gap-2\">");
2671 if let Some(ref avatar) = props.user_avatar {
2672 html.push_str(&format!(
2673 "<img src=\"{}\" alt=\"User avatar\" class=\"h-8 w-8 rounded-full object-cover\">",
2674 html_escape(avatar)
2675 ));
2676 } else if let Some(ref name) = props.user_name {
2677 let initials: String = name
2678 .split_whitespace()
2679 .filter_map(|w| w.chars().next())
2680 .take(2)
2681 .collect();
2682 html.push_str(&format!(
2683 "<span class=\"inline-flex items-center justify-center h-8 w-8 rounded-full bg-card text-text-muted text-sm font-medium\">{}</span>",
2684 html_escape(&initials)
2685 ));
2686 html.push_str(&format!(
2687 "<span class=\"text-sm text-text\">{}</span>",
2688 html_escape(name)
2689 ));
2690 }
2691 if let Some(ref logout) = props.logout_url {
2692 html.push_str(&format!(
2693 "<a href=\"{}\" class=\"text-sm text-text-muted hover:text-text\">Logout</a>",
2694 html_escape(logout)
2695 ));
2696 }
2697 html.push_str("</div></div></header>");
2698 html
2699}
2700
2701pub(crate) fn html_escape(s: &str) -> String {
2705 let mut escaped = String::with_capacity(s.len());
2706 for c in s.chars() {
2707 match c {
2708 '&' => escaped.push_str("&"),
2709 '<' => escaped.push_str("<"),
2710 '>' => escaped.push_str(">"),
2711 '"' => escaped.push_str("""),
2712 '\'' => escaped.push_str("'"),
2713 _ => escaped.push(c),
2714 }
2715 }
2716 escaped
2717}
2718
2719#[cfg(test)]
2720mod tests {
2721 use super::*;
2722 use crate::action::{Action, HttpMethod};
2723 use crate::component::*;
2724 use serde_json::json;
2725
2726 fn text_node(key: &str, content: &str, element: TextElement) -> ComponentNode {
2729 ComponentNode {
2730 key: key.to_string(),
2731 component: Component::Text(TextProps {
2732 content: content.to_string(),
2733 element,
2734 }),
2735 action: None,
2736 visibility: None,
2737 }
2738 }
2739
2740 fn button_node(key: &str, label: &str, variant: ButtonVariant, size: Size) -> ComponentNode {
2741 ComponentNode {
2742 key: key.to_string(),
2743 component: Component::Button(ButtonProps {
2744 label: label.to_string(),
2745 variant,
2746 size,
2747 disabled: None,
2748 icon: None,
2749 icon_position: None,
2750 button_type: None,
2751 }),
2752 action: None,
2753 visibility: None,
2754 }
2755 }
2756
2757 fn make_action(handler: &str, method: HttpMethod) -> Action {
2758 Action {
2759 handler: handler.to_string(),
2760 url: None,
2761 method,
2762 confirm: None,
2763 on_success: None,
2764 on_error: None,
2765 target: None,
2766 }
2767 }
2768
2769 fn make_action_with_url(handler: &str, method: HttpMethod, url: &str) -> Action {
2770 Action {
2771 handler: handler.to_string(),
2772 url: Some(url.to_string()),
2773 method,
2774 confirm: None,
2775 on_success: None,
2776 on_error: None,
2777 target: None,
2778 }
2779 }
2780
2781 #[test]
2784 fn render_empty_view_produces_wrapper_div() {
2785 let view = JsonUiView::new();
2786 let html = render_to_html(&view, &json!({}));
2787 assert_eq!(html, "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\"></div>");
2788 }
2789
2790 #[test]
2791 fn render_view_with_component_wraps_in_div() {
2792 let view = JsonUiView::new().component(text_node("t", "Hello", TextElement::P));
2793 let html = render_to_html(&view, &json!({}));
2794 assert!(html.starts_with(
2795 "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">"
2796 ));
2797 assert!(html.ends_with("</div>"));
2798 assert!(html.contains("<p class=\"text-base leading-relaxed text-text\">Hello</p>"));
2799 }
2800
2801 #[test]
2804 fn text_p_variant() {
2805 let view = JsonUiView::new().component(text_node("t", "Paragraph", TextElement::P));
2806 let html = render_to_html(&view, &json!({}));
2807 assert!(html.contains("<p class=\"text-base leading-relaxed text-text\">Paragraph</p>"));
2808 }
2809
2810 #[test]
2811 fn text_h1_variant() {
2812 let view = JsonUiView::new().component(text_node("t", "Title", TextElement::H1));
2813 let html = render_to_html(&view, &json!({}));
2814 assert!(html.contains(
2815 "<h1 class=\"text-3xl font-bold leading-tight tracking-tight text-text\">Title</h1>"
2816 ));
2817 }
2818
2819 #[test]
2820 fn text_h2_variant() {
2821 let view = JsonUiView::new().component(text_node("t", "Subtitle", TextElement::H2));
2822 let html = render_to_html(&view, &json!({}));
2823 assert!(html.contains(
2824 "<h2 class=\"text-2xl font-semibold leading-tight tracking-tight text-text\">Subtitle</h2>"
2825 ));
2826 }
2827
2828 #[test]
2829 fn text_h3_variant() {
2830 let view = JsonUiView::new().component(text_node("t", "Section", TextElement::H3));
2831 let html = render_to_html(&view, &json!({}));
2832 assert!(html
2833 .contains("<h3 class=\"text-xl font-semibold leading-snug text-text\">Section</h3>"));
2834 }
2835
2836 #[test]
2837 fn text_span_variant() {
2838 let view = JsonUiView::new().component(text_node("t", "Inline", TextElement::Span));
2839 let html = render_to_html(&view, &json!({}));
2840 assert!(html.contains("<span class=\"text-base text-text\">Inline</span>"));
2841 }
2842
2843 #[test]
2846 fn button_default_variant() {
2847 let view = JsonUiView::new().component(button_node(
2848 "b",
2849 "Click",
2850 ButtonVariant::Default,
2851 Size::Default,
2852 ));
2853 let html = render_to_html(&view, &json!({}));
2854 assert!(html.contains("bg-primary text-primary-foreground hover:bg-primary/90"));
2855 assert!(html.contains(">Click</button>"));
2856 }
2857
2858 #[test]
2859 fn button_secondary_variant() {
2860 let view = JsonUiView::new().component(button_node(
2861 "b",
2862 "Click",
2863 ButtonVariant::Secondary,
2864 Size::Default,
2865 ));
2866 let html = render_to_html(&view, &json!({}));
2867 assert!(html.contains("bg-secondary text-secondary-foreground hover:bg-secondary/90"));
2868 }
2869
2870 #[test]
2871 fn button_destructive_variant() {
2872 let view = JsonUiView::new().component(button_node(
2873 "b",
2874 "Delete",
2875 ButtonVariant::Destructive,
2876 Size::Default,
2877 ));
2878 let html = render_to_html(&view, &json!({}));
2879 assert!(html.contains("bg-destructive text-primary-foreground hover:bg-destructive/90"));
2880 }
2881
2882 #[test]
2883 fn button_outline_variant() {
2884 let view = JsonUiView::new().component(button_node(
2885 "b",
2886 "Click",
2887 ButtonVariant::Outline,
2888 Size::Default,
2889 ));
2890 let html = render_to_html(&view, &json!({}));
2891 assert!(html.contains("border border-border bg-background text-text hover:bg-surface"));
2892 }
2893
2894 #[test]
2895 fn button_ghost_variant() {
2896 let view = JsonUiView::new().component(button_node(
2897 "b",
2898 "Click",
2899 ButtonVariant::Ghost,
2900 Size::Default,
2901 ));
2902 let html = render_to_html(&view, &json!({}));
2903 assert!(html.contains("text-text hover:bg-surface"));
2904 assert!(html.contains("py-2 text-sm"));
2905 assert!(!html.contains("px-4"));
2906 }
2907
2908 #[test]
2909 fn button_ghost_omits_horizontal_padding_across_sizes() {
2910 for size in [Size::Xs, Size::Sm, Size::Default, Size::Lg] {
2911 let view =
2912 JsonUiView::new().component(button_node("b", "G", ButtonVariant::Ghost, size));
2913 let html = render_to_html(&view, &json!({}));
2914 assert!(!html.contains("px-2"), "ghost must not set px-2");
2915 assert!(!html.contains("px-3"), "ghost must not set px-3");
2916 assert!(!html.contains("px-4"), "ghost must not set px-4");
2917 assert!(!html.contains("px-6"), "ghost must not set px-6");
2918 }
2919 }
2920
2921 #[test]
2922 fn button_link_variant() {
2923 let view = JsonUiView::new().component(button_node(
2924 "b",
2925 "Click",
2926 ButtonVariant::Link,
2927 Size::Default,
2928 ));
2929 let html = render_to_html(&view, &json!({}));
2930 assert!(html.contains("text-primary underline hover:text-primary/80"));
2931 }
2932
2933 #[test]
2934 fn button_disabled_state() {
2935 let view = JsonUiView::new().component(ComponentNode {
2936 key: "b".to_string(),
2937 component: Component::Button(ButtonProps {
2938 label: "Disabled".to_string(),
2939 variant: ButtonVariant::Default,
2940 size: Size::Default,
2941 disabled: Some(true),
2942 icon: None,
2943 icon_position: None,
2944 button_type: None,
2945 }),
2946 action: None,
2947 visibility: None,
2948 });
2949 let html = render_to_html(&view, &json!({}));
2950 assert!(html.contains("opacity-50 cursor-not-allowed"));
2951 assert!(html.contains(" disabled"));
2952 }
2953
2954 #[test]
2955 fn button_with_icon_left() {
2956 let view = JsonUiView::new().component(ComponentNode {
2957 key: "b".to_string(),
2958 component: Component::Button(ButtonProps {
2959 label: "Save".to_string(),
2960 variant: ButtonVariant::Default,
2961 size: Size::Default,
2962 disabled: None,
2963 icon: Some("save".to_string()),
2964 icon_position: Some(IconPosition::Left),
2965 button_type: None,
2966 }),
2967 action: None,
2968 visibility: None,
2969 });
2970 let html = render_to_html(&view, &json!({}));
2971 assert!(html.contains("data-icon=\"save\""));
2972 let icon_pos = html.find("data-icon").unwrap();
2974 let label_pos = html.find("Save").unwrap();
2975 assert!(icon_pos < label_pos);
2976 }
2977
2978 #[test]
2979 fn button_with_icon_right() {
2980 let view = JsonUiView::new().component(ComponentNode {
2981 key: "b".to_string(),
2982 component: Component::Button(ButtonProps {
2983 label: "Next".to_string(),
2984 variant: ButtonVariant::Default,
2985 size: Size::Default,
2986 disabled: None,
2987 icon: Some("arrow-right".to_string()),
2988 icon_position: Some(IconPosition::Right),
2989 button_type: None,
2990 }),
2991 action: None,
2992 visibility: None,
2993 });
2994 let html = render_to_html(&view, &json!({}));
2995 assert!(html.contains("data-icon=\"arrow-right\""));
2996 let label_pos = html.find("Next").unwrap();
2998 let icon_pos = html.find("data-icon").unwrap();
2999 assert!(label_pos < icon_pos);
3000 }
3001
3002 #[test]
3005 fn button_size_xs() {
3006 let view =
3007 JsonUiView::new().component(button_node("b", "X", ButtonVariant::Default, Size::Xs));
3008 let html = render_to_html(&view, &json!({}));
3009 assert!(html.contains("px-2 py-1 text-xs"));
3010 }
3011
3012 #[test]
3013 fn button_size_sm() {
3014 let view =
3015 JsonUiView::new().component(button_node("b", "S", ButtonVariant::Default, Size::Sm));
3016 let html = render_to_html(&view, &json!({}));
3017 assert!(html.contains("px-3 py-1.5 text-sm"));
3018 }
3019
3020 #[test]
3021 fn button_size_default() {
3022 let view = JsonUiView::new().component(button_node(
3023 "b",
3024 "D",
3025 ButtonVariant::Default,
3026 Size::Default,
3027 ));
3028 let html = render_to_html(&view, &json!({}));
3029 assert!(html.contains("px-4 py-2 text-sm"));
3030 }
3031
3032 #[test]
3033 fn button_size_lg() {
3034 let view =
3035 JsonUiView::new().component(button_node("b", "L", ButtonVariant::Default, Size::Lg));
3036 let html = render_to_html(&view, &json!({}));
3037 assert!(html.contains("px-6 py-3 text-base"));
3038 }
3039
3040 #[test]
3043 fn badge_default_variant() {
3044 let view = JsonUiView::new().component(ComponentNode {
3045 key: "bg".to_string(),
3046 component: Component::Badge(BadgeProps {
3047 label: "New".to_string(),
3048 variant: BadgeVariant::Default,
3049 }),
3050 action: None,
3051 visibility: None,
3052 });
3053 let html = render_to_html(&view, &json!({}));
3054 assert!(html.contains("bg-primary/10 text-primary"));
3055 assert!(html.contains(">New</span>"));
3056 }
3057
3058 #[test]
3059 fn badge_secondary_variant() {
3060 let view = JsonUiView::new().component(ComponentNode {
3061 key: "bg".to_string(),
3062 component: Component::Badge(BadgeProps {
3063 label: "Draft".to_string(),
3064 variant: BadgeVariant::Secondary,
3065 }),
3066 action: None,
3067 visibility: None,
3068 });
3069 let html = render_to_html(&view, &json!({}));
3070 assert!(html.contains("bg-secondary/10 text-secondary-foreground"));
3071 }
3072
3073 #[test]
3074 fn badge_destructive_variant() {
3075 let view = JsonUiView::new().component(ComponentNode {
3076 key: "bg".to_string(),
3077 component: Component::Badge(BadgeProps {
3078 label: "Deleted".to_string(),
3079 variant: BadgeVariant::Destructive,
3080 }),
3081 action: None,
3082 visibility: None,
3083 });
3084 let html = render_to_html(&view, &json!({}));
3085 assert!(html.contains("bg-destructive/10 text-destructive"));
3086 }
3087
3088 #[test]
3089 fn badge_outline_variant() {
3090 let view = JsonUiView::new().component(ComponentNode {
3091 key: "bg".to_string(),
3092 component: Component::Badge(BadgeProps {
3093 label: "Info".to_string(),
3094 variant: BadgeVariant::Outline,
3095 }),
3096 action: None,
3097 visibility: None,
3098 });
3099 let html = render_to_html(&view, &json!({}));
3100 assert!(html.contains("border border-border text-text"));
3101 }
3102
3103 #[test]
3104 fn badge_has_base_classes() {
3105 let view = JsonUiView::new().component(ComponentNode {
3106 key: "bg".to_string(),
3107 component: Component::Badge(BadgeProps {
3108 label: "Test".to_string(),
3109 variant: BadgeVariant::Default,
3110 }),
3111 action: None,
3112 visibility: None,
3113 });
3114 let html = render_to_html(&view, &json!({}));
3115 assert!(html
3116 .contains("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"));
3117 }
3118
3119 #[test]
3122 fn alert_info_variant() {
3123 let view = JsonUiView::new().component(ComponentNode {
3124 key: "a".to_string(),
3125 component: Component::Alert(AlertProps {
3126 message: "Info message".to_string(),
3127 variant: AlertVariant::Info,
3128 title: None,
3129 }),
3130 action: None,
3131 visibility: None,
3132 });
3133 let html = render_to_html(&view, &json!({}));
3134 assert!(html.contains("bg-primary/10 border-primary text-primary"));
3135 assert!(html.contains("role=\"alert\""));
3136 assert!(html.contains("<p>Info message</p>"));
3137 }
3138
3139 #[test]
3140 fn alert_success_variant() {
3141 let view = JsonUiView::new().component(ComponentNode {
3142 key: "a".to_string(),
3143 component: Component::Alert(AlertProps {
3144 message: "Done".to_string(),
3145 variant: AlertVariant::Success,
3146 title: None,
3147 }),
3148 action: None,
3149 visibility: None,
3150 });
3151 let html = render_to_html(&view, &json!({}));
3152 assert!(html.contains("bg-success/10 border-success text-success"));
3153 }
3154
3155 #[test]
3156 fn alert_warning_variant() {
3157 let view = JsonUiView::new().component(ComponentNode {
3158 key: "a".to_string(),
3159 component: Component::Alert(AlertProps {
3160 message: "Careful".to_string(),
3161 variant: AlertVariant::Warning,
3162 title: None,
3163 }),
3164 action: None,
3165 visibility: None,
3166 });
3167 let html = render_to_html(&view, &json!({}));
3168 assert!(html.contains("bg-warning/10 border-warning text-warning"));
3169 }
3170
3171 #[test]
3172 fn alert_error_variant() {
3173 let view = JsonUiView::new().component(ComponentNode {
3174 key: "a".to_string(),
3175 component: Component::Alert(AlertProps {
3176 message: "Failed".to_string(),
3177 variant: AlertVariant::Error,
3178 title: None,
3179 }),
3180 action: None,
3181 visibility: None,
3182 });
3183 let html = render_to_html(&view, &json!({}));
3184 assert!(html.contains("bg-destructive/10 border-destructive text-destructive"));
3185 }
3186
3187 #[test]
3188 fn alert_with_title() {
3189 let view = JsonUiView::new().component(ComponentNode {
3190 key: "a".to_string(),
3191 component: Component::Alert(AlertProps {
3192 message: "Details here".to_string(),
3193 variant: AlertVariant::Warning,
3194 title: Some("Warning".to_string()),
3195 }),
3196 action: None,
3197 visibility: None,
3198 });
3199 let html = render_to_html(&view, &json!({}));
3200 assert!(html.contains("<h4 class=\"font-semibold mb-1\">Warning</h4>"));
3201 assert!(html.contains("<p>Details here</p>"));
3202 }
3203
3204 #[test]
3205 fn alert_without_title() {
3206 let view = JsonUiView::new().component(ComponentNode {
3207 key: "a".to_string(),
3208 component: Component::Alert(AlertProps {
3209 message: "No title".to_string(),
3210 variant: AlertVariant::Info,
3211 title: None,
3212 }),
3213 action: None,
3214 visibility: None,
3215 });
3216 let html = render_to_html(&view, &json!({}));
3217 assert!(!html.contains("<h4"));
3218 }
3219
3220 #[test]
3223 fn separator_horizontal() {
3224 let view = JsonUiView::new().component(ComponentNode {
3225 key: "s".to_string(),
3226 component: Component::Separator(SeparatorProps {
3227 orientation: Some(Orientation::Horizontal),
3228 }),
3229 action: None,
3230 visibility: None,
3231 });
3232 let html = render_to_html(&view, &json!({}));
3233 assert!(html.contains("<hr class=\"my-4 border-border\">"));
3234 }
3235
3236 #[test]
3237 fn separator_vertical() {
3238 let view = JsonUiView::new().component(ComponentNode {
3239 key: "s".to_string(),
3240 component: Component::Separator(SeparatorProps {
3241 orientation: Some(Orientation::Vertical),
3242 }),
3243 action: None,
3244 visibility: None,
3245 });
3246 let html = render_to_html(&view, &json!({}));
3247 assert!(html.contains("<div class=\"mx-4 h-full w-px bg-border\"></div>"));
3248 }
3249
3250 #[test]
3251 fn separator_default_is_horizontal() {
3252 let view = JsonUiView::new().component(ComponentNode {
3253 key: "s".to_string(),
3254 component: Component::Separator(SeparatorProps { orientation: None }),
3255 action: None,
3256 visibility: None,
3257 });
3258 let html = render_to_html(&view, &json!({}));
3259 assert!(html.contains("<hr"));
3260 }
3261
3262 #[test]
3265 fn progress_renders_bar() {
3266 let view = JsonUiView::new().component(ComponentNode {
3267 key: "p".to_string(),
3268 component: Component::Progress(ProgressProps {
3269 value: 50,
3270 max: None,
3271 label: None,
3272 }),
3273 action: None,
3274 visibility: None,
3275 });
3276 let html = render_to_html(&view, &json!({}));
3277 assert!(html.contains("style=\"width: 50%\""));
3278 assert!(html.contains("bg-primary h-2.5"));
3279 }
3280
3281 #[test]
3282 fn progress_with_label() {
3283 let view = JsonUiView::new().component(ComponentNode {
3284 key: "p".to_string(),
3285 component: Component::Progress(ProgressProps {
3286 value: 75,
3287 max: None,
3288 label: Some("Uploading...".to_string()),
3289 }),
3290 action: None,
3291 visibility: None,
3292 });
3293 let html = render_to_html(&view, &json!({}));
3294 assert!(html.contains("Uploading..."));
3295 assert!(html.contains("text-sm text-text-muted"));
3296 }
3297
3298 #[test]
3299 fn progress_with_custom_max() {
3300 let view = JsonUiView::new().component(ComponentNode {
3301 key: "p".to_string(),
3302 component: Component::Progress(ProgressProps {
3303 value: 25,
3304 max: Some(50),
3305 label: None,
3306 }),
3307 action: None,
3308 visibility: None,
3309 });
3310 let html = render_to_html(&view, &json!({}));
3311 assert!(html.contains("style=\"width: 50%\""));
3313 }
3314
3315 #[test]
3318 fn avatar_with_src() {
3319 let view = JsonUiView::new().component(ComponentNode {
3320 key: "av".to_string(),
3321 component: Component::Avatar(AvatarProps {
3322 src: Some("/img/user.jpg".to_string()),
3323 alt: "User".to_string(),
3324 fallback: None,
3325 size: None,
3326 }),
3327 action: None,
3328 visibility: None,
3329 });
3330 let html = render_to_html(&view, &json!({}));
3331 assert!(html.contains("<img"));
3332 assert!(html.contains("src=\"/img/user.jpg\""));
3333 assert!(html.contains("alt=\"User\""));
3334 assert!(html.contains("rounded-full object-cover"));
3335 }
3336
3337 #[test]
3338 fn avatar_without_src_uses_fallback() {
3339 let view = JsonUiView::new().component(ComponentNode {
3340 key: "av".to_string(),
3341 component: Component::Avatar(AvatarProps {
3342 src: None,
3343 alt: "John Doe".to_string(),
3344 fallback: Some("JD".to_string()),
3345 size: None,
3346 }),
3347 action: None,
3348 visibility: None,
3349 });
3350 let html = render_to_html(&view, &json!({}));
3351 assert!(!html.contains("<img"));
3352 assert!(html.contains("<span"));
3353 assert!(html.contains("bg-card text-text-muted"));
3354 assert!(html.contains(">JD</span>"));
3355 }
3356
3357 #[test]
3358 fn avatar_without_src_or_fallback_uses_alt_initials() {
3359 let view = JsonUiView::new().component(ComponentNode {
3360 key: "av".to_string(),
3361 component: Component::Avatar(AvatarProps {
3362 src: None,
3363 alt: "Alice".to_string(),
3364 fallback: None,
3365 size: Some(Size::Lg),
3366 }),
3367 action: None,
3368 visibility: None,
3369 });
3370 let html = render_to_html(&view, &json!({}));
3371 assert!(html.contains(">Al</span>"));
3372 assert!(html.contains("h-12 w-12 text-base"));
3373 }
3374
3375 #[test]
3378 fn image_with_aspect_ratio() {
3379 let view = JsonUiView::new().component(ComponentNode {
3380 key: "img".to_string(),
3381 component: Component::Image(ImageProps {
3382 src: "/img/page.png".to_string(),
3383 alt: "Page".to_string(),
3384 aspect_ratio: Some("16/9".to_string()),
3385 placeholder_label: None,
3386 }),
3387 action: None,
3388 visibility: None,
3389 });
3390 let html = render_to_html(&view, &json!({}));
3391 assert!(html.contains("<img"));
3392 assert!(html.contains("src=\"/img/page.png\""));
3393 assert!(html.contains("alt=\"Page\""));
3394 assert!(html.contains("w-full h-full rounded-md object-cover"));
3395 assert!(html.contains("style=\"aspect-ratio: 16/9\""));
3396 assert!(html.contains("loading=\"lazy\""));
3397 }
3398
3399 #[test]
3400 fn image_without_aspect_ratio_omits_style() {
3401 let view = JsonUiView::new().component(ComponentNode {
3402 key: "img".to_string(),
3403 component: Component::Image(ImageProps {
3404 src: "/img/page.png".to_string(),
3405 alt: "Page".to_string(),
3406 aspect_ratio: None,
3407 placeholder_label: None,
3408 }),
3409 action: None,
3410 visibility: None,
3411 });
3412 let html = render_to_html(&view, &json!({}));
3413 assert!(!html.contains("style="));
3414 assert!(html.contains("loading=\"lazy\""));
3415 }
3416
3417 #[test]
3418 fn image_xss_src_escaped() {
3419 let view = JsonUiView::new().component(ComponentNode {
3420 key: "img".to_string(),
3421 component: Component::Image(ImageProps {
3422 src: "x\" onerror=\"alert(1)".to_string(),
3423 alt: "Test".to_string(),
3424 aspect_ratio: None,
3425 placeholder_label: None,
3426 }),
3427 action: None,
3428 visibility: None,
3429 });
3430 let html = render_to_html(&view, &json!({}));
3431 assert!(html.contains("src=\"x" onerror="alert(1)\""));
3432 }
3433
3434 #[test]
3437 fn skeleton_default() {
3438 let view = JsonUiView::new().component(ComponentNode {
3439 key: "sk".to_string(),
3440 component: Component::Skeleton(SkeletonProps {
3441 width: None,
3442 height: None,
3443 rounded: None,
3444 }),
3445 action: None,
3446 visibility: None,
3447 });
3448 let html = render_to_html(&view, &json!({}));
3449 assert!(html.contains("ferro-shimmer"));
3450 assert!(html.contains("rounded-md"));
3451 assert!(html.contains("width: 100%"));
3452 assert!(html.contains("height: 1rem"));
3453 }
3454
3455 #[test]
3456 fn skeleton_custom_dimensions() {
3457 let view = JsonUiView::new().component(ComponentNode {
3458 key: "sk".to_string(),
3459 component: Component::Skeleton(SkeletonProps {
3460 width: Some("200px".to_string()),
3461 height: Some("40px".to_string()),
3462 rounded: Some(true),
3463 }),
3464 action: None,
3465 visibility: None,
3466 });
3467 let html = render_to_html(&view, &json!({}));
3468 assert!(html.contains("rounded-full"));
3469 assert!(html.contains("width: 200px"));
3470 assert!(html.contains("height: 40px"));
3471 }
3472
3473 #[test]
3476 fn breadcrumb_items_with_links() {
3477 let view = JsonUiView::new().component(ComponentNode {
3478 key: "bc".to_string(),
3479 component: Component::Breadcrumb(BreadcrumbProps {
3480 items: vec![
3481 BreadcrumbItem {
3482 label: "Home".to_string(),
3483 url: Some("/".to_string()),
3484 },
3485 BreadcrumbItem {
3486 label: "Users".to_string(),
3487 url: Some("/users".to_string()),
3488 },
3489 BreadcrumbItem {
3490 label: "Edit".to_string(),
3491 url: None,
3492 },
3493 ],
3494 }),
3495 action: None,
3496 visibility: None,
3497 });
3498 let html = render_to_html(&view, &json!({}));
3499 assert!(html.contains("<nav"));
3500 assert!(
3501 html.contains("<a href=\"/\""),
3502 "breadcrumb Home link should exist"
3503 );
3504 assert!(
3505 html.contains(">Home</a>"),
3506 "breadcrumb Home label should exist"
3507 );
3508 assert!(
3509 html.contains("<a href=\"/users\""),
3510 "breadcrumb Users link should exist"
3511 );
3512 assert!(
3513 html.contains(">Users</a>"),
3514 "breadcrumb Users label should exist"
3515 );
3516 assert!(html.contains("<span class=\"text-text font-medium\">Edit</span>"));
3518 assert!(html.contains("<svg"));
3520 }
3521
3522 #[test]
3523 fn breadcrumb_single_item() {
3524 let view = JsonUiView::new().component(ComponentNode {
3525 key: "bc".to_string(),
3526 component: Component::Breadcrumb(BreadcrumbProps {
3527 items: vec![BreadcrumbItem {
3528 label: "Home".to_string(),
3529 url: Some("/".to_string()),
3530 }],
3531 }),
3532 action: None,
3533 visibility: None,
3534 });
3535 let html = render_to_html(&view, &json!({}));
3536 assert!(html.contains("<span class=\"text-text font-medium\">Home</span>"));
3538 assert!(!html.contains("<span>/</span>"));
3540 }
3541
3542 #[test]
3545 fn pagination_renders_page_links() {
3546 let view = JsonUiView::new().component(ComponentNode {
3547 key: "pg".to_string(),
3548 component: Component::Pagination(PaginationProps {
3549 current_page: 2,
3550 per_page: 10,
3551 total: 50,
3552 base_url: None,
3553 }),
3554 action: None,
3555 visibility: None,
3556 });
3557 let html = render_to_html(&view, &json!({}));
3558 assert!(html.contains("<nav"));
3559 assert!(html.contains("bg-primary text-primary-foreground\">2</span>"));
3561 assert!(html.contains("?page=1"));
3563 assert!(html.contains("?page=3"));
3564 }
3565
3566 #[test]
3567 fn pagination_single_page_produces_no_output() {
3568 let view = JsonUiView::new().component(ComponentNode {
3569 key: "pg".to_string(),
3570 component: Component::Pagination(PaginationProps {
3571 current_page: 1,
3572 per_page: 10,
3573 total: 5,
3574 base_url: None,
3575 }),
3576 action: None,
3577 visibility: None,
3578 });
3579 let html = render_to_html(&view, &json!({}));
3580 assert!(!html.contains("<nav"));
3582 }
3583
3584 #[test]
3585 fn pagination_prev_and_next_buttons() {
3586 let view = JsonUiView::new().component(ComponentNode {
3587 key: "pg".to_string(),
3588 component: Component::Pagination(PaginationProps {
3589 current_page: 3,
3590 per_page: 10,
3591 total: 100,
3592 base_url: None,
3593 }),
3594 action: None,
3595 visibility: None,
3596 });
3597 let html = render_to_html(&view, &json!({}));
3598 assert!(html.contains("?page=2"));
3600 assert!(html.contains("?page=4"));
3602 }
3603
3604 #[test]
3605 fn pagination_no_prev_on_first_page() {
3606 let view = JsonUiView::new().component(ComponentNode {
3607 key: "pg".to_string(),
3608 component: Component::Pagination(PaginationProps {
3609 current_page: 1,
3610 per_page: 10,
3611 total: 30,
3612 base_url: None,
3613 }),
3614 action: None,
3615 visibility: None,
3616 });
3617 let html = render_to_html(&view, &json!({}));
3618 assert!(!html.contains("«"));
3620 assert!(html.contains("»"));
3622 }
3623
3624 #[test]
3625 fn pagination_custom_base_url() {
3626 let view = JsonUiView::new().component(ComponentNode {
3627 key: "pg".to_string(),
3628 component: Component::Pagination(PaginationProps {
3629 current_page: 1,
3630 per_page: 10,
3631 total: 30,
3632 base_url: Some("/users?sort=name&".to_string()),
3633 }),
3634 action: None,
3635 visibility: None,
3636 });
3637 let html = render_to_html(&view, &json!({}));
3638 assert!(html.contains("/users?sort=name&page=2"));
3639 }
3640
3641 #[test]
3644 fn description_list_renders_dl_dt_dd() {
3645 let view = JsonUiView::new().component(ComponentNode {
3646 key: "dl".to_string(),
3647 component: Component::DescriptionList(DescriptionListProps {
3648 items: vec![
3649 DescriptionItem {
3650 label: "Name".to_string(),
3651 value: "Alice".to_string(),
3652 format: None,
3653 },
3654 DescriptionItem {
3655 label: "Email".to_string(),
3656 value: "alice@example.com".to_string(),
3657 format: None,
3658 },
3659 ],
3660 columns: None,
3661 }),
3662 action: None,
3663 visibility: None,
3664 });
3665 let html = render_to_html(&view, &json!({}));
3666 assert!(html.contains("<dl"));
3667 assert!(html.contains("grid-cols-1"));
3668 assert!(html.contains("<dt class=\"text-sm font-medium text-text-muted\">Name</dt>"));
3669 assert!(html.contains("<dd class=\"mt-1 text-sm text-text\">Alice</dd>"));
3670 assert!(html.contains("<dt class=\"text-sm font-medium text-text-muted\">Email</dt>"));
3671 }
3672
3673 #[test]
3674 fn description_list_with_columns() {
3675 let view = JsonUiView::new().component(ComponentNode {
3676 key: "dl".to_string(),
3677 component: Component::DescriptionList(DescriptionListProps {
3678 items: vec![DescriptionItem {
3679 label: "Status".to_string(),
3680 value: "Active".to_string(),
3681 format: None,
3682 }],
3683 columns: Some(3),
3684 }),
3685 action: None,
3686 visibility: None,
3687 });
3688 let html = render_to_html(&view, &json!({}));
3689 assert!(html.contains("grid-cols-3"));
3690 }
3691
3692 #[test]
3695 fn xss_script_tags_escaped_in_text() {
3696 let view = JsonUiView::new().component(text_node(
3697 "t",
3698 "<script>alert('xss')</script>",
3699 TextElement::P,
3700 ));
3701 let html = render_to_html(&view, &json!({}));
3702 assert!(!html.contains("<script>"));
3703 assert!(html.contains("<script>"));
3704 assert!(html.contains("'"));
3705 }
3706
3707 #[test]
3708 fn xss_quotes_escaped_in_attributes() {
3709 let view = JsonUiView::new().component(ComponentNode {
3710 key: "av".to_string(),
3711 component: Component::Avatar(AvatarProps {
3712 src: Some("x\" onload=\"alert(1)".to_string()),
3713 alt: "Test".to_string(),
3714 fallback: None,
3715 size: None,
3716 }),
3717 action: None,
3718 visibility: None,
3719 });
3720 let html = render_to_html(&view, &json!({}));
3721 assert!(html.contains("""));
3723 assert!(html.contains("src=\"x" onload="alert(1)\""));
3725 }
3726
3727 #[test]
3728 fn xss_in_button_label() {
3729 let view = JsonUiView::new().component(ComponentNode {
3730 key: "b".to_string(),
3731 component: Component::Button(ButtonProps {
3732 label: "<img src=x onerror=alert(1)>".to_string(),
3733 variant: ButtonVariant::Default,
3734 size: Size::Default,
3735 disabled: None,
3736 icon: None,
3737 icon_position: None,
3738 button_type: None,
3739 }),
3740 action: None,
3741 visibility: None,
3742 });
3743 let html = render_to_html(&view, &json!({}));
3744 assert!(!html.contains("<img"));
3745 assert!(html.contains("<img"));
3746 }
3747
3748 #[test]
3749 fn xss_ampersand_in_content() {
3750 let view = JsonUiView::new().component(text_node("t", "Tom & Jerry", TextElement::P));
3751 let html = render_to_html(&view, &json!({}));
3752 assert!(html.contains("Tom & Jerry"));
3753 }
3754
3755 #[test]
3756 fn html_escape_function_covers_all_chars() {
3757 let result = html_escape("&<>\"'normal");
3758 assert_eq!(result, "&<>"'normal");
3759 }
3760
3761 #[test]
3764 fn get_action_wraps_in_anchor() {
3765 let view = JsonUiView::new().component(ComponentNode {
3766 key: "b".to_string(),
3767 component: Component::Button(ButtonProps {
3768 label: "View".to_string(),
3769 variant: ButtonVariant::Default,
3770 size: Size::Default,
3771 disabled: None,
3772 icon: None,
3773 icon_position: None,
3774 button_type: None,
3775 }),
3776 action: Some(make_action_with_url(
3777 "users.show",
3778 HttpMethod::Get,
3779 "/users/1",
3780 )),
3781 visibility: None,
3782 });
3783 let html = render_to_html(&view, &json!({}));
3784 assert!(html.contains("<a href=\"/users/1\" class=\"block\">"));
3785 assert!(html.contains("</a>"));
3786 assert!(html.contains("<button"));
3787 }
3788
3789 #[test]
3790 fn post_action_does_not_wrap_in_anchor() {
3791 let view = JsonUiView::new().component(ComponentNode {
3792 key: "b".to_string(),
3793 component: Component::Button(ButtonProps {
3794 label: "Submit".to_string(),
3795 variant: ButtonVariant::Default,
3796 size: Size::Default,
3797 disabled: None,
3798 icon: None,
3799 icon_position: None,
3800 button_type: None,
3801 }),
3802 action: Some(make_action_with_url(
3803 "users.store",
3804 HttpMethod::Post,
3805 "/users",
3806 )),
3807 visibility: None,
3808 });
3809 let html = render_to_html(&view, &json!({}));
3810 assert!(!html.contains("<a href="));
3811 assert!(html.contains("<button"));
3812 }
3813
3814 #[test]
3815 fn get_action_without_url_does_not_wrap() {
3816 let view = JsonUiView::new().component(ComponentNode {
3817 key: "b".to_string(),
3818 component: Component::Button(ButtonProps {
3819 label: "View".to_string(),
3820 variant: ButtonVariant::Default,
3821 size: Size::Default,
3822 disabled: None,
3823 icon: None,
3824 icon_position: None,
3825 button_type: None,
3826 }),
3827 action: Some(make_action("users.show", HttpMethod::Get)),
3828 visibility: None,
3829 });
3830 let html = render_to_html(&view, &json!({}));
3831 assert!(!html.contains("<a href="));
3832 }
3833
3834 #[test]
3835 fn delete_action_does_not_wrap_in_anchor() {
3836 let view = JsonUiView::new().component(ComponentNode {
3837 key: "b".to_string(),
3838 component: Component::Button(ButtonProps {
3839 label: "Delete".to_string(),
3840 variant: ButtonVariant::Destructive,
3841 size: Size::Default,
3842 disabled: None,
3843 icon: None,
3844 icon_position: None,
3845 button_type: None,
3846 }),
3847 action: Some(make_action_with_url(
3848 "users.destroy",
3849 HttpMethod::Delete,
3850 "/users/1",
3851 )),
3852 visibility: None,
3853 });
3854 let html = render_to_html(&view, &json!({}));
3855 assert!(!html.contains("<a href="));
3856 }
3857
3858 #[test]
3859 fn action_url_is_html_escaped() {
3860 let view = JsonUiView::new().component(ComponentNode {
3861 key: "b".to_string(),
3862 component: Component::Button(ButtonProps {
3863 label: "View".to_string(),
3864 variant: ButtonVariant::Default,
3865 size: Size::Default,
3866 disabled: None,
3867 icon: None,
3868 icon_position: None,
3869 button_type: None,
3870 }),
3871 action: Some(make_action_with_url(
3872 "users.show",
3873 HttpMethod::Get,
3874 "/users?id=1&name=test",
3875 )),
3876 visibility: None,
3877 });
3878 let html = render_to_html(&view, &json!({}));
3879 assert!(html.contains("href=\"/users?id=1&name=test\""));
3880 }
3881
3882 #[test]
3885 fn card_renders_title_and_description() {
3886 let view = JsonUiView::new().component(ComponentNode {
3887 key: "c".to_string(),
3888 component: Component::Card(CardProps {
3889 title: "My Card".to_string(),
3890 description: Some("A description".to_string()),
3891 children: vec![],
3892 footer: vec![],
3893 max_width: None,
3894 }),
3895 action: None,
3896 visibility: None,
3897 });
3898 let html = render_to_html(&view, &json!({}));
3899 assert!(html.contains("rounded-lg border border-border bg-card shadow-sm overflow-visible"));
3900 assert!(html
3901 .contains("<h3 class=\"text-base font-semibold leading-snug text-text\">My Card</h3>"));
3902 assert!(html.contains("<p class=\"mt-1 text-sm text-text-muted\">A description</p>"));
3903 }
3904
3905 #[test]
3906 fn card_renders_children_recursively() {
3907 let view = JsonUiView::new().component(ComponentNode {
3908 key: "c".to_string(),
3909 component: Component::Card(CardProps {
3910 title: "Card".to_string(),
3911 description: None,
3912 children: vec![text_node("t", "Child content", TextElement::P)],
3913 footer: vec![],
3914 max_width: None,
3915 }),
3916 action: None,
3917 visibility: None,
3918 });
3919 let html = render_to_html(&view, &json!({}));
3920 assert!(
3921 html.contains("mt-3 flex flex-wrap gap-3 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto overflow-visible")
3922 );
3923 assert!(html.contains("Child content"));
3924 }
3925
3926 #[test]
3927 fn card_renders_footer() {
3928 let view = JsonUiView::new().component(ComponentNode {
3929 key: "c".to_string(),
3930 component: Component::Card(CardProps {
3931 title: "Card".to_string(),
3932 description: None,
3933 children: vec![],
3934 max_width: None,
3935 footer: vec![button_node(
3936 "btn",
3937 "Save",
3938 ButtonVariant::Default,
3939 Size::Default,
3940 )],
3941 }),
3942 action: None,
3943 visibility: None,
3944 });
3945 let html = render_to_html(&view, &json!({}));
3946 assert!(html
3947 .contains("border-t border-border px-6 py-4 flex items-center justify-between gap-2"));
3948 assert!(html.contains(">Save</button>"));
3949 }
3950
3951 #[test]
3954 fn modal_renders_dialog_element() {
3955 let view = JsonUiView::new().component(ComponentNode {
3956 key: "m".to_string(),
3957 component: Component::Modal(ModalProps {
3958 id: "modal-confirm".to_string(),
3959 title: "Confirm".to_string(),
3960 description: Some("Are you sure?".to_string()),
3961 children: vec![text_node("t", "Body text", TextElement::P)],
3962 footer: vec![button_node(
3963 "ok",
3964 "OK",
3965 ButtonVariant::Default,
3966 Size::Default,
3967 )],
3968 trigger_label: Some("Open Modal".to_string()),
3969 }),
3970 action: None,
3971 visibility: None,
3972 });
3973 let html = render_to_html(&view, &json!({}));
3974 assert!(html.contains("<dialog"), "uses dialog element");
3975 assert!(html.contains("aria-modal=\"true\""), "has aria-modal");
3976 assert!(
3977 html.contains("data-modal-open=\"modal-confirm\""),
3978 "trigger has data-modal-open"
3979 );
3980 assert!(html.contains("data-modal-close"), "has close button");
3981 assert!(html.contains("Confirm"), "shows title");
3982 assert!(html.contains("Are you sure?"), "shows description");
3983 assert!(html.contains("Body text"), "shows children");
3984 assert!(html.contains(">OK</button>"), "shows footer");
3985 assert!(!html.contains("<details"), "no details element");
3986 assert!(!html.contains("<summary"), "no summary element");
3987 }
3988
3989 #[test]
3990 fn modal_default_trigger_label() {
3991 let view = JsonUiView::new().component(ComponentNode {
3992 key: "m".to_string(),
3993 component: Component::Modal(ModalProps {
3994 id: "modal-dialog".to_string(),
3995 title: "Dialog".to_string(),
3996 description: None,
3997 children: vec![],
3998 footer: vec![],
3999 trigger_label: None,
4000 }),
4001 action: None,
4002 visibility: None,
4003 });
4004 let html = render_to_html(&view, &json!({}));
4005 assert!(html.contains("Open"), "default trigger label");
4006 assert!(html.contains("<dialog"), "uses dialog element");
4007 }
4008
4009 #[test]
4012 fn tabs_renders_only_default_tab_content() {
4013 let view = JsonUiView::new().component(ComponentNode {
4014 key: "tabs".to_string(),
4015 component: Component::Tabs(TabsProps {
4016 default_tab: "general".to_string(),
4017 tabs: vec![
4018 Tab {
4019 value: "general".to_string(),
4020 label: "General".to_string(),
4021 children: vec![text_node("t1", "General content", TextElement::P)],
4022 },
4023 Tab {
4024 value: "security".to_string(),
4025 label: "Security".to_string(),
4026 children: vec![text_node("t2", "Security content", TextElement::P)],
4027 },
4028 ],
4029 }),
4030 action: None,
4031 visibility: None,
4032 });
4033 let html = render_to_html(&view, &json!({}));
4034 assert!(html.contains("border-b-2 border-primary text-primary"));
4036 assert!(html.contains(">General</button>"));
4037 assert!(html.contains("border-transparent text-text-muted"));
4039 assert!(html.contains(">Security</button>"));
4040 assert!(html.contains("General content"));
4042 assert!(html.contains("Security content"));
4043 assert!(html.contains("data-tab-panel=\"general\" class=\"pt-4 flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\""));
4045 assert!(html.contains("data-tab-panel=\"security\" class=\"pt-4 flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto hidden\""));
4046 }
4047
4048 #[test]
4051 fn form_renders_action_url_and_method() {
4052 let view = JsonUiView::new().component(ComponentNode {
4053 key: "f".to_string(),
4054 component: Component::Form(FormProps {
4055 action: Action {
4056 handler: "users.store".to_string(),
4057 url: Some("/users".to_string()),
4058 method: HttpMethod::Post,
4059 confirm: None,
4060 on_success: None,
4061 on_error: None,
4062 target: None,
4063 },
4064 fields: vec![],
4065 method: None,
4066 guard: None,
4067 max_width: None,
4068 }),
4069 action: None,
4070 visibility: None,
4071 });
4072 let html = render_to_html(&view, &json!({}));
4073 assert!(html.contains("action=\"/users\""));
4074 assert!(html.contains("method=\"post\""));
4075 assert!(html.contains(
4076 "class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\""
4077 ));
4078 }
4079
4080 #[test]
4081 fn form_method_spoofing_for_delete() {
4082 let view = JsonUiView::new().component(ComponentNode {
4083 key: "f".to_string(),
4084 component: Component::Form(FormProps {
4085 action: Action {
4086 handler: "users.destroy".to_string(),
4087 url: Some("/users/1".to_string()),
4088 method: HttpMethod::Delete,
4089 confirm: None,
4090 on_success: None,
4091 on_error: None,
4092 target: None,
4093 },
4094 fields: vec![],
4095 method: None,
4096 guard: None,
4097 max_width: None,
4098 }),
4099 action: None,
4100 visibility: None,
4101 });
4102 let html = render_to_html(&view, &json!({}));
4103 assert!(html.contains("method=\"post\""));
4104 assert!(html.contains("<input type=\"hidden\" name=\"_method\" value=\"DELETE\">"));
4105 }
4106
4107 #[test]
4108 fn form_method_spoofing_for_put() {
4109 let view = JsonUiView::new().component(ComponentNode {
4110 key: "f".to_string(),
4111 component: Component::Form(FormProps {
4112 action: Action {
4113 handler: "users.update".to_string(),
4114 url: Some("/users/1".to_string()),
4115 method: HttpMethod::Put,
4116 confirm: None,
4117 on_success: None,
4118 on_error: None,
4119 target: None,
4120 },
4121 fields: vec![],
4122 method: Some(HttpMethod::Put),
4123 guard: None,
4124 max_width: None,
4125 }),
4126 action: None,
4127 visibility: None,
4128 });
4129 let html = render_to_html(&view, &json!({}));
4130 assert!(html.contains("method=\"post\""));
4131 assert!(html.contains("name=\"_method\" value=\"PUT\""));
4132 }
4133
4134 #[test]
4135 fn form_get_method_no_spoofing() {
4136 let view = JsonUiView::new().component(ComponentNode {
4137 key: "f".to_string(),
4138 component: Component::Form(FormProps {
4139 action: Action {
4140 handler: "users.index".to_string(),
4141 url: Some("/users".to_string()),
4142 method: HttpMethod::Get,
4143 confirm: None,
4144 on_success: None,
4145 on_error: None,
4146 target: None,
4147 },
4148 fields: vec![],
4149 method: None,
4150 guard: None,
4151 max_width: None,
4152 }),
4153 action: None,
4154 visibility: None,
4155 });
4156 let html = render_to_html(&view, &json!({}));
4157 assert!(html.contains("method=\"get\""));
4158 assert!(!html.contains("_method"));
4159 }
4160
4161 #[test]
4164 fn input_renders_label_and_field() {
4165 let view = JsonUiView::new().component(ComponentNode {
4166 key: "i".to_string(),
4167 component: Component::Input(InputProps {
4168 field: "email".to_string(),
4169 label: "Email".to_string(),
4170 input_type: InputType::Email,
4171 placeholder: Some("user@example.com".to_string()),
4172 required: Some(true),
4173 disabled: None,
4174 error: None,
4175 description: Some("Your work email".to_string()),
4176 default_value: None,
4177 data_path: None,
4178 step: None,
4179 list: None,
4180 }),
4181 action: None,
4182 visibility: None,
4183 });
4184 let html = render_to_html(&view, &json!({}));
4185 assert!(html.contains("for=\"email\""));
4186 assert!(html.contains(">Email</label>"));
4187 assert!(html.contains("Your work email"));
4188 assert!(html.contains("type=\"email\""));
4189 assert!(html.contains("id=\"email\""));
4190 assert!(html.contains("name=\"email\""));
4191 assert!(html.contains("placeholder=\"user@example.com\""));
4192 assert!(html.contains(" required"));
4193 assert!(html.contains("border-border"));
4194 }
4195
4196 #[test]
4197 fn input_renders_error_with_red_border() {
4198 let view = JsonUiView::new().component(ComponentNode {
4199 key: "i".to_string(),
4200 component: Component::Input(InputProps {
4201 field: "name".to_string(),
4202 label: "Name".to_string(),
4203 input_type: InputType::Text,
4204 placeholder: None,
4205 required: None,
4206 disabled: None,
4207 error: Some("Name is required".to_string()),
4208 description: None,
4209 default_value: None,
4210 data_path: None,
4211 step: None,
4212 list: None,
4213 }),
4214 action: None,
4215 visibility: None,
4216 });
4217 let html = render_to_html(&view, &json!({}));
4218 assert!(html.contains("border-destructive"));
4219 assert!(html.contains("text-destructive") && html.contains("Name is required"));
4220 assert!(
4221 html.contains("ring-destructive"),
4222 "error input should have destructive ring"
4223 );
4224 }
4225
4226 #[test]
4227 fn input_resolves_data_path_for_value() {
4228 let data = json!({"user": {"name": "Alice"}});
4229 let view = JsonUiView::new().component(ComponentNode {
4230 key: "i".to_string(),
4231 component: Component::Input(InputProps {
4232 field: "name".to_string(),
4233 label: "Name".to_string(),
4234 input_type: InputType::Text,
4235 placeholder: None,
4236 required: None,
4237 disabled: None,
4238 error: None,
4239 description: None,
4240 default_value: None,
4241 data_path: Some("/user/name".to_string()),
4242 step: None,
4243 list: None,
4244 }),
4245 action: None,
4246 visibility: None,
4247 });
4248 let html = render_to_html(&view, &data);
4249 assert!(html.contains("value=\"Alice\""));
4250 }
4251
4252 #[test]
4253 fn input_default_value_overrides_data_path() {
4254 let data = json!({"user": {"name": "Alice"}});
4255 let view = JsonUiView::new().component(ComponentNode {
4256 key: "i".to_string(),
4257 component: Component::Input(InputProps {
4258 field: "name".to_string(),
4259 label: "Name".to_string(),
4260 input_type: InputType::Text,
4261 placeholder: None,
4262 required: None,
4263 disabled: None,
4264 error: None,
4265 description: None,
4266 default_value: Some("Bob".to_string()),
4267 data_path: Some("/user/name".to_string()),
4268 step: None,
4269 list: None,
4270 }),
4271 action: None,
4272 visibility: None,
4273 });
4274 let html = render_to_html(&view, &data);
4275 assert!(html.contains("value=\"Bob\""));
4276 assert!(!html.contains("Alice"));
4277 }
4278
4279 #[test]
4280 fn input_textarea_renders_textarea_element() {
4281 let view = JsonUiView::new().component(ComponentNode {
4282 key: "i".to_string(),
4283 component: Component::Input(InputProps {
4284 field: "bio".to_string(),
4285 label: "Bio".to_string(),
4286 input_type: InputType::Textarea,
4287 placeholder: Some("Tell us about yourself".to_string()),
4288 required: None,
4289 disabled: None,
4290 error: None,
4291 description: None,
4292 default_value: Some("Hello world".to_string()),
4293 data_path: None,
4294 step: None,
4295 list: None,
4296 }),
4297 action: None,
4298 visibility: None,
4299 });
4300 let html = render_to_html(&view, &json!({}));
4301 assert!(html.contains("<textarea"));
4302 assert!(html.contains(">Hello world</textarea>"));
4303 assert!(html.contains("placeholder=\"Tell us about yourself\""));
4304 }
4305
4306 #[test]
4307 fn input_hidden_renders_hidden_field() {
4308 let view = JsonUiView::new().component(ComponentNode {
4309 key: "i".to_string(),
4310 component: Component::Input(InputProps {
4311 field: "token".to_string(),
4312 label: "Token".to_string(),
4313 input_type: InputType::Hidden,
4314 placeholder: None,
4315 required: None,
4316 disabled: None,
4317 error: None,
4318 description: None,
4319 default_value: Some("abc123".to_string()),
4320 data_path: None,
4321 step: None,
4322 list: None,
4323 }),
4324 action: None,
4325 visibility: None,
4326 });
4327 let html = render_to_html(&view, &json!({}));
4328 assert!(html.contains("type=\"hidden\""));
4329 assert!(html.contains("value=\"abc123\""));
4330 }
4331
4332 #[test]
4333 fn input_renders_datalist() {
4334 let props = InputProps {
4335 field: "category".to_string(),
4336 label: "Category".to_string(),
4337 input_type: InputType::Text,
4338 placeholder: None,
4339 required: None,
4340 disabled: None,
4341 error: None,
4342 description: None,
4343 default_value: None,
4344 data_path: None,
4345 step: None,
4346 list: Some("cat-suggestions".to_string()),
4347 };
4348 let data = serde_json::json!({
4349 "cat-suggestions": ["Pizza", "Pasta", "Bevande"]
4350 });
4351 let html = render_input(&props, &data);
4352 assert!(
4353 html.contains("list=\"cat-suggestions\""),
4354 "input should have list attribute"
4355 );
4356 assert!(
4357 html.contains("<datalist id=\"cat-suggestions\">"),
4358 "should render datalist element"
4359 );
4360 assert!(
4361 html.contains("<option value=\"Pizza\">"),
4362 "should render option for Pizza"
4363 );
4364 assert!(
4365 html.contains("<option value=\"Pasta\">"),
4366 "should render option for Pasta"
4367 );
4368 assert!(
4369 html.contains("<option value=\"Bevande\">"),
4370 "should render option for Bevande"
4371 );
4372 assert!(html.contains("</datalist>"), "should close datalist");
4373 }
4374
4375 #[test]
4376 fn input_no_datalist_without_data() {
4377 let props = InputProps {
4378 field: "category".to_string(),
4379 label: "Category".to_string(),
4380 input_type: InputType::Text,
4381 placeholder: None,
4382 required: None,
4383 disabled: None,
4384 error: None,
4385 description: None,
4386 default_value: None,
4387 data_path: None,
4388 step: None,
4389 list: Some("missing-key".to_string()),
4390 };
4391 let data = serde_json::json!({});
4392 let html = render_input(&props, &data);
4393 assert!(
4394 html.contains("list=\"missing-key\""),
4395 "input should still have list attribute"
4396 );
4397 assert!(
4398 !html.contains("<datalist"),
4399 "should NOT render datalist when data key missing"
4400 );
4401 }
4402
4403 #[test]
4406 fn select_renders_options_with_selected() {
4407 let view = JsonUiView::new().component(ComponentNode {
4408 key: "s".to_string(),
4409 component: Component::Select(SelectProps {
4410 field: "role".to_string(),
4411 label: "Role".to_string(),
4412 options: vec![
4413 SelectOption {
4414 value: "admin".to_string(),
4415 label: "Admin".to_string(),
4416 },
4417 SelectOption {
4418 value: "user".to_string(),
4419 label: "User".to_string(),
4420 },
4421 ],
4422 placeholder: Some("Select a role".to_string()),
4423 required: Some(true),
4424 disabled: None,
4425 error: None,
4426 description: None,
4427 default_value: Some("admin".to_string()),
4428 data_path: None,
4429 }),
4430 action: None,
4431 visibility: None,
4432 });
4433 let html = render_to_html(&view, &json!({}));
4434 assert!(html.contains("for=\"role\""));
4435 assert!(html.contains("id=\"role\""));
4436 assert!(html.contains("name=\"role\""));
4437 assert!(html.contains("<option value=\"\">Select a role</option>"));
4438 assert!(html.contains("<option value=\"admin\" selected>Admin</option>"));
4439 assert!(html.contains("<option value=\"user\">User</option>"));
4440 assert!(html.contains(" required"));
4441 }
4442
4443 #[test]
4444 fn select_resolves_data_path_for_selected() {
4445 let data = json!({"user": {"role": "user"}});
4446 let view = JsonUiView::new().component(ComponentNode {
4447 key: "s".to_string(),
4448 component: Component::Select(SelectProps {
4449 field: "role".to_string(),
4450 label: "Role".to_string(),
4451 options: vec![
4452 SelectOption {
4453 value: "admin".to_string(),
4454 label: "Admin".to_string(),
4455 },
4456 SelectOption {
4457 value: "user".to_string(),
4458 label: "User".to_string(),
4459 },
4460 ],
4461 placeholder: None,
4462 required: None,
4463 disabled: None,
4464 error: None,
4465 description: None,
4466 default_value: None,
4467 data_path: Some("/user/role".to_string()),
4468 }),
4469 action: None,
4470 visibility: None,
4471 });
4472 let html = render_to_html(&view, &data);
4473 assert!(html.contains("<option value=\"user\" selected>User</option>"));
4474 assert!(!html.contains("<option value=\"admin\" selected>"));
4475 }
4476
4477 #[test]
4478 fn select_renders_error() {
4479 let view = JsonUiView::new().component(ComponentNode {
4480 key: "s".to_string(),
4481 component: Component::Select(SelectProps {
4482 field: "role".to_string(),
4483 label: "Role".to_string(),
4484 options: vec![],
4485 placeholder: None,
4486 required: None,
4487 disabled: None,
4488 error: Some("Role is required".to_string()),
4489 description: None,
4490 default_value: None,
4491 data_path: None,
4492 }),
4493 action: None,
4494 visibility: None,
4495 });
4496 let html = render_to_html(&view, &json!({}));
4497 assert!(html.contains("border-destructive"));
4498 assert!(html.contains("Role is required"));
4499 assert!(
4500 html.contains("ring-destructive"),
4501 "error select should have destructive ring"
4502 );
4503 }
4504
4505 #[test]
4508 fn checkbox_renders_checked_state() {
4509 let view = JsonUiView::new().component(ComponentNode {
4510 key: "cb".to_string(),
4511 component: Component::Checkbox(CheckboxProps {
4512 field: "terms".to_string(),
4513 value: None,
4514 label: "Accept Terms".to_string(),
4515 description: Some("You must accept".to_string()),
4516 checked: Some(true),
4517 data_path: None,
4518 required: Some(true),
4519 disabled: None,
4520 error: None,
4521 }),
4522 action: None,
4523 visibility: None,
4524 });
4525 let html = render_to_html(&view, &json!({}));
4526 assert!(html.contains("type=\"checkbox\""));
4527 assert!(html.contains("id=\"terms\""));
4528 assert!(html.contains("name=\"terms\""));
4529 assert!(html.contains("value=\"1\""));
4530 assert!(html.contains(" checked"));
4531 assert!(html.contains(" required"));
4532 assert!(html.contains("for=\"terms\""));
4533 assert!(html.contains(">Accept Terms</label>"));
4534 assert!(html.contains("ml-6 text-sm text-text-muted"));
4535 assert!(html.contains("You must accept"));
4536 }
4537
4538 #[test]
4539 fn checkbox_resolves_data_path_for_checked() {
4540 let data = json!({"user": {"accepted": true}});
4541 let view = JsonUiView::new().component(ComponentNode {
4542 key: "cb".to_string(),
4543 component: Component::Checkbox(CheckboxProps {
4544 field: "accepted".to_string(),
4545 value: None,
4546 label: "Accepted".to_string(),
4547 description: None,
4548 checked: None,
4549 data_path: Some("/user/accepted".to_string()),
4550 required: None,
4551 disabled: None,
4552 error: None,
4553 }),
4554 action: None,
4555 visibility: None,
4556 });
4557 let html = render_to_html(&view, &data);
4558 assert!(html.contains(" checked"));
4559 }
4560
4561 #[test]
4562 fn checkbox_renders_error() {
4563 let view = JsonUiView::new().component(ComponentNode {
4564 key: "cb".to_string(),
4565 component: Component::Checkbox(CheckboxProps {
4566 field: "terms".to_string(),
4567 value: None,
4568 label: "Terms".to_string(),
4569 description: None,
4570 checked: None,
4571 data_path: None,
4572 required: None,
4573 disabled: None,
4574 error: Some("Must accept".to_string()),
4575 }),
4576 action: None,
4577 visibility: None,
4578 });
4579 let html = render_to_html(&view, &json!({}));
4580 assert!(html.contains("ml-6 text-sm text-destructive"));
4581 assert!(html.contains("Must accept"));
4582 }
4583
4584 #[test]
4587 fn switch_renders_toggle_structure() {
4588 let view = JsonUiView::new().component(ComponentNode {
4589 key: "sw".to_string(),
4590 component: Component::Switch(SwitchProps {
4591 field: "notifications".to_string(),
4592 label: "Notifications".to_string(),
4593 description: Some("Get email updates".to_string()),
4594 checked: Some(true),
4595 data_path: None,
4596 required: None,
4597 disabled: None,
4598 error: None,
4599 action: None,
4600 }),
4601 action: None,
4602 visibility: None,
4603 });
4604 let html = render_to_html(&view, &json!({}));
4605 assert!(html.contains("sr-only peer"));
4606 assert!(html.contains("id=\"notifications\""));
4607 assert!(html.contains("name=\"notifications\""));
4608 assert!(html.contains("value=\"1\""));
4609 assert!(html.contains(" checked"));
4610 assert!(html.contains("peer-checked:bg-primary"));
4611 assert!(html.contains("for=\"notifications\""));
4612 assert!(html.contains(">Notifications</label>"));
4613 assert!(html.contains("Get email updates"));
4614 }
4615
4616 #[test]
4617 fn switch_renders_error() {
4618 let view = JsonUiView::new().component(ComponentNode {
4619 key: "sw".to_string(),
4620 component: Component::Switch(SwitchProps {
4621 field: "agree".to_string(),
4622 label: "Agree".to_string(),
4623 description: None,
4624 checked: None,
4625 data_path: None,
4626 required: None,
4627 disabled: None,
4628 error: Some("Required".to_string()),
4629 action: None,
4630 }),
4631 action: None,
4632 visibility: None,
4633 });
4634 let html = render_to_html(&view, &json!({}));
4635 assert!(html.contains("text-sm text-destructive"));
4636 assert!(html.contains("Required"));
4637 }
4638
4639 #[test]
4642 fn table_renders_headers_and_data_rows() {
4643 let data = json!({
4644 "users": [
4645 {"name": "Alice", "email": "alice@example.com"},
4646 {"name": "Bob", "email": "bob@example.com"}
4647 ]
4648 });
4649 let view = JsonUiView::new().component(ComponentNode {
4650 key: "t".to_string(),
4651 component: Component::Table(TableProps {
4652 columns: vec![
4653 Column {
4654 key: "name".to_string(),
4655 label: "Name".to_string(),
4656 format: None,
4657 },
4658 Column {
4659 key: "email".to_string(),
4660 label: "Email".to_string(),
4661 format: None,
4662 },
4663 ],
4664 data_path: "/users".to_string(),
4665 row_actions: None,
4666 empty_message: Some("No users".to_string()),
4667 sortable: None,
4668 sort_column: None,
4669 sort_direction: None,
4670 }),
4671 action: None,
4672 visibility: None,
4673 });
4674 let html = render_to_html(&view, &data);
4675 assert!(html.contains("tracking-wider text-text-muted\">Name</th>"));
4677 assert!(html.contains("tracking-wider text-text-muted\">Email</th>"));
4678 assert!(html.contains(">Alice</td>"));
4680 assert!(html.contains(">alice@example.com</td>"));
4681 assert!(html.contains(">Bob</td>"));
4682 assert!(html.contains(">bob@example.com</td>"));
4683 assert!(html.contains("overflow-x-auto"));
4685 }
4686
4687 #[test]
4688 fn table_renders_empty_message() {
4689 let data = json!({"users": []});
4690 let view = JsonUiView::new().component(ComponentNode {
4691 key: "t".to_string(),
4692 component: Component::Table(TableProps {
4693 columns: vec![Column {
4694 key: "name".to_string(),
4695 label: "Name".to_string(),
4696 format: None,
4697 }],
4698 data_path: "/users".to_string(),
4699 row_actions: None,
4700 empty_message: Some("No users found".to_string()),
4701 sortable: None,
4702 sort_column: None,
4703 sort_direction: None,
4704 }),
4705 action: None,
4706 visibility: None,
4707 });
4708 let html = render_to_html(&view, &data);
4709 assert!(html.contains("No users found"));
4710 assert!(html.contains("text-center text-sm text-text-muted"));
4711 }
4712
4713 #[test]
4714 fn table_renders_empty_message_when_path_missing() {
4715 let data = json!({});
4716 let view = JsonUiView::new().component(ComponentNode {
4717 key: "t".to_string(),
4718 component: Component::Table(TableProps {
4719 columns: vec![Column {
4720 key: "name".to_string(),
4721 label: "Name".to_string(),
4722 format: None,
4723 }],
4724 data_path: "/users".to_string(),
4725 row_actions: None,
4726 empty_message: Some("No data".to_string()),
4727 sortable: None,
4728 sort_column: None,
4729 sort_direction: None,
4730 }),
4731 action: None,
4732 visibility: None,
4733 });
4734 let html = render_to_html(&view, &data);
4735 assert!(html.contains("No data"));
4736 }
4737
4738 #[test]
4739 fn table_renders_row_actions() {
4740 let data = json!({"items": [{"name": "Item 1"}]});
4741 let view = JsonUiView::new().component(ComponentNode {
4742 key: "t".to_string(),
4743 component: Component::Table(TableProps {
4744 columns: vec![Column {
4745 key: "name".to_string(),
4746 label: "Name".to_string(),
4747 format: None,
4748 }],
4749 data_path: "/items".to_string(),
4750 row_actions: Some(vec![
4751 make_action_with_url("items.edit", HttpMethod::Get, "/items/1/edit"),
4752 make_action_with_url("items.destroy", HttpMethod::Delete, "/items/1"),
4753 ]),
4754 empty_message: None,
4755 sortable: None,
4756 sort_column: None,
4757 sort_direction: None,
4758 }),
4759 action: None,
4760 visibility: None,
4761 });
4762 let html = render_to_html(&view, &data);
4763 assert!(html.contains(">Azioni</th>"));
4765 assert!(html.contains("href=\"/items/1/edit\""));
4767 assert!(html.contains(">edit</a>"));
4768 assert!(html.contains("href=\"/items/1\""));
4769 assert!(html.contains(">destroy</a>"));
4770 }
4771
4772 #[test]
4773 fn table_handles_numeric_and_bool_cells() {
4774 let data = json!({"rows": [{"count": 42, "active": true}]});
4775 let view = JsonUiView::new().component(ComponentNode {
4776 key: "t".to_string(),
4777 component: Component::Table(TableProps {
4778 columns: vec![
4779 Column {
4780 key: "count".to_string(),
4781 label: "Count".to_string(),
4782 format: None,
4783 },
4784 Column {
4785 key: "active".to_string(),
4786 label: "Active".to_string(),
4787 format: None,
4788 },
4789 ],
4790 data_path: "/rows".to_string(),
4791 row_actions: None,
4792 empty_message: None,
4793 sortable: None,
4794 sort_column: None,
4795 sort_direction: None,
4796 }),
4797 action: None,
4798 visibility: None,
4799 });
4800 let html = render_to_html(&view, &data);
4801 assert!(html.contains(">42</td>"));
4802 assert!(html.contains(">true</td>"));
4803 }
4804
4805 #[test]
4808 fn plugin_renders_error_div_when_not_registered() {
4809 let view = JsonUiView::new().component(ComponentNode {
4810 key: "map-1".to_string(),
4811 component: Component::Plugin(PluginProps {
4812 plugin_type: "UnknownPluginXyz".to_string(),
4813 props: json!({"lat": 0}),
4814 }),
4815 action: None,
4816 visibility: None,
4817 });
4818 let html = render_to_html(&view, &json!({}));
4819 assert!(html.contains("Unknown plugin component: UnknownPluginXyz"));
4820 assert!(html.contains("bg-destructive/10"));
4821 }
4822
4823 #[test]
4824 fn collect_plugin_types_finds_top_level_plugins() {
4825 let view = JsonUiView::new()
4826 .component(ComponentNode {
4827 key: "map".to_string(),
4828 component: Component::Plugin(PluginProps {
4829 plugin_type: "Map".to_string(),
4830 props: json!({}),
4831 }),
4832 action: None,
4833 visibility: None,
4834 })
4835 .component(ComponentNode {
4836 key: "text".to_string(),
4837 component: Component::Text(TextProps {
4838 content: "Hello".to_string(),
4839 element: TextElement::P,
4840 }),
4841 action: None,
4842 visibility: None,
4843 });
4844 let types = collect_plugin_types(&view);
4845 assert_eq!(types.len(), 1);
4846 assert!(types.contains("Map"));
4847 }
4848
4849 #[test]
4850 fn collect_plugin_types_finds_nested_in_card() {
4851 let view = JsonUiView::new().component(ComponentNode {
4852 key: "card".to_string(),
4853 component: Component::Card(CardProps {
4854 title: "Test".to_string(),
4855 description: None,
4856 children: vec![ComponentNode {
4857 key: "chart".to_string(),
4858 component: Component::Plugin(PluginProps {
4859 plugin_type: "Chart".to_string(),
4860 props: json!({}),
4861 }),
4862 action: None,
4863 visibility: None,
4864 }],
4865 footer: vec![],
4866 max_width: None,
4867 }),
4868 action: None,
4869 visibility: None,
4870 });
4871 let types = collect_plugin_types(&view);
4872 assert!(types.contains("Chart"));
4873 }
4874
4875 #[test]
4876 fn collect_plugin_types_deduplicates() {
4877 let view = JsonUiView::new()
4878 .component(ComponentNode {
4879 key: "map1".to_string(),
4880 component: Component::Plugin(PluginProps {
4881 plugin_type: "Map".to_string(),
4882 props: json!({}),
4883 }),
4884 action: None,
4885 visibility: None,
4886 })
4887 .component(ComponentNode {
4888 key: "map2".to_string(),
4889 component: Component::Plugin(PluginProps {
4890 plugin_type: "Map".to_string(),
4891 props: json!({"zoom": 5}),
4892 }),
4893 action: None,
4894 visibility: None,
4895 });
4896 let types = collect_plugin_types(&view);
4897 assert_eq!(types.len(), 1);
4898 }
4899
4900 #[test]
4901 fn collect_plugin_types_empty_for_builtin_only() {
4902 let view = JsonUiView::new().component(ComponentNode {
4903 key: "text".to_string(),
4904 component: Component::Text(TextProps {
4905 content: "Hello".to_string(),
4906 element: TextElement::P,
4907 }),
4908 action: None,
4909 visibility: None,
4910 });
4911 let types = collect_plugin_types(&view);
4912 assert!(types.is_empty());
4913 }
4914
4915 #[test]
4916 fn render_to_html_with_plugins_returns_empty_assets_for_builtin_only() {
4917 let view = JsonUiView::new().component(ComponentNode {
4918 key: "text".to_string(),
4919 component: Component::Text(TextProps {
4920 content: "Hello".to_string(),
4921 element: TextElement::P,
4922 }),
4923 action: None,
4924 visibility: None,
4925 });
4926 let result = render_to_html_with_plugins(&view, &json!({}));
4927 assert!(result.css_head.is_empty());
4928 assert!(result.scripts.is_empty());
4929 assert!(result.html.contains("Hello"));
4930 }
4931
4932 #[test]
4933 fn render_css_tags_generates_link_elements() {
4934 let assets = vec![Asset::new("https://cdn.example.com/style.css")
4935 .integrity("sha256-abc")
4936 .crossorigin("")];
4937 let tags = render_css_tags(&assets);
4938 assert!(tags.contains("rel=\"stylesheet\""));
4939 assert!(tags.contains("href=\"https://cdn.example.com/style.css\""));
4940 assert!(tags.contains("integrity=\"sha256-abc\""));
4941 assert!(tags.contains("crossorigin=\"\""));
4942 }
4943
4944 #[test]
4945 fn render_js_tags_generates_script_elements() {
4946 let assets = vec![Asset::new("https://cdn.example.com/lib.js")];
4947 let init = vec!["initLib();".to_string()];
4948 let tags = render_js_tags(&assets, &init);
4949 assert!(tags.contains("src=\"https://cdn.example.com/lib.js\""));
4950 assert!(tags.contains("<script>initLib();</script>"));
4951 }
4952
4953 #[test]
4956 fn stat_card_renders_label_and_value() {
4957 let view = JsonUiView::new().component(ComponentNode::stat_card(
4958 "rev",
4959 StatCardProps {
4960 label: "Revenue".to_string(),
4961 value: "$1,234".to_string(),
4962 icon: None,
4963 subtitle: None,
4964 sse_target: None,
4965 },
4966 ));
4967 let html = render_to_html(&view, &json!({}));
4968 assert!(html.contains("Revenue"));
4969 assert!(html.contains("$1,234"));
4970 assert!(html.contains("bg-card rounded-lg shadow-sm"));
4971 }
4972
4973 #[test]
4974 fn stat_card_renders_icon_and_subtitle() {
4975 let view = JsonUiView::new().component(ComponentNode::stat_card(
4976 "users",
4977 StatCardProps {
4978 label: "Users".to_string(),
4979 value: "42".to_string(),
4980 icon: Some("👤".to_string()),
4981 subtitle: Some("active today".to_string()),
4982 sse_target: None,
4983 },
4984 ));
4985 let html = render_to_html(&view, &json!({}));
4986 assert!(html.contains("👤"));
4987 assert!(html.contains("active today"));
4988 }
4989
4990 #[test]
4991 fn stat_card_renders_svg_icon_without_escaping() {
4992 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/></svg>"#;
4993 let view = JsonUiView::new().component(ComponentNode::stat_card(
4994 "svg-icon",
4995 StatCardProps {
4996 label: "Test".to_string(),
4997 value: "0".to_string(),
4998 icon: Some(svg.to_string()),
4999 subtitle: None,
5000 sse_target: None,
5001 },
5002 ));
5003 let html = render_to_html(&view, &json!({}));
5004 assert!(
5005 html.contains("<svg"),
5006 "SVG should render as markup, not escaped text"
5007 );
5008 assert!(!html.contains("<svg"), "SVG should NOT be HTML-escaped");
5009 }
5010
5011 #[test]
5012 fn stat_card_renders_sse_target_data_attributes() {
5013 let view = JsonUiView::new().component(ComponentNode::stat_card(
5014 "live",
5015 StatCardProps {
5016 label: "Live count".to_string(),
5017 value: "100".to_string(),
5018 icon: None,
5019 subtitle: None,
5020 sse_target: Some("visitor_count".to_string()),
5021 },
5022 ));
5023 let html = render_to_html(&view, &json!({}));
5024 assert!(html.contains("data-sse-target=\"visitor_count\""));
5025 assert!(html.contains("data-live-value"));
5026 }
5027
5028 #[test]
5029 fn stat_card_no_sse_target_omits_data_attributes() {
5030 let view = JsonUiView::new().component(ComponentNode::stat_card(
5031 "static",
5032 StatCardProps {
5033 label: "Label".to_string(),
5034 value: "99".to_string(),
5035 icon: None,
5036 subtitle: None,
5037 sse_target: None,
5038 },
5039 ));
5040 let html = render_to_html(&view, &json!({}));
5041 assert!(!html.contains("data-sse-target"));
5042 assert!(!html.contains("data-live-value"));
5043 }
5044
5045 #[test]
5048 fn checklist_renders_title_and_items() {
5049 let view = JsonUiView::new().component(ComponentNode::checklist(
5050 "tasks",
5051 ChecklistProps {
5052 title: "Setup Tasks".to_string(),
5053 items: vec![
5054 ChecklistItem {
5055 label: "Create account".to_string(),
5056 checked: true,
5057 href: None,
5058 },
5059 ChecklistItem {
5060 label: "Add team member".to_string(),
5061 checked: false,
5062 href: None,
5063 },
5064 ],
5065 dismissible: true,
5066 dismiss_label: None,
5067 data_key: None,
5068 },
5069 ));
5070 let html = render_to_html(&view, &json!({}));
5071 assert!(html.contains("Setup Tasks"));
5072 assert!(html.contains("Create account"));
5073 assert!(html.contains("Add team member"));
5074 }
5075
5076 #[test]
5077 fn checklist_checked_item_has_strikethrough() {
5078 let view = JsonUiView::new().component(ComponentNode::checklist(
5079 "tasks",
5080 ChecklistProps {
5081 title: "Tasks".to_string(),
5082 items: vec![ChecklistItem {
5083 label: "Done".to_string(),
5084 checked: true,
5085 href: None,
5086 }],
5087 dismissible: false,
5088 dismiss_label: None,
5089 data_key: None,
5090 },
5091 ));
5092 let html = render_to_html(&view, &json!({}));
5093 assert!(html.contains("line-through"));
5094 assert!(html.contains("checked"));
5095 }
5096
5097 #[test]
5098 fn checklist_dismissible_renders_dismiss_button() {
5099 let view = JsonUiView::new().component(ComponentNode::checklist(
5100 "tasks",
5101 ChecklistProps {
5102 title: "Tasks".to_string(),
5103 items: vec![],
5104 dismissible: true,
5105 dismiss_label: Some("Close".to_string()),
5106 data_key: None,
5107 },
5108 ));
5109 let html = render_to_html(&view, &json!({}));
5110 assert!(html.contains("Close"));
5111 assert!(html.contains("data-dismissible"));
5112 assert!(html.contains("font-medium"));
5113 assert!(html.contains("text-text"));
5114 assert!(html.contains("hover:text-primary"));
5115 }
5116
5117 #[test]
5118 fn checklist_data_key_added_to_container() {
5119 let view = JsonUiView::new().component(ComponentNode::checklist(
5120 "tasks",
5121 ChecklistProps {
5122 title: "Tasks".to_string(),
5123 items: vec![],
5124 dismissible: false,
5125 dismiss_label: None,
5126 data_key: Some("onboarding_checklist".to_string()),
5127 },
5128 ));
5129 let html = render_to_html(&view, &json!({}));
5130 assert!(html.contains("data-checklist-key=\"onboarding_checklist\""));
5131 }
5132
5133 #[test]
5134 fn checklist_item_with_href_renders_link() {
5135 let view = JsonUiView::new().component(ComponentNode::checklist(
5136 "tasks",
5137 ChecklistProps {
5138 title: "Tasks".to_string(),
5139 items: vec![ChecklistItem {
5140 label: "Visit docs".to_string(),
5141 checked: false,
5142 href: Some("/docs".to_string()),
5143 }],
5144 dismissible: false,
5145 dismiss_label: None,
5146 data_key: None,
5147 },
5148 ));
5149 let html = render_to_html(&view, &json!({}));
5150 assert!(html.contains("href=\"/docs\""));
5151 assert!(html.contains("Visit docs"));
5152 }
5153
5154 #[test]
5157 fn toast_renders_message_and_variant() {
5158 let view = JsonUiView::new().component(ComponentNode::toast(
5159 "t",
5160 ToastProps {
5161 message: "Saved successfully!".to_string(),
5162 variant: ToastVariant::Success,
5163 timeout: None,
5164 dismissible: true,
5165 },
5166 ));
5167 let html = render_to_html(&view, &json!({}));
5168 assert!(html.contains("Saved successfully!"));
5169 assert!(html.contains("data-toast-variant=\"success\""));
5170 }
5171
5172 #[test]
5173 fn toast_renders_timeout_attribute() {
5174 let view = JsonUiView::new().component(ComponentNode::toast(
5175 "t",
5176 ToastProps {
5177 message: "Warning!".to_string(),
5178 variant: ToastVariant::Warning,
5179 timeout: Some(10),
5180 dismissible: false,
5181 },
5182 ));
5183 let html = render_to_html(&view, &json!({}));
5184 assert!(html.contains("data-toast-timeout=\"10\""));
5185 assert!(!html.contains("data-toast-dismissible"));
5186 }
5187
5188 #[test]
5189 fn toast_default_timeout_is_five_seconds() {
5190 let view = JsonUiView::new().component(ComponentNode::toast(
5191 "t",
5192 ToastProps {
5193 message: "Hello".to_string(),
5194 variant: ToastVariant::Info,
5195 timeout: None,
5196 dismissible: false,
5197 },
5198 ));
5199 let html = render_to_html(&view, &json!({}));
5200 assert!(html.contains("data-toast-timeout=\"5\""));
5201 }
5202
5203 #[test]
5204 fn toast_dismissible_renders_dismiss_button() {
5205 let view = JsonUiView::new().component(ComponentNode::toast(
5206 "t",
5207 ToastProps {
5208 message: "Error occurred".to_string(),
5209 variant: ToastVariant::Error,
5210 timeout: None,
5211 dismissible: true,
5212 },
5213 ));
5214 let html = render_to_html(&view, &json!({}));
5215 assert!(html.contains("data-toast-dismissible"));
5216 assert!(html.contains("×"));
5217 }
5218
5219 #[test]
5220 fn toast_info_variant_uses_blue_classes() {
5221 let view = JsonUiView::new().component(ComponentNode::toast(
5222 "t",
5223 ToastProps {
5224 message: "Info".to_string(),
5225 variant: ToastVariant::Info,
5226 timeout: None,
5227 dismissible: false,
5228 },
5229 ));
5230 let html = render_to_html(&view, &json!({}));
5231 assert!(html.contains("bg-primary/10"));
5232 assert!(html.contains("data-toast-variant=\"info\""));
5233 }
5234
5235 #[test]
5236 fn toast_has_fixed_position_classes() {
5237 let view = JsonUiView::new().component(ComponentNode::toast(
5238 "t",
5239 ToastProps {
5240 message: "msg".to_string(),
5241 variant: ToastVariant::Info,
5242 timeout: None,
5243 dismissible: false,
5244 },
5245 ));
5246 let html = render_to_html(&view, &json!({}));
5247 assert!(html.contains("fixed top-4 right-4 z-50"));
5248 }
5249
5250 #[test]
5253 fn notification_dropdown_renders_bell_icon() {
5254 let view = JsonUiView::new().component(ComponentNode::notification_dropdown(
5255 "notifs",
5256 NotificationDropdownProps {
5257 notifications: vec![],
5258 empty_text: None,
5259 },
5260 ));
5261 let html = render_to_html(&view, &json!({}));
5262 assert!(html.contains("data-notification-dropdown"));
5263 assert!(html.contains("data-notification-count=\"0\""));
5264 }
5265
5266 #[test]
5267 fn notification_dropdown_shows_unread_count_badge() {
5268 let view = JsonUiView::new().component(ComponentNode::notification_dropdown(
5269 "notifs",
5270 NotificationDropdownProps {
5271 notifications: vec![
5272 NotificationItem {
5273 icon: None,
5274 text: "New message".to_string(),
5275 timestamp: None,
5276 read: false,
5277 action_url: None,
5278 },
5279 NotificationItem {
5280 icon: None,
5281 text: "Old message".to_string(),
5282 timestamp: None,
5283 read: true,
5284 action_url: None,
5285 },
5286 ],
5287 empty_text: None,
5288 },
5289 ));
5290 let html = render_to_html(&view, &json!({}));
5291 assert!(html.contains("data-notification-count=\"1\""));
5292 assert!(html.contains("New message"));
5293 assert!(html.contains("Old message"));
5294 }
5295
5296 #[test]
5297 fn notification_dropdown_shows_empty_text_when_no_notifications() {
5298 let view = JsonUiView::new().component(ComponentNode::notification_dropdown(
5299 "notifs",
5300 NotificationDropdownProps {
5301 notifications: vec![],
5302 empty_text: Some("All caught up!".to_string()),
5303 },
5304 ));
5305 let html = render_to_html(&view, &json!({}));
5306 assert!(html.contains("All caught up!"));
5307 }
5308
5309 #[test]
5310 fn notification_dropdown_unread_indicator_for_unread_items() {
5311 let view = JsonUiView::new().component(ComponentNode::notification_dropdown(
5312 "notifs",
5313 NotificationDropdownProps {
5314 notifications: vec![NotificationItem {
5315 icon: None,
5316 text: "Unread".to_string(),
5317 timestamp: None,
5318 read: false,
5319 action_url: None,
5320 }],
5321 empty_text: None,
5322 },
5323 ));
5324 let html = render_to_html(&view, &json!({}));
5325 assert!(html.contains("bg-primary"));
5326 }
5327
5328 #[test]
5331 fn sidebar_renders_aside_element() {
5332 let view = JsonUiView::new().component(ComponentNode::sidebar(
5333 "nav",
5334 SidebarProps {
5335 fixed_top: vec![],
5336 groups: vec![],
5337 fixed_bottom: vec![],
5338 },
5339 ));
5340 let html = render_to_html(&view, &json!({}));
5341 assert!(html.contains("<aside"));
5342 assert!(html.contains("</aside>"));
5343 }
5344
5345 #[test]
5346 fn sidebar_renders_fixed_top_items() {
5347 let view = JsonUiView::new().component(ComponentNode::sidebar(
5348 "nav",
5349 SidebarProps {
5350 fixed_top: vec![SidebarNavItem {
5351 label: "Dashboard".to_string(),
5352 href: "/dashboard".to_string(),
5353 icon: None,
5354 active: true,
5355 }],
5356 groups: vec![],
5357 fixed_bottom: vec![],
5358 },
5359 ));
5360 let html = render_to_html(&view, &json!({}));
5361 assert!(html.contains("href=\"/dashboard\""));
5362 assert!(html.contains("Dashboard"));
5363 assert!(html.contains("bg-card text-primary"));
5364 }
5365
5366 #[test]
5367 fn sidebar_renders_groups_with_data_attribute() {
5368 let view = JsonUiView::new().component(ComponentNode::sidebar(
5369 "nav",
5370 SidebarProps {
5371 fixed_top: vec![],
5372 groups: vec![SidebarGroup {
5373 label: "Management".to_string(),
5374 collapsed: false,
5375 items: vec![SidebarNavItem {
5376 label: "Users".to_string(),
5377 href: "/users".to_string(),
5378 icon: None,
5379 active: false,
5380 }],
5381 }],
5382 fixed_bottom: vec![],
5383 },
5384 ));
5385 let html = render_to_html(&view, &json!({}));
5386 assert!(html.contains("data-sidebar-group"));
5387 assert!(html.contains("Management"));
5388 assert!(html.contains("Users"));
5389 assert!(!html.contains("data-collapsed"));
5390 }
5391
5392 #[test]
5393 fn sidebar_collapsed_group_has_data_collapsed() {
5394 let view = JsonUiView::new().component(ComponentNode::sidebar(
5395 "nav",
5396 SidebarProps {
5397 fixed_top: vec![],
5398 groups: vec![SidebarGroup {
5399 label: "Advanced".to_string(),
5400 collapsed: true,
5401 items: vec![],
5402 }],
5403 fixed_bottom: vec![],
5404 },
5405 ));
5406 let html = render_to_html(&view, &json!({}));
5407 assert!(html.contains("data-collapsed"));
5408 }
5409
5410 #[test]
5411 fn sidebar_inactive_item_uses_gray_classes() {
5412 let view = JsonUiView::new().component(ComponentNode::sidebar(
5413 "nav",
5414 SidebarProps {
5415 fixed_top: vec![SidebarNavItem {
5416 label: "Settings".to_string(),
5417 href: "/settings".to_string(),
5418 icon: None,
5419 active: false,
5420 }],
5421 groups: vec![],
5422 fixed_bottom: vec![],
5423 },
5424 ));
5425 let html = render_to_html(&view, &json!({}));
5426 assert!(html.contains("text-text-muted"));
5427 assert!(!html.contains("text-primary"));
5428 }
5429
5430 #[test]
5433 fn header_renders_business_name() {
5434 let view = JsonUiView::new().component(ComponentNode::header(
5435 "hdr",
5436 HeaderProps {
5437 business_name: "Acme Corp".to_string(),
5438 notification_count: None,
5439 user_name: None,
5440 user_avatar: None,
5441 logout_url: None,
5442 },
5443 ));
5444 let html = render_to_html(&view, &json!({}));
5445 assert!(html.contains("<header"));
5446 assert!(html.contains("Acme Corp"));
5447 }
5448
5449 #[test]
5450 fn header_renders_notification_count_badge() {
5451 let view = JsonUiView::new().component(ComponentNode::header(
5452 "hdr",
5453 HeaderProps {
5454 business_name: "Acme".to_string(),
5455 notification_count: Some(3),
5456 user_name: None,
5457 user_avatar: None,
5458 logout_url: None,
5459 },
5460 ));
5461 let html = render_to_html(&view, &json!({}));
5462 assert!(html.contains("data-notification-count=\"3\""));
5463 }
5464
5465 #[test]
5466 fn header_no_badge_when_count_is_zero() {
5467 let view = JsonUiView::new().component(ComponentNode::header(
5468 "hdr",
5469 HeaderProps {
5470 business_name: "Acme".to_string(),
5471 notification_count: Some(0),
5472 user_name: None,
5473 user_avatar: None,
5474 logout_url: None,
5475 },
5476 ));
5477 let html = render_to_html(&view, &json!({}));
5478 assert!(html.contains("data-notification-count=\"0\""));
5479 assert!(!html.contains("bg-destructive"));
5481 }
5482
5483 #[test]
5484 fn header_renders_user_name_initials() {
5485 let view = JsonUiView::new().component(ComponentNode::header(
5486 "hdr",
5487 HeaderProps {
5488 business_name: "Acme".to_string(),
5489 notification_count: None,
5490 user_name: Some("John Doe".to_string()),
5491 user_avatar: None,
5492 logout_url: None,
5493 },
5494 ));
5495 let html = render_to_html(&view, &json!({}));
5496 assert!(html.contains("JD"));
5497 assert!(html.contains("John Doe"));
5498 }
5499
5500 #[test]
5501 fn header_renders_avatar_image_when_provided() {
5502 let view = JsonUiView::new().component(ComponentNode::header(
5503 "hdr",
5504 HeaderProps {
5505 business_name: "Acme".to_string(),
5506 notification_count: None,
5507 user_name: None,
5508 user_avatar: Some("/avatar.jpg".to_string()),
5509 logout_url: None,
5510 },
5511 ));
5512 let html = render_to_html(&view, &json!({}));
5513 assert!(html.contains("src=\"/avatar.jpg\""));
5514 assert!(html.contains("rounded-full"));
5515 }
5516
5517 #[test]
5518 fn header_renders_logout_link() {
5519 let view = JsonUiView::new().component(ComponentNode::header(
5520 "hdr",
5521 HeaderProps {
5522 business_name: "Acme".to_string(),
5523 notification_count: None,
5524 user_name: None,
5525 user_avatar: None,
5526 logout_url: Some("/logout".to_string()),
5527 },
5528 ));
5529 let html = render_to_html(&view, &json!({}));
5530 assert!(html.contains("href=\"/logout\""));
5531 assert!(html.contains("Logout"));
5532 }
5533
5534 #[test]
5535 fn header_escapes_business_name_xss() {
5536 let view = JsonUiView::new().component(ComponentNode::header(
5537 "hdr",
5538 HeaderProps {
5539 business_name: "<script>alert(1)</script>".to_string(),
5540 notification_count: None,
5541 user_name: None,
5542 user_avatar: None,
5543 logout_url: None,
5544 },
5545 ));
5546 let html = render_to_html(&view, &json!({}));
5547 assert!(!html.contains("<script>"));
5548 assert!(html.contains("<script>"));
5549 }
5550
5551 #[test]
5554 fn test_render_deeply_nested_components() {
5555 let inner_card = ComponentNode::card(
5557 "inner-card",
5558 CardProps {
5559 title: "Inner Card".to_string(),
5560 description: None,
5561 children: vec![ComponentNode {
5562 key: "inner-text".to_string(),
5563 component: Component::Text(TextProps {
5564 content: "Deep content".to_string(),
5565 element: TextElement::P,
5566 }),
5567 action: None,
5568 visibility: None,
5569 }],
5570 footer: vec![],
5571 max_width: None,
5572 },
5573 );
5574 let outer_card = ComponentNode::card(
5575 "outer-card",
5576 CardProps {
5577 title: "Outer Card".to_string(),
5578 description: None,
5579 children: vec![inner_card],
5580 footer: vec![],
5581 max_width: None,
5582 },
5583 );
5584 let view = JsonUiView::new().component(outer_card);
5585 let html = render_to_html(&view, &json!({}));
5586
5587 assert!(
5588 html.contains("Outer Card"),
5589 "outer card title should be rendered"
5590 );
5591 assert!(
5592 html.contains("Inner Card"),
5593 "inner card title should be rendered"
5594 );
5595 assert!(
5596 html.contains("Deep content"),
5597 "nested text content should be rendered"
5598 );
5599 }
5600
5601 #[test]
5602 fn test_render_empty_view() {
5603 let view = JsonUiView::new();
5604 let html = render_to_html(&view, &json!({}));
5605 assert_eq!(html, "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\"></div>", "empty view renders empty div");
5606 }
5607
5608 #[test]
5609 fn test_render_component_with_visibility_and_action() {
5610 use crate::action::{Action, HttpMethod};
5611 use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
5612
5613 let node = ComponentNode {
5615 key: "admin-link".to_string(),
5616 component: Component::Button(ButtonProps {
5617 label: "View Reports".to_string(),
5618 variant: ButtonVariant::Default,
5619 size: Size::Default,
5620 disabled: None,
5621 icon: None,
5622 icon_position: None,
5623 button_type: None,
5624 }),
5625 action: Some(Action {
5626 handler: "reports.index".to_string(),
5627 url: Some("/reports".to_string()),
5628 method: HttpMethod::Get,
5629 confirm: None,
5630 on_success: None,
5631 on_error: None,
5632 target: None,
5633 }),
5634 visibility: Some(Visibility::Condition(VisibilityCondition {
5635 path: "/auth/user/role".to_string(),
5636 operator: VisibilityOperator::Eq,
5637 value: Some(serde_json::Value::String("admin".to_string())),
5638 })),
5639 };
5640 let view = JsonUiView::new().component(node);
5641 let html = render_to_html(&view, &json!({}));
5642
5643 assert!(
5645 html.contains("View Reports"),
5646 "button label should be rendered"
5647 );
5648 assert!(
5649 html.contains("href=\"/reports\""),
5650 "GET action with URL should produce anchor href"
5651 );
5652 assert!(
5653 html.contains("<a "),
5654 "GET action should wrap component in anchor tag"
5655 );
5656 }
5657
5658 #[test]
5661 fn grid_renders_columns_and_gap() {
5662 let view = JsonUiView::new().component(ComponentNode::grid(
5663 "g",
5664 crate::component::GridProps {
5665 columns: 4,
5666 md_columns: None,
5667 lg_columns: None,
5668 gap: crate::component::GapSize::Lg,
5669 scrollable: None,
5670 children: vec![text_node("c1", "Cell 1", TextElement::P)],
5671 },
5672 ));
5673 let html = render_to_html(&view, &json!({}));
5674 assert!(html.contains("grid w-full grid-cols-4 gap-6"));
5675 assert!(html.contains("Cell 1"));
5676 }
5677
5678 #[test]
5679 fn grid_clamps_columns() {
5680 let view = JsonUiView::new().component(ComponentNode::grid(
5681 "g",
5682 crate::component::GridProps {
5683 columns: 20,
5684 md_columns: None,
5685 lg_columns: None,
5686 gap: crate::component::GapSize::default(),
5687 scrollable: None,
5688 children: vec![],
5689 },
5690 ));
5691 let html = render_to_html(&view, &json!({}));
5692 assert!(html.contains("grid-cols-12"));
5693 }
5694
5695 #[test]
5696 fn grid_responsive_md_columns() {
5697 let view = JsonUiView::new().component(ComponentNode::grid(
5698 "g",
5699 crate::component::GridProps {
5700 columns: 1,
5701 md_columns: Some(3),
5702 lg_columns: None,
5703 gap: crate::component::GapSize::Md,
5704 scrollable: None,
5705 children: vec![text_node("c1", "Cell 1", TextElement::P)],
5706 },
5707 ));
5708 let html = render_to_html(&view, &json!({}));
5709 assert!(html.contains("grid-cols-1 md:grid-cols-3"));
5710 }
5711
5712 #[test]
5713 fn grid_scrollable_renders_overflow() {
5714 let view = JsonUiView::new().component(ComponentNode::grid(
5715 "g",
5716 crate::component::GridProps {
5717 columns: 3,
5718 md_columns: None,
5719 lg_columns: None,
5720 gap: crate::component::GapSize::Md,
5721 scrollable: Some(true),
5722 children: vec![
5723 text_node("c1", "Col 1", TextElement::P),
5724 text_node("c2", "Col 2", TextElement::P),
5725 ],
5726 },
5727 ));
5728 let html = render_to_html(&view, &json!({}));
5729 assert!(
5730 html.contains("overflow-x-auto"),
5731 "should wrap with overflow-x-auto"
5732 );
5733 assert!(
5734 html.contains("grid-flow-col"),
5735 "should use grid-flow-col for scrollable"
5736 );
5737 assert!(
5738 html.contains("auto-cols-[minmax(280px,1fr)]"),
5739 "should use auto-cols"
5740 );
5741 assert!(html.contains("Col 1"));
5742 assert!(html.contains("Col 2"));
5743 }
5744
5745 #[test]
5746 fn grid_non_scrollable_unchanged() {
5747 let view = JsonUiView::new().component(ComponentNode::grid(
5748 "g",
5749 crate::component::GridProps {
5750 columns: 3,
5751 md_columns: None,
5752 lg_columns: None,
5753 gap: crate::component::GapSize::Md,
5754 scrollable: None,
5755 children: vec![text_node("c1", "Cell 1", TextElement::P)],
5756 },
5757 ));
5758 let html = render_to_html(&view, &json!({}));
5759 assert!(
5760 html.contains("grid w-full grid-cols-3 gap-4"),
5761 "non-scrollable should use grid-cols-N"
5762 );
5763 assert!(
5764 !html.contains("overflow-x-auto"),
5765 "non-scrollable should not have overflow-x-auto"
5766 );
5767 assert!(
5768 !html.contains("grid-flow-col"),
5769 "non-scrollable should not have grid-flow-col"
5770 );
5771 }
5772
5773 #[test]
5774 fn form_guard_renders_data_attribute() {
5775 let view = JsonUiView::new().component(ComponentNode {
5776 key: "f".to_string(),
5777 component: Component::Form(crate::component::FormProps {
5778 action: Action {
5779 handler: "orders.create".to_string(),
5780 url: Some("/orders".to_string()),
5781 method: HttpMethod::Post,
5782 confirm: None,
5783 on_success: None,
5784 on_error: None,
5785 target: None,
5786 },
5787 fields: vec![],
5788 method: None,
5789 guard: Some("number-gt-0".to_string()),
5790 max_width: None,
5791 }),
5792 action: None,
5793 visibility: None,
5794 });
5795 let html = render_to_html(&view, &json!({}));
5796 assert!(
5797 html.contains("data-form-guard=\"number-gt-0\""),
5798 "form with guard should render data-form-guard attribute"
5799 );
5800 }
5801
5802 #[test]
5803 fn form_without_guard_unchanged() {
5804 let view = JsonUiView::new().component(ComponentNode {
5805 key: "f".to_string(),
5806 component: Component::Form(crate::component::FormProps {
5807 action: Action {
5808 handler: "orders.create".to_string(),
5809 url: Some("/orders".to_string()),
5810 method: HttpMethod::Post,
5811 confirm: None,
5812 on_success: None,
5813 on_error: None,
5814 target: None,
5815 },
5816 fields: vec![],
5817 method: None,
5818 guard: None,
5819 max_width: None,
5820 }),
5821 action: None,
5822 visibility: None,
5823 });
5824 let html = render_to_html(&view, &json!({}));
5825 assert!(
5826 !html.contains("data-form-guard"),
5827 "form without guard should not render data-form-guard attribute"
5828 );
5829 }
5830
5831 #[test]
5834 fn collapsible_renders_details_summary() {
5835 let view = JsonUiView::new().component(ComponentNode::collapsible(
5836 "c",
5837 crate::component::CollapsibleProps {
5838 title: "More info".into(),
5839 expanded: false,
5840 children: vec![text_node("t", "Hidden text", TextElement::P)],
5841 },
5842 ));
5843 let html = render_to_html(&view, &json!({}));
5844 assert!(html.contains("<details"));
5845 assert!(!html.contains(" open"));
5846 assert!(html.contains("<summary"));
5847 assert!(html.contains("More info"));
5848 assert!(html.contains("Hidden text"));
5849 }
5850
5851 #[test]
5852 fn collapsible_expanded_has_open() {
5853 let view = JsonUiView::new().component(ComponentNode::collapsible(
5854 "c",
5855 crate::component::CollapsibleProps {
5856 title: "Open".into(),
5857 expanded: true,
5858 children: vec![],
5859 },
5860 ));
5861 let html = render_to_html(&view, &json!({}));
5862 assert!(html.contains("<details") && html.contains(" open"));
5863 }
5864
5865 #[test]
5868 fn empty_state_renders_title_and_description() {
5869 let view = JsonUiView::new().component(ComponentNode::empty_state(
5870 "es",
5871 crate::component::EmptyStateProps {
5872 title: "No orders".into(),
5873 description: Some("Create your first order".into()),
5874 action: None,
5875 action_label: None,
5876 },
5877 ));
5878 let html = render_to_html(&view, &json!({}));
5879 assert!(html.contains("No orders"));
5880 assert!(html.contains("Create your first order"));
5881 assert!(!html.contains("<a "));
5882 }
5883
5884 #[test]
5885 fn empty_state_renders_action_link() {
5886 let view = JsonUiView::new().component(ComponentNode::empty_state(
5887 "es",
5888 crate::component::EmptyStateProps {
5889 title: "Empty".into(),
5890 description: None,
5891 action: Some(Action {
5892 handler: "orders.new".into(),
5893 url: Some("/orders/new".into()),
5894 method: HttpMethod::Get,
5895 confirm: None,
5896 on_success: None,
5897 on_error: None,
5898 target: None,
5899 }),
5900 action_label: Some("New order".into()),
5901 },
5902 ));
5903 let html = render_to_html(&view, &json!({}));
5904 assert!(html.contains("href=\"/orders/new\""));
5905 assert!(html.contains("New order"));
5906 }
5907
5908 #[test]
5911 fn form_section_renders_fieldset() {
5912 let view = JsonUiView::new().component(ComponentNode::form_section(
5913 "fs",
5914 crate::component::FormSectionProps {
5915 title: "Contact".into(),
5916 description: Some("Enter details".into()),
5917 children: vec![text_node("n", "Name field", TextElement::P)],
5918 layout: None,
5919 },
5920 ));
5921 let html = render_to_html(&view, &json!({}));
5922 assert!(html.contains("<fieldset"));
5923 assert!(html.contains("<legend"));
5924 assert!(html.contains("Contact"));
5925 assert!(html.contains("Enter details"));
5926 assert!(html.contains("Name field"));
5927 }
5928
5929 #[test]
5932 fn switch_with_action_renders_form() {
5933 let view = JsonUiView::new().component(ComponentNode::switch(
5934 "sw",
5935 SwitchProps {
5936 field: "active".into(),
5937 label: "Active".into(),
5938 description: None,
5939 checked: Some(true),
5940 data_path: None,
5941 required: None,
5942 disabled: None,
5943 error: None,
5944 action: Some(Action {
5945 handler: "settings.toggle".into(),
5946 url: Some("/settings/toggle".into()),
5947 method: HttpMethod::Post,
5948 confirm: None,
5949 on_success: None,
5950 on_error: None,
5951 target: None,
5952 }),
5953 },
5954 ));
5955 let html = render_to_html(&view, &json!({}));
5956 assert!(html.contains("<form action=\"/settings/toggle\" method=\"post\">"));
5957 assert!(html.contains("onchange=\"this.closest('form').submit()\""));
5958 assert!(html.contains("</form>"));
5959 }
5960
5961 #[test]
5962 fn switch_without_action_no_form() {
5963 let view = JsonUiView::new().component(ComponentNode::switch(
5964 "sw",
5965 SwitchProps {
5966 field: "f".into(),
5967 label: "L".into(),
5968 description: None,
5969 checked: None,
5970 data_path: None,
5971 required: None,
5972 disabled: None,
5973 error: None,
5974 action: None,
5975 },
5976 ));
5977 let html = render_to_html(&view, &json!({}));
5978 assert!(!html.contains("<form"));
5979 assert!(!html.contains("onchange"));
5980 }
5981
5982 #[test]
5985 fn test_render_page_header_title_only() {
5986 let view = JsonUiView::new().component(ComponentNode {
5987 key: "ph".to_string(),
5988 component: Component::PageHeader(PageHeaderProps {
5989 title: "My Page".to_string(),
5990 breadcrumb: vec![],
5991 actions: vec![],
5992 }),
5993 action: None,
5994 visibility: None,
5995 });
5996 let html = render_to_html(&view, &json!({}));
5997 assert!(html.contains("pb-4"), "flex container with pb-4");
5998 assert!(html.contains(
5999 "<h2 class=\"text-2xl font-semibold leading-tight tracking-tight text-text truncate\">My Page</h2>"
6000 ));
6001 assert!(!html.contains("<nav"), "no breadcrumb nav when empty");
6002 assert!(!html.contains("flex-shrink-0"), "no actions div when empty");
6003 }
6004
6005 #[test]
6006 fn test_render_page_header_with_breadcrumb() {
6007 let view = JsonUiView::new().component(ComponentNode {
6008 key: "ph".to_string(),
6009 component: Component::PageHeader(PageHeaderProps {
6010 title: "Users".to_string(),
6011 breadcrumb: vec![
6012 BreadcrumbItem {
6013 label: "Home".to_string(),
6014 url: Some("/".to_string()),
6015 },
6016 BreadcrumbItem {
6017 label: "Users".to_string(),
6018 url: None,
6019 },
6020 ],
6021 actions: vec![],
6022 }),
6023 action: None,
6024 visibility: None,
6025 });
6026 let html = render_to_html(&view, &json!({}));
6027 assert!(html.contains("<a href=\"/\" class=\"text-sm text-text-muted hover:text-text whitespace-nowrap\">Home</a>"));
6028 assert!(
6029 html.contains("<span class=\"text-sm text-text-muted whitespace-nowrap\">Users</span>")
6030 );
6031 assert!(
6032 html.contains("<svg"),
6033 "SVG chevron separator between breadcrumb items"
6034 );
6035 }
6036
6037 #[test]
6038 fn test_render_page_header_with_actions() {
6039 let view = JsonUiView::new().component(ComponentNode {
6040 key: "ph".to_string(),
6041 component: Component::PageHeader(PageHeaderProps {
6042 title: "Dashboard".to_string(),
6043 breadcrumb: vec![],
6044 actions: vec![ComponentNode {
6045 key: "add-btn".to_string(),
6046 component: Component::Button(ButtonProps {
6047 label: "Add New".to_string(),
6048 variant: ButtonVariant::Default,
6049 size: Size::Default,
6050 disabled: None,
6051 icon: None,
6052 icon_position: None,
6053 button_type: None,
6054 }),
6055 action: None,
6056 visibility: None,
6057 }],
6058 }),
6059 action: None,
6060 visibility: None,
6061 });
6062 let html = render_to_html(&view, &json!({}));
6063 assert!(
6064 html.contains("flex flex-wrap items-center gap-2"),
6065 "actions wrapper with flex"
6066 );
6067 assert!(
6068 html.contains(">Add New</button>"),
6069 "action button rendered inside"
6070 );
6071 }
6072
6073 #[test]
6076 fn test_render_button_group() {
6077 let view = JsonUiView::new().component(ComponentNode {
6078 key: "bg".to_string(),
6079 component: Component::ButtonGroup(ButtonGroupProps {
6080 buttons: vec![
6081 ComponentNode {
6082 key: "save".to_string(),
6083 component: Component::Button(ButtonProps {
6084 label: "Save".to_string(),
6085 variant: ButtonVariant::Default,
6086 size: Size::Default,
6087 disabled: None,
6088 icon: None,
6089 icon_position: None,
6090 button_type: None,
6091 }),
6092 action: None,
6093 visibility: None,
6094 },
6095 ComponentNode {
6096 key: "cancel".to_string(),
6097 component: Component::Button(ButtonProps {
6098 label: "Cancel".to_string(),
6099 variant: ButtonVariant::Outline,
6100 size: Size::Default,
6101 disabled: None,
6102 icon: None,
6103 icon_position: None,
6104 button_type: None,
6105 }),
6106 action: None,
6107 visibility: None,
6108 },
6109 ],
6110 }),
6111 action: None,
6112 visibility: None,
6113 });
6114 let html = render_to_html(&view, &json!({}));
6115 assert!(
6116 html.contains("flex items-center gap-2 flex-wrap"),
6117 "horizontal flex container"
6118 );
6119 assert!(html.contains(">Save</button>"));
6120 assert!(html.contains(">Cancel</button>"));
6121 }
6122
6123 #[test]
6124 fn test_render_button_group_empty() {
6125 let view = JsonUiView::new().component(ComponentNode {
6126 key: "bg".to_string(),
6127 component: Component::ButtonGroup(ButtonGroupProps { buttons: vec![] }),
6128 action: None,
6129 visibility: None,
6130 });
6131 let html = render_to_html(&view, &json!({}));
6132 assert!(html.contains("<div class=\"flex items-center gap-2 flex-wrap\"></div>"));
6133 }
6134
6135 #[test]
6138 fn test_render_select_appearance_none() {
6139 let view = JsonUiView::new().component(ComponentNode {
6140 key: "sel".to_string(),
6141 component: Component::Select(SelectProps {
6142 field: "role".to_string(),
6143 label: "Role".to_string(),
6144 options: vec![SelectOption {
6145 value: "admin".to_string(),
6146 label: "Admin".to_string(),
6147 }],
6148 placeholder: None,
6149 required: None,
6150 disabled: None,
6151 error: None,
6152 description: None,
6153 default_value: None,
6154 data_path: None,
6155 }),
6156 action: None,
6157 visibility: None,
6158 });
6159 let html = render_to_html(&view, &json!({}));
6160 assert!(
6161 html.contains("appearance-none"),
6162 "select must have appearance-none class"
6163 );
6164 assert!(
6165 html.contains("bg-background"),
6166 "select must have bg-background class"
6167 );
6168 }
6169
6170 #[test]
6173 fn test_render_tabs_single_tab() {
6174 let view = JsonUiView::new().component(ComponentNode {
6175 key: "tabs".to_string(),
6176 component: Component::Tabs(TabsProps {
6177 default_tab: "only".to_string(),
6178 tabs: vec![Tab {
6179 value: "only".to_string(),
6180 label: "Only Tab".to_string(),
6181 children: vec![ComponentNode {
6182 key: "txt".to_string(),
6183 component: Component::Text(TextProps {
6184 content: "Content here".to_string(),
6185 element: TextElement::P,
6186 }),
6187 action: None,
6188 visibility: None,
6189 }],
6190 }],
6191 }),
6192 action: None,
6193 visibility: None,
6194 });
6195 let html = render_to_html(&view, &json!({}));
6196 assert!(
6197 !html.contains("data-tabs"),
6198 "no data-tabs wrapper for single tab"
6199 );
6200 assert!(
6201 !html.contains("role=\"tablist\""),
6202 "no tab nav for single tab"
6203 );
6204 assert!(html.contains("Content here"), "tab content still rendered");
6205 }
6206
6207 #[test]
6208 fn test_render_tabs_multi_still_works() {
6209 let view = JsonUiView::new().component(ComponentNode {
6210 key: "tabs".to_string(),
6211 component: Component::Tabs(TabsProps {
6212 default_tab: "tab1".to_string(),
6213 tabs: vec![
6214 Tab {
6215 value: "tab1".to_string(),
6216 label: "Tab One".to_string(),
6217 children: vec![ComponentNode {
6218 key: "t1".to_string(),
6219 component: Component::Text(TextProps {
6220 content: "Tab 1 content".to_string(),
6221 element: TextElement::P,
6222 }),
6223 action: None,
6224 visibility: None,
6225 }],
6226 },
6227 Tab {
6228 value: "tab2".to_string(),
6229 label: "Tab Two".to_string(),
6230 children: vec![ComponentNode {
6231 key: "t2".to_string(),
6232 component: Component::Text(TextProps {
6233 content: "Tab 2 content".to_string(),
6234 element: TextElement::P,
6235 }),
6236 action: None,
6237 visibility: None,
6238 }],
6239 },
6240 ],
6241 }),
6242 action: None,
6243 visibility: None,
6244 });
6245 let html = render_to_html(&view, &json!({}));
6246 assert!(
6247 html.contains("data-tabs"),
6248 "multi-tab still has data-tabs wrapper"
6249 );
6250 assert!(
6251 html.contains("role=\"tablist\""),
6252 "multi-tab still has nav with role=tablist"
6253 );
6254 assert!(html.contains("Tab One"), "tab label rendered");
6255 assert!(html.contains("Tab Two"), "tab label rendered");
6256 }
6257
6258 fn has_class(html: &str, class: &str) -> bool {
6264 html.contains(&format!("class=\"{class}\""))
6265 || html.contains(&format!("class=\"{class} "))
6266 || html.contains(&format!(" {class}\""))
6267 || html.contains(&format!(" {class} "))
6268 }
6269
6270 fn assert_element(html: &str, tag: &str, content: &str) {
6272 assert!(
6273 html.contains(&format!("<{tag} ")) || html.contains(&format!("<{tag}>")),
6274 "expected <{tag}> element in HTML"
6275 );
6276 assert!(
6277 html.contains(content),
6278 "expected content '{content}' in HTML"
6279 );
6280 }
6281
6282 mod structural_tests {
6292 use super::*;
6293 use serde_json::json;
6294
6295 #[test]
6298 fn h1_structural_element_and_semantic_class() {
6299 let view = JsonUiView::new().component(text_node("t", "Page Title", TextElement::H1));
6300 let html = render_to_html(&view, &json!({}));
6301 assert_element(&html, "h1", "Page Title");
6302 assert!(
6303 has_class(&html, "text-text"),
6304 "h1 should have text-text class"
6305 );
6306 }
6307
6308 #[test]
6311 fn h2_structural_element_and_semantic_class() {
6312 let view =
6313 JsonUiView::new().component(text_node("t", "Section Title", TextElement::H2));
6314 let html = render_to_html(&view, &json!({}));
6315 assert_element(&html, "h2", "Section Title");
6316 assert!(
6317 has_class(&html, "text-text"),
6318 "h2 should have text-text class"
6319 );
6320 }
6321
6322 #[test]
6325 fn h3_structural_element_and_semantic_class() {
6326 let view = JsonUiView::new().component(text_node("t", "Subsection", TextElement::H3));
6327 let html = render_to_html(&view, &json!({}));
6328 assert_element(&html, "h3", "Subsection");
6329 assert!(
6330 has_class(&html, "text-text"),
6331 "h3 should have text-text class"
6332 );
6333 }
6334
6335 #[test]
6338 fn p_structural_element_and_semantic_class() {
6339 let view = JsonUiView::new().component(text_node("t", "Body text", TextElement::P));
6340 let html = render_to_html(&view, &json!({}));
6341 assert_element(&html, "p", "Body text");
6342 assert!(
6343 has_class(&html, "text-text"),
6344 "p should have text-text class"
6345 );
6346 }
6347
6348 #[test]
6351 fn card_structural_title_and_description() {
6352 let view = JsonUiView::new().component(ComponentNode {
6353 key: "c".to_string(),
6354 component: Component::Card(CardProps {
6355 title: "Card Title".to_string(),
6356 description: Some("Card description".to_string()),
6357 children: vec![],
6358 footer: vec![],
6359 max_width: None,
6360 }),
6361 action: None,
6362 visibility: None,
6363 });
6364 let html = render_to_html(&view, &json!({}));
6365 assert!(html.contains("<div"), "card should render a div container");
6366 assert!(html.contains("Card Title"), "card title should be present");
6367 assert!(
6368 html.contains("Card description"),
6369 "card description should be present"
6370 );
6371 assert!(
6372 has_class(&html, "border-border"),
6373 "card container should have border-border"
6374 );
6375 }
6376
6377 #[test]
6380 fn alert_structural_container_and_message() {
6381 let view = JsonUiView::new().component(ComponentNode {
6382 key: "a".to_string(),
6383 component: Component::Alert(AlertProps {
6384 message: "Something went wrong".to_string(),
6385 variant: AlertVariant::Warning,
6386 title: None,
6387 }),
6388 action: None,
6389 visibility: None,
6390 });
6391 let html = render_to_html(&view, &json!({}));
6392 assert!(
6393 html.contains("role=\"alert\""),
6394 "alert should have role=alert"
6395 );
6396 assert!(
6397 html.contains("Something went wrong"),
6398 "alert message should be present"
6399 );
6400 assert!(
6401 has_class(&html, "text-warning"),
6402 "warning alert should have text-warning class"
6403 );
6404 }
6405
6406 #[test]
6409 fn input_structural_element_and_type() {
6410 let view = JsonUiView::new().component(ComponentNode {
6411 key: "i".to_string(),
6412 component: Component::Input(InputProps {
6413 field: "username".to_string(),
6414 label: "Username".to_string(),
6415 input_type: InputType::Text,
6416 placeholder: None,
6417 required: None,
6418 disabled: None,
6419 error: None,
6420 description: None,
6421 default_value: None,
6422 data_path: None,
6423 step: None,
6424 list: None,
6425 }),
6426 action: None,
6427 visibility: None,
6428 });
6429 let html = render_to_html(&view, &json!({}));
6430 assert!(
6431 html.contains("<input"),
6432 "input should render an <input element"
6433 );
6434 assert!(html.contains("type=\"text\""), "input type should be text");
6435 assert!(
6436 html.contains("name=\"username\""),
6437 "input name should match field"
6438 );
6439 }
6440
6441 #[test]
6444 fn select_structural_element_and_options() {
6445 let view = JsonUiView::new().component(ComponentNode {
6446 key: "s".to_string(),
6447 component: Component::Select(SelectProps {
6448 field: "status".to_string(),
6449 label: "Status".to_string(),
6450 options: vec![
6451 SelectOption {
6452 value: "active".to_string(),
6453 label: "Active".to_string(),
6454 },
6455 SelectOption {
6456 value: "inactive".to_string(),
6457 label: "Inactive".to_string(),
6458 },
6459 ],
6460 placeholder: None,
6461 required: None,
6462 disabled: None,
6463 error: None,
6464 description: None,
6465 default_value: None,
6466 data_path: None,
6467 }),
6468 action: None,
6469 visibility: None,
6470 });
6471 let html = render_to_html(&view, &json!({}));
6472 assert!(
6473 html.contains("<select"),
6474 "select should render a <select element"
6475 );
6476 assert!(
6477 html.contains("Active"),
6478 "select should render option labels"
6479 );
6480 assert!(
6481 html.contains("Inactive"),
6482 "select should render all options"
6483 );
6484 }
6485
6486 #[test]
6489 fn table_structural_headers_and_body() {
6490 let data = json!({
6491 "items": [{"name": "Widget", "price": "9.99"}]
6492 });
6493 let view = JsonUiView::new().component(ComponentNode {
6494 key: "t".to_string(),
6495 component: Component::Table(TableProps {
6496 columns: vec![
6497 Column {
6498 key: "name".to_string(),
6499 label: "Name".to_string(),
6500 format: None,
6501 },
6502 Column {
6503 key: "price".to_string(),
6504 label: "Price".to_string(),
6505 format: None,
6506 },
6507 ],
6508 data_path: "/items".to_string(),
6509 row_actions: None,
6510 empty_message: None,
6511 sortable: None,
6512 sort_column: None,
6513 sort_direction: None,
6514 }),
6515 action: None,
6516 visibility: None,
6517 });
6518 let html = render_to_html(&view, &data);
6519 assert!(
6520 html.contains("<table"),
6521 "table should render a <table element"
6522 );
6523 assert!(html.contains("<th"), "table should render header cells");
6524 assert!(
6525 html.contains("Name"),
6526 "table should render Name column header"
6527 );
6528 assert!(
6529 html.contains("Price"),
6530 "table should render Price column header"
6531 );
6532 assert!(html.contains("Widget"), "table should render row data");
6533 }
6534
6535 #[test]
6538 fn breadcrumb_structural_nav_and_links() {
6539 let view = JsonUiView::new().component(ComponentNode {
6540 key: "bc".to_string(),
6541 component: Component::Breadcrumb(BreadcrumbProps {
6542 items: vec![
6543 BreadcrumbItem {
6544 label: "Home".to_string(),
6545 url: Some("/".to_string()),
6546 },
6547 BreadcrumbItem {
6548 label: "Products".to_string(),
6549 url: Some("/products".to_string()),
6550 },
6551 BreadcrumbItem {
6552 label: "Widget".to_string(),
6553 url: None,
6554 },
6555 ],
6556 }),
6557 action: None,
6558 visibility: None,
6559 });
6560 let html = render_to_html(&view, &json!({}));
6561 assert!(
6562 html.contains("<nav"),
6563 "breadcrumb should render a <nav element"
6564 );
6565 assert!(
6566 html.contains("href=\"/\""),
6567 "breadcrumb should render Home link"
6568 );
6569 assert!(
6570 html.contains("href=\"/products\""),
6571 "breadcrumb should render Products link"
6572 );
6573 assert!(
6574 html.contains("Widget"),
6575 "breadcrumb should render last item text"
6576 );
6577 }
6578
6579 #[test]
6582 fn tabs_structural_buttons_and_content() {
6583 let view = JsonUiView::new().component(ComponentNode {
6584 key: "tabs".to_string(),
6585 component: Component::Tabs(TabsProps {
6586 default_tab: "overview".to_string(),
6587 tabs: vec![
6588 Tab {
6589 value: "overview".to_string(),
6590 label: "Overview".to_string(),
6591 children: vec![text_node("t1", "Overview content", TextElement::P)],
6592 },
6593 Tab {
6594 value: "details".to_string(),
6595 label: "Details".to_string(),
6596 children: vec![text_node("t2", "Details content", TextElement::P)],
6597 },
6598 ],
6599 }),
6600 action: None,
6601 visibility: None,
6602 });
6603 let html = render_to_html(&view, &json!({}));
6604 assert!(
6605 html.contains("<button"),
6606 "tabs should render button elements"
6607 );
6608 assert!(html.contains("Overview"), "tabs should render tab labels");
6609 assert!(
6610 html.contains("Details"),
6611 "tabs should render all tab labels"
6612 );
6613 assert!(
6614 html.contains("Overview content"),
6615 "tabs should render active tab content"
6616 );
6617 }
6618
6619 #[test]
6622 fn stat_card_structural_value_and_label() {
6623 let view = JsonUiView::new().component(ComponentNode::stat_card(
6624 "sales",
6625 StatCardProps {
6626 label: "Total Sales".to_string(),
6627 value: "1,024".to_string(),
6628 icon: None,
6629 subtitle: None,
6630 sse_target: None,
6631 },
6632 ));
6633 let html = render_to_html(&view, &json!({}));
6634 assert!(
6635 html.contains("Total Sales"),
6636 "stat card should render label"
6637 );
6638 assert!(html.contains("1,024"), "stat card should render value");
6639 assert!(
6640 has_class(&html, "rounded-lg"),
6641 "stat card should have rounded-lg class"
6642 );
6643 }
6644
6645 #[test]
6648 fn skeleton_structural_animate_class() {
6649 let view = JsonUiView::new().component(ComponentNode {
6650 key: "sk".to_string(),
6651 component: Component::Skeleton(SkeletonProps {
6652 width: None,
6653 height: None,
6654 rounded: None,
6655 }),
6656 action: None,
6657 visibility: None,
6658 });
6659 let html = render_to_html(&view, &json!({}));
6660 assert!(html.contains("<div"), "skeleton should render a div");
6661 assert!(
6662 html.contains("ferro-shimmer"),
6663 "skeleton should have ferro-shimmer class"
6664 );
6665 }
6666
6667 #[test]
6670 fn collapsible_structural_details_element() {
6671 let view = JsonUiView::new().component(ComponentNode::collapsible(
6672 "col",
6673 crate::component::CollapsibleProps {
6674 title: "Show more".into(),
6675 expanded: false,
6676 children: vec![text_node("t", "Collapsed content", TextElement::P)],
6677 },
6678 ));
6679 let html = render_to_html(&view, &json!({}));
6680 assert!(
6681 html.contains("<details"),
6682 "collapsible should render a <details element"
6683 );
6684 assert!(
6685 html.contains("Show more"),
6686 "collapsible should render the title"
6687 );
6688 assert!(
6689 html.contains("Collapsed content"),
6690 "collapsible should render children"
6691 );
6692 }
6693
6694 #[test]
6697 fn alert_svg_icon_per_variant() {
6698 use crate::component::AlertVariant;
6699 let variants = [
6700 AlertVariant::Info,
6701 AlertVariant::Success,
6702 AlertVariant::Warning,
6703 AlertVariant::Error,
6704 ];
6705 for variant in variants {
6706 let view = JsonUiView::new().component(ComponentNode {
6707 key: "a".to_string(),
6708 component: Component::Alert(AlertProps {
6709 variant,
6710 title: None,
6711 message: "Test message".to_string(),
6712 }),
6713 action: None,
6714 visibility: None,
6715 });
6716 let html = render_to_html(&view, &json!({}));
6717 assert!(html.contains("<svg"), "alert should contain SVG icon");
6718 assert!(
6719 html.contains("role=\"alert\""),
6720 "alert should preserve accessibility role"
6721 );
6722 assert!(
6723 has_class(&html, "flex"),
6724 "alert container should have flex class"
6725 );
6726 }
6727 }
6728
6729 #[test]
6732 fn skeleton_shimmer_class() {
6733 let view = JsonUiView::new().component(ComponentNode {
6734 key: "sk".to_string(),
6735 component: Component::Skeleton(SkeletonProps {
6736 width: None,
6737 height: None,
6738 rounded: None,
6739 }),
6740 action: None,
6741 visibility: None,
6742 });
6743 let html = render_to_html(&view, &json!({}));
6744 assert!(
6745 html.contains("ferro-shimmer"),
6746 "shimmer class should be present"
6747 );
6748 assert!(
6749 !html.contains("animate-pulse"),
6750 "old pulse class should be removed"
6751 );
6752 assert!(
6753 html.contains("@keyframes ferro-shimmer"),
6754 "CSS keyframe should be injected"
6755 );
6756 }
6757
6758 #[test]
6761 fn breadcrumb_svg_separator() {
6762 let view = JsonUiView::new().component(ComponentNode {
6763 key: "bc".to_string(),
6764 component: Component::Breadcrumb(BreadcrumbProps {
6765 items: vec![
6766 BreadcrumbItem {
6767 label: "Home".to_string(),
6768 url: Some("/".to_string()),
6769 },
6770 BreadcrumbItem {
6771 label: "Products".to_string(),
6772 url: Some("/products".to_string()),
6773 },
6774 BreadcrumbItem {
6775 label: "Detail".to_string(),
6776 url: None,
6777 },
6778 ],
6779 }),
6780 action: None,
6781 visibility: None,
6782 });
6783 let html = render_to_html(&view, &json!({}));
6784 assert!(html.contains("<svg"), "SVG separator should be present");
6785 assert!(
6786 !html.contains("<span>/</span>"),
6787 "old text separator should be removed"
6788 );
6789 assert!(
6790 html.contains("aria-hidden"),
6791 "separator should be decorative (aria-hidden)"
6792 );
6793 }
6794
6795 #[test]
6798 fn tab_active_font_semibold() {
6799 let view = JsonUiView::new().component(ComponentNode {
6800 key: "tabs".to_string(),
6801 component: Component::Tabs(TabsProps {
6802 default_tab: "tab1".to_string(),
6803 tabs: vec![
6804 Tab {
6805 value: "tab1".to_string(),
6806 label: "First Tab".to_string(),
6807 children: vec![text_node("t1", "Content one", TextElement::P)],
6808 },
6809 Tab {
6810 value: "tab2".to_string(),
6811 label: "Second Tab".to_string(),
6812 children: vec![text_node("t2", "Content two", TextElement::P)],
6813 },
6814 ],
6815 }),
6816 action: None,
6817 visibility: None,
6818 });
6819 let html = render_to_html(&view, &json!({}));
6820 assert!(
6821 has_class(&html, "font-semibold"),
6822 "active tab should have font-semibold class"
6823 );
6824 let count = html.matches("font-semibold").count();
6825 assert_eq!(count, 1, "only the active tab should have font-semibold");
6826 }
6827
6828 #[test]
6831 fn notification_bell_svg() {
6832 let view = JsonUiView::new().component(ComponentNode {
6833 key: "nd".to_string(),
6834 component: Component::NotificationDropdown(NotificationDropdownProps {
6835 notifications: vec![crate::component::NotificationItem {
6836 text: "New message".to_string(),
6837 read: false,
6838 icon: None,
6839 action_url: None,
6840 timestamp: None,
6841 }],
6842 empty_text: None,
6843 }),
6844 action: None,
6845 visibility: None,
6846 });
6847 let html = render_to_html(&view, &json!({}));
6848 assert!(html.contains("<svg"), "SVG bell should be present");
6849 assert!(
6850 !html.contains("🔔"),
6851 "bell emoji entity should be removed"
6852 );
6853 }
6854
6855 #[test]
6858 fn collapsible_svg_chevron() {
6859 let view = JsonUiView::new().component(ComponentNode::collapsible(
6860 "col",
6861 crate::component::CollapsibleProps {
6862 title: "Section".into(),
6863 expanded: false,
6864 children: vec![text_node("t", "Body text", TextElement::P)],
6865 },
6866 ));
6867 let html = render_to_html(&view, &json!({}));
6868 assert!(html.contains("<svg"), "SVG chevron should be present");
6869 assert!(
6870 !html.contains("▼"),
6871 "old down-arrow entity should be removed"
6872 );
6873 assert!(
6874 has_class(&html, "group-open:rotate-180"),
6875 "rotation class should be preserved"
6876 );
6877 assert!(
6878 has_class(&html, "transition-transform"),
6879 "transition class should be preserved"
6880 );
6881 }
6882
6883 #[test]
6886 fn select_renders_chevron_wrapper() {
6887 let view = JsonUiView::new().component(ComponentNode {
6888 key: "s".to_string(),
6889 component: Component::Select(SelectProps {
6890 field: "role".to_string(),
6891 label: "Role".to_string(),
6892 options: vec![],
6893 placeholder: None,
6894 required: None,
6895 disabled: None,
6896 error: None,
6897 description: None,
6898 default_value: None,
6899 data_path: None,
6900 }),
6901 action: None,
6902 visibility: None,
6903 });
6904 let html = render_to_html(&view, &json!({}));
6905 assert!(
6906 html.contains("<div class=\"relative\">"),
6907 "select should be wrapped in relative div"
6908 );
6909 assert!(
6910 html.contains("aria-hidden=\"true\""),
6911 "SVG span should have aria-hidden"
6912 );
6913 assert!(
6914 html.contains("<svg"),
6915 "inline SVG chevron should be present"
6916 );
6917 assert!(
6918 html.contains("pointer-events-none"),
6919 "SVG span should be non-interactive"
6920 );
6921 assert!(has_class(&html, "pr-10"), "select should have pr-10 class");
6922 }
6923
6924 #[test]
6925 fn input_renders_transition_classes() {
6926 let view = JsonUiView::new().component(ComponentNode {
6927 key: "i".to_string(),
6928 component: Component::Input(InputProps {
6929 field: "name".to_string(),
6930 label: "Name".to_string(),
6931 input_type: InputType::Text,
6932 placeholder: None,
6933 required: None,
6934 disabled: None,
6935 error: None,
6936 description: None,
6937 default_value: None,
6938 data_path: None,
6939 step: None,
6940 list: None,
6941 }),
6942 action: None,
6943 visibility: None,
6944 });
6945 let html = render_to_html(&view, &json!({}));
6946 assert!(
6947 has_class(&html, "transition-colors"),
6948 "input should have transition-colors"
6949 );
6950 assert!(
6951 has_class(&html, "duration-150"),
6952 "input should have duration-150"
6953 );
6954 assert!(
6955 html.contains("motion-reduce:transition-none"),
6956 "input should support reduced motion"
6957 );
6958 }
6959
6960 #[test]
6961 fn input_disabled_renders_disabled_classes() {
6962 let view = JsonUiView::new().component(ComponentNode {
6963 key: "i".to_string(),
6964 component: Component::Input(InputProps {
6965 field: "name".to_string(),
6966 label: "Name".to_string(),
6967 input_type: InputType::Text,
6968 placeholder: None,
6969 required: None,
6970 disabled: Some(true),
6971 error: None,
6972 description: None,
6973 default_value: None,
6974 data_path: None,
6975 step: None,
6976 list: None,
6977 }),
6978 action: None,
6979 visibility: None,
6980 });
6981 let html = render_to_html(&view, &json!({}));
6982 assert!(
6983 html.contains("disabled:opacity-50"),
6984 "input should have disabled:opacity-50"
6985 );
6986 assert!(
6987 html.contains("disabled:cursor-not-allowed"),
6988 "input should have disabled:cursor-not-allowed"
6989 );
6990 assert!(
6991 html.contains(" disabled"),
6992 "input should have disabled HTML attribute"
6993 );
6994 }
6995
6996 #[test]
6997 fn textarea_renders_error_focus_ring() {
6998 let view = JsonUiView::new().component(ComponentNode {
6999 key: "i".to_string(),
7000 component: Component::Input(InputProps {
7001 field: "bio".to_string(),
7002 label: "Bio".to_string(),
7003 input_type: InputType::Textarea,
7004 placeholder: None,
7005 required: None,
7006 disabled: None,
7007 error: Some("Required".to_string()),
7008 description: None,
7009 default_value: None,
7010 data_path: None,
7011 step: None,
7012 list: None,
7013 }),
7014 action: None,
7015 visibility: None,
7016 });
7017 let html = render_to_html(&view, &json!({}));
7018 assert!(
7019 html.contains("ring-destructive"),
7020 "textarea with error should have ring-destructive"
7021 );
7022 }
7023
7024 #[test]
7025 fn input_description_order() {
7026 let view = JsonUiView::new().component(ComponentNode {
7027 key: "i".to_string(),
7028 component: Component::Input(InputProps {
7029 field: "name".to_string(),
7030 label: "Name".to_string(),
7031 input_type: InputType::Text,
7032 placeholder: None,
7033 required: None,
7034 disabled: None,
7035 error: None,
7036 description: Some("Help text".to_string()),
7037 default_value: None,
7038 data_path: None,
7039 step: None,
7040 list: None,
7041 }),
7042 action: None,
7043 visibility: None,
7044 });
7045 let html = render_to_html(&view, &json!({}));
7046 let input_pos = html.find("<input").expect("input element should exist");
7047 let desc_pos = html.find("Help text").expect("description should exist");
7048 assert!(
7049 input_pos < desc_pos,
7050 "input should appear before description in DOM"
7051 );
7052 }
7053
7054 #[test]
7055 fn select_description_order() {
7056 let view = JsonUiView::new().component(ComponentNode {
7057 key: "s".to_string(),
7058 component: Component::Select(SelectProps {
7059 field: "role".to_string(),
7060 label: "Role".to_string(),
7061 options: vec![],
7062 placeholder: None,
7063 required: None,
7064 disabled: None,
7065 error: None,
7066 description: Some("Pick one".to_string()),
7067 default_value: None,
7068 data_path: None,
7069 }),
7070 action: None,
7071 visibility: None,
7072 });
7073 let html = render_to_html(&view, &json!({}));
7074 let select_close_pos = html.find("</select>").expect("select close should exist");
7075 let desc_pos = html.find("Pick one").expect("description should exist");
7076 assert!(
7077 select_close_pos < desc_pos,
7078 "select close should appear before description in DOM"
7079 );
7080 }
7081
7082 #[test]
7085 fn button_structural_element_and_text() {
7086 let view = JsonUiView::new().component(button_node(
7087 "btn",
7088 "Submit",
7089 ButtonVariant::Default,
7090 Size::Default,
7091 ));
7092 let html = render_to_html(&view, &json!({}));
7093 assert!(
7094 html.contains("<button"),
7095 "button should render a <button element"
7096 );
7097 assert!(html.contains("Submit"), "button should render label text");
7098 assert!(
7099 has_class(&html, "bg-primary"),
7100 "default button should have bg-primary class"
7101 );
7102 }
7103
7104 #[test]
7107 fn button_focus_ring() {
7108 let view = JsonUiView::new().component(button_node(
7109 "btn",
7110 "Click me",
7111 ButtonVariant::Default,
7112 Size::Default,
7113 ));
7114 let html = render_to_html(&view, &json!({}));
7115 assert!(
7116 has_class(&html, "focus-visible:ring-primary"),
7117 "button should have focus-visible:ring-primary class (INT-01)"
7118 );
7119 assert!(
7120 has_class(&html, "duration-150"),
7121 "button should have duration-150 class (INT-07)"
7122 );
7123 assert!(
7124 html.contains("motion-reduce:transition-none"),
7125 "button should have motion-reduce:transition-none (INT-07)"
7126 );
7127 }
7128
7129 #[test]
7132 fn tabs_focus_ring() {
7133 let view = JsonUiView::new().component(ComponentNode {
7134 key: "tabs".to_string(),
7135 component: Component::Tabs(TabsProps {
7136 default_tab: "tab1".to_string(),
7137 tabs: vec![
7138 Tab {
7139 value: "tab1".to_string(),
7140 label: "Tab One".to_string(),
7141 children: vec![text_node("t1", "Content one", TextElement::P)],
7142 },
7143 Tab {
7144 value: "tab2".to_string(),
7145 label: "Tab Two".to_string(),
7146 children: vec![text_node("t2", "Content two", TextElement::P)],
7147 },
7148 ],
7149 }),
7150 action: None,
7151 visibility: None,
7152 });
7153 let html = render_to_html(&view, &json!({}));
7154 assert!(
7155 has_class(&html, "focus-visible:ring-primary"),
7156 "tab button/link should have focus-visible:ring-primary class (INT-02)"
7157 );
7158 assert!(
7159 has_class(&html, "duration-150"),
7160 "tab button/link should have duration-150 class (INT-07)"
7161 );
7162 }
7163
7164 #[test]
7167 fn pagination_focus_ring() {
7168 let view = JsonUiView::new().component(ComponentNode {
7169 key: "pg".to_string(),
7170 component: Component::Pagination(PaginationProps {
7171 total: 30,
7172 per_page: 10,
7173 current_page: 2,
7174 base_url: Some("?".to_string()),
7175 }),
7176 action: None,
7177 visibility: None,
7178 });
7179 let html = render_to_html(&view, &json!({}));
7180 assert!(
7181 has_class(&html, "focus-visible:ring-primary"),
7182 "pagination <a> links should have focus-visible:ring-primary class (INT-03)"
7183 );
7184 assert!(
7185 has_class(&html, "duration-150"),
7186 "pagination <a> links should have duration-150 class (INT-07)"
7187 );
7188 }
7189
7190 #[test]
7193 fn breadcrumb_focus_ring() {
7194 let view = JsonUiView::new().component(ComponentNode {
7195 key: "bc".to_string(),
7196 component: Component::Breadcrumb(BreadcrumbProps {
7197 items: vec![
7198 BreadcrumbItem {
7199 label: "Home".to_string(),
7200 url: Some("/".to_string()),
7201 },
7202 BreadcrumbItem {
7203 label: "Current".to_string(),
7204 url: None,
7205 },
7206 ],
7207 }),
7208 action: None,
7209 visibility: None,
7210 });
7211 let html = render_to_html(&view, &json!({}));
7212 assert!(
7213 has_class(&html, "focus-visible:ring-primary"),
7214 "breadcrumb <a> link should have focus-visible:ring-primary class (INT-04)"
7215 );
7216 assert!(
7217 has_class(&html, "duration-150"),
7218 "breadcrumb <a> link should have duration-150 class (INT-07)"
7219 );
7220 }
7221
7222 #[test]
7225 fn sidebar_nav_focus_ring() {
7226 let view = JsonUiView::new().component(ComponentNode::sidebar(
7227 "nav",
7228 SidebarProps {
7229 fixed_top: vec![SidebarNavItem {
7230 label: "Dashboard".to_string(),
7231 href: "/dashboard".to_string(),
7232 icon: None,
7233 active: false,
7234 }],
7235 groups: vec![],
7236 fixed_bottom: vec![],
7237 },
7238 ));
7239 let html = render_to_html(&view, &json!({}));
7240 assert!(
7241 has_class(&html, "focus-visible:ring-primary"),
7242 "sidebar nav <a> item should have focus-visible:ring-primary class (INT-05)"
7243 );
7244 assert!(
7245 has_class(&html, "duration-150"),
7246 "sidebar nav <a> item should have duration-150 class (INT-07)"
7247 );
7248 }
7249
7250 #[test]
7253 fn table_row_hover() {
7254 let data = json!({"items": [{"name": "Alice"}]});
7255 let view = JsonUiView::new().component(ComponentNode {
7256 key: "t".to_string(),
7257 component: Component::Table(TableProps {
7258 columns: vec![Column {
7259 key: "name".to_string(),
7260 label: "Name".to_string(),
7261 format: None,
7262 }],
7263 data_path: "/items".to_string(),
7264 row_actions: None,
7265 empty_message: None,
7266 sortable: None,
7267 sort_column: None,
7268 sort_direction: None,
7269 }),
7270 action: None,
7271 visibility: None,
7272 });
7273 let html = render_to_html(&view, &data);
7274 assert!(
7275 html.contains("<tr class=\"hover:bg-surface\">"),
7276 "table body row should have hover:bg-surface class (INT-06)"
7277 );
7278 }
7279 }
7280
7281 #[test]
7284 fn test_render_dropdown_menu() {
7285 let props = DropdownMenuProps {
7286 menu_id: "actions-1".to_string(),
7287 trigger_label: "Azioni".to_string(),
7288 items: vec![
7289 DropdownMenuAction {
7290 label: "Modifica".to_string(),
7291 action: Action {
7292 handler: "items.edit".to_string(),
7293 url: Some("/items/1/edit".to_string()),
7294 method: HttpMethod::Get,
7295 confirm: None,
7296 on_success: None,
7297 on_error: None,
7298 target: None,
7299 },
7300 destructive: false,
7301 },
7302 DropdownMenuAction {
7303 label: "Elimina".to_string(),
7304 action: Action {
7305 handler: "items.destroy".to_string(),
7306 url: Some("/items/1".to_string()),
7307 method: HttpMethod::Delete,
7308 confirm: None,
7309 on_success: None,
7310 on_error: None,
7311 target: None,
7312 },
7313 destructive: true,
7314 },
7315 ],
7316 trigger_variant: None,
7317 };
7318
7319 let view = JsonUiView::new().component(ComponentNode::dropdown_menu("menu", props));
7320 let html = render_to_html(&view, &json!({}));
7321
7322 assert!(
7323 html.contains("data-dropdown-toggle=\"actions-1\""),
7324 "trigger has data-dropdown-toggle"
7325 );
7326 assert!(
7327 html.contains("data-dropdown=\"actions-1\""),
7328 "panel has data-dropdown"
7329 );
7330 assert!(html.contains("hidden"), "panel starts hidden");
7331 assert!(
7332 html.contains("text-destructive"),
7333 "destructive item has text-destructive class"
7334 );
7335 assert!(html.contains("type=\"button\""), "trigger is type=button");
7336 assert!(
7337 html.contains("aria-label=\"Azioni\""),
7338 "trigger has aria-label"
7339 );
7340 assert!(html.contains("Modifica"), "normal item label present");
7341 assert!(html.contains("Elimina"), "destructive item label present");
7342 assert!(
7344 html.contains("<a href=\"/items/1/edit\""),
7345 "GET action renders as link"
7346 );
7347 assert!(
7348 html.contains("<form action=\"/items/1\" method=\"post\">"),
7349 "DELETE action renders as form"
7350 );
7351 assert!(
7352 html.contains("name=\"_method\" value=\"DELETE\""),
7353 "DELETE method spoofing"
7354 );
7355 }
7356
7357 #[test]
7358 fn test_render_dropdown_menu_confirm() {
7359 use crate::action::{ConfirmDialog, DialogVariant};
7360
7361 let props = DropdownMenuProps {
7362 menu_id: "confirm-menu".to_string(),
7363 trigger_label: "Menu".to_string(),
7364 items: vec![DropdownMenuAction {
7365 label: "Elimina".to_string(),
7366 action: Action {
7367 handler: "items.destroy".to_string(),
7368 url: Some("/items/1".to_string()),
7369 method: HttpMethod::Delete,
7370 confirm: Some(ConfirmDialog {
7371 title: "Conferma eliminazione".to_string(),
7372 message: Some("Sei sicuro?".to_string()),
7373 variant: DialogVariant::Danger,
7374 }),
7375 on_success: None,
7376 on_error: None,
7377 target: None,
7378 },
7379 destructive: true,
7380 }],
7381 trigger_variant: None,
7382 };
7383
7384 let view = JsonUiView::new().component(ComponentNode::dropdown_menu("cm", props));
7385 let html = render_to_html(&view, &json!({}));
7386
7387 assert!(
7388 html.contains("data-confirm-title=\"Conferma eliminazione\""),
7389 "confirm title attribute"
7390 );
7391 assert!(
7392 html.contains("data-confirm-message=\"Sei sicuro?\""),
7393 "confirm message attribute"
7394 );
7395 assert!(html.contains("data-confirm"), "has data-confirm attribute");
7396 }
7397
7398 fn make_kanban_props() -> KanbanBoardProps {
7401 use crate::component::{CardProps, KanbanBoardProps, KanbanColumnProps};
7402
7403 KanbanBoardProps {
7404 columns: vec![
7405 KanbanColumnProps {
7406 id: "new".to_string(),
7407 title: "Nuovi".to_string(),
7408 count: 3,
7409 children: vec![ComponentNode::card(
7410 "card-1",
7411 CardProps {
7412 title: "Ordine #1".to_string(),
7413 description: None,
7414 children: vec![],
7415 footer: vec![],
7416 max_width: None,
7417 },
7418 )],
7419 },
7420 KanbanColumnProps {
7421 id: "progress".to_string(),
7422 title: "In corso".to_string(),
7423 count: 1,
7424 children: vec![ComponentNode::card(
7425 "card-2",
7426 CardProps {
7427 title: "Ordine #2".to_string(),
7428 description: None,
7429 children: vec![],
7430 footer: vec![],
7431 max_width: None,
7432 },
7433 )],
7434 },
7435 ],
7436 mobile_default_column: None,
7437 }
7438 }
7439
7440 #[test]
7441 fn test_render_kanban_board_desktop() {
7442 let props = make_kanban_props();
7443 let view = JsonUiView::new().component(ComponentNode::kanban_board("kb", props));
7444 let html = render_to_html(&view, &json!({}));
7445
7446 assert!(html.contains("hidden md:block"), "desktop wrapper present");
7447 assert!(html.contains("min-w-[260px]"), "column min width");
7448 assert!(html.contains("overflow-x-auto"), "scrollable container");
7449 assert!(html.contains("Nuovi"), "first column title");
7450 assert!(html.contains("In corso"), "second column title");
7451 assert!(
7452 html.contains("bg-primary text-primary-foreground"),
7453 "count badge styling"
7454 );
7455 assert!(html.contains(">3<"), "first column count");
7456 assert!(html.contains(">1<"), "second column count");
7457 }
7458
7459 #[test]
7460 fn test_render_kanban_board_mobile() {
7461 let props = make_kanban_props();
7462 let view = JsonUiView::new().component(ComponentNode::kanban_board("kb", props));
7463 let html = render_to_html(&view, &json!({}));
7464
7465 assert!(html.contains("block md:hidden"), "mobile wrapper present");
7466 assert!(html.contains("data-tabs"), "tab container attribute");
7467 assert!(html.contains("data-tab=\"new\""), "first tab button");
7468 assert!(html.contains("data-tab=\"progress\""), "second tab button");
7469 assert!(html.contains("data-tab-panel=\"new\""), "first tab panel");
7470 assert!(
7471 html.contains("data-tab-panel=\"progress\""),
7472 "second tab panel"
7473 );
7474 assert!(
7476 html.contains("aria-selected=\"true\""),
7477 "default tab selected"
7478 );
7479 assert!(
7480 html.contains("aria-selected=\"false\""),
7481 "non-default tab not selected"
7482 );
7483 }
7484
7485 #[test]
7486 fn test_render_kanban_board_custom_default_column() {
7487 let mut props = make_kanban_props();
7488 props.mobile_default_column = Some("progress".to_string());
7489 let view = JsonUiView::new().component(ComponentNode::kanban_board("kb", props));
7490 let html = render_to_html(&view, &json!({}));
7491
7492 assert!(
7495 !html.contains("data-tab-panel=\"progress\" class=\"space-y-3 hidden\""),
7496 "progress panel visible"
7497 );
7498 }
7499
7500 #[test]
7503 fn test_render_calendar_cell_today() {
7504 let props = CalendarCellProps {
7505 day: 15,
7506 is_today: true,
7507 is_current_month: true,
7508 event_count: 0,
7509 dot_colors: vec![],
7510 };
7511 let html = render_calendar_cell(&props);
7512 assert!(html.contains("bg-primary"), "today has bg-primary");
7513 assert!(
7514 html.contains("text-primary-foreground"),
7515 "today has foreground color"
7516 );
7517 assert!(html.contains("font-semibold"), "today is bold");
7518 assert!(html.contains("15"), "shows day number");
7519 }
7520
7521 #[test]
7522 fn test_render_calendar_cell_out_of_month() {
7523 let props = CalendarCellProps {
7524 day: 30,
7525 is_today: false,
7526 is_current_month: false,
7527 event_count: 0,
7528 dot_colors: vec![],
7529 };
7530 let html = render_calendar_cell(&props);
7531 assert!(html.contains("opacity-40"), "out-of-month has opacity");
7532 }
7533
7534 #[test]
7535 fn test_render_calendar_cell_events() {
7536 let props = CalendarCellProps {
7537 day: 5,
7538 is_today: false,
7539 is_current_month: true,
7540 event_count: 3,
7541 dot_colors: vec![],
7542 };
7543 let html = render_calendar_cell(&props);
7544 assert!(
7545 html.contains("w-1.5 h-1.5 rounded-full bg-primary"),
7546 "shows event dots"
7547 );
7548 assert!(html.contains("flex gap-1"), "dots container present");
7549 }
7550
7551 #[test]
7552 fn test_render_calendar_cell_single_event_dot() {
7553 let props = CalendarCellProps {
7554 day: 5,
7555 is_today: false,
7556 is_current_month: true,
7557 event_count: 1,
7558 dot_colors: vec![],
7559 };
7560 let html = render_calendar_cell(&props);
7561 assert!(
7562 html.contains("w-1.5 h-1.5 rounded-full bg-primary"),
7563 "single event shows dot"
7564 );
7565 }
7566
7567 #[test]
7570 fn test_render_action_card_default() {
7571 let props = ActionCardProps {
7572 title: "Nuovo ordine".into(),
7573 description: "Crea un ordine".into(),
7574 icon: Some("📦".into()),
7575 variant: ActionCardVariant::Default,
7576 href: None,
7577 };
7578 let html = render_action_card(&props);
7579 assert!(
7580 html.contains("border-l-primary"),
7581 "default variant has primary border"
7582 );
7583 assert!(html.contains("Nuovo ordine"), "shows title");
7584 assert!(html.contains("Crea un ordine"), "shows description");
7585 assert!(html.contains("rsaquo"), "shows chevron");
7586 }
7587
7588 #[test]
7589 fn test_render_action_card_setup() {
7590 let props = ActionCardProps {
7591 title: "Configura".into(),
7592 description: "Completa la configurazione".into(),
7593 icon: None,
7594 variant: ActionCardVariant::Setup,
7595 href: None,
7596 };
7597 let html = render_action_card(&props);
7598 assert!(
7599 html.contains("border-l-warning"),
7600 "setup variant has warning border"
7601 );
7602 }
7603
7604 #[test]
7605 fn test_render_action_card_danger() {
7606 let props = ActionCardProps {
7607 title: "Elimina".into(),
7608 description: "Elimina questo elemento".into(),
7609 icon: None,
7610 variant: ActionCardVariant::Danger,
7611 href: None,
7612 };
7613 let html = render_action_card(&props);
7614 assert!(
7615 html.contains("border-l-destructive"),
7616 "danger variant has destructive border"
7617 );
7618 }
7619
7620 #[test]
7623 fn test_render_product_tile() {
7624 let props = ProductTileProps {
7625 product_id: "p1".into(),
7626 name: "Margherita".into(),
7627 price: "\u{20AC}8,50".into(),
7628 field: "qty_p1".into(),
7629 default_quantity: None,
7630 };
7631 let html = render_product_tile(&props);
7632 assert!(html.contains("Margherita"), "shows product name");
7633 assert!(html.contains("\u{20AC}8,50"), "shows price");
7634 assert!(
7635 html.contains("data-qty-inc=\"qty_p1\""),
7636 "inc button has data attr"
7637 );
7638 assert!(
7639 html.contains("data-qty-dec=\"qty_p1\""),
7640 "dec button has data attr"
7641 );
7642 assert!(
7643 html.contains("data-qty-display=\"qty_p1\""),
7644 "display span has data attr"
7645 );
7646 assert!(
7647 html.contains("data-qty-input=\"qty_p1\""),
7648 "hidden input has data attr"
7649 );
7650 assert!(html.contains("type=\"button\""), "buttons use type=button");
7651 assert!(html.contains("type=\"hidden\""), "hidden input present");
7652 assert!(html.contains("min-h-[44px]"), "44px touch target height");
7653 assert!(html.contains("min-w-[44px]"), "44px touch target width");
7654 assert!(
7655 html.contains("touch-manipulation"),
7656 "touch-manipulation on container"
7657 );
7658 assert!(html.contains("value=\"0\""), "default quantity is 0");
7659 }
7660
7661 #[test]
7662 fn test_render_product_tile_default_qty() {
7663 let props = ProductTileProps {
7664 product_id: "p2".into(),
7665 name: "Diavola".into(),
7666 price: "\u{20AC}10,00".into(),
7667 field: "qty_p2".into(),
7668 default_quantity: Some(2),
7669 };
7670 let html = render_product_tile(&props);
7671 assert!(html.contains("value=\"2\""), "default quantity is 2");
7672 assert!(html.contains(">2<"), "display shows 2");
7673 }
7674
7675 #[test]
7676 fn test_render_data_table_rows() {
7677 let props = DataTableProps {
7678 columns: vec![
7679 Column {
7680 key: "name".into(),
7681 label: "Nome".into(),
7682 format: None,
7683 },
7684 Column {
7685 key: "price".into(),
7686 label: "Prezzo".into(),
7687 format: None,
7688 },
7689 ],
7690 data_path: "items".into(),
7691 row_actions: None,
7692 empty_message: None,
7693 row_key: None,
7694 row_href: None,
7695 };
7696 let data = json!({
7697 "items": [
7698 {"name": "Margherita", "price": "8.50"},
7699 {"name": "Diavola", "price": "10.00"}
7700 ]
7701 });
7702 let html = render_data_table(&props, &data);
7703 assert!(html.contains("hidden md:block"), "desktop wrapper");
7704 assert!(html.contains("even:bg-surface"), "alternating rows");
7705 assert!(html.contains("block md:hidden"), "mobile wrapper");
7706 assert!(html.contains("uppercase"), "column header style");
7707 assert!(html.contains("Margherita"), "first row value");
7708 assert!(html.contains("Diavola"), "second row value");
7709 }
7710
7711 #[test]
7712 fn test_render_data_table_with_actions() {
7713 let props = DataTableProps {
7714 columns: vec![Column {
7715 key: "name".into(),
7716 label: "Nome".into(),
7717 format: None,
7718 }],
7719 data_path: "items".into(),
7720 row_actions: Some(vec![
7721 DropdownMenuAction {
7722 label: "Modifica".into(),
7723 action: Action {
7724 handler: "edit".into(),
7725 method: HttpMethod::Get,
7726 url: Some("/edit".into()),
7727 confirm: None,
7728 on_success: None,
7729 on_error: None,
7730 target: None,
7731 },
7732 destructive: false,
7733 },
7734 DropdownMenuAction {
7735 label: "Elimina".into(),
7736 action: Action {
7737 handler: "delete".into(),
7738 method: HttpMethod::Delete,
7739 url: Some("/delete".into()),
7740 confirm: None,
7741 on_success: None,
7742 on_error: None,
7743 target: None,
7744 },
7745 destructive: true,
7746 },
7747 ]),
7748 empty_message: None,
7749 row_key: Some("id".into()),
7750 row_href: None,
7751 };
7752 let data = json!({
7753 "items": [{"id": "p1", "name": "Margherita"}]
7754 });
7755 let html = render_data_table(&props, &data);
7756 assert!(
7757 html.contains("data-dropdown-toggle"),
7758 "DropdownMenu trigger present"
7759 );
7760 assert!(
7761 html.contains("text-destructive"),
7762 "destructive action in menu"
7763 );
7764 }
7765
7766 #[test]
7767 fn test_render_data_table_empty() {
7768 let props = DataTableProps {
7769 columns: vec![Column {
7770 key: "name".into(),
7771 label: "Nome".into(),
7772 format: None,
7773 }],
7774 data_path: "items".into(),
7775 row_actions: None,
7776 empty_message: None,
7777 row_key: None,
7778 row_href: None,
7779 };
7780 let data = json!({"items": []});
7781 let html = render_data_table(&props, &data);
7782 assert!(
7783 html.contains("Nessun elemento trovato"),
7784 "default empty message"
7785 );
7786 }
7787
7788 #[test]
7789 fn test_render_data_table_mobile_cards() {
7790 let props = DataTableProps {
7791 columns: vec![
7792 Column {
7793 key: "name".into(),
7794 label: "Nome".into(),
7795 format: None,
7796 },
7797 Column {
7798 key: "price".into(),
7799 label: "Prezzo".into(),
7800 format: None,
7801 },
7802 ],
7803 data_path: "items".into(),
7804 row_actions: None,
7805 empty_message: None,
7806 row_key: None,
7807 row_href: None,
7808 };
7809 let data = json!({
7810 "items": [
7811 {"name": "Margherita", "price": "8.50"},
7812 {"name": "Diavola", "price": "10.00"}
7813 ]
7814 });
7815 let html = render_data_table(&props, &data);
7816 assert!(html.contains("block md:hidden"), "mobile cards shown");
7817 assert!(
7818 html.contains("text-xs font-semibold text-text-muted uppercase"),
7819 "label styling"
7820 );
7821 }
7822
7823 #[test]
7826 fn test_render_modal_dialog() {
7827 let props = ModalProps {
7828 id: "modal-test".into(),
7829 title: "Test Title".into(),
7830 description: None,
7831 children: vec![],
7832 footer: vec![],
7833 trigger_label: Some("Open".into()),
7834 };
7835 let html = render_modal(&props, &serde_json::Value::Null);
7836 assert!(html.contains("<dialog"), "uses dialog element");
7837 assert!(html.contains("aria-modal=\"true\""), "has aria-modal");
7838 assert!(
7839 html.contains("aria-labelledby=\"modal-test-title\""),
7840 "has aria-labelledby"
7841 );
7842 assert!(
7843 html.contains("data-modal-open=\"modal-test\""),
7844 "trigger has data-modal-open"
7845 );
7846 assert!(html.contains("data-modal-close"), "has close button");
7847 assert!(
7848 html.contains("Chiudi"),
7849 "close button has Italian aria-label"
7850 );
7851 assert!(!html.contains("<details"), "no details element");
7852 assert!(!html.contains("<summary"), "no summary element");
7853 }
7854
7855 #[test]
7856 fn test_render_modal_with_description() {
7857 let props = ModalProps {
7858 id: "modal-desc".into(),
7859 title: "Title".into(),
7860 description: Some("A description".into()),
7861 children: vec![],
7862 footer: vec![],
7863 trigger_label: None,
7864 };
7865 let html = render_modal(&props, &serde_json::Value::Null);
7866 assert!(html.contains("A description"), "shows description");
7867 }
7868
7869 #[test]
7872 fn test_render_form_max_width_narrow() {
7873 let props = FormProps {
7874 action: Action::new("save"),
7875 fields: vec![],
7876 method: None,
7877 guard: None,
7878 max_width: Some(FormMaxWidth::Narrow),
7879 };
7880 let html = render_form(&props, &serde_json::Value::Null);
7881 assert!(html.contains("max-w-2xl"), "narrow form has max-w-2xl");
7882 assert!(html.contains("mx-auto"), "narrow form is centered");
7883 }
7884
7885 #[test]
7886 fn test_render_form_max_width_default() {
7887 let props = FormProps {
7888 action: Action::new("save"),
7889 fields: vec![],
7890 method: None,
7891 guard: None,
7892 max_width: None,
7893 };
7894 let html = render_form(&props, &serde_json::Value::Null);
7895 assert!(
7896 !html.contains("max-w-2xl"),
7897 "default form has no max-width wrapper"
7898 );
7899 }
7900
7901 #[test]
7902 fn test_render_form_narrow_wraps_in_w_full_flex_slot() {
7903 let props = FormProps {
7907 action: Action::new("save"),
7908 fields: vec![],
7909 method: None,
7910 guard: None,
7911 max_width: Some(FormMaxWidth::Narrow),
7912 };
7913 let html = render_form(&props, &serde_json::Value::Null);
7914 assert!(
7915 html.starts_with("<div class=\"w-full\">"),
7916 "narrow form outer wrapper must be w-full so flex-wrap stacks siblings: {html}"
7917 );
7918 assert!(
7919 html.contains("<div class=\"max-w-2xl mx-auto\">"),
7920 "narrow form inner wrapper must carry max-w-2xl mx-auto"
7921 );
7922 }
7923
7924 #[test]
7925 fn test_render_form_section_two_column() {
7926 let props = FormSectionProps {
7927 title: "Section".into(),
7928 description: Some("Desc".into()),
7929 children: vec![],
7930 layout: Some(FormSectionLayout::TwoColumn),
7931 };
7932 let html = render_form_section(&props, &serde_json::Value::Null);
7933 assert!(html.contains("md:grid"), "two-column uses grid");
7934 assert!(
7935 html.contains("md:grid-cols-5"),
7936 "two-column uses 5-col grid"
7937 );
7938 assert!(html.contains("md:col-span-2"), "description takes 2 cols");
7939 assert!(html.contains("md:col-span-3"), "controls take 3 cols");
7940 }
7941
7942 #[test]
7945 fn test_render_input_with_error() {
7946 let props = InputProps {
7947 field: "email".into(),
7948 label: "Email".into(),
7949 input_type: InputType::Email,
7950 error: Some("Campo obbligatorio".into()),
7951 placeholder: None,
7952 default_value: None,
7953 data_path: None,
7954 required: None,
7955 disabled: None,
7956 step: None,
7957 description: None,
7958 list: None,
7959 };
7960 let html = render_input(&props, &serde_json::Value::Null);
7961 assert!(
7962 html.contains("aria-invalid=\"true\""),
7963 "input has aria-invalid"
7964 );
7965 assert!(
7966 html.contains("aria-describedby=\"err-email\""),
7967 "input has aria-describedby"
7968 );
7969 assert!(
7970 html.contains("id=\"err-email\""),
7971 "error paragraph has matching id"
7972 );
7973 assert!(
7974 html.contains("Campo obbligatorio"),
7975 "error message rendered"
7976 );
7977 }
7978
7979 #[test]
7980 fn test_render_input_hidden_no_label() {
7981 let props = InputProps {
7982 field: "csrf".into(),
7983 label: "".into(),
7984 input_type: InputType::Hidden,
7985 default_value: Some("token123".into()),
7986 error: None,
7987 placeholder: None,
7988 data_path: None,
7989 required: None,
7990 disabled: None,
7991 step: None,
7992 description: None,
7993 list: None,
7994 };
7995 let html = render_input(&props, &serde_json::Value::Null);
7996 assert!(!html.contains("<label"), "hidden input has no label");
7997 assert!(
7998 !html.contains("space-y-1"),
7999 "hidden input has no wrapper div"
8000 );
8001 assert!(html.contains("type=\"hidden\""), "hidden input present");
8002 }
8003
8004 #[test]
8007 fn test_render_switch_role_switch() {
8008 let props = SwitchProps {
8009 field: "active".into(),
8010 label: "Attivo".into(),
8011 description: None,
8012 checked: Some(true),
8013 data_path: None,
8014 required: None,
8015 disabled: None,
8016 error: None,
8017 action: None,
8018 };
8019 let html = render_switch(&props, &serde_json::Value::Null);
8020 assert!(html.contains("role=\"switch\""), "switch has role=switch");
8021 assert!(
8022 html.contains("aria-checked=\"true\""),
8023 "checked switch has aria-checked=true"
8024 );
8025 }
8026
8027 #[test]
8030 fn test_render_tabs_aria_attributes() {
8031 let props = TabsProps {
8032 default_tab: "general".into(),
8033 tabs: vec![
8034 Tab {
8035 value: "general".into(),
8036 label: "Generale".into(),
8037 children: vec![],
8038 },
8039 Tab {
8040 value: "advanced".into(),
8041 label: "Avanzate".into(),
8042 children: vec![],
8043 },
8044 ],
8045 };
8046 let html = render_tabs(&props, &serde_json::Value::Null);
8047 assert!(html.contains("id=\"tab-btn-general\""), "tab button has id");
8048 assert!(
8049 html.contains("aria-controls=\"tab-panel-general\""),
8050 "tab button has aria-controls"
8051 );
8052 assert!(
8053 html.contains("id=\"tab-panel-general\""),
8054 "tab panel has id"
8055 );
8056 assert!(
8057 html.contains("aria-labelledby=\"tab-btn-general\""),
8058 "tab panel has aria-labelledby"
8059 );
8060 }
8061
8062 #[test]
8065 fn test_render_collapsible_aria_expanded() {
8066 let props = CollapsibleProps {
8067 title: "Details".into(),
8068 expanded: false,
8069 children: vec![],
8070 };
8071 let html = render_collapsible(&props, &serde_json::Value::Null);
8072 assert!(
8073 html.contains("aria-expanded=\"false\""),
8074 "closed collapsible has aria-expanded=false"
8075 );
8076 }
8077
8078 #[test]
8081 fn test_render_action_card_with_href() {
8082 let props = ActionCardProps {
8083 title: "Nuovo ordine".into(),
8084 description: "Crea un ordine".into(),
8085 icon: None,
8086 variant: ActionCardVariant::Default,
8087 href: Some("/ordini/nuovo".into()),
8088 };
8089 let html = render_action_card(&props);
8090 assert!(
8091 html.contains("<a href=\"/ordini/nuovo\""),
8092 "card wraps in <a> with href"
8093 );
8094 assert!(
8095 html.contains("aria-label=\"Nuovo ordine\""),
8096 "card link has aria-label"
8097 );
8098 assert!(
8099 !html.contains("<div class=\"rounded"),
8100 "no div wrapper when href present"
8101 );
8102 }
8103
8104 #[test]
8105 fn test_render_action_card_without_href() {
8106 let props = ActionCardProps {
8107 title: "Test".into(),
8108 description: "Desc".into(),
8109 icon: None,
8110 variant: ActionCardVariant::Default,
8111 href: None,
8112 };
8113 let html = render_action_card(&props);
8114 assert!(
8115 html.contains("<div class=\"rounded"),
8116 "uses div when no href"
8117 );
8118 assert!(!html.contains("<a "), "no anchor when no href");
8119 }
8120
8121 #[test]
8122 fn test_render_button_type_button_default() {
8123 let props = ButtonProps {
8124 label: "Click".into(),
8125 variant: ButtonVariant::Default,
8126 size: Size::Default,
8127 disabled: None,
8128 icon: None,
8129 icon_position: None,
8130 button_type: None,
8131 };
8132 let html = render_button(&props);
8133 assert!(
8134 !html.contains("type=\""),
8135 "default omits type attribute so browser applies HTML default (submit in forms)"
8136 );
8137 }
8138
8139 #[test]
8140 fn test_render_button_type_button_explicit() {
8141 let props = ButtonProps {
8142 label: "Click".into(),
8143 variant: ButtonVariant::Default,
8144 size: Size::Default,
8145 disabled: None,
8146 icon: None,
8147 icon_position: None,
8148 button_type: Some(ButtonType::Button),
8149 };
8150 let html = render_button(&props);
8151 assert!(html.contains("type=\"button\""));
8152 }
8153
8154 #[test]
8155 fn test_render_button_type_submit() {
8156 let props = ButtonProps {
8157 label: "Salva".into(),
8158 variant: ButtonVariant::Default,
8159 size: Size::Default,
8160 disabled: None,
8161 icon: None,
8162 icon_position: None,
8163 button_type: Some(ButtonType::Submit),
8164 };
8165 let html = render_button(&props);
8166 assert!(
8167 html.contains("type=\"submit\""),
8168 "submit button type is submit"
8169 );
8170 }
8171
8172 #[test]
8173 fn data_table_row_actions_url_templating() {
8174 use crate::action::*;
8175 use crate::component::*;
8176 let props = DataTableProps {
8177 columns: vec![Column {
8178 key: "name".into(),
8179 label: "Name".into(),
8180 format: None,
8181 }],
8182 data_path: "items".into(),
8183 row_actions: Some(vec![DropdownMenuAction {
8184 label: "Delete".into(),
8185 action: {
8186 let mut a = Action::new("items.delete");
8187 a.url = Some("/items/{row_key}/delete".into());
8188 a.method = HttpMethod::Delete;
8189 a
8190 },
8191 destructive: true,
8192 }]),
8193 empty_message: None,
8194 row_key: Some("id".into()),
8195 row_href: None,
8196 };
8197 let data = serde_json::json!({ "items": [{"id": "42", "name": "Widget"}] });
8198 let html = render_data_table(&props, &data);
8199 assert!(
8200 html.contains("/items/42/delete"),
8201 "URL must have {{row_key}} replaced with actual row key value '42'"
8202 );
8203 assert!(
8204 !html.contains("{row_key}"),
8205 "No unreplaced {{row_key}} placeholders should remain"
8206 );
8207 }
8208}