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