1use 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
15pub struct TemplateFormRenderer<'t> {
20 templates: &'t FrameworkTemplates,
21 options: FormRenderOptions,
22}
23
24impl<'t> TemplateFormRenderer<'t> {
25 #[must_use]
27 pub fn new(templates: &'t FrameworkTemplates) -> Self {
28 Self {
29 templates,
30 options: FormRenderOptions::default(),
31 }
32 }
33
34 #[must_use]
36 pub const fn with_options(templates: &'t FrameworkTemplates, options: FormRenderOptions) -> Self {
37 Self { templates, options }
38 }
39
40 pub fn render(&self, form: &FormBuilder<'_>) -> Result<String, FormRenderError> {
46 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 let hx_attrs = Self::build_htmx_form_attrs(form);
54
55 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 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 if matches!(field.kind, FieldKind::Input(InputType::Hidden)) {
93 return self.render_input(field, InputType::Hidden, has_errors);
94 }
95
96 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 let is_checkbox = matches!(field.kind, FieldKind::Checkbox { .. });
111
112 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 let errors_html = if field_errors.is_empty() {
125 String::new()
126 } else {
127 self.render_field_errors(&field_errors)?
128 };
129
130 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 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 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 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 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 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 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 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 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 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 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 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 fn build_field_attrs(field: &FormField) -> Vec<(String, String)> {
384 let mut attrs = Vec::new();
385
386 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 for (name, value) in &field.data_attrs {
423 attrs.push((format!("data-{name}"), value.clone()));
424 }
425
426 for (name, value) in &field.custom_attrs {
428 attrs.push((name.clone(), value.clone()));
429 }
430
431 attrs
432 }
433
434 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 for (name, value) in &form.custom_attrs {
477 attrs.push(format!(r#"{name}="{value}""#));
478 }
479
480 attrs
481 }
482}
483
484#[derive(Debug, Clone, Serialize)]
486struct SelectOptionCtx {
487 value: String,
488 label: String,
489 selected: bool,
490 disabled: bool,
491}
492
493#[derive(Debug, Clone, Serialize)]
495struct RadioOptionCtx {
496 id: String,
497 value: String,
498 label: String,
499 checked: bool,
500 disabled: bool,
501}
502
503#[derive(Debug, thiserror::Error)]
505pub enum FormRenderError {
506 #[error("template error: {0}")]
508 TemplateError(#[from] crate::template::framework::FrameworkTemplateError),
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 #[test]
519 fn test_template_renderer_creation() {
520 let result = FrameworkTemplates::new();
522 if result.is_err() {
523 return;
525 }
526
527 let templates = result.unwrap();
528 let _renderer = TemplateFormRenderer::new(&templates);
529 }
530}