acton_htmx/forms/
render.rs

1//! Form rendering to HTML
2//!
3//! Renders form builders to HTML strings with proper escaping
4//! and validation error display.
5
6use std::fmt::Write;
7
8use super::builder::FormBuilder;
9use super::field::{FieldKind, FormField, InputType};
10
11/// Options for customizing form rendering
12#[derive(Debug, Clone)]
13pub struct FormRenderOptions {
14    /// CSS class for form groups (wrapper around label + input + errors)
15    pub group_class: String,
16    /// CSS class for labels
17    pub label_class: String,
18    /// CSS class for input elements
19    pub input_class: String,
20    /// CSS class for error messages
21    pub error_class: String,
22    /// CSS class for help text
23    pub help_class: String,
24    /// CSS class for submit button
25    pub submit_class: String,
26    /// CSS class applied to inputs with errors
27    pub input_error_class: String,
28    /// Whether to wrap fields in a div
29    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
47/// Renders forms to HTML
48pub struct FormRenderer;
49
50impl FormRenderer {
51    /// Render a form to HTML string
52    #[must_use]
53    pub fn render(form: &FormBuilder<'_>) -> String {
54        Self::render_with_options(form, &FormRenderOptions::default())
55    }
56
57    /// Render a form with custom options
58    #[must_use]
59    pub fn render_with_options(form: &FormBuilder<'_>, options: &FormRenderOptions) -> String {
60        let mut html = String::with_capacity(1024);
61
62        // Open form tag
63        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        // HTMX attributes
81        Self::write_htmx_form_attrs(&mut html, form);
82
83        // Custom attributes
84        for (name, value) in &form.custom_attrs {
85            Self::write_attr(&mut html, name, value);
86        }
87
88        html.push_str(">\n");
89
90        // CSRF token
91        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        // hx-validate attribute if enabled
100        if form.htmx_validate {
101            html.push_str(r#"  <input type="hidden" name="_hx_validate" value="true">"#);
102            html.push('\n');
103        }
104
105        // Render fields
106        for field in &form.fields {
107            html.push_str(&Self::render_field(field, form.errors, options));
108        }
109
110        // Submit button
111        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        // Open wrapper if enabled (skip for hidden fields)
138        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        // Label (skip for hidden and checkbox - checkbox label comes after input)
144        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        // Render the actual input element
158        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        // Checkbox label comes after input
176        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        // Field errors
190        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        // Help text
200        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        // Close wrapper
210        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        // Hidden fields don't need wrapper indentation
226        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        // Class with potential error class
239        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        // File-specific attributes (only for file inputs)
285        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            // Add max size as data attribute for client-side validation hints
293            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        // Data attributes
308        for (name, value) in &field.data_attrs {
309            Self::write_attr(&mut html, &format!("data-{name}"), value);
310        }
311
312        // Custom attributes
313        for (name, value) in &field.custom_attrs {
314            Self::write_attr(&mut html, name, value);
315        }
316
317        // HTMX field attributes
318        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            // Use single quotes for hx-vals since it contains JSON
594            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    /// Escape a string for use in HTML attribute values
604    fn escape_attr(s: &str) -> String {
605        s.replace('&', "&amp;")
606            .replace('"', "&quot;")
607            .replace('<', "&lt;")
608            .replace('>', "&gt;")
609    }
610
611    /// Escape a string for use in HTML content
612    fn escape_html(s: &str) -> String {
613        s.replace('&', "&amp;")
614            .replace('<', "&lt;")
615            .replace('>', "&gt;")
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>"), "&lt;script&gt;");
742        assert_eq!(FormRenderer::escape_html("a & b"), "a &amp; b");
743    }
744
745    #[test]
746    fn test_escape_attr() {
747        assert_eq!(FormRenderer::escape_attr("\"test\""), "&quot;test&quot;");
748    }
749}