1use std::collections::HashSet;
9
10use serde_json::Value;
11
12use crate::action::HttpMethod;
13use crate::component::{
14 AlertProps, AlertVariant, AvatarProps, BadgeProps, BadgeVariant, BreadcrumbProps, ButtonProps,
15 ButtonVariant, CardProps, CheckboxProps, Component, ComponentNode, DescriptionListProps,
16 FormProps, IconPosition, InputProps, InputType, ModalProps, Orientation, PaginationProps,
17 PluginProps, ProgressProps, SelectProps, SeparatorProps, Size, SkeletonProps, SwitchProps,
18 TableProps, TabsProps, TextElement, TextProps,
19};
20use crate::data::{resolve_path, resolve_path_string};
21use crate::plugin::{collect_plugin_assets, Asset};
22use crate::view::JsonUiView;
23
24pub fn render_to_html(view: &JsonUiView, data: &Value) -> String {
33 let mut html = String::from("<div>");
34 for node in &view.components {
35 html.push_str(&render_node(node, data));
36 }
37 html.push_str("</div>");
38 html
39}
40
41pub struct RenderResult {
46 pub html: String,
48 pub css_head: String,
50 pub scripts: String,
52}
53
54pub fn render_to_html_with_plugins(view: &JsonUiView, data: &Value) -> RenderResult {
60 let html = render_to_html(view, data);
61
62 let plugin_types = collect_plugin_types(view);
63 if plugin_types.is_empty() {
64 return RenderResult {
65 html,
66 css_head: String::new(),
67 scripts: String::new(),
68 };
69 }
70
71 let type_names: Vec<String> = plugin_types.into_iter().collect();
72 let assets = collect_plugin_assets(&type_names);
73
74 let css_head = render_css_tags(&assets.css);
75 let scripts = render_js_tags(&assets.js, &assets.init_scripts);
76
77 RenderResult {
78 html,
79 css_head,
80 scripts,
81 }
82}
83
84pub fn collect_plugin_types(view: &JsonUiView) -> HashSet<String> {
86 let mut types = HashSet::new();
87 for node in &view.components {
88 collect_plugin_types_node(node, &mut types);
89 }
90 types
91}
92
93fn collect_plugin_types_node(node: &ComponentNode, types: &mut HashSet<String>) {
95 match &node.component {
96 Component::Plugin(props) => {
97 types.insert(props.plugin_type.clone());
98 }
99 Component::Card(props) => {
100 for child in &props.children {
101 collect_plugin_types_node(child, types);
102 }
103 for child in &props.footer {
104 collect_plugin_types_node(child, types);
105 }
106 }
107 Component::Form(props) => {
108 for field in &props.fields {
109 collect_plugin_types_node(field, types);
110 }
111 }
112 Component::Modal(props) => {
113 for child in &props.children {
114 collect_plugin_types_node(child, types);
115 }
116 for child in &props.footer {
117 collect_plugin_types_node(child, types);
118 }
119 }
120 Component::Tabs(props) => {
121 for tab in &props.tabs {
122 for child in &tab.children {
123 collect_plugin_types_node(child, types);
124 }
125 }
126 }
127 Component::Table(_)
129 | Component::Button(_)
130 | Component::Input(_)
131 | Component::Select(_)
132 | Component::Alert(_)
133 | Component::Badge(_)
134 | Component::Text(_)
135 | Component::Checkbox(_)
136 | Component::Switch(_)
137 | Component::Separator(_)
138 | Component::DescriptionList(_)
139 | Component::Breadcrumb(_)
140 | Component::Pagination(_)
141 | Component::Progress(_)
142 | Component::Avatar(_)
143 | Component::Skeleton(_) => {}
144 }
145}
146
147fn render_css_tags(assets: &[Asset]) -> String {
149 let mut out = String::new();
150 for asset in assets {
151 out.push_str("<link rel=\"stylesheet\" href=\"");
152 out.push_str(&html_escape(&asset.url));
153 out.push('"');
154 if let Some(ref integrity) = asset.integrity {
155 out.push_str(" integrity=\"");
156 out.push_str(&html_escape(integrity));
157 out.push('"');
158 }
159 if let Some(ref co) = asset.crossorigin {
160 out.push_str(" crossorigin=\"");
161 out.push_str(&html_escape(co));
162 out.push('"');
163 }
164 out.push('>');
165 }
166 out
167}
168
169fn render_js_tags(assets: &[Asset], init_scripts: &[String]) -> String {
171 let mut out = String::new();
172 for asset in assets {
173 out.push_str("<script src=\"");
174 out.push_str(&html_escape(&asset.url));
175 out.push('"');
176 if let Some(ref integrity) = asset.integrity {
177 out.push_str(" integrity=\"");
178 out.push_str(&html_escape(integrity));
179 out.push('"');
180 }
181 if let Some(ref co) = asset.crossorigin {
182 out.push_str(" crossorigin=\"");
183 out.push_str(&html_escape(co));
184 out.push('"');
185 }
186 out.push_str("></script>");
187 }
188 if !init_scripts.is_empty() {
189 out.push_str("<script>");
190 for script in init_scripts {
191 out.push_str(script);
192 }
193 out.push_str("</script>");
194 }
195 out
196}
197
198fn render_node(node: &ComponentNode, data: &Value) -> String {
200 let component_html = render_component(&node.component, data);
201
202 if let Some(ref action) = node.action {
204 if action.method == HttpMethod::Get {
205 if let Some(ref url) = action.url {
206 return format!(
207 "<a href=\"{}\" class=\"block\">{}</a>",
208 html_escape(url),
209 component_html
210 );
211 }
212 }
213 }
214
215 component_html
216}
217
218fn render_component(component: &Component, data: &Value) -> String {
220 match component {
221 Component::Text(props) => render_text(props),
222 Component::Button(props) => render_button(props),
223 Component::Badge(props) => render_badge(props),
224 Component::Alert(props) => render_alert(props),
225 Component::Separator(props) => render_separator(props),
226 Component::Progress(props) => render_progress(props),
227 Component::Avatar(props) => render_avatar(props),
228 Component::Skeleton(props) => render_skeleton(props),
229 Component::Breadcrumb(props) => render_breadcrumb(props),
230 Component::Pagination(props) => render_pagination(props),
231 Component::DescriptionList(props) => render_description_list(props),
232
233 Component::Card(props) => render_card(props, data),
235 Component::Form(props) => render_form(props, data),
236 Component::Modal(props) => render_modal(props, data),
237 Component::Tabs(props) => render_tabs(props, data),
238 Component::Table(props) => render_table(props, data),
239
240 Component::Input(props) => render_input(props, data),
242 Component::Select(props) => render_select(props, data),
243 Component::Checkbox(props) => render_checkbox(props, data),
244 Component::Switch(props) => render_switch(props, data),
245
246 Component::Plugin(props) => render_plugin(props, data),
248 }
249}
250
251fn render_plugin(props: &PluginProps, data: &Value) -> String {
254 crate::plugin::with_plugin(&props.plugin_type, |plugin| {
255 plugin.render(&props.props, data)
256 })
257 .unwrap_or_else(|| {
258 format!(
259 "<div class=\"p-4 bg-red-50 text-red-600 rounded\">Unknown plugin component: {}</div>",
260 html_escape(&props.plugin_type)
261 )
262 })
263}
264
265fn render_card(props: &CardProps, data: &Value) -> String {
268 let mut html = String::from(
269 "<div class=\"rounded-lg border border-gray-200 bg-white shadow-sm\"><div class=\"p-6\">",
270 );
271 html.push_str(&format!(
272 "<h3 class=\"text-lg font-semibold text-gray-900\">{}</h3>",
273 html_escape(&props.title)
274 ));
275 if let Some(ref desc) = props.description {
276 html.push_str(&format!(
277 "<p class=\"mt-1 text-sm text-gray-500\">{}</p>",
278 html_escape(desc)
279 ));
280 }
281 if !props.children.is_empty() {
282 html.push_str("<div class=\"mt-4 space-y-4\">");
283 for child in &props.children {
284 html.push_str(&render_node(child, data));
285 }
286 html.push_str("</div>");
287 }
288 html.push_str("</div>"); if !props.footer.is_empty() {
290 html.push_str("<div class=\"border-t border-gray-200 px-6 py-4 flex items-center gap-2\">");
291 for child in &props.footer {
292 html.push_str(&render_node(child, data));
293 }
294 html.push_str("</div>");
295 }
296 html.push_str("</div>"); html
298}
299
300fn render_modal(props: &ModalProps, data: &Value) -> String {
301 let trigger = props.trigger_label.as_deref().unwrap_or("Open");
302 let mut html = String::from("<details class=\"group\">");
303 html.push_str(&format!(
304 "<summary class=\"inline-flex items-center justify-center rounded-md bg-blue-600 text-white px-4 py-2 text-sm font-medium cursor-pointer\">{}</summary>",
305 html_escape(trigger)
306 ));
307 html.push_str("<div class=\"fixed inset-0 z-50 flex items-center justify-center bg-black/50 group-open:block hidden\">");
308 html.push_str(
309 "<div class=\"relative bg-white rounded-lg shadow-lg max-w-lg w-full mx-4 p-6\">",
310 );
311 html.push_str(&format!(
312 "<h3 class=\"text-lg font-semibold text-gray-900\">{}</h3>",
313 html_escape(&props.title)
314 ));
315 if let Some(ref desc) = props.description {
316 html.push_str(&format!(
317 "<p class=\"mt-1 text-sm text-gray-500\">{}</p>",
318 html_escape(desc)
319 ));
320 }
321 html.push_str("<div class=\"mt-4 space-y-4\">");
322 for child in &props.children {
323 html.push_str(&render_node(child, data));
324 }
325 html.push_str("</div>");
326 if !props.footer.is_empty() {
327 html.push_str("<div class=\"mt-6 flex items-center justify-end gap-2\">");
328 for child in &props.footer {
329 html.push_str(&render_node(child, data));
330 }
331 html.push_str("</div>");
332 }
333 html.push_str("</div></div></details>");
334 html
335}
336
337fn render_tabs(props: &TabsProps, data: &Value) -> String {
338 let mut html = String::from("<div>");
339 html.push_str("<div class=\"border-b border-gray-200\">");
340 html.push_str("<nav class=\"flex -mb-px space-x-4\">");
341 for tab in &props.tabs {
342 if tab.value == props.default_tab {
343 html.push_str(&format!(
344 "<span class=\"border-b-2 border-blue-600 text-blue-600 px-3 py-2 text-sm font-medium\">{}</span>",
345 html_escape(&tab.label)
346 ));
347 } else {
348 html.push_str(&format!(
349 "<span class=\"border-b-2 border-transparent text-gray-500 px-3 py-2 text-sm font-medium\">{}</span>",
350 html_escape(&tab.label)
351 ));
352 }
353 }
354 html.push_str("</nav></div>");
355 for tab in &props.tabs {
357 if tab.value == props.default_tab {
358 html.push_str("<div class=\"pt-4 space-y-4\">");
359 for child in &tab.children {
360 html.push_str(&render_node(child, data));
361 }
362 html.push_str("</div>");
363 break;
364 }
365 }
366 html.push_str("</div>");
367 html
368}
369
370fn render_form(props: &FormProps, data: &Value) -> String {
371 let effective_method = props
373 .method
374 .as_ref()
375 .unwrap_or(&props.action.method)
376 .clone();
377
378 let (form_method, needs_spoofing) = match effective_method {
380 HttpMethod::Get => ("get", false),
381 HttpMethod::Post => ("post", false),
382 HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => ("post", true),
383 };
384
385 let action_url = props.action.url.as_deref().unwrap_or("#");
386 let mut html = format!(
387 "<form action=\"{}\" method=\"{}\" class=\"space-y-4\">",
388 html_escape(action_url),
389 form_method
390 );
391
392 if needs_spoofing {
393 let method_value = match effective_method {
394 HttpMethod::Put => "PUT",
395 HttpMethod::Patch => "PATCH",
396 HttpMethod::Delete => "DELETE",
397 _ => unreachable!(),
398 };
399 html.push_str(&format!(
400 "<input type=\"hidden\" name=\"_method\" value=\"{method_value}\">"
401 ));
402 }
403
404 for field in &props.fields {
405 html.push_str(&render_node(field, data));
406 }
407 html.push_str("</form>");
408 html
409}
410
411fn render_table(props: &TableProps, data: &Value) -> String {
412 let mut html = String::from(
413 "<div class=\"overflow-x-auto\"><table class=\"min-w-full divide-y divide-gray-200\">",
414 );
415
416 html.push_str("<thead class=\"bg-gray-50\"><tr>");
418 for col in &props.columns {
419 html.push_str(&format!(
420 "<th class=\"px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500\">{}</th>",
421 html_escape(&col.label)
422 ));
423 }
424 if props.row_actions.is_some() {
425 html.push_str("<th class=\"px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500\">Actions</th>");
426 }
427 html.push_str("</tr></thead>");
428
429 html.push_str("<tbody class=\"divide-y divide-gray-200 bg-white\">");
431
432 let rows = resolve_path(data, &props.data_path);
433 let row_array = rows.and_then(|v| v.as_array());
434
435 if let Some(items) = row_array {
436 if items.is_empty() {
437 if let Some(ref msg) = props.empty_message {
438 let col_count =
439 props.columns.len() + if props.row_actions.is_some() { 1 } else { 0 };
440 html.push_str(&format!(
441 "<tr><td colspan=\"{}\" class=\"px-6 py-8 text-center text-sm text-gray-500\">{}</td></tr>",
442 col_count,
443 html_escape(msg)
444 ));
445 }
446 } else {
447 for row in items {
448 html.push_str("<tr>");
449 for col in &props.columns {
450 let cell_value = row.get(&col.key);
451 let cell_text = match cell_value {
452 Some(Value::String(s)) => s.clone(),
453 Some(Value::Number(n)) => n.to_string(),
454 Some(Value::Bool(b)) => b.to_string(),
455 Some(Value::Null) | None => String::new(),
456 Some(v @ Value::Array(_)) | Some(v @ Value::Object(_)) => {
457 serde_json::to_string(v).unwrap_or_default()
458 }
459 };
460 html.push_str(&format!(
461 "<td class=\"px-6 py-4 text-sm text-gray-900 whitespace-nowrap\">{}</td>",
462 html_escape(&cell_text)
463 ));
464 }
465 if let Some(ref actions) = props.row_actions {
466 html.push_str("<td class=\"px-6 py-4 text-right text-sm space-x-2\">");
467 for action in actions {
468 let url = action.url.as_deref().unwrap_or("#");
469 let label = action
470 .handler
471 .split('.')
472 .next_back()
473 .unwrap_or(&action.handler);
474 html.push_str(&format!(
475 "<a href=\"{}\" class=\"text-blue-600 hover:text-blue-800\">{}</a>",
476 html_escape(url),
477 html_escape(label)
478 ));
479 }
480 html.push_str("</td>");
481 }
482 html.push_str("</tr>");
483 }
484 }
485 } else if let Some(ref msg) = props.empty_message {
486 let col_count = props.columns.len() + if props.row_actions.is_some() { 1 } else { 0 };
487 html.push_str(&format!(
488 "<tr><td colspan=\"{}\" class=\"px-6 py-8 text-center text-sm text-gray-500\">{}</td></tr>",
489 col_count,
490 html_escape(msg)
491 ));
492 }
493
494 html.push_str("</tbody></table></div>");
495 html
496}
497
498fn render_input(props: &InputProps, data: &Value) -> String {
501 let resolved_value = if let Some(ref dv) = props.default_value {
503 Some(dv.clone())
504 } else if let Some(ref dp) = props.data_path {
505 resolve_path_string(data, dp)
506 } else {
507 None
508 };
509
510 let has_error = props.error.is_some();
511 let border_class = if has_error {
512 "border-red-500"
513 } else {
514 "border-gray-300"
515 };
516
517 let mut html = String::from("<div class=\"space-y-1\">");
518 html.push_str(&format!(
519 "<label class=\"block text-sm font-medium text-gray-700\" for=\"{}\">{}</label>",
520 html_escape(&props.field),
521 html_escape(&props.label)
522 ));
523
524 if let Some(ref desc) = props.description {
525 html.push_str(&format!(
526 "<p class=\"text-sm text-gray-500\">{}</p>",
527 html_escape(desc)
528 ));
529 }
530
531 match props.input_type {
532 InputType::Hidden => {
533 let val = resolved_value.as_deref().unwrap_or("");
534 html.push_str(&format!(
535 "<input type=\"hidden\" id=\"{}\" name=\"{}\" value=\"{}\">",
536 html_escape(&props.field),
537 html_escape(&props.field),
538 html_escape(val)
539 ));
540 }
541 InputType::Textarea => {
542 let val = resolved_value.as_deref().unwrap_or("");
543 html.push_str(&format!(
544 "<textarea id=\"{}\" name=\"{}\" class=\"block w-full rounded-md border {} px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"",
545 html_escape(&props.field),
546 html_escape(&props.field),
547 border_class
548 ));
549 if let Some(ref placeholder) = props.placeholder {
550 html.push_str(&format!(" placeholder=\"{}\"", html_escape(placeholder)));
551 }
552 if props.required == Some(true) {
553 html.push_str(" required");
554 }
555 if props.disabled == Some(true) {
556 html.push_str(" disabled");
557 }
558 html.push_str(&format!(">{}</textarea>", html_escape(val)));
559 }
560 _ => {
561 let input_type = match props.input_type {
562 InputType::Text => "text",
563 InputType::Email => "email",
564 InputType::Password => "password",
565 InputType::Number => "number",
566 InputType::Date => "date",
567 InputType::Time => "time",
568 InputType::Url => "url",
569 InputType::Tel => "tel",
570 InputType::Search => "search",
571 InputType::Textarea | InputType::Hidden => unreachable!(),
572 };
573 html.push_str(&format!(
574 "<input type=\"{}\" id=\"{}\" name=\"{}\" class=\"block w-full rounded-md border {} px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"",
575 input_type,
576 html_escape(&props.field),
577 html_escape(&props.field),
578 border_class
579 ));
580 if let Some(ref placeholder) = props.placeholder {
581 html.push_str(&format!(" placeholder=\"{}\"", html_escape(placeholder)));
582 }
583 if let Some(ref val) = resolved_value {
584 html.push_str(&format!(" value=\"{}\"", html_escape(val)));
585 }
586 if let Some(ref step) = props.step {
587 html.push_str(&format!(" step=\"{}\"", html_escape(step)));
588 }
589 if props.required == Some(true) {
590 html.push_str(" required");
591 }
592 if props.disabled == Some(true) {
593 html.push_str(" disabled");
594 }
595 html.push('>');
596 }
597 }
598
599 if let Some(ref error) = props.error {
600 html.push_str(&format!(
601 "<p class=\"text-sm text-red-600\">{}</p>",
602 html_escape(error)
603 ));
604 }
605 html.push_str("</div>");
606 html
607}
608
609fn render_select(props: &SelectProps, data: &Value) -> String {
610 let selected_value = if let Some(ref dv) = props.default_value {
612 Some(dv.clone())
613 } else if let Some(ref dp) = props.data_path {
614 resolve_path_string(data, dp)
615 } else {
616 None
617 };
618
619 let has_error = props.error.is_some();
620 let border_class = if has_error {
621 "border-red-500"
622 } else {
623 "border-gray-300"
624 };
625
626 let mut html = String::from("<div class=\"space-y-1\">");
627 html.push_str(&format!(
628 "<label class=\"block text-sm font-medium text-gray-700\" for=\"{}\">{}</label>",
629 html_escape(&props.field),
630 html_escape(&props.label)
631 ));
632
633 if let Some(ref desc) = props.description {
634 html.push_str(&format!(
635 "<p class=\"text-sm text-gray-500\">{}</p>",
636 html_escape(desc)
637 ));
638 }
639
640 html.push_str(&format!(
641 "<select id=\"{}\" name=\"{}\" class=\"block w-full rounded-md border {} px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500\"",
642 html_escape(&props.field),
643 html_escape(&props.field),
644 border_class
645 ));
646 if props.required == Some(true) {
647 html.push_str(" required");
648 }
649 if props.disabled == Some(true) {
650 html.push_str(" disabled");
651 }
652 html.push('>');
653
654 if let Some(ref placeholder) = props.placeholder {
655 html.push_str(&format!(
656 "<option value=\"\">{}</option>",
657 html_escape(placeholder)
658 ));
659 }
660
661 for opt in &props.options {
662 let is_selected = selected_value.as_deref() == Some(&opt.value);
663 let selected_attr = if is_selected { " selected" } else { "" };
664 html.push_str(&format!(
665 "<option value=\"{}\"{}>{}</option>",
666 html_escape(&opt.value),
667 selected_attr,
668 html_escape(&opt.label)
669 ));
670 }
671
672 html.push_str("</select>");
673
674 if let Some(ref error) = props.error {
675 html.push_str(&format!(
676 "<p class=\"text-sm text-red-600\">{}</p>",
677 html_escape(error)
678 ));
679 }
680 html.push_str("</div>");
681 html
682}
683
684fn render_checkbox(props: &CheckboxProps, data: &Value) -> String {
685 let is_checked = if let Some(c) = props.checked {
687 c
688 } else if let Some(ref dp) = props.data_path {
689 resolve_path(data, dp)
690 .map(|v| match v {
691 Value::Bool(b) => *b,
692 Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
693 Value::String(s) => !s.is_empty() && s != "false" && s != "0",
694 Value::Null => false,
695 _ => true,
696 })
697 .unwrap_or(false)
698 } else {
699 false
700 };
701
702 let mut html = String::from("<div class=\"space-y-1\">");
703 html.push_str("<div class=\"flex items-center gap-2\">");
704 html.push_str(&format!(
705 "<input type=\"checkbox\" id=\"{}\" name=\"{}\" value=\"1\" class=\"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500\"",
706 html_escape(&props.field),
707 html_escape(&props.field)
708 ));
709 if is_checked {
710 html.push_str(" checked");
711 }
712 if props.required == Some(true) {
713 html.push_str(" required");
714 }
715 if props.disabled == Some(true) {
716 html.push_str(" disabled");
717 }
718 html.push('>');
719 html.push_str(&format!(
720 "<label class=\"text-sm font-medium text-gray-700\" for=\"{}\">{}</label>",
721 html_escape(&props.field),
722 html_escape(&props.label)
723 ));
724 html.push_str("</div>");
725
726 if let Some(ref desc) = props.description {
727 html.push_str(&format!(
728 "<p class=\"ml-6 text-sm text-gray-500\">{}</p>",
729 html_escape(desc)
730 ));
731 }
732
733 if let Some(ref error) = props.error {
734 html.push_str(&format!(
735 "<p class=\"ml-6 text-sm text-red-600\">{}</p>",
736 html_escape(error)
737 ));
738 }
739 html.push_str("</div>");
740 html
741}
742
743fn render_switch(props: &SwitchProps, data: &Value) -> String {
744 let is_checked = if let Some(c) = props.checked {
746 c
747 } else if let Some(ref dp) = props.data_path {
748 resolve_path(data, dp)
749 .map(|v| match v {
750 Value::Bool(b) => *b,
751 Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
752 Value::String(s) => !s.is_empty() && s != "false" && s != "0",
753 Value::Null => false,
754 _ => true,
755 })
756 .unwrap_or(false)
757 } else {
758 false
759 };
760
761 let mut html = String::from("<div class=\"space-y-1\">");
762 html.push_str("<div class=\"flex items-center justify-between\">");
763
764 html.push_str("<div>");
766 html.push_str(&format!(
767 "<label class=\"text-sm font-medium text-gray-700\" for=\"{}\">{}</label>",
768 html_escape(&props.field),
769 html_escape(&props.label)
770 ));
771 if let Some(ref desc) = props.description {
772 html.push_str(&format!(
773 "<p class=\"text-sm text-gray-500\">{}</p>",
774 html_escape(desc)
775 ));
776 }
777 html.push_str("</div>");
778
779 html.push_str("<label class=\"relative inline-flex items-center cursor-pointer\">");
781 html.push_str(&format!(
782 "<input type=\"checkbox\" id=\"{}\" name=\"{}\" value=\"1\" class=\"sr-only peer\"",
783 html_escape(&props.field),
784 html_escape(&props.field)
785 ));
786 if is_checked {
787 html.push_str(" checked");
788 }
789 if props.required == Some(true) {
790 html.push_str(" required");
791 }
792 if props.disabled == Some(true) {
793 html.push_str(" disabled");
794 }
795 html.push('>');
796 html.push_str("<div class=\"w-11 h-6 bg-gray-200 rounded-full peer peer-checked:bg-blue-600 peer-focus:ring-2 peer-focus:ring-blue-300 after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-full\"></div>");
797 html.push_str("</label>");
798 html.push_str("</div>");
799
800 if let Some(ref error) = props.error {
801 html.push_str(&format!(
802 "<p class=\"text-sm text-red-600\">{}</p>",
803 html_escape(error)
804 ));
805 }
806 html.push_str("</div>");
807 html
808}
809
810fn render_text(props: &TextProps) -> String {
813 let content = html_escape(&props.content);
814 match props.element {
815 TextElement::P => format!("<p class=\"text-base text-gray-700\">{content}</p>"),
816 TextElement::H1 => format!("<h1 class=\"text-3xl font-bold text-gray-900\">{content}</h1>"),
817 TextElement::H2 => {
818 format!("<h2 class=\"text-2xl font-semibold text-gray-900\">{content}</h2>")
819 }
820 TextElement::H3 => {
821 format!("<h3 class=\"text-xl font-semibold text-gray-900\">{content}</h3>")
822 }
823 TextElement::Span => format!("<span class=\"text-base text-gray-700\">{content}</span>"),
824 TextElement::Div => format!("<div class=\"text-base text-gray-700\">{content}</div>"),
825 TextElement::Section => {
826 format!("<section class=\"text-base text-gray-700\">{content}</section>")
827 }
828 }
829}
830
831fn render_button(props: &ButtonProps) -> String {
832 let base = "inline-flex items-center justify-center rounded-md font-medium transition-colors";
833
834 let variant_classes = match props.variant {
835 ButtonVariant::Default => "bg-blue-600 text-white hover:bg-blue-700",
836 ButtonVariant::Secondary => "bg-gray-100 text-gray-900 hover:bg-gray-200",
837 ButtonVariant::Destructive => "bg-red-600 text-white hover:bg-red-700",
838 ButtonVariant::Outline => "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50",
839 ButtonVariant::Ghost => "text-gray-700 hover:bg-gray-100",
840 ButtonVariant::Link => "text-blue-600 underline hover:text-blue-700",
841 };
842
843 let size_classes = match props.size {
844 Size::Xs => "px-2 py-1 text-xs",
845 Size::Sm => "px-3 py-1.5 text-sm",
846 Size::Default => "px-4 py-2 text-sm",
847 Size::Lg => "px-6 py-3 text-base",
848 };
849
850 let disabled_classes = if props.disabled == Some(true) {
851 " opacity-50 cursor-not-allowed"
852 } else {
853 ""
854 };
855
856 let disabled_attr = if props.disabled == Some(true) {
857 " disabled"
858 } else {
859 ""
860 };
861
862 let label = html_escape(&props.label);
863
864 let content = if let Some(ref icon) = props.icon {
866 let icon_span = format!(
867 "<span class=\"icon\" data-icon=\"{}\">{}</span>",
868 html_escape(icon),
869 html_escape(icon)
870 );
871 let position = props.icon_position.as_ref().cloned().unwrap_or_default();
872 match position {
873 IconPosition::Left => format!("{icon_span} {label}"),
874 IconPosition::Right => format!("{label} {icon_span}"),
875 }
876 } else {
877 label
878 };
879
880 format!(
881 "<button class=\"{base} {variant_classes} {size_classes}{disabled_classes}\"{disabled_attr}>{content}</button>"
882 )
883}
884
885fn render_badge(props: &BadgeProps) -> String {
886 let base = "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium";
887 let variant_classes = match props.variant {
888 BadgeVariant::Default => "bg-blue-100 text-blue-800",
889 BadgeVariant::Secondary => "bg-gray-100 text-gray-800",
890 BadgeVariant::Destructive => "bg-red-100 text-red-800",
891 BadgeVariant::Outline => "border border-gray-300 text-gray-700",
892 };
893 format!(
894 "<span class=\"{} {}\">{}</span>",
895 base,
896 variant_classes,
897 html_escape(&props.label)
898 )
899}
900
901fn render_alert(props: &AlertProps) -> String {
902 let variant_classes = match props.variant {
903 AlertVariant::Info => "bg-blue-50 border-blue-200 text-blue-800",
904 AlertVariant::Success => "bg-green-50 border-green-200 text-green-800",
905 AlertVariant::Warning => "bg-yellow-50 border-yellow-200 text-yellow-800",
906 AlertVariant::Error => "bg-red-50 border-red-200 text-red-800",
907 };
908 let mut html =
909 format!("<div role=\"alert\" class=\"rounded-md border p-4 {variant_classes}\">");
910 if let Some(ref title) = props.title {
911 html.push_str(&format!(
912 "<h4 class=\"font-semibold mb-1\">{}</h4>",
913 html_escape(title)
914 ));
915 }
916 html.push_str(&format!("<p>{}</p>", html_escape(&props.message)));
917 html.push_str("</div>");
918 html
919}
920
921fn render_separator(props: &SeparatorProps) -> String {
922 let orientation = props.orientation.as_ref().cloned().unwrap_or_default();
923 match orientation {
924 Orientation::Horizontal => "<hr class=\"my-4 border-gray-200\">".to_string(),
925 Orientation::Vertical => "<div class=\"mx-4 h-full w-px bg-gray-200\"></div>".to_string(),
926 }
927}
928
929fn render_progress(props: &ProgressProps) -> String {
930 let max = props.max.unwrap_or(100) as f64;
931 let pct = if max > 0.0 {
932 ((props.value as f64 * 100.0 / max).round() as u8).min(100)
933 } else {
934 0
935 };
936
937 let mut html = String::from("<div class=\"w-full\">");
938 if let Some(ref label) = props.label {
939 html.push_str(&format!(
940 "<div class=\"mb-1 text-sm text-gray-600\">{}</div>",
941 html_escape(label)
942 ));
943 }
944 html.push_str(&format!(
945 "<div class=\"w-full rounded-full bg-gray-200 h-2.5\"><div class=\"rounded-full bg-blue-600 h-2.5\" style=\"width: {pct}%\"></div></div>"
946 ));
947 html.push_str("</div>");
948 html
949}
950
951fn render_avatar(props: &AvatarProps) -> String {
952 let size = props.size.as_ref().cloned().unwrap_or_default();
953 let size_classes = match size {
954 Size::Xs => "h-6 w-6 text-xs",
955 Size::Sm => "h-8 w-8 text-sm",
956 Size::Default => "h-10 w-10 text-sm",
957 Size::Lg => "h-12 w-12 text-base",
958 };
959
960 if let Some(ref src) = props.src {
961 format!(
962 "<img src=\"{}\" alt=\"{}\" class=\"rounded-full object-cover {}\">",
963 html_escape(src),
964 html_escape(&props.alt),
965 size_classes
966 )
967 } else {
968 let fallback_text = props.fallback.as_deref().unwrap_or_else(|| {
969 &props.alt
971 });
972 let initials: String = fallback_text.chars().take(2).collect();
974 format!(
975 "<span class=\"inline-flex items-center justify-center rounded-full bg-gray-200 text-gray-600 {}\">{}</span>",
976 size_classes,
977 html_escape(&initials)
978 )
979 }
980}
981
982fn render_skeleton(props: &SkeletonProps) -> String {
983 let width = props.width.as_deref().unwrap_or("100%");
984 let height = props.height.as_deref().unwrap_or("1rem");
985 let rounded = if props.rounded == Some(true) {
986 "rounded-full"
987 } else {
988 "rounded-md"
989 };
990 format!(
991 "<div class=\"animate-pulse bg-gray-200 {rounded}\" style=\"width: {width}; height: {height}\"></div>"
992 )
993}
994
995fn render_breadcrumb(props: &BreadcrumbProps) -> String {
996 let mut html =
997 String::from("<nav class=\"flex items-center space-x-2 text-sm text-gray-500\">");
998 let len = props.items.len();
999 for (i, item) in props.items.iter().enumerate() {
1000 let is_last = i == len - 1;
1001 if is_last {
1002 html.push_str(&format!(
1003 "<span class=\"text-gray-900 font-medium\">{}</span>",
1004 html_escape(&item.label)
1005 ));
1006 } else if let Some(ref url) = item.url {
1007 html.push_str(&format!(
1008 "<a href=\"{}\" class=\"hover:text-gray-700\">{}</a>",
1009 html_escape(url),
1010 html_escape(&item.label)
1011 ));
1012 } else {
1013 html.push_str(&format!("<span>{}</span>", html_escape(&item.label)));
1014 }
1015 if !is_last {
1016 html.push_str("<span>/</span>");
1017 }
1018 }
1019 html.push_str("</nav>");
1020 html
1021}
1022
1023fn render_pagination(props: &PaginationProps) -> String {
1024 if props.total == 0 || props.per_page == 0 {
1025 return String::new();
1026 }
1027
1028 let total_pages = props.total.div_ceil(props.per_page);
1029 if total_pages <= 1 {
1030 return String::new();
1031 }
1032
1033 let base_url = props.base_url.as_deref().unwrap_or("?");
1034 let current = props.current_page;
1035
1036 let mut html = String::from("<nav class=\"flex items-center space-x-1\">");
1037
1038 if current > 1 {
1040 html.push_str(&format!(
1041 "<a href=\"{}page={}\" class=\"px-3 py-1 rounded-md bg-white text-gray-700 hover:bg-gray-50\">«</a>",
1042 html_escape(base_url),
1043 current - 1
1044 ));
1045 }
1046
1047 let pages = compute_page_range(current, total_pages);
1049 let mut prev_page = 0u32;
1050 for page in pages {
1051 if prev_page > 0 && page > prev_page + 1 {
1052 html.push_str("<span class=\"px-2 text-gray-400\">…</span>");
1053 }
1054 if page == current {
1055 html.push_str(&format!(
1056 "<span class=\"px-3 py-1 rounded-md bg-blue-600 text-white\">{page}</span>"
1057 ));
1058 } else {
1059 html.push_str(&format!(
1060 "<a href=\"{}page={}\" class=\"px-3 py-1 rounded-md bg-white text-gray-700 hover:bg-gray-50\">{}</a>",
1061 html_escape(base_url),
1062 page,
1063 page
1064 ));
1065 }
1066 prev_page = page;
1067 }
1068
1069 if current < total_pages {
1071 html.push_str(&format!(
1072 "<a href=\"{}page={}\" class=\"px-3 py-1 rounded-md bg-white text-gray-700 hover:bg-gray-50\">»</a>",
1073 html_escape(base_url),
1074 current + 1
1075 ));
1076 }
1077
1078 html.push_str("</nav>");
1079 html
1080}
1081
1082fn compute_page_range(current: u32, total: u32) -> Vec<u32> {
1084 if total <= 7 {
1085 return (1..=total).collect();
1086 }
1087 let mut pages = Vec::new();
1088 pages.push(1);
1089 let start = current.saturating_sub(1).max(2);
1090 let end = (current + 1).min(total - 1);
1091 for p in start..=end {
1092 if !pages.contains(&p) {
1093 pages.push(p);
1094 }
1095 }
1096 if !pages.contains(&total) {
1097 pages.push(total);
1098 }
1099 pages.sort();
1100 pages.dedup();
1101 pages
1102}
1103
1104fn render_description_list(props: &DescriptionListProps) -> String {
1105 let columns = props.columns.unwrap_or(1);
1106 let mut html = format!("<dl class=\"grid grid-cols-{columns} gap-4\">");
1107 for item in &props.items {
1108 html.push_str(&format!(
1109 "<div><dt class=\"text-sm font-medium text-gray-500\">{}</dt><dd class=\"mt-1 text-sm text-gray-900\">{}</dd></div>",
1110 html_escape(&item.label),
1111 html_escape(&item.value)
1112 ));
1113 }
1114 html.push_str("</dl>");
1115 html
1116}
1117
1118pub(crate) fn html_escape(s: &str) -> String {
1122 let mut escaped = String::with_capacity(s.len());
1123 for c in s.chars() {
1124 match c {
1125 '&' => escaped.push_str("&"),
1126 '<' => escaped.push_str("<"),
1127 '>' => escaped.push_str(">"),
1128 '"' => escaped.push_str("""),
1129 '\'' => escaped.push_str("'"),
1130 _ => escaped.push(c),
1131 }
1132 }
1133 escaped
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138 use super::*;
1139 use crate::action::{Action, HttpMethod};
1140 use crate::component::*;
1141 use serde_json::json;
1142
1143 fn text_node(key: &str, content: &str, element: TextElement) -> ComponentNode {
1146 ComponentNode {
1147 key: key.to_string(),
1148 component: Component::Text(TextProps {
1149 content: content.to_string(),
1150 element,
1151 }),
1152 action: None,
1153 visibility: None,
1154 }
1155 }
1156
1157 fn button_node(key: &str, label: &str, variant: ButtonVariant, size: Size) -> ComponentNode {
1158 ComponentNode {
1159 key: key.to_string(),
1160 component: Component::Button(ButtonProps {
1161 label: label.to_string(),
1162 variant,
1163 size,
1164 disabled: None,
1165 icon: None,
1166 icon_position: None,
1167 }),
1168 action: None,
1169 visibility: None,
1170 }
1171 }
1172
1173 fn make_action(handler: &str, method: HttpMethod) -> Action {
1174 Action {
1175 handler: handler.to_string(),
1176 url: None,
1177 method,
1178 confirm: None,
1179 on_success: None,
1180 on_error: None,
1181 }
1182 }
1183
1184 fn make_action_with_url(handler: &str, method: HttpMethod, url: &str) -> Action {
1185 Action {
1186 handler: handler.to_string(),
1187 url: Some(url.to_string()),
1188 method,
1189 confirm: None,
1190 on_success: None,
1191 on_error: None,
1192 }
1193 }
1194
1195 #[test]
1198 fn render_empty_view_produces_wrapper_div() {
1199 let view = JsonUiView::new();
1200 let html = render_to_html(&view, &json!({}));
1201 assert_eq!(html, "<div></div>");
1202 }
1203
1204 #[test]
1205 fn render_view_with_component_wraps_in_div() {
1206 let view = JsonUiView::new().component(text_node("t", "Hello", TextElement::P));
1207 let html = render_to_html(&view, &json!({}));
1208 assert!(html.starts_with("<div>"));
1209 assert!(html.ends_with("</div>"));
1210 assert!(html.contains("<p class=\"text-base text-gray-700\">Hello</p>"));
1211 }
1212
1213 #[test]
1216 fn text_p_variant() {
1217 let view = JsonUiView::new().component(text_node("t", "Paragraph", TextElement::P));
1218 let html = render_to_html(&view, &json!({}));
1219 assert!(html.contains("<p class=\"text-base text-gray-700\">Paragraph</p>"));
1220 }
1221
1222 #[test]
1223 fn text_h1_variant() {
1224 let view = JsonUiView::new().component(text_node("t", "Title", TextElement::H1));
1225 let html = render_to_html(&view, &json!({}));
1226 assert!(html.contains("<h1 class=\"text-3xl font-bold text-gray-900\">Title</h1>"));
1227 }
1228
1229 #[test]
1230 fn text_h2_variant() {
1231 let view = JsonUiView::new().component(text_node("t", "Subtitle", TextElement::H2));
1232 let html = render_to_html(&view, &json!({}));
1233 assert!(html.contains("<h2 class=\"text-2xl font-semibold text-gray-900\">Subtitle</h2>"));
1234 }
1235
1236 #[test]
1237 fn text_h3_variant() {
1238 let view = JsonUiView::new().component(text_node("t", "Section", TextElement::H3));
1239 let html = render_to_html(&view, &json!({}));
1240 assert!(html.contains("<h3 class=\"text-xl font-semibold text-gray-900\">Section</h3>"));
1241 }
1242
1243 #[test]
1244 fn text_span_variant() {
1245 let view = JsonUiView::new().component(text_node("t", "Inline", TextElement::Span));
1246 let html = render_to_html(&view, &json!({}));
1247 assert!(html.contains("<span class=\"text-base text-gray-700\">Inline</span>"));
1248 }
1249
1250 #[test]
1253 fn button_default_variant() {
1254 let view = JsonUiView::new().component(button_node(
1255 "b",
1256 "Click",
1257 ButtonVariant::Default,
1258 Size::Default,
1259 ));
1260 let html = render_to_html(&view, &json!({}));
1261 assert!(html.contains("bg-blue-600 text-white hover:bg-blue-700"));
1262 assert!(html.contains(">Click</button>"));
1263 }
1264
1265 #[test]
1266 fn button_secondary_variant() {
1267 let view = JsonUiView::new().component(button_node(
1268 "b",
1269 "Click",
1270 ButtonVariant::Secondary,
1271 Size::Default,
1272 ));
1273 let html = render_to_html(&view, &json!({}));
1274 assert!(html.contains("bg-gray-100 text-gray-900 hover:bg-gray-200"));
1275 }
1276
1277 #[test]
1278 fn button_destructive_variant() {
1279 let view = JsonUiView::new().component(button_node(
1280 "b",
1281 "Delete",
1282 ButtonVariant::Destructive,
1283 Size::Default,
1284 ));
1285 let html = render_to_html(&view, &json!({}));
1286 assert!(html.contains("bg-red-600 text-white hover:bg-red-700"));
1287 }
1288
1289 #[test]
1290 fn button_outline_variant() {
1291 let view = JsonUiView::new().component(button_node(
1292 "b",
1293 "Click",
1294 ButtonVariant::Outline,
1295 Size::Default,
1296 ));
1297 let html = render_to_html(&view, &json!({}));
1298 assert!(html.contains("border border-gray-300 bg-white text-gray-700"));
1299 }
1300
1301 #[test]
1302 fn button_ghost_variant() {
1303 let view = JsonUiView::new().component(button_node(
1304 "b",
1305 "Click",
1306 ButtonVariant::Ghost,
1307 Size::Default,
1308 ));
1309 let html = render_to_html(&view, &json!({}));
1310 assert!(html.contains("text-gray-700 hover:bg-gray-100"));
1311 }
1312
1313 #[test]
1314 fn button_link_variant() {
1315 let view = JsonUiView::new().component(button_node(
1316 "b",
1317 "Click",
1318 ButtonVariant::Link,
1319 Size::Default,
1320 ));
1321 let html = render_to_html(&view, &json!({}));
1322 assert!(html.contains("text-blue-600 underline hover:text-blue-700"));
1323 }
1324
1325 #[test]
1326 fn button_disabled_state() {
1327 let view = JsonUiView::new().component(ComponentNode {
1328 key: "b".to_string(),
1329 component: Component::Button(ButtonProps {
1330 label: "Disabled".to_string(),
1331 variant: ButtonVariant::Default,
1332 size: Size::Default,
1333 disabled: Some(true),
1334 icon: None,
1335 icon_position: None,
1336 }),
1337 action: None,
1338 visibility: None,
1339 });
1340 let html = render_to_html(&view, &json!({}));
1341 assert!(html.contains("opacity-50 cursor-not-allowed"));
1342 assert!(html.contains(" disabled"));
1343 }
1344
1345 #[test]
1346 fn button_with_icon_left() {
1347 let view = JsonUiView::new().component(ComponentNode {
1348 key: "b".to_string(),
1349 component: Component::Button(ButtonProps {
1350 label: "Save".to_string(),
1351 variant: ButtonVariant::Default,
1352 size: Size::Default,
1353 disabled: None,
1354 icon: Some("save".to_string()),
1355 icon_position: Some(IconPosition::Left),
1356 }),
1357 action: None,
1358 visibility: None,
1359 });
1360 let html = render_to_html(&view, &json!({}));
1361 assert!(html.contains("data-icon=\"save\""));
1362 let icon_pos = html.find("data-icon").unwrap();
1364 let label_pos = html.find("Save").unwrap();
1365 assert!(icon_pos < label_pos);
1366 }
1367
1368 #[test]
1369 fn button_with_icon_right() {
1370 let view = JsonUiView::new().component(ComponentNode {
1371 key: "b".to_string(),
1372 component: Component::Button(ButtonProps {
1373 label: "Next".to_string(),
1374 variant: ButtonVariant::Default,
1375 size: Size::Default,
1376 disabled: None,
1377 icon: Some("arrow-right".to_string()),
1378 icon_position: Some(IconPosition::Right),
1379 }),
1380 action: None,
1381 visibility: None,
1382 });
1383 let html = render_to_html(&view, &json!({}));
1384 assert!(html.contains("data-icon=\"arrow-right\""));
1385 let label_pos = html.find("Next").unwrap();
1387 let icon_pos = html.find("data-icon").unwrap();
1388 assert!(label_pos < icon_pos);
1389 }
1390
1391 #[test]
1394 fn button_size_xs() {
1395 let view =
1396 JsonUiView::new().component(button_node("b", "X", ButtonVariant::Default, Size::Xs));
1397 let html = render_to_html(&view, &json!({}));
1398 assert!(html.contains("px-2 py-1 text-xs"));
1399 }
1400
1401 #[test]
1402 fn button_size_sm() {
1403 let view =
1404 JsonUiView::new().component(button_node("b", "S", ButtonVariant::Default, Size::Sm));
1405 let html = render_to_html(&view, &json!({}));
1406 assert!(html.contains("px-3 py-1.5 text-sm"));
1407 }
1408
1409 #[test]
1410 fn button_size_default() {
1411 let view = JsonUiView::new().component(button_node(
1412 "b",
1413 "D",
1414 ButtonVariant::Default,
1415 Size::Default,
1416 ));
1417 let html = render_to_html(&view, &json!({}));
1418 assert!(html.contains("px-4 py-2 text-sm"));
1419 }
1420
1421 #[test]
1422 fn button_size_lg() {
1423 let view =
1424 JsonUiView::new().component(button_node("b", "L", ButtonVariant::Default, Size::Lg));
1425 let html = render_to_html(&view, &json!({}));
1426 assert!(html.contains("px-6 py-3 text-base"));
1427 }
1428
1429 #[test]
1432 fn badge_default_variant() {
1433 let view = JsonUiView::new().component(ComponentNode {
1434 key: "bg".to_string(),
1435 component: Component::Badge(BadgeProps {
1436 label: "New".to_string(),
1437 variant: BadgeVariant::Default,
1438 }),
1439 action: None,
1440 visibility: None,
1441 });
1442 let html = render_to_html(&view, &json!({}));
1443 assert!(html.contains("bg-blue-100 text-blue-800"));
1444 assert!(html.contains(">New</span>"));
1445 }
1446
1447 #[test]
1448 fn badge_secondary_variant() {
1449 let view = JsonUiView::new().component(ComponentNode {
1450 key: "bg".to_string(),
1451 component: Component::Badge(BadgeProps {
1452 label: "Draft".to_string(),
1453 variant: BadgeVariant::Secondary,
1454 }),
1455 action: None,
1456 visibility: None,
1457 });
1458 let html = render_to_html(&view, &json!({}));
1459 assert!(html.contains("bg-gray-100 text-gray-800"));
1460 }
1461
1462 #[test]
1463 fn badge_destructive_variant() {
1464 let view = JsonUiView::new().component(ComponentNode {
1465 key: "bg".to_string(),
1466 component: Component::Badge(BadgeProps {
1467 label: "Deleted".to_string(),
1468 variant: BadgeVariant::Destructive,
1469 }),
1470 action: None,
1471 visibility: None,
1472 });
1473 let html = render_to_html(&view, &json!({}));
1474 assert!(html.contains("bg-red-100 text-red-800"));
1475 }
1476
1477 #[test]
1478 fn badge_outline_variant() {
1479 let view = JsonUiView::new().component(ComponentNode {
1480 key: "bg".to_string(),
1481 component: Component::Badge(BadgeProps {
1482 label: "Info".to_string(),
1483 variant: BadgeVariant::Outline,
1484 }),
1485 action: None,
1486 visibility: None,
1487 });
1488 let html = render_to_html(&view, &json!({}));
1489 assert!(html.contains("border border-gray-300 text-gray-700"));
1490 }
1491
1492 #[test]
1493 fn badge_has_base_classes() {
1494 let view = JsonUiView::new().component(ComponentNode {
1495 key: "bg".to_string(),
1496 component: Component::Badge(BadgeProps {
1497 label: "Test".to_string(),
1498 variant: BadgeVariant::Default,
1499 }),
1500 action: None,
1501 visibility: None,
1502 });
1503 let html = render_to_html(&view, &json!({}));
1504 assert!(html
1505 .contains("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"));
1506 }
1507
1508 #[test]
1511 fn alert_info_variant() {
1512 let view = JsonUiView::new().component(ComponentNode {
1513 key: "a".to_string(),
1514 component: Component::Alert(AlertProps {
1515 message: "Info message".to_string(),
1516 variant: AlertVariant::Info,
1517 title: None,
1518 }),
1519 action: None,
1520 visibility: None,
1521 });
1522 let html = render_to_html(&view, &json!({}));
1523 assert!(html.contains("bg-blue-50 border-blue-200 text-blue-800"));
1524 assert!(html.contains("role=\"alert\""));
1525 assert!(html.contains("<p>Info message</p>"));
1526 }
1527
1528 #[test]
1529 fn alert_success_variant() {
1530 let view = JsonUiView::new().component(ComponentNode {
1531 key: "a".to_string(),
1532 component: Component::Alert(AlertProps {
1533 message: "Done".to_string(),
1534 variant: AlertVariant::Success,
1535 title: None,
1536 }),
1537 action: None,
1538 visibility: None,
1539 });
1540 let html = render_to_html(&view, &json!({}));
1541 assert!(html.contains("bg-green-50 border-green-200 text-green-800"));
1542 }
1543
1544 #[test]
1545 fn alert_warning_variant() {
1546 let view = JsonUiView::new().component(ComponentNode {
1547 key: "a".to_string(),
1548 component: Component::Alert(AlertProps {
1549 message: "Careful".to_string(),
1550 variant: AlertVariant::Warning,
1551 title: None,
1552 }),
1553 action: None,
1554 visibility: None,
1555 });
1556 let html = render_to_html(&view, &json!({}));
1557 assert!(html.contains("bg-yellow-50 border-yellow-200 text-yellow-800"));
1558 }
1559
1560 #[test]
1561 fn alert_error_variant() {
1562 let view = JsonUiView::new().component(ComponentNode {
1563 key: "a".to_string(),
1564 component: Component::Alert(AlertProps {
1565 message: "Failed".to_string(),
1566 variant: AlertVariant::Error,
1567 title: None,
1568 }),
1569 action: None,
1570 visibility: None,
1571 });
1572 let html = render_to_html(&view, &json!({}));
1573 assert!(html.contains("bg-red-50 border-red-200 text-red-800"));
1574 }
1575
1576 #[test]
1577 fn alert_with_title() {
1578 let view = JsonUiView::new().component(ComponentNode {
1579 key: "a".to_string(),
1580 component: Component::Alert(AlertProps {
1581 message: "Details here".to_string(),
1582 variant: AlertVariant::Warning,
1583 title: Some("Warning".to_string()),
1584 }),
1585 action: None,
1586 visibility: None,
1587 });
1588 let html = render_to_html(&view, &json!({}));
1589 assert!(html.contains("<h4 class=\"font-semibold mb-1\">Warning</h4>"));
1590 assert!(html.contains("<p>Details here</p>"));
1591 }
1592
1593 #[test]
1594 fn alert_without_title() {
1595 let view = JsonUiView::new().component(ComponentNode {
1596 key: "a".to_string(),
1597 component: Component::Alert(AlertProps {
1598 message: "No title".to_string(),
1599 variant: AlertVariant::Info,
1600 title: None,
1601 }),
1602 action: None,
1603 visibility: None,
1604 });
1605 let html = render_to_html(&view, &json!({}));
1606 assert!(!html.contains("<h4"));
1607 }
1608
1609 #[test]
1612 fn separator_horizontal() {
1613 let view = JsonUiView::new().component(ComponentNode {
1614 key: "s".to_string(),
1615 component: Component::Separator(SeparatorProps {
1616 orientation: Some(Orientation::Horizontal),
1617 }),
1618 action: None,
1619 visibility: None,
1620 });
1621 let html = render_to_html(&view, &json!({}));
1622 assert!(html.contains("<hr class=\"my-4 border-gray-200\">"));
1623 }
1624
1625 #[test]
1626 fn separator_vertical() {
1627 let view = JsonUiView::new().component(ComponentNode {
1628 key: "s".to_string(),
1629 component: Component::Separator(SeparatorProps {
1630 orientation: Some(Orientation::Vertical),
1631 }),
1632 action: None,
1633 visibility: None,
1634 });
1635 let html = render_to_html(&view, &json!({}));
1636 assert!(html.contains("<div class=\"mx-4 h-full w-px bg-gray-200\"></div>"));
1637 }
1638
1639 #[test]
1640 fn separator_default_is_horizontal() {
1641 let view = JsonUiView::new().component(ComponentNode {
1642 key: "s".to_string(),
1643 component: Component::Separator(SeparatorProps { orientation: None }),
1644 action: None,
1645 visibility: None,
1646 });
1647 let html = render_to_html(&view, &json!({}));
1648 assert!(html.contains("<hr"));
1649 }
1650
1651 #[test]
1654 fn progress_renders_bar() {
1655 let view = JsonUiView::new().component(ComponentNode {
1656 key: "p".to_string(),
1657 component: Component::Progress(ProgressProps {
1658 value: 50,
1659 max: None,
1660 label: None,
1661 }),
1662 action: None,
1663 visibility: None,
1664 });
1665 let html = render_to_html(&view, &json!({}));
1666 assert!(html.contains("style=\"width: 50%\""));
1667 assert!(html.contains("bg-blue-600 h-2.5"));
1668 }
1669
1670 #[test]
1671 fn progress_with_label() {
1672 let view = JsonUiView::new().component(ComponentNode {
1673 key: "p".to_string(),
1674 component: Component::Progress(ProgressProps {
1675 value: 75,
1676 max: None,
1677 label: Some("Uploading...".to_string()),
1678 }),
1679 action: None,
1680 visibility: None,
1681 });
1682 let html = render_to_html(&view, &json!({}));
1683 assert!(html.contains("Uploading..."));
1684 assert!(html.contains("text-sm text-gray-600"));
1685 }
1686
1687 #[test]
1688 fn progress_with_custom_max() {
1689 let view = JsonUiView::new().component(ComponentNode {
1690 key: "p".to_string(),
1691 component: Component::Progress(ProgressProps {
1692 value: 25,
1693 max: Some(50),
1694 label: None,
1695 }),
1696 action: None,
1697 visibility: None,
1698 });
1699 let html = render_to_html(&view, &json!({}));
1700 assert!(html.contains("style=\"width: 50%\""));
1702 }
1703
1704 #[test]
1707 fn avatar_with_src() {
1708 let view = JsonUiView::new().component(ComponentNode {
1709 key: "av".to_string(),
1710 component: Component::Avatar(AvatarProps {
1711 src: Some("/img/user.jpg".to_string()),
1712 alt: "User".to_string(),
1713 fallback: None,
1714 size: None,
1715 }),
1716 action: None,
1717 visibility: None,
1718 });
1719 let html = render_to_html(&view, &json!({}));
1720 assert!(html.contains("<img"));
1721 assert!(html.contains("src=\"/img/user.jpg\""));
1722 assert!(html.contains("alt=\"User\""));
1723 assert!(html.contains("rounded-full object-cover"));
1724 }
1725
1726 #[test]
1727 fn avatar_without_src_uses_fallback() {
1728 let view = JsonUiView::new().component(ComponentNode {
1729 key: "av".to_string(),
1730 component: Component::Avatar(AvatarProps {
1731 src: None,
1732 alt: "John Doe".to_string(),
1733 fallback: Some("JD".to_string()),
1734 size: None,
1735 }),
1736 action: None,
1737 visibility: None,
1738 });
1739 let html = render_to_html(&view, &json!({}));
1740 assert!(!html.contains("<img"));
1741 assert!(html.contains("<span"));
1742 assert!(html.contains("bg-gray-200 text-gray-600"));
1743 assert!(html.contains(">JD</span>"));
1744 }
1745
1746 #[test]
1747 fn avatar_without_src_or_fallback_uses_alt_initials() {
1748 let view = JsonUiView::new().component(ComponentNode {
1749 key: "av".to_string(),
1750 component: Component::Avatar(AvatarProps {
1751 src: None,
1752 alt: "Alice".to_string(),
1753 fallback: None,
1754 size: Some(Size::Lg),
1755 }),
1756 action: None,
1757 visibility: None,
1758 });
1759 let html = render_to_html(&view, &json!({}));
1760 assert!(html.contains(">Al</span>"));
1761 assert!(html.contains("h-12 w-12 text-base"));
1762 }
1763
1764 #[test]
1767 fn skeleton_default() {
1768 let view = JsonUiView::new().component(ComponentNode {
1769 key: "sk".to_string(),
1770 component: Component::Skeleton(SkeletonProps {
1771 width: None,
1772 height: None,
1773 rounded: None,
1774 }),
1775 action: None,
1776 visibility: None,
1777 });
1778 let html = render_to_html(&view, &json!({}));
1779 assert!(html.contains("animate-pulse bg-gray-200"));
1780 assert!(html.contains("rounded-md"));
1781 assert!(html.contains("width: 100%"));
1782 assert!(html.contains("height: 1rem"));
1783 }
1784
1785 #[test]
1786 fn skeleton_custom_dimensions() {
1787 let view = JsonUiView::new().component(ComponentNode {
1788 key: "sk".to_string(),
1789 component: Component::Skeleton(SkeletonProps {
1790 width: Some("200px".to_string()),
1791 height: Some("40px".to_string()),
1792 rounded: Some(true),
1793 }),
1794 action: None,
1795 visibility: None,
1796 });
1797 let html = render_to_html(&view, &json!({}));
1798 assert!(html.contains("rounded-full"));
1799 assert!(html.contains("width: 200px"));
1800 assert!(html.contains("height: 40px"));
1801 }
1802
1803 #[test]
1806 fn breadcrumb_items_with_links() {
1807 let view = JsonUiView::new().component(ComponentNode {
1808 key: "bc".to_string(),
1809 component: Component::Breadcrumb(BreadcrumbProps {
1810 items: vec![
1811 BreadcrumbItem {
1812 label: "Home".to_string(),
1813 url: Some("/".to_string()),
1814 },
1815 BreadcrumbItem {
1816 label: "Users".to_string(),
1817 url: Some("/users".to_string()),
1818 },
1819 BreadcrumbItem {
1820 label: "Edit".to_string(),
1821 url: None,
1822 },
1823 ],
1824 }),
1825 action: None,
1826 visibility: None,
1827 });
1828 let html = render_to_html(&view, &json!({}));
1829 assert!(html.contains("<nav"));
1830 assert!(html.contains("<a href=\"/\" class=\"hover:text-gray-700\">Home</a>"));
1831 assert!(html.contains("<a href=\"/users\" class=\"hover:text-gray-700\">Users</a>"));
1832 assert!(html.contains("<span class=\"text-gray-900 font-medium\">Edit</span>"));
1834 assert!(html.contains("<span>/</span>"));
1836 }
1837
1838 #[test]
1839 fn breadcrumb_single_item() {
1840 let view = JsonUiView::new().component(ComponentNode {
1841 key: "bc".to_string(),
1842 component: Component::Breadcrumb(BreadcrumbProps {
1843 items: vec![BreadcrumbItem {
1844 label: "Home".to_string(),
1845 url: Some("/".to_string()),
1846 }],
1847 }),
1848 action: None,
1849 visibility: None,
1850 });
1851 let html = render_to_html(&view, &json!({}));
1852 assert!(html.contains("<span class=\"text-gray-900 font-medium\">Home</span>"));
1854 assert!(!html.contains("<span>/</span>"));
1856 }
1857
1858 #[test]
1861 fn pagination_renders_page_links() {
1862 let view = JsonUiView::new().component(ComponentNode {
1863 key: "pg".to_string(),
1864 component: Component::Pagination(PaginationProps {
1865 current_page: 2,
1866 per_page: 10,
1867 total: 50,
1868 base_url: None,
1869 }),
1870 action: None,
1871 visibility: None,
1872 });
1873 let html = render_to_html(&view, &json!({}));
1874 assert!(html.contains("<nav"));
1875 assert!(html.contains("bg-blue-600 text-white\">2</span>"));
1877 assert!(html.contains("?page=1"));
1879 assert!(html.contains("?page=3"));
1880 }
1881
1882 #[test]
1883 fn pagination_single_page_produces_no_output() {
1884 let view = JsonUiView::new().component(ComponentNode {
1885 key: "pg".to_string(),
1886 component: Component::Pagination(PaginationProps {
1887 current_page: 1,
1888 per_page: 10,
1889 total: 5,
1890 base_url: None,
1891 }),
1892 action: None,
1893 visibility: None,
1894 });
1895 let html = render_to_html(&view, &json!({}));
1896 assert!(!html.contains("<nav"));
1898 }
1899
1900 #[test]
1901 fn pagination_prev_and_next_buttons() {
1902 let view = JsonUiView::new().component(ComponentNode {
1903 key: "pg".to_string(),
1904 component: Component::Pagination(PaginationProps {
1905 current_page: 3,
1906 per_page: 10,
1907 total: 100,
1908 base_url: None,
1909 }),
1910 action: None,
1911 visibility: None,
1912 });
1913 let html = render_to_html(&view, &json!({}));
1914 assert!(html.contains("?page=2"));
1916 assert!(html.contains("?page=4"));
1918 }
1919
1920 #[test]
1921 fn pagination_no_prev_on_first_page() {
1922 let view = JsonUiView::new().component(ComponentNode {
1923 key: "pg".to_string(),
1924 component: Component::Pagination(PaginationProps {
1925 current_page: 1,
1926 per_page: 10,
1927 total: 30,
1928 base_url: None,
1929 }),
1930 action: None,
1931 visibility: None,
1932 });
1933 let html = render_to_html(&view, &json!({}));
1934 assert!(!html.contains("«"));
1936 assert!(html.contains("»"));
1938 }
1939
1940 #[test]
1941 fn pagination_custom_base_url() {
1942 let view = JsonUiView::new().component(ComponentNode {
1943 key: "pg".to_string(),
1944 component: Component::Pagination(PaginationProps {
1945 current_page: 1,
1946 per_page: 10,
1947 total: 30,
1948 base_url: Some("/users?sort=name&".to_string()),
1949 }),
1950 action: None,
1951 visibility: None,
1952 });
1953 let html = render_to_html(&view, &json!({}));
1954 assert!(html.contains("/users?sort=name&page=2"));
1955 }
1956
1957 #[test]
1960 fn description_list_renders_dl_dt_dd() {
1961 let view = JsonUiView::new().component(ComponentNode {
1962 key: "dl".to_string(),
1963 component: Component::DescriptionList(DescriptionListProps {
1964 items: vec![
1965 DescriptionItem {
1966 label: "Name".to_string(),
1967 value: "Alice".to_string(),
1968 format: None,
1969 },
1970 DescriptionItem {
1971 label: "Email".to_string(),
1972 value: "alice@example.com".to_string(),
1973 format: None,
1974 },
1975 ],
1976 columns: None,
1977 }),
1978 action: None,
1979 visibility: None,
1980 });
1981 let html = render_to_html(&view, &json!({}));
1982 assert!(html.contains("<dl"));
1983 assert!(html.contains("grid-cols-1"));
1984 assert!(html.contains("<dt class=\"text-sm font-medium text-gray-500\">Name</dt>"));
1985 assert!(html.contains("<dd class=\"mt-1 text-sm text-gray-900\">Alice</dd>"));
1986 assert!(html.contains("<dt class=\"text-sm font-medium text-gray-500\">Email</dt>"));
1987 }
1988
1989 #[test]
1990 fn description_list_with_columns() {
1991 let view = JsonUiView::new().component(ComponentNode {
1992 key: "dl".to_string(),
1993 component: Component::DescriptionList(DescriptionListProps {
1994 items: vec![DescriptionItem {
1995 label: "Status".to_string(),
1996 value: "Active".to_string(),
1997 format: None,
1998 }],
1999 columns: Some(3),
2000 }),
2001 action: None,
2002 visibility: None,
2003 });
2004 let html = render_to_html(&view, &json!({}));
2005 assert!(html.contains("grid-cols-3"));
2006 }
2007
2008 #[test]
2011 fn xss_script_tags_escaped_in_text() {
2012 let view = JsonUiView::new().component(text_node(
2013 "t",
2014 "<script>alert('xss')</script>",
2015 TextElement::P,
2016 ));
2017 let html = render_to_html(&view, &json!({}));
2018 assert!(!html.contains("<script>"));
2019 assert!(html.contains("<script>"));
2020 assert!(html.contains("'"));
2021 }
2022
2023 #[test]
2024 fn xss_quotes_escaped_in_attributes() {
2025 let view = JsonUiView::new().component(ComponentNode {
2026 key: "av".to_string(),
2027 component: Component::Avatar(AvatarProps {
2028 src: Some("x\" onload=\"alert(1)".to_string()),
2029 alt: "Test".to_string(),
2030 fallback: None,
2031 size: None,
2032 }),
2033 action: None,
2034 visibility: None,
2035 });
2036 let html = render_to_html(&view, &json!({}));
2037 assert!(html.contains("""));
2039 assert!(html.contains("src=\"x" onload="alert(1)\""));
2041 }
2042
2043 #[test]
2044 fn xss_in_button_label() {
2045 let view = JsonUiView::new().component(ComponentNode {
2046 key: "b".to_string(),
2047 component: Component::Button(ButtonProps {
2048 label: "<img src=x onerror=alert(1)>".to_string(),
2049 variant: ButtonVariant::Default,
2050 size: Size::Default,
2051 disabled: None,
2052 icon: None,
2053 icon_position: None,
2054 }),
2055 action: None,
2056 visibility: None,
2057 });
2058 let html = render_to_html(&view, &json!({}));
2059 assert!(!html.contains("<img"));
2060 assert!(html.contains("<img"));
2061 }
2062
2063 #[test]
2064 fn xss_ampersand_in_content() {
2065 let view = JsonUiView::new().component(text_node("t", "Tom & Jerry", TextElement::P));
2066 let html = render_to_html(&view, &json!({}));
2067 assert!(html.contains("Tom & Jerry"));
2068 }
2069
2070 #[test]
2071 fn html_escape_function_covers_all_chars() {
2072 let result = html_escape("&<>\"'normal");
2073 assert_eq!(result, "&<>"'normal");
2074 }
2075
2076 #[test]
2079 fn get_action_wraps_in_anchor() {
2080 let view = JsonUiView::new().component(ComponentNode {
2081 key: "b".to_string(),
2082 component: Component::Button(ButtonProps {
2083 label: "View".to_string(),
2084 variant: ButtonVariant::Default,
2085 size: Size::Default,
2086 disabled: None,
2087 icon: None,
2088 icon_position: None,
2089 }),
2090 action: Some(make_action_with_url(
2091 "users.show",
2092 HttpMethod::Get,
2093 "/users/1",
2094 )),
2095 visibility: None,
2096 });
2097 let html = render_to_html(&view, &json!({}));
2098 assert!(html.contains("<a href=\"/users/1\" class=\"block\">"));
2099 assert!(html.contains("</a>"));
2100 assert!(html.contains("<button"));
2101 }
2102
2103 #[test]
2104 fn post_action_does_not_wrap_in_anchor() {
2105 let view = JsonUiView::new().component(ComponentNode {
2106 key: "b".to_string(),
2107 component: Component::Button(ButtonProps {
2108 label: "Submit".to_string(),
2109 variant: ButtonVariant::Default,
2110 size: Size::Default,
2111 disabled: None,
2112 icon: None,
2113 icon_position: None,
2114 }),
2115 action: Some(make_action_with_url(
2116 "users.store",
2117 HttpMethod::Post,
2118 "/users",
2119 )),
2120 visibility: None,
2121 });
2122 let html = render_to_html(&view, &json!({}));
2123 assert!(!html.contains("<a href="));
2124 assert!(html.contains("<button"));
2125 }
2126
2127 #[test]
2128 fn get_action_without_url_does_not_wrap() {
2129 let view = JsonUiView::new().component(ComponentNode {
2130 key: "b".to_string(),
2131 component: Component::Button(ButtonProps {
2132 label: "View".to_string(),
2133 variant: ButtonVariant::Default,
2134 size: Size::Default,
2135 disabled: None,
2136 icon: None,
2137 icon_position: None,
2138 }),
2139 action: Some(make_action("users.show", HttpMethod::Get)),
2140 visibility: None,
2141 });
2142 let html = render_to_html(&view, &json!({}));
2143 assert!(!html.contains("<a href="));
2144 }
2145
2146 #[test]
2147 fn delete_action_does_not_wrap_in_anchor() {
2148 let view = JsonUiView::new().component(ComponentNode {
2149 key: "b".to_string(),
2150 component: Component::Button(ButtonProps {
2151 label: "Delete".to_string(),
2152 variant: ButtonVariant::Destructive,
2153 size: Size::Default,
2154 disabled: None,
2155 icon: None,
2156 icon_position: None,
2157 }),
2158 action: Some(make_action_with_url(
2159 "users.destroy",
2160 HttpMethod::Delete,
2161 "/users/1",
2162 )),
2163 visibility: None,
2164 });
2165 let html = render_to_html(&view, &json!({}));
2166 assert!(!html.contains("<a href="));
2167 }
2168
2169 #[test]
2170 fn action_url_is_html_escaped() {
2171 let view = JsonUiView::new().component(ComponentNode {
2172 key: "b".to_string(),
2173 component: Component::Button(ButtonProps {
2174 label: "View".to_string(),
2175 variant: ButtonVariant::Default,
2176 size: Size::Default,
2177 disabled: None,
2178 icon: None,
2179 icon_position: None,
2180 }),
2181 action: Some(make_action_with_url(
2182 "users.show",
2183 HttpMethod::Get,
2184 "/users?id=1&name=test",
2185 )),
2186 visibility: None,
2187 });
2188 let html = render_to_html(&view, &json!({}));
2189 assert!(html.contains("href=\"/users?id=1&name=test\""));
2190 }
2191
2192 #[test]
2195 fn card_renders_title_and_description() {
2196 let view = JsonUiView::new().component(ComponentNode {
2197 key: "c".to_string(),
2198 component: Component::Card(CardProps {
2199 title: "My Card".to_string(),
2200 description: Some("A description".to_string()),
2201 children: vec![],
2202 footer: vec![],
2203 }),
2204 action: None,
2205 visibility: None,
2206 });
2207 let html = render_to_html(&view, &json!({}));
2208 assert!(html.contains("rounded-lg border border-gray-200 bg-white shadow-sm"));
2209 assert!(html.contains("<h3 class=\"text-lg font-semibold text-gray-900\">My Card</h3>"));
2210 assert!(html.contains("<p class=\"mt-1 text-sm text-gray-500\">A description</p>"));
2211 }
2212
2213 #[test]
2214 fn card_renders_children_recursively() {
2215 let view = JsonUiView::new().component(ComponentNode {
2216 key: "c".to_string(),
2217 component: Component::Card(CardProps {
2218 title: "Card".to_string(),
2219 description: None,
2220 children: vec![text_node("t", "Child content", TextElement::P)],
2221 footer: vec![],
2222 }),
2223 action: None,
2224 visibility: None,
2225 });
2226 let html = render_to_html(&view, &json!({}));
2227 assert!(html.contains("mt-4 space-y-4"));
2228 assert!(html.contains("Child content"));
2229 }
2230
2231 #[test]
2232 fn card_renders_footer() {
2233 let view = JsonUiView::new().component(ComponentNode {
2234 key: "c".to_string(),
2235 component: Component::Card(CardProps {
2236 title: "Card".to_string(),
2237 description: None,
2238 children: vec![],
2239 footer: vec![button_node(
2240 "btn",
2241 "Save",
2242 ButtonVariant::Default,
2243 Size::Default,
2244 )],
2245 }),
2246 action: None,
2247 visibility: None,
2248 });
2249 let html = render_to_html(&view, &json!({}));
2250 assert!(html.contains("border-t border-gray-200 px-6 py-4 flex items-center gap-2"));
2251 assert!(html.contains(">Save</button>"));
2252 }
2253
2254 #[test]
2257 fn modal_renders_details_summary() {
2258 let view = JsonUiView::new().component(ComponentNode {
2259 key: "m".to_string(),
2260 component: Component::Modal(ModalProps {
2261 title: "Confirm".to_string(),
2262 description: Some("Are you sure?".to_string()),
2263 children: vec![text_node("t", "Body text", TextElement::P)],
2264 footer: vec![button_node(
2265 "ok",
2266 "OK",
2267 ButtonVariant::Default,
2268 Size::Default,
2269 )],
2270 trigger_label: Some("Open Modal".to_string()),
2271 }),
2272 action: None,
2273 visibility: None,
2274 });
2275 let html = render_to_html(&view, &json!({}));
2276 assert!(html.contains("<details class=\"group\">"));
2277 assert!(html.contains("<summary"));
2278 assert!(html.contains("Open Modal</summary>"));
2279 assert!(html.contains("<h3 class=\"text-lg font-semibold text-gray-900\">Confirm</h3>"));
2280 assert!(html.contains("Are you sure?"));
2281 assert!(html.contains("Body text"));
2282 assert!(html.contains(">OK</button>"));
2283 assert!(html.contains("</details>"));
2284 }
2285
2286 #[test]
2287 fn modal_default_trigger_label() {
2288 let view = JsonUiView::new().component(ComponentNode {
2289 key: "m".to_string(),
2290 component: Component::Modal(ModalProps {
2291 title: "Dialog".to_string(),
2292 description: None,
2293 children: vec![],
2294 footer: vec![],
2295 trigger_label: None,
2296 }),
2297 action: None,
2298 visibility: None,
2299 });
2300 let html = render_to_html(&view, &json!({}));
2301 assert!(html.contains("Open</summary>"));
2302 }
2303
2304 #[test]
2307 fn tabs_renders_only_default_tab_content() {
2308 let view = JsonUiView::new().component(ComponentNode {
2309 key: "tabs".to_string(),
2310 component: Component::Tabs(TabsProps {
2311 default_tab: "general".to_string(),
2312 tabs: vec![
2313 Tab {
2314 value: "general".to_string(),
2315 label: "General".to_string(),
2316 children: vec![text_node("t1", "General content", TextElement::P)],
2317 },
2318 Tab {
2319 value: "security".to_string(),
2320 label: "Security".to_string(),
2321 children: vec![text_node("t2", "Security content", TextElement::P)],
2322 },
2323 ],
2324 }),
2325 action: None,
2326 visibility: None,
2327 });
2328 let html = render_to_html(&view, &json!({}));
2329 assert!(html.contains("border-b-2 border-blue-600 text-blue-600"));
2331 assert!(html.contains(">General</span>"));
2332 assert!(html.contains("border-transparent text-gray-500"));
2334 assert!(html.contains(">Security</span>"));
2335 assert!(html.contains("General content"));
2337 assert!(!html.contains("Security content"));
2338 }
2339
2340 #[test]
2343 fn form_renders_action_url_and_method() {
2344 let view = JsonUiView::new().component(ComponentNode {
2345 key: "f".to_string(),
2346 component: Component::Form(FormProps {
2347 action: Action {
2348 handler: "users.store".to_string(),
2349 url: Some("/users".to_string()),
2350 method: HttpMethod::Post,
2351 confirm: None,
2352 on_success: None,
2353 on_error: None,
2354 },
2355 fields: vec![],
2356 method: None,
2357 }),
2358 action: None,
2359 visibility: None,
2360 });
2361 let html = render_to_html(&view, &json!({}));
2362 assert!(html.contains("action=\"/users\""));
2363 assert!(html.contains("method=\"post\""));
2364 assert!(html.contains("class=\"space-y-4\""));
2365 }
2366
2367 #[test]
2368 fn form_method_spoofing_for_delete() {
2369 let view = JsonUiView::new().component(ComponentNode {
2370 key: "f".to_string(),
2371 component: Component::Form(FormProps {
2372 action: Action {
2373 handler: "users.destroy".to_string(),
2374 url: Some("/users/1".to_string()),
2375 method: HttpMethod::Delete,
2376 confirm: None,
2377 on_success: None,
2378 on_error: None,
2379 },
2380 fields: vec![],
2381 method: None,
2382 }),
2383 action: None,
2384 visibility: None,
2385 });
2386 let html = render_to_html(&view, &json!({}));
2387 assert!(html.contains("method=\"post\""));
2388 assert!(html.contains("<input type=\"hidden\" name=\"_method\" value=\"DELETE\">"));
2389 }
2390
2391 #[test]
2392 fn form_method_spoofing_for_put() {
2393 let view = JsonUiView::new().component(ComponentNode {
2394 key: "f".to_string(),
2395 component: Component::Form(FormProps {
2396 action: Action {
2397 handler: "users.update".to_string(),
2398 url: Some("/users/1".to_string()),
2399 method: HttpMethod::Put,
2400 confirm: None,
2401 on_success: None,
2402 on_error: None,
2403 },
2404 fields: vec![],
2405 method: Some(HttpMethod::Put),
2406 }),
2407 action: None,
2408 visibility: None,
2409 });
2410 let html = render_to_html(&view, &json!({}));
2411 assert!(html.contains("method=\"post\""));
2412 assert!(html.contains("name=\"_method\" value=\"PUT\""));
2413 }
2414
2415 #[test]
2416 fn form_get_method_no_spoofing() {
2417 let view = JsonUiView::new().component(ComponentNode {
2418 key: "f".to_string(),
2419 component: Component::Form(FormProps {
2420 action: Action {
2421 handler: "users.index".to_string(),
2422 url: Some("/users".to_string()),
2423 method: HttpMethod::Get,
2424 confirm: None,
2425 on_success: None,
2426 on_error: None,
2427 },
2428 fields: vec![],
2429 method: None,
2430 }),
2431 action: None,
2432 visibility: None,
2433 });
2434 let html = render_to_html(&view, &json!({}));
2435 assert!(html.contains("method=\"get\""));
2436 assert!(!html.contains("_method"));
2437 }
2438
2439 #[test]
2442 fn input_renders_label_and_field() {
2443 let view = JsonUiView::new().component(ComponentNode {
2444 key: "i".to_string(),
2445 component: Component::Input(InputProps {
2446 field: "email".to_string(),
2447 label: "Email".to_string(),
2448 input_type: InputType::Email,
2449 placeholder: Some("user@example.com".to_string()),
2450 required: Some(true),
2451 disabled: None,
2452 error: None,
2453 description: Some("Your work email".to_string()),
2454 default_value: None,
2455 data_path: None,
2456 step: None,
2457 }),
2458 action: None,
2459 visibility: None,
2460 });
2461 let html = render_to_html(&view, &json!({}));
2462 assert!(html.contains("for=\"email\""));
2463 assert!(html.contains(">Email</label>"));
2464 assert!(html.contains("Your work email"));
2465 assert!(html.contains("type=\"email\""));
2466 assert!(html.contains("id=\"email\""));
2467 assert!(html.contains("name=\"email\""));
2468 assert!(html.contains("placeholder=\"user@example.com\""));
2469 assert!(html.contains(" required"));
2470 assert!(html.contains("border-gray-300"));
2471 }
2472
2473 #[test]
2474 fn input_renders_error_with_red_border() {
2475 let view = JsonUiView::new().component(ComponentNode {
2476 key: "i".to_string(),
2477 component: Component::Input(InputProps {
2478 field: "name".to_string(),
2479 label: "Name".to_string(),
2480 input_type: InputType::Text,
2481 placeholder: None,
2482 required: None,
2483 disabled: None,
2484 error: Some("Name is required".to_string()),
2485 description: None,
2486 default_value: None,
2487 data_path: None,
2488 step: None,
2489 }),
2490 action: None,
2491 visibility: None,
2492 });
2493 let html = render_to_html(&view, &json!({}));
2494 assert!(html.contains("border-red-500"));
2495 assert!(html.contains("<p class=\"text-sm text-red-600\">Name is required</p>"));
2496 }
2497
2498 #[test]
2499 fn input_resolves_data_path_for_value() {
2500 let data = json!({"user": {"name": "Alice"}});
2501 let view = JsonUiView::new().component(ComponentNode {
2502 key: "i".to_string(),
2503 component: Component::Input(InputProps {
2504 field: "name".to_string(),
2505 label: "Name".to_string(),
2506 input_type: InputType::Text,
2507 placeholder: None,
2508 required: None,
2509 disabled: None,
2510 error: None,
2511 description: None,
2512 default_value: None,
2513 data_path: Some("/user/name".to_string()),
2514 step: None,
2515 }),
2516 action: None,
2517 visibility: None,
2518 });
2519 let html = render_to_html(&view, &data);
2520 assert!(html.contains("value=\"Alice\""));
2521 }
2522
2523 #[test]
2524 fn input_default_value_overrides_data_path() {
2525 let data = json!({"user": {"name": "Alice"}});
2526 let view = JsonUiView::new().component(ComponentNode {
2527 key: "i".to_string(),
2528 component: Component::Input(InputProps {
2529 field: "name".to_string(),
2530 label: "Name".to_string(),
2531 input_type: InputType::Text,
2532 placeholder: None,
2533 required: None,
2534 disabled: None,
2535 error: None,
2536 description: None,
2537 default_value: Some("Bob".to_string()),
2538 data_path: Some("/user/name".to_string()),
2539 step: None,
2540 }),
2541 action: None,
2542 visibility: None,
2543 });
2544 let html = render_to_html(&view, &data);
2545 assert!(html.contains("value=\"Bob\""));
2546 assert!(!html.contains("Alice"));
2547 }
2548
2549 #[test]
2550 fn input_textarea_renders_textarea_element() {
2551 let view = JsonUiView::new().component(ComponentNode {
2552 key: "i".to_string(),
2553 component: Component::Input(InputProps {
2554 field: "bio".to_string(),
2555 label: "Bio".to_string(),
2556 input_type: InputType::Textarea,
2557 placeholder: Some("Tell us about yourself".to_string()),
2558 required: None,
2559 disabled: None,
2560 error: None,
2561 description: None,
2562 default_value: Some("Hello world".to_string()),
2563 data_path: None,
2564 step: None,
2565 }),
2566 action: None,
2567 visibility: None,
2568 });
2569 let html = render_to_html(&view, &json!({}));
2570 assert!(html.contains("<textarea"));
2571 assert!(html.contains(">Hello world</textarea>"));
2572 assert!(html.contains("placeholder=\"Tell us about yourself\""));
2573 }
2574
2575 #[test]
2576 fn input_hidden_renders_hidden_field() {
2577 let view = JsonUiView::new().component(ComponentNode {
2578 key: "i".to_string(),
2579 component: Component::Input(InputProps {
2580 field: "token".to_string(),
2581 label: "Token".to_string(),
2582 input_type: InputType::Hidden,
2583 placeholder: None,
2584 required: None,
2585 disabled: None,
2586 error: None,
2587 description: None,
2588 default_value: Some("abc123".to_string()),
2589 data_path: None,
2590 step: None,
2591 }),
2592 action: None,
2593 visibility: None,
2594 });
2595 let html = render_to_html(&view, &json!({}));
2596 assert!(html.contains("type=\"hidden\""));
2597 assert!(html.contains("value=\"abc123\""));
2598 }
2599
2600 #[test]
2603 fn select_renders_options_with_selected() {
2604 let view = JsonUiView::new().component(ComponentNode {
2605 key: "s".to_string(),
2606 component: Component::Select(SelectProps {
2607 field: "role".to_string(),
2608 label: "Role".to_string(),
2609 options: vec![
2610 SelectOption {
2611 value: "admin".to_string(),
2612 label: "Admin".to_string(),
2613 },
2614 SelectOption {
2615 value: "user".to_string(),
2616 label: "User".to_string(),
2617 },
2618 ],
2619 placeholder: Some("Select a role".to_string()),
2620 required: Some(true),
2621 disabled: None,
2622 error: None,
2623 description: None,
2624 default_value: Some("admin".to_string()),
2625 data_path: None,
2626 }),
2627 action: None,
2628 visibility: None,
2629 });
2630 let html = render_to_html(&view, &json!({}));
2631 assert!(html.contains("for=\"role\""));
2632 assert!(html.contains("id=\"role\""));
2633 assert!(html.contains("name=\"role\""));
2634 assert!(html.contains("<option value=\"\">Select a role</option>"));
2635 assert!(html.contains("<option value=\"admin\" selected>Admin</option>"));
2636 assert!(html.contains("<option value=\"user\">User</option>"));
2637 assert!(html.contains(" required"));
2638 }
2639
2640 #[test]
2641 fn select_resolves_data_path_for_selected() {
2642 let data = json!({"user": {"role": "user"}});
2643 let view = JsonUiView::new().component(ComponentNode {
2644 key: "s".to_string(),
2645 component: Component::Select(SelectProps {
2646 field: "role".to_string(),
2647 label: "Role".to_string(),
2648 options: vec![
2649 SelectOption {
2650 value: "admin".to_string(),
2651 label: "Admin".to_string(),
2652 },
2653 SelectOption {
2654 value: "user".to_string(),
2655 label: "User".to_string(),
2656 },
2657 ],
2658 placeholder: None,
2659 required: None,
2660 disabled: None,
2661 error: None,
2662 description: None,
2663 default_value: None,
2664 data_path: Some("/user/role".to_string()),
2665 }),
2666 action: None,
2667 visibility: None,
2668 });
2669 let html = render_to_html(&view, &data);
2670 assert!(html.contains("<option value=\"user\" selected>User</option>"));
2671 assert!(!html.contains("<option value=\"admin\" selected>"));
2672 }
2673
2674 #[test]
2675 fn select_renders_error() {
2676 let view = JsonUiView::new().component(ComponentNode {
2677 key: "s".to_string(),
2678 component: Component::Select(SelectProps {
2679 field: "role".to_string(),
2680 label: "Role".to_string(),
2681 options: vec![],
2682 placeholder: None,
2683 required: None,
2684 disabled: None,
2685 error: Some("Role is required".to_string()),
2686 description: None,
2687 default_value: None,
2688 data_path: None,
2689 }),
2690 action: None,
2691 visibility: None,
2692 });
2693 let html = render_to_html(&view, &json!({}));
2694 assert!(html.contains("border-red-500"));
2695 assert!(html.contains("Role is required"));
2696 }
2697
2698 #[test]
2701 fn checkbox_renders_checked_state() {
2702 let view = JsonUiView::new().component(ComponentNode {
2703 key: "cb".to_string(),
2704 component: Component::Checkbox(CheckboxProps {
2705 field: "terms".to_string(),
2706 label: "Accept Terms".to_string(),
2707 description: Some("You must accept".to_string()),
2708 checked: Some(true),
2709 data_path: None,
2710 required: Some(true),
2711 disabled: None,
2712 error: None,
2713 }),
2714 action: None,
2715 visibility: None,
2716 });
2717 let html = render_to_html(&view, &json!({}));
2718 assert!(html.contains("type=\"checkbox\""));
2719 assert!(html.contains("id=\"terms\""));
2720 assert!(html.contains("name=\"terms\""));
2721 assert!(html.contains("value=\"1\""));
2722 assert!(html.contains(" checked"));
2723 assert!(html.contains(" required"));
2724 assert!(html.contains("for=\"terms\""));
2725 assert!(html.contains(">Accept Terms</label>"));
2726 assert!(html.contains("ml-6 text-sm text-gray-500"));
2727 assert!(html.contains("You must accept"));
2728 }
2729
2730 #[test]
2731 fn checkbox_resolves_data_path_for_checked() {
2732 let data = json!({"user": {"accepted": true}});
2733 let view = JsonUiView::new().component(ComponentNode {
2734 key: "cb".to_string(),
2735 component: Component::Checkbox(CheckboxProps {
2736 field: "accepted".to_string(),
2737 label: "Accepted".to_string(),
2738 description: None,
2739 checked: None,
2740 data_path: Some("/user/accepted".to_string()),
2741 required: None,
2742 disabled: None,
2743 error: None,
2744 }),
2745 action: None,
2746 visibility: None,
2747 });
2748 let html = render_to_html(&view, &data);
2749 assert!(html.contains(" checked"));
2750 }
2751
2752 #[test]
2753 fn checkbox_renders_error() {
2754 let view = JsonUiView::new().component(ComponentNode {
2755 key: "cb".to_string(),
2756 component: Component::Checkbox(CheckboxProps {
2757 field: "terms".to_string(),
2758 label: "Terms".to_string(),
2759 description: None,
2760 checked: None,
2761 data_path: None,
2762 required: None,
2763 disabled: None,
2764 error: Some("Must accept".to_string()),
2765 }),
2766 action: None,
2767 visibility: None,
2768 });
2769 let html = render_to_html(&view, &json!({}));
2770 assert!(html.contains("ml-6 text-sm text-red-600"));
2771 assert!(html.contains("Must accept"));
2772 }
2773
2774 #[test]
2777 fn switch_renders_toggle_structure() {
2778 let view = JsonUiView::new().component(ComponentNode {
2779 key: "sw".to_string(),
2780 component: Component::Switch(SwitchProps {
2781 field: "notifications".to_string(),
2782 label: "Notifications".to_string(),
2783 description: Some("Get email updates".to_string()),
2784 checked: Some(true),
2785 data_path: None,
2786 required: None,
2787 disabled: None,
2788 error: None,
2789 }),
2790 action: None,
2791 visibility: None,
2792 });
2793 let html = render_to_html(&view, &json!({}));
2794 assert!(html.contains("sr-only peer"));
2795 assert!(html.contains("id=\"notifications\""));
2796 assert!(html.contains("name=\"notifications\""));
2797 assert!(html.contains("value=\"1\""));
2798 assert!(html.contains(" checked"));
2799 assert!(html.contains("peer-checked:bg-blue-600"));
2800 assert!(html.contains("for=\"notifications\""));
2801 assert!(html.contains(">Notifications</label>"));
2802 assert!(html.contains("Get email updates"));
2803 }
2804
2805 #[test]
2806 fn switch_renders_error() {
2807 let view = JsonUiView::new().component(ComponentNode {
2808 key: "sw".to_string(),
2809 component: Component::Switch(SwitchProps {
2810 field: "agree".to_string(),
2811 label: "Agree".to_string(),
2812 description: None,
2813 checked: None,
2814 data_path: None,
2815 required: None,
2816 disabled: None,
2817 error: Some("Required".to_string()),
2818 }),
2819 action: None,
2820 visibility: None,
2821 });
2822 let html = render_to_html(&view, &json!({}));
2823 assert!(html.contains("text-sm text-red-600"));
2824 assert!(html.contains("Required"));
2825 }
2826
2827 #[test]
2830 fn table_renders_headers_and_data_rows() {
2831 let data = json!({
2832 "users": [
2833 {"name": "Alice", "email": "alice@example.com"},
2834 {"name": "Bob", "email": "bob@example.com"}
2835 ]
2836 });
2837 let view = JsonUiView::new().component(ComponentNode {
2838 key: "t".to_string(),
2839 component: Component::Table(TableProps {
2840 columns: vec![
2841 Column {
2842 key: "name".to_string(),
2843 label: "Name".to_string(),
2844 format: None,
2845 },
2846 Column {
2847 key: "email".to_string(),
2848 label: "Email".to_string(),
2849 format: None,
2850 },
2851 ],
2852 data_path: "/users".to_string(),
2853 row_actions: None,
2854 empty_message: Some("No users".to_string()),
2855 sortable: None,
2856 sort_column: None,
2857 sort_direction: None,
2858 }),
2859 action: None,
2860 visibility: None,
2861 });
2862 let html = render_to_html(&view, &data);
2863 assert!(html.contains("tracking-wider text-gray-500\">Name</th>"));
2865 assert!(html.contains("tracking-wider text-gray-500\">Email</th>"));
2866 assert!(html.contains(">Alice</td>"));
2868 assert!(html.contains(">alice@example.com</td>"));
2869 assert!(html.contains(">Bob</td>"));
2870 assert!(html.contains(">bob@example.com</td>"));
2871 assert!(html.contains("overflow-x-auto"));
2873 }
2874
2875 #[test]
2876 fn table_renders_empty_message() {
2877 let data = json!({"users": []});
2878 let view = JsonUiView::new().component(ComponentNode {
2879 key: "t".to_string(),
2880 component: Component::Table(TableProps {
2881 columns: vec![Column {
2882 key: "name".to_string(),
2883 label: "Name".to_string(),
2884 format: None,
2885 }],
2886 data_path: "/users".to_string(),
2887 row_actions: None,
2888 empty_message: Some("No users found".to_string()),
2889 sortable: None,
2890 sort_column: None,
2891 sort_direction: None,
2892 }),
2893 action: None,
2894 visibility: None,
2895 });
2896 let html = render_to_html(&view, &data);
2897 assert!(html.contains("No users found"));
2898 assert!(html.contains("text-center text-sm text-gray-500"));
2899 }
2900
2901 #[test]
2902 fn table_renders_empty_message_when_path_missing() {
2903 let data = json!({});
2904 let view = JsonUiView::new().component(ComponentNode {
2905 key: "t".to_string(),
2906 component: Component::Table(TableProps {
2907 columns: vec![Column {
2908 key: "name".to_string(),
2909 label: "Name".to_string(),
2910 format: None,
2911 }],
2912 data_path: "/users".to_string(),
2913 row_actions: None,
2914 empty_message: Some("No data".to_string()),
2915 sortable: None,
2916 sort_column: None,
2917 sort_direction: None,
2918 }),
2919 action: None,
2920 visibility: None,
2921 });
2922 let html = render_to_html(&view, &data);
2923 assert!(html.contains("No data"));
2924 }
2925
2926 #[test]
2927 fn table_renders_row_actions() {
2928 let data = json!({"items": [{"name": "Item 1"}]});
2929 let view = JsonUiView::new().component(ComponentNode {
2930 key: "t".to_string(),
2931 component: Component::Table(TableProps {
2932 columns: vec![Column {
2933 key: "name".to_string(),
2934 label: "Name".to_string(),
2935 format: None,
2936 }],
2937 data_path: "/items".to_string(),
2938 row_actions: Some(vec![
2939 make_action_with_url("items.edit", HttpMethod::Get, "/items/1/edit"),
2940 make_action_with_url("items.destroy", HttpMethod::Delete, "/items/1"),
2941 ]),
2942 empty_message: None,
2943 sortable: None,
2944 sort_column: None,
2945 sort_direction: None,
2946 }),
2947 action: None,
2948 visibility: None,
2949 });
2950 let html = render_to_html(&view, &data);
2951 assert!(html.contains(">Actions</th>"));
2953 assert!(html.contains("href=\"/items/1/edit\""));
2955 assert!(html.contains(">edit</a>"));
2956 assert!(html.contains("href=\"/items/1\""));
2957 assert!(html.contains(">destroy</a>"));
2958 }
2959
2960 #[test]
2961 fn table_handles_numeric_and_bool_cells() {
2962 let data = json!({"rows": [{"count": 42, "active": true}]});
2963 let view = JsonUiView::new().component(ComponentNode {
2964 key: "t".to_string(),
2965 component: Component::Table(TableProps {
2966 columns: vec![
2967 Column {
2968 key: "count".to_string(),
2969 label: "Count".to_string(),
2970 format: None,
2971 },
2972 Column {
2973 key: "active".to_string(),
2974 label: "Active".to_string(),
2975 format: None,
2976 },
2977 ],
2978 data_path: "/rows".to_string(),
2979 row_actions: None,
2980 empty_message: None,
2981 sortable: None,
2982 sort_column: None,
2983 sort_direction: None,
2984 }),
2985 action: None,
2986 visibility: None,
2987 });
2988 let html = render_to_html(&view, &data);
2989 assert!(html.contains(">42</td>"));
2990 assert!(html.contains(">true</td>"));
2991 }
2992
2993 #[test]
2996 fn plugin_renders_error_div_when_not_registered() {
2997 let view = JsonUiView::new().component(ComponentNode {
2998 key: "map-1".to_string(),
2999 component: Component::Plugin(PluginProps {
3000 plugin_type: "UnknownPluginXyz".to_string(),
3001 props: json!({"lat": 0}),
3002 }),
3003 action: None,
3004 visibility: None,
3005 });
3006 let html = render_to_html(&view, &json!({}));
3007 assert!(html.contains("Unknown plugin component: UnknownPluginXyz"));
3008 assert!(html.contains("bg-red-50"));
3009 }
3010
3011 #[test]
3012 fn collect_plugin_types_finds_top_level_plugins() {
3013 let view = JsonUiView::new()
3014 .component(ComponentNode {
3015 key: "map".to_string(),
3016 component: Component::Plugin(PluginProps {
3017 plugin_type: "Map".to_string(),
3018 props: json!({}),
3019 }),
3020 action: None,
3021 visibility: None,
3022 })
3023 .component(ComponentNode {
3024 key: "text".to_string(),
3025 component: Component::Text(TextProps {
3026 content: "Hello".to_string(),
3027 element: TextElement::P,
3028 }),
3029 action: None,
3030 visibility: None,
3031 });
3032 let types = collect_plugin_types(&view);
3033 assert_eq!(types.len(), 1);
3034 assert!(types.contains("Map"));
3035 }
3036
3037 #[test]
3038 fn collect_plugin_types_finds_nested_in_card() {
3039 let view = JsonUiView::new().component(ComponentNode {
3040 key: "card".to_string(),
3041 component: Component::Card(CardProps {
3042 title: "Test".to_string(),
3043 description: None,
3044 children: vec![ComponentNode {
3045 key: "chart".to_string(),
3046 component: Component::Plugin(PluginProps {
3047 plugin_type: "Chart".to_string(),
3048 props: json!({}),
3049 }),
3050 action: None,
3051 visibility: None,
3052 }],
3053 footer: vec![],
3054 }),
3055 action: None,
3056 visibility: None,
3057 });
3058 let types = collect_plugin_types(&view);
3059 assert!(types.contains("Chart"));
3060 }
3061
3062 #[test]
3063 fn collect_plugin_types_deduplicates() {
3064 let view = JsonUiView::new()
3065 .component(ComponentNode {
3066 key: "map1".to_string(),
3067 component: Component::Plugin(PluginProps {
3068 plugin_type: "Map".to_string(),
3069 props: json!({}),
3070 }),
3071 action: None,
3072 visibility: None,
3073 })
3074 .component(ComponentNode {
3075 key: "map2".to_string(),
3076 component: Component::Plugin(PluginProps {
3077 plugin_type: "Map".to_string(),
3078 props: json!({"zoom": 5}),
3079 }),
3080 action: None,
3081 visibility: None,
3082 });
3083 let types = collect_plugin_types(&view);
3084 assert_eq!(types.len(), 1);
3085 }
3086
3087 #[test]
3088 fn collect_plugin_types_empty_for_builtin_only() {
3089 let view = JsonUiView::new().component(ComponentNode {
3090 key: "text".to_string(),
3091 component: Component::Text(TextProps {
3092 content: "Hello".to_string(),
3093 element: TextElement::P,
3094 }),
3095 action: None,
3096 visibility: None,
3097 });
3098 let types = collect_plugin_types(&view);
3099 assert!(types.is_empty());
3100 }
3101
3102 #[test]
3103 fn render_to_html_with_plugins_returns_empty_assets_for_builtin_only() {
3104 let view = JsonUiView::new().component(ComponentNode {
3105 key: "text".to_string(),
3106 component: Component::Text(TextProps {
3107 content: "Hello".to_string(),
3108 element: TextElement::P,
3109 }),
3110 action: None,
3111 visibility: None,
3112 });
3113 let result = render_to_html_with_plugins(&view, &json!({}));
3114 assert!(result.css_head.is_empty());
3115 assert!(result.scripts.is_empty());
3116 assert!(result.html.contains("Hello"));
3117 }
3118
3119 #[test]
3120 fn render_css_tags_generates_link_elements() {
3121 let assets = vec![Asset::new("https://cdn.example.com/style.css")
3122 .integrity("sha256-abc")
3123 .crossorigin("")];
3124 let tags = render_css_tags(&assets);
3125 assert!(tags.contains("rel=\"stylesheet\""));
3126 assert!(tags.contains("href=\"https://cdn.example.com/style.css\""));
3127 assert!(tags.contains("integrity=\"sha256-abc\""));
3128 assert!(tags.contains("crossorigin=\"\""));
3129 }
3130
3131 #[test]
3132 fn render_js_tags_generates_script_elements() {
3133 let assets = vec![Asset::new("https://cdn.example.com/lib.js")];
3134 let init = vec!["initLib();".to_string()];
3135 let tags = render_js_tags(&assets, &init);
3136 assert!(tags.contains("src=\"https://cdn.example.com/lib.js\""));
3137 assert!(tags.contains("<script>initLib();</script>"));
3138 }
3139}