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