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