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