acton_htmx/forms/
template_render.rs

1//! Template-based form rendering
2//!
3//! Renders forms using minijinja templates from the XDG template directory.
4//! Templates must be initialized via `acton-htmx templates init` before use.
5
6use minijinja::Value;
7use serde::Serialize;
8
9use super::builder::FormBuilder;
10use super::error::ValidationErrors;
11use super::field::{FieldKind, FormField, InputType, SelectOption};
12use super::render::FormRenderOptions;
13use crate::template::framework::FrameworkTemplates;
14
15/// Renders forms using minijinja templates
16///
17/// This renderer uses templates from the XDG template directory,
18/// allowing users to customize form HTML structure.
19pub struct TemplateFormRenderer<'t> {
20    templates: &'t FrameworkTemplates,
21    options: FormRenderOptions,
22}
23
24impl<'t> TemplateFormRenderer<'t> {
25    /// Create a new template-based form renderer
26    #[must_use]
27    pub fn new(templates: &'t FrameworkTemplates) -> Self {
28        Self {
29            templates,
30            options: FormRenderOptions::default(),
31        }
32    }
33
34    /// Create a renderer with custom options
35    #[must_use]
36    pub const fn with_options(templates: &'t FrameworkTemplates, options: FormRenderOptions) -> Self {
37        Self { templates, options }
38    }
39
40    /// Render a form to HTML string
41    ///
42    /// # Errors
43    ///
44    /// Returns error if template rendering fails.
45    pub fn render(&self, form: &FormBuilder<'_>) -> Result<String, FormRenderError> {
46        // Render all fields first
47        let mut fields_html = String::new();
48        for field in &form.fields {
49            fields_html.push_str(&self.render_field(field, form.errors)?);
50        }
51
52        // Build HTMX attributes list
53        let hx_attrs = Self::build_htmx_form_attrs(form);
54
55        // Render the form wrapper
56        let html = self.templates.render(
57            "forms/form.html",
58            minijinja::context! {
59                action => &form.action,
60                method => &form.method,
61                id => &form.id,
62                class => &form.class,
63                enctype => &form.enctype,
64                novalidate => form.novalidate,
65                csrf_token => &form.csrf_token,
66                hx_validate => form.htmx_validate,
67                hx_attrs => hx_attrs,
68                content => Value::from_safe_string(fields_html),
69                submit_label => &form.submit_text,
70                submit_class => form.submit_class.as_deref().unwrap_or(&self.options.submit_class),
71            },
72        )?;
73
74        Ok(html)
75    }
76
77    /// Render a single field
78    fn render_field(
79        &self,
80        field: &FormField,
81        errors: Option<&ValidationErrors>,
82    ) -> Result<String, FormRenderError> {
83        let field_errors: Vec<String> = errors
84            .map(|e| e.for_field(&field.name))
85            .unwrap_or_default()
86            .iter()
87            .map(|e| e.message.clone())
88            .collect();
89        let has_errors = !field_errors.is_empty();
90
91        // Hidden fields don't get wrapped
92        if matches!(field.kind, FieldKind::Input(InputType::Hidden)) {
93            return self.render_input(field, InputType::Hidden, has_errors);
94        }
95
96        // Render the field element itself
97        let field_html = match &field.kind {
98            FieldKind::Input(input_type) => self.render_input(field, *input_type, has_errors)?,
99            FieldKind::Textarea { rows, cols } => {
100                self.render_textarea(field, *rows, *cols, has_errors)?
101            }
102            FieldKind::Select { options, multiple } => {
103                self.render_select(field, options, *multiple, has_errors)?
104            }
105            FieldKind::Checkbox { checked } => self.render_checkbox(field, *checked, has_errors)?,
106            FieldKind::Radio { options } => self.render_radio(field, options, has_errors)?,
107        };
108
109        // Checkbox has label after, others before
110        let is_checkbox = matches!(field.kind, FieldKind::Checkbox { .. });
111
112        // Render label if present
113        let label_html = if let Some(ref label) = field.label {
114            if is_checkbox {
115                String::new()
116            } else {
117                self.render_label(field.effective_id(), label, field.flags.required)?
118            }
119        } else {
120            String::new()
121        };
122
123        // Render errors
124        let errors_html = if field_errors.is_empty() {
125            String::new()
126        } else {
127            self.render_field_errors(&field_errors)?
128        };
129
130        // Wrap in field wrapper
131        let html = self.templates.render(
132            "forms/field-wrapper.html",
133            minijinja::context! {
134                wrapper_class => &self.options.group_class,
135                error_class => if has_errors { &self.options.input_error_class } else { "" },
136                has_error => has_errors,
137                label_position => if is_checkbox { "after" } else { "before" },
138                label_html => Value::from_safe_string(label_html),
139                field_html => Value::from_safe_string(field_html),
140                errors => !field_errors.is_empty(),
141                errors_html => Value::from_safe_string(errors_html),
142                help_text => &field.help_text,
143                help_class => &self.options.help_class,
144            },
145        )?;
146
147        Ok(html)
148    }
149
150    /// Render an input field
151    fn render_input(
152        &self,
153        field: &FormField,
154        input_type: InputType,
155        has_errors: bool,
156    ) -> Result<String, FormRenderError> {
157        let class = self.build_input_class(field, has_errors);
158        let extra_attrs = Self::build_field_attrs(field);
159
160        let html = self.templates.render(
161            "forms/input.html",
162            minijinja::context! {
163                input_type => input_type.as_str(),
164                name => &field.name,
165                id => field.effective_id(),
166                value => &field.value,
167                class => class,
168                placeholder => &field.placeholder,
169                required => field.flags.required,
170                disabled => field.flags.disabled,
171                readonly => field.flags.readonly,
172                autofocus => field.flags.autofocus,
173                min => &field.min,
174                max => &field.max,
175                step => &field.step,
176                minlength => field.min_length,
177                maxlength => field.max_length,
178                pattern => &field.pattern,
179                autocomplete => &field.autocomplete,
180                // File-specific
181                accept => &field.file_attrs.accept,
182                multiple => field.file_attrs.multiple,
183                data_preview => field.file_attrs.show_preview,
184                data_drag_drop => field.file_attrs.drag_drop,
185                data_progress_url => &field.file_attrs.progress_endpoint,
186                data_max_size => field.file_attrs.max_size_mb,
187                extra_attrs => extra_attrs,
188            },
189        )?;
190
191        Ok(html)
192    }
193
194    /// Render a textarea
195    fn render_textarea(
196        &self,
197        field: &FormField,
198        rows: Option<u32>,
199        cols: Option<u32>,
200        has_errors: bool,
201    ) -> Result<String, FormRenderError> {
202        let class = self.build_input_class(field, has_errors);
203        let extra_attrs = Self::build_field_attrs(field);
204
205        let html = self.templates.render(
206            "forms/textarea.html",
207            minijinja::context! {
208                name => &field.name,
209                id => field.effective_id(),
210                class => class,
211                placeholder => &field.placeholder,
212                required => field.flags.required,
213                disabled => field.flags.disabled,
214                readonly => field.flags.readonly,
215                rows => rows,
216                cols => cols,
217                minlength => field.min_length,
218                maxlength => field.max_length,
219                text_value => field.value.as_deref().unwrap_or(""),
220                extra_attrs => extra_attrs,
221            },
222        )?;
223
224        Ok(html)
225    }
226
227    /// Render a select dropdown
228    fn render_select(
229        &self,
230        field: &FormField,
231        options: &[SelectOption],
232        multiple: bool,
233        has_errors: bool,
234    ) -> Result<String, FormRenderError> {
235        let class = self.build_input_class(field, has_errors);
236        let extra_attrs = Self::build_field_attrs(field);
237
238        // Build options with selected state
239        let select_options: Vec<SelectOptionCtx> = options
240            .iter()
241            .map(|opt| SelectOptionCtx {
242                value: opt.value.clone(),
243                label: opt.label.clone(),
244                selected: field.value.as_ref() == Some(&opt.value),
245                disabled: opt.disabled,
246            })
247            .collect();
248
249        let html = self.templates.render(
250            "forms/select.html",
251            minijinja::context! {
252                name => &field.name,
253                id => field.effective_id(),
254                class => class,
255                required => field.flags.required,
256                disabled => field.flags.disabled,
257                multiple => multiple,
258                options => select_options,
259                extra_attrs => extra_attrs,
260            },
261        )?;
262
263        Ok(html)
264    }
265
266    /// Render a checkbox
267    fn render_checkbox(
268        &self,
269        field: &FormField,
270        checked: bool,
271        has_errors: bool,
272    ) -> Result<String, FormRenderError> {
273        let class = self.build_input_class(field, has_errors);
274        let extra_attrs = Self::build_field_attrs(field);
275
276        let html = self.templates.render(
277            "forms/checkbox.html",
278            minijinja::context! {
279                name => &field.name,
280                id => field.effective_id(),
281                checkbox_value => field.value.as_deref().unwrap_or("true"),
282                class => class,
283                checked => checked,
284                required => field.flags.required,
285                disabled => field.flags.disabled,
286                label => &field.label,
287                label_class => &self.options.label_class,
288                extra_attrs => extra_attrs,
289            },
290        )?;
291
292        Ok(html)
293    }
294
295    /// Render radio buttons
296    fn render_radio(
297        &self,
298        field: &FormField,
299        options: &[SelectOption],
300        has_errors: bool,
301    ) -> Result<String, FormRenderError> {
302        let class = self.build_input_class(field, has_errors);
303
304        // Build options with IDs
305        let radio_options: Vec<RadioOptionCtx> = options
306            .iter()
307            .enumerate()
308            .map(|(i, opt)| RadioOptionCtx {
309                id: format!("{}_{}", field.effective_id(), i),
310                value: opt.value.clone(),
311                label: opt.label.clone(),
312                checked: field.value.as_ref() == Some(&opt.value),
313                disabled: opt.disabled,
314            })
315            .collect();
316
317        let html = self.templates.render(
318            "forms/radio-group.html",
319            minijinja::context! {
320                name => &field.name,
321                class => class,
322                required => field.flags.required,
323                disabled => field.flags.disabled,
324                options => radio_options,
325                radio_wrapper_class => "form-radio",
326                label_class => &self.options.label_class,
327            },
328        )?;
329
330        Ok(html)
331    }
332
333    /// Render a label
334    fn render_label(
335        &self,
336        for_id: &str,
337        text: &str,
338        required: bool,
339    ) -> Result<String, FormRenderError> {
340        let html = self.templates.render(
341            "forms/label.html",
342            minijinja::context! {
343                for => for_id,
344                class => &self.options.label_class,
345                text => text,
346                required => required,
347                required_class => "required",
348            },
349        )?;
350
351        Ok(html)
352    }
353
354    /// Render field errors
355    fn render_field_errors(&self, errors: &[String]) -> Result<String, FormRenderError> {
356        let html = self.templates.render(
357            "validation/field-errors.html",
358            minijinja::context! {
359                container_class => &self.options.error_class,
360                error_class => "error",
361                errors => errors,
362            },
363        )?;
364
365        Ok(html)
366    }
367
368    /// Build CSS class for input with error state
369    fn build_input_class(&self, field: &FormField, has_errors: bool) -> String {
370        let mut classes = vec![self.options.input_class.as_str()];
371
372        if let Some(ref class) = field.class {
373            classes.push(class.as_str());
374        }
375        if has_errors {
376            classes.push(self.options.input_error_class.as_str());
377        }
378
379        classes.join(" ")
380    }
381
382    /// Build extra attributes including HTMX and data attributes
383    fn build_field_attrs(field: &FormField) -> Vec<(String, String)> {
384        let mut attrs = Vec::new();
385
386        // HTMX attributes
387        if let Some(ref url) = field.htmx.get {
388            attrs.push(("hx-get".to_string(), url.clone()));
389        }
390        if let Some(ref url) = field.htmx.post {
391            attrs.push(("hx-post".to_string(), url.clone()));
392        }
393        if let Some(ref url) = field.htmx.put {
394            attrs.push(("hx-put".to_string(), url.clone()));
395        }
396        if let Some(ref url) = field.htmx.delete {
397            attrs.push(("hx-delete".to_string(), url.clone()));
398        }
399        if let Some(ref url) = field.htmx.patch {
400            attrs.push(("hx-patch".to_string(), url.clone()));
401        }
402        if let Some(ref selector) = field.htmx.target {
403            attrs.push(("hx-target".to_string(), selector.clone()));
404        }
405        if let Some(ref strategy) = field.htmx.swap {
406            attrs.push(("hx-swap".to_string(), strategy.clone()));
407        }
408        if let Some(ref trigger) = field.htmx.trigger {
409            attrs.push(("hx-trigger".to_string(), trigger.clone()));
410        }
411        if let Some(ref selector) = field.htmx.indicator {
412            attrs.push(("hx-indicator".to_string(), selector.clone()));
413        }
414        if let Some(ref vals) = field.htmx.vals {
415            attrs.push(("hx-vals".to_string(), vals.clone()));
416        }
417        if field.htmx.validate {
418            attrs.push(("hx-validate".to_string(), "true".to_string()));
419        }
420
421        // Data attributes
422        for (name, value) in &field.data_attrs {
423            attrs.push((format!("data-{name}"), value.clone()));
424        }
425
426        // Custom attributes
427        for (name, value) in &field.custom_attrs {
428            attrs.push((name.clone(), value.clone()));
429        }
430
431        attrs
432    }
433
434    /// Build HTMX form attributes as string list
435    fn build_htmx_form_attrs(form: &FormBuilder<'_>) -> Vec<String> {
436        let mut attrs = Vec::new();
437
438        if let Some(ref url) = form.htmx.get {
439            attrs.push(format!(r#"hx-get="{url}""#));
440        }
441        if let Some(ref url) = form.htmx.post {
442            attrs.push(format!(r#"hx-post="{url}""#));
443        }
444        if let Some(ref url) = form.htmx.put {
445            attrs.push(format!(r#"hx-put="{url}""#));
446        }
447        if let Some(ref url) = form.htmx.delete {
448            attrs.push(format!(r#"hx-delete="{url}""#));
449        }
450        if let Some(ref url) = form.htmx.patch {
451            attrs.push(format!(r#"hx-patch="{url}""#));
452        }
453        if let Some(ref selector) = form.htmx.target {
454            attrs.push(format!(r#"hx-target="{selector}""#));
455        }
456        if let Some(ref strategy) = form.htmx.swap {
457            attrs.push(format!(r#"hx-swap="{strategy}""#));
458        }
459        if let Some(ref trigger) = form.htmx.trigger {
460            attrs.push(format!(r#"hx-trigger="{trigger}""#));
461        }
462        if let Some(ref selector) = form.htmx.indicator {
463            attrs.push(format!(r#"hx-indicator="{selector}""#));
464        }
465        if let Some(ref url) = form.htmx.push_url {
466            attrs.push(format!(r#"hx-push-url="{url}""#));
467        }
468        if let Some(ref message) = form.htmx.confirm {
469            attrs.push(format!(r#"hx-confirm="{message}""#));
470        }
471        if let Some(ref selector) = form.htmx.disabled_elt {
472            attrs.push(format!(r#"hx-disabled-elt="{selector}""#));
473        }
474
475        // Custom attributes
476        for (name, value) in &form.custom_attrs {
477            attrs.push(format!(r#"{name}="{value}""#));
478        }
479
480        attrs
481    }
482}
483
484/// Context for select options in templates
485#[derive(Debug, Clone, Serialize)]
486struct SelectOptionCtx {
487    value: String,
488    label: String,
489    selected: bool,
490    disabled: bool,
491}
492
493/// Context for radio options in templates
494#[derive(Debug, Clone, Serialize)]
495struct RadioOptionCtx {
496    id: String,
497    value: String,
498    label: String,
499    checked: bool,
500    disabled: bool,
501}
502
503/// Errors that can occur during form rendering
504#[derive(Debug, thiserror::Error)]
505pub enum FormRenderError {
506    /// Template rendering failed
507    #[error("template error: {0}")]
508    TemplateError(#[from] crate::template::framework::FrameworkTemplateError),
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    // Note: Tests require templates to be initialized
516    // Run `acton-htmx templates init` before running tests
517
518    #[test]
519    fn test_template_renderer_creation() {
520        // This will fail if templates aren't initialized, which is expected
521        let result = FrameworkTemplates::new();
522        if result.is_err() {
523            // Templates not initialized - skip test
524            return;
525        }
526
527        let templates = result.unwrap();
528        let _renderer = TemplateFormRenderer::new(&templates);
529    }
530}