1use std::fmt::Write;
7
8use super::builder::FormBuilder;
9use super::field::{FieldKind, FormField, InputType};
10
11#[derive(Debug, Clone)]
13pub struct FormRenderOptions {
14 pub group_class: String,
16 pub label_class: String,
18 pub input_class: String,
20 pub error_class: String,
22 pub help_class: String,
24 pub submit_class: String,
26 pub input_error_class: String,
28 pub wrap_fields: bool,
30}
31
32impl Default for FormRenderOptions {
33 fn default() -> Self {
34 Self {
35 group_class: "form-group".into(),
36 label_class: "form-label".into(),
37 input_class: "form-input".into(),
38 error_class: "form-error".into(),
39 help_class: "form-help".into(),
40 submit_class: "form-submit".into(),
41 input_error_class: "form-input-error".into(),
42 wrap_fields: true,
43 }
44 }
45}
46
47pub struct FormRenderer;
49
50impl FormRenderer {
51 #[must_use]
53 pub fn render(form: &FormBuilder<'_>) -> String {
54 Self::render_with_options(form, &FormRenderOptions::default())
55 }
56
57 #[must_use]
59 pub fn render_with_options(form: &FormBuilder<'_>, options: &FormRenderOptions) -> String {
60 let mut html = String::with_capacity(1024);
61
62 html.push_str("<form");
64 Self::write_attr(&mut html, "action", &form.action);
65 Self::write_attr(&mut html, "method", &form.method);
66
67 if let Some(ref id) = form.id {
68 Self::write_attr(&mut html, "id", id);
69 }
70 if let Some(ref class) = form.class {
71 Self::write_attr(&mut html, "class", class);
72 }
73 if let Some(ref enctype) = form.enctype {
74 Self::write_attr(&mut html, "enctype", enctype);
75 }
76 if form.novalidate {
77 html.push_str(" novalidate");
78 }
79
80 Self::write_htmx_form_attrs(&mut html, form);
82
83 for (name, value) in &form.custom_attrs {
85 Self::write_attr(&mut html, name, value);
86 }
87
88 html.push_str(">\n");
89
90 if let Some(ref token) = form.csrf_token {
92 let _ = writeln!(
93 html,
94 r#" <input type="hidden" name="_csrf_token" value="{}">"#,
95 Self::escape_attr(token)
96 );
97 }
98
99 if form.htmx_validate {
101 html.push_str(r#" <input type="hidden" name="_hx_validate" value="true">"#);
102 html.push('\n');
103 }
104
105 for field in &form.fields {
107 html.push_str(&Self::render_field(field, form.errors, options));
108 }
109
110 if let Some(ref text) = form.submit_text {
112 let submit_class = form
113 .submit_class
114 .as_deref()
115 .unwrap_or(&options.submit_class);
116 let _ = writeln!(
117 html,
118 r#" <button type="submit" class="{}">{}</button>"#,
119 Self::escape_attr(submit_class),
120 Self::escape_html(text)
121 );
122 }
123
124 html.push_str("</form>");
125 html
126 }
127
128 fn render_field(
129 field: &FormField,
130 errors: Option<&super::ValidationErrors>,
131 options: &FormRenderOptions,
132 ) -> String {
133 let mut html = String::with_capacity(256);
134 let field_errors = errors.as_ref().map_or_else(<&[_]>::default, |e| e.for_field(&field.name));
135 let has_errors = !field_errors.is_empty();
136
137 let is_hidden = matches!(field.kind, FieldKind::Input(InputType::Hidden));
139 if options.wrap_fields && !is_hidden {
140 let _ = writeln!(html, r#" <div class="{}">"#, options.group_class);
141 }
142
143 let is_checkbox = matches!(field.kind, FieldKind::Checkbox { .. });
145 if let Some(ref label) = field.label {
146 if !is_hidden && !is_checkbox {
147 let _ = writeln!(
148 html,
149 r#" <label for="{}" class="{}">{}</label>"#,
150 Self::escape_attr(field.effective_id()),
151 options.label_class,
152 Self::escape_html(label)
153 );
154 }
155 }
156
157 let input_html = match &field.kind {
159 FieldKind::Input(input_type) => Self::render_input(field, *input_type, has_errors, options),
160 FieldKind::Textarea { rows, cols } => {
161 Self::render_textarea(field, *rows, *cols, has_errors, options)
162 }
163 FieldKind::Select { options: opts, multiple } => {
164 Self::render_select(field, opts, *multiple, has_errors, options)
165 }
166 FieldKind::Checkbox { checked } => {
167 Self::render_checkbox(field, *checked, has_errors, options)
168 }
169 FieldKind::Radio { options: opts } => {
170 Self::render_radio(field, opts, has_errors, options)
171 }
172 };
173 html.push_str(&input_html);
174
175 if is_checkbox {
177 if let Some(ref label) = field.label {
178 let _ = write!(
179 html,
180 r#" <label for="{}" class="{}">{}</label>"#,
181 Self::escape_attr(field.effective_id()),
182 options.label_class,
183 Self::escape_html(label)
184 );
185 }
186 html.push('\n');
187 }
188
189 for error in field_errors {
191 let _ = writeln!(
192 html,
193 r#" <span class="{}">{}</span>"#,
194 options.error_class,
195 Self::escape_html(&error.message)
196 );
197 }
198
199 if let Some(ref help) = field.help_text {
201 let _ = writeln!(
202 html,
203 r#" <span class="{}">{}</span>"#,
204 options.help_class,
205 Self::escape_html(help)
206 );
207 }
208
209 if options.wrap_fields && !is_hidden {
211 html.push_str(" </div>\n");
212 }
213
214 html
215 }
216
217 fn render_input(
218 field: &FormField,
219 input_type: InputType,
220 has_errors: bool,
221 options: &FormRenderOptions,
222 ) -> String {
223 let mut html = String::with_capacity(128);
224
225 let indent = if input_type == InputType::Hidden {
227 " "
228 } else {
229 " "
230 };
231
232 html.push_str(indent);
233 html.push_str("<input");
234 Self::write_attr(&mut html, "type", input_type.as_str());
235 Self::write_attr(&mut html, "name", &field.name);
236 Self::write_attr(&mut html, "id", field.effective_id());
237
238 let class = Self::build_input_class(field, has_errors, options);
240 if !class.is_empty() {
241 Self::write_attr(&mut html, "class", &class);
242 }
243
244 if let Some(ref value) = field.value {
245 Self::write_attr(&mut html, "value", value);
246 }
247 if let Some(ref placeholder) = field.placeholder {
248 Self::write_attr(&mut html, "placeholder", placeholder);
249 }
250 if field.flags.required {
251 html.push_str(" required");
252 }
253 if field.flags.disabled {
254 html.push_str(" disabled");
255 }
256 if field.flags.readonly {
257 html.push_str(" readonly");
258 }
259 if field.flags.autofocus {
260 html.push_str(" autofocus");
261 }
262 if let Some(ref autocomplete) = field.autocomplete {
263 Self::write_attr(&mut html, "autocomplete", autocomplete);
264 }
265 if let Some(len) = field.min_length {
266 Self::write_attr(&mut html, "minlength", &len.to_string());
267 }
268 if let Some(len) = field.max_length {
269 Self::write_attr(&mut html, "maxlength", &len.to_string());
270 }
271 if let Some(ref min) = field.min {
272 Self::write_attr(&mut html, "min", min);
273 }
274 if let Some(ref max) = field.max {
275 Self::write_attr(&mut html, "max", max);
276 }
277 if let Some(ref step) = field.step {
278 Self::write_attr(&mut html, "step", step);
279 }
280 if let Some(ref pattern) = field.pattern {
281 Self::write_attr(&mut html, "pattern", pattern);
282 }
283
284 if input_type == InputType::File {
286 if let Some(ref accept) = field.file_attrs.accept {
287 Self::write_attr(&mut html, "accept", accept);
288 }
289 if field.file_attrs.multiple {
290 html.push_str(" multiple");
291 }
292 if let Some(size_mb) = field.file_attrs.max_size_mb {
294 Self::write_attr(&mut html, "data-max-size-mb", &size_mb.to_string());
295 }
296 if field.file_attrs.show_preview {
297 html.push_str(r#" data-preview="true""#);
298 }
299 if field.file_attrs.drag_drop {
300 html.push_str(r#" data-drag-drop="true""#);
301 }
302 if let Some(ref endpoint) = field.file_attrs.progress_endpoint {
303 Self::write_attr(&mut html, "data-progress-endpoint", endpoint);
304 }
305 }
306
307 for (name, value) in &field.data_attrs {
309 Self::write_attr(&mut html, &format!("data-{name}"), value);
310 }
311
312 for (name, value) in &field.custom_attrs {
314 Self::write_attr(&mut html, name, value);
315 }
316
317 Self::write_htmx_field_attrs(&mut html, field);
319
320 html.push_str(">\n");
321 html
322 }
323
324 fn render_textarea(
325 field: &FormField,
326 rows: Option<u32>,
327 cols: Option<u32>,
328 has_errors: bool,
329 options: &FormRenderOptions,
330 ) -> String {
331 let mut html = String::with_capacity(128);
332
333 html.push_str(" <textarea");
334 Self::write_attr(&mut html, "name", &field.name);
335 Self::write_attr(&mut html, "id", field.effective_id());
336
337 let class = Self::build_input_class(field, has_errors, options);
338 if !class.is_empty() {
339 Self::write_attr(&mut html, "class", &class);
340 }
341
342 if let Some(ref placeholder) = field.placeholder {
343 Self::write_attr(&mut html, "placeholder", placeholder);
344 }
345 if let Some(r) = rows {
346 Self::write_attr(&mut html, "rows", &r.to_string());
347 }
348 if let Some(c) = cols {
349 Self::write_attr(&mut html, "cols", &c.to_string());
350 }
351 if field.flags.required {
352 html.push_str(" required");
353 }
354 if field.flags.disabled {
355 html.push_str(" disabled");
356 }
357 if field.flags.readonly {
358 html.push_str(" readonly");
359 }
360
361 Self::write_htmx_field_attrs(&mut html, field);
362
363 html.push('>');
364 if let Some(ref value) = field.value {
365 html.push_str(&Self::escape_html(value));
366 }
367 html.push_str("</textarea>\n");
368 html
369 }
370
371 fn render_select(
372 field: &FormField,
373 opts: &[super::field::SelectOption],
374 multiple: bool,
375 has_errors: bool,
376 options: &FormRenderOptions,
377 ) -> String {
378 let mut html = String::with_capacity(256);
379
380 html.push_str(" <select");
381 Self::write_attr(&mut html, "name", &field.name);
382 Self::write_attr(&mut html, "id", field.effective_id());
383
384 let class = Self::build_input_class(field, has_errors, options);
385 if !class.is_empty() {
386 Self::write_attr(&mut html, "class", &class);
387 }
388
389 if multiple {
390 html.push_str(" multiple");
391 }
392 if field.flags.required {
393 html.push_str(" required");
394 }
395 if field.flags.disabled {
396 html.push_str(" disabled");
397 }
398
399 Self::write_htmx_field_attrs(&mut html, field);
400
401 html.push_str(">\n");
402
403 for opt in opts {
404 html.push_str(" <option");
405 Self::write_attr(&mut html, "value", &opt.value);
406 if opt.disabled {
407 html.push_str(" disabled");
408 }
409 if field.value.as_ref() == Some(&opt.value) {
410 html.push_str(" selected");
411 }
412 html.push('>');
413 html.push_str(&Self::escape_html(&opt.label));
414 html.push_str("</option>\n");
415 }
416
417 html.push_str(" </select>\n");
418 html
419 }
420
421 fn render_checkbox(
422 field: &FormField,
423 checked: bool,
424 has_errors: bool,
425 options: &FormRenderOptions,
426 ) -> String {
427 let mut html = String::with_capacity(128);
428
429 html.push_str(" <input");
430 Self::write_attr(&mut html, "type", "checkbox");
431 Self::write_attr(&mut html, "name", &field.name);
432 Self::write_attr(&mut html, "id", field.effective_id());
433
434 let class = Self::build_input_class(field, has_errors, options);
435 if !class.is_empty() {
436 Self::write_attr(&mut html, "class", &class);
437 }
438
439 if let Some(ref value) = field.value {
440 Self::write_attr(&mut html, "value", value);
441 } else {
442 Self::write_attr(&mut html, "value", "true");
443 }
444
445 if checked {
446 html.push_str(" checked");
447 }
448 if field.flags.required {
449 html.push_str(" required");
450 }
451 if field.flags.disabled {
452 html.push_str(" disabled");
453 }
454
455 Self::write_htmx_field_attrs(&mut html, field);
456
457 html.push('>');
458 html
459 }
460
461 fn render_radio(
462 field: &FormField,
463 opts: &[super::field::SelectOption],
464 has_errors: bool,
465 options: &FormRenderOptions,
466 ) -> String {
467 let mut html = String::with_capacity(256);
468 let class = Self::build_input_class(field, has_errors, options);
469
470 for (i, opt) in opts.iter().enumerate() {
471 let opt_id = format!("{}_{}", field.effective_id(), i);
472 html.push_str(" <div class=\"form-radio\">\n");
473 html.push_str(" <input");
474 Self::write_attr(&mut html, "type", "radio");
475 Self::write_attr(&mut html, "name", &field.name);
476 Self::write_attr(&mut html, "id", &opt_id);
477 Self::write_attr(&mut html, "value", &opt.value);
478 if !class.is_empty() {
479 Self::write_attr(&mut html, "class", &class);
480 }
481 if field.value.as_ref() == Some(&opt.value) {
482 html.push_str(" checked");
483 }
484 if opt.disabled {
485 html.push_str(" disabled");
486 }
487 if field.flags.required && i == 0 {
488 html.push_str(" required");
489 }
490 html.push_str(">\n");
491 let _ = writeln!(
492 html,
493 " <label for=\"{}\">{}</label>",
494 Self::escape_attr(&opt_id),
495 Self::escape_html(&opt.label)
496 );
497 html.push_str(" </div>\n");
498 }
499
500 html
501 }
502
503 fn build_input_class(field: &FormField, has_errors: bool, options: &FormRenderOptions) -> String {
504 let mut classes = Vec::new();
505 classes.push(options.input_class.as_str());
506
507 if let Some(ref class) = field.class {
508 classes.push(class.as_str());
509 }
510 if has_errors {
511 classes.push(options.input_error_class.as_str());
512 }
513
514 classes.join(" ")
515 }
516
517 fn write_attr(html: &mut String, name: &str, value: &str) {
518 html.push(' ');
519 html.push_str(name);
520 html.push_str("=\"");
521 html.push_str(&Self::escape_attr(value));
522 html.push('"');
523 }
524
525 fn write_htmx_form_attrs(html: &mut String, form: &FormBuilder<'_>) {
526 if let Some(ref url) = form.htmx.get {
527 Self::write_attr(html, "hx-get", url);
528 }
529 if let Some(ref url) = form.htmx.post {
530 Self::write_attr(html, "hx-post", url);
531 }
532 if let Some(ref url) = form.htmx.put {
533 Self::write_attr(html, "hx-put", url);
534 }
535 if let Some(ref url) = form.htmx.delete {
536 Self::write_attr(html, "hx-delete", url);
537 }
538 if let Some(ref url) = form.htmx.patch {
539 Self::write_attr(html, "hx-patch", url);
540 }
541 if let Some(ref selector) = form.htmx.target {
542 Self::write_attr(html, "hx-target", selector);
543 }
544 if let Some(ref strategy) = form.htmx.swap {
545 Self::write_attr(html, "hx-swap", strategy);
546 }
547 if let Some(ref trigger) = form.htmx.trigger {
548 Self::write_attr(html, "hx-trigger", trigger);
549 }
550 if let Some(ref selector) = form.htmx.indicator {
551 Self::write_attr(html, "hx-indicator", selector);
552 }
553 if let Some(ref url) = form.htmx.push_url {
554 Self::write_attr(html, "hx-push-url", url);
555 }
556 if let Some(ref message) = form.htmx.confirm {
557 Self::write_attr(html, "hx-confirm", message);
558 }
559 if let Some(ref selector) = form.htmx.disabled_elt {
560 Self::write_attr(html, "hx-disabled-elt", selector);
561 }
562 }
563
564 fn write_htmx_field_attrs(html: &mut String, field: &FormField) {
565 if let Some(ref url) = field.htmx.get {
566 Self::write_attr(html, "hx-get", url);
567 }
568 if let Some(ref url) = field.htmx.post {
569 Self::write_attr(html, "hx-post", url);
570 }
571 if let Some(ref url) = field.htmx.put {
572 Self::write_attr(html, "hx-put", url);
573 }
574 if let Some(ref url) = field.htmx.delete {
575 Self::write_attr(html, "hx-delete", url);
576 }
577 if let Some(ref url) = field.htmx.patch {
578 Self::write_attr(html, "hx-patch", url);
579 }
580 if let Some(ref selector) = field.htmx.target {
581 Self::write_attr(html, "hx-target", selector);
582 }
583 if let Some(ref strategy) = field.htmx.swap {
584 Self::write_attr(html, "hx-swap", strategy);
585 }
586 if let Some(ref trigger) = field.htmx.trigger {
587 Self::write_attr(html, "hx-trigger", trigger);
588 }
589 if let Some(ref selector) = field.htmx.indicator {
590 Self::write_attr(html, "hx-indicator", selector);
591 }
592 if let Some(ref vals) = field.htmx.vals {
593 html.push_str(" hx-vals='");
595 html.push_str(vals);
596 html.push('\'');
597 }
598 if field.htmx.validate {
599 Self::write_attr(html, "hx-validate", "true");
600 }
601 }
602
603 fn escape_attr(s: &str) -> String {
605 s.replace('&', "&")
606 .replace('"', """)
607 .replace('<', "<")
608 .replace('>', ">")
609 }
610
611 fn escape_html(s: &str) -> String {
613 s.replace('&', "&")
614 .replace('<', "<")
615 .replace('>', ">")
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use crate::forms::ValidationErrors;
623
624 #[test]
625 fn test_render_simple_form() {
626 let form = FormBuilder::new("/test", "POST").submit("Submit");
627 let html = FormRenderer::render(&form);
628
629 assert!(html.contains(r#"action="/test""#));
630 assert!(html.contains(r#"method="POST""#));
631 assert!(html.contains("<button"));
632 assert!(html.contains("Submit"));
633 }
634
635 #[test]
636 fn test_render_with_csrf() {
637 let form = FormBuilder::new("/test", "POST").csrf_token("abc123");
638 let html = FormRenderer::render(&form);
639
640 assert!(html.contains(r#"name="_csrf_token""#));
641 assert!(html.contains(r#"value="abc123""#));
642 }
643
644 #[test]
645 fn test_render_input_field() {
646 let form = FormBuilder::new("/test", "POST")
647 .field("email", InputType::Email)
648 .label("Email")
649 .placeholder("test@example.com")
650 .required()
651 .done();
652 let html = FormRenderer::render(&form);
653
654 assert!(html.contains(r#"type="email""#));
655 assert!(html.contains(r#"name="email""#));
656 assert!(html.contains(r#"placeholder="test@example.com""#));
657 assert!(html.contains("required"));
658 assert!(html.contains(r#"<label for="email""#));
659 }
660
661 #[test]
662 fn test_render_with_errors() {
663 let mut errors = ValidationErrors::new();
664 errors.add("email", "is invalid");
665
666 let form = FormBuilder::new("/test", "POST")
667 .errors(&errors)
668 .field("email", InputType::Email)
669 .label("Email")
670 .done();
671 let html = FormRenderer::render(&form);
672
673 assert!(html.contains("is invalid"));
674 assert!(html.contains("form-error"));
675 assert!(html.contains("form-input-error"));
676 }
677
678 #[test]
679 fn test_render_textarea() {
680 let form = FormBuilder::new("/test", "POST")
681 .textarea("bio")
682 .rows(5)
683 .cols(40)
684 .value("Hello world")
685 .done();
686 let html = FormRenderer::render(&form);
687
688 assert!(html.contains("<textarea"));
689 assert!(html.contains(r#"rows="5""#));
690 assert!(html.contains(r#"cols="40""#));
691 assert!(html.contains("Hello world"));
692 assert!(html.contains("</textarea>"));
693 }
694
695 #[test]
696 fn test_render_select() {
697 let form = FormBuilder::new("/test", "POST")
698 .select("country")
699 .option("us", "United States")
700 .option("ca", "Canada")
701 .selected("us")
702 .done();
703 let html = FormRenderer::render(&form);
704
705 assert!(html.contains("<select"));
706 assert!(html.contains("<option"));
707 assert!(html.contains(r#"value="us""#));
708 assert!(html.contains("selected"));
709 assert!(html.contains("United States"));
710 }
711
712 #[test]
713 fn test_render_checkbox() {
714 let form = FormBuilder::new("/test", "POST")
715 .checkbox("terms")
716 .label("I agree")
717 .checked()
718 .done();
719 let html = FormRenderer::render(&form);
720
721 assert!(html.contains(r#"type="checkbox""#));
722 assert!(html.contains("checked"));
723 assert!(html.contains("I agree"));
724 }
725
726 #[test]
727 fn test_render_htmx_attrs() {
728 let form = FormBuilder::new("/test", "POST")
729 .htmx_post("/api/test")
730 .htmx_target("#result")
731 .htmx_swap("innerHTML");
732 let html = FormRenderer::render(&form);
733
734 assert!(html.contains(r#"hx-post="/api/test""#));
735 assert!(html.contains(r##"hx-target="#result""##));
736 assert!(html.contains(r#"hx-swap="innerHTML""#));
737 }
738
739 #[test]
740 fn test_escape_html() {
741 assert_eq!(FormRenderer::escape_html("<script>"), "<script>");
742 assert_eq!(FormRenderer::escape_html("a & b"), "a & b");
743 }
744
745 #[test]
746 fn test_escape_attr() {
747 assert_eq!(FormRenderer::escape_attr("\"test\""), ""test"");
748 }
749}