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