acton_htmx/forms/
mod.rs

1//! Form handling, building, and validation for HTMX applications
2//!
3//! This module provides a builder-pattern API for creating forms with:
4//! - Automatic CSRF token injection
5//! - HTMX attribute support
6//! - Integration with the `validator` crate
7//! - Field-level error rendering
8//!
9//! # Quick Start
10//!
11//! ```rust
12//! use acton_htmx::forms::{FormBuilder, InputType};
13//!
14//! let form = FormBuilder::new("/users", "POST")
15//!     .csrf_token("abc123")
16//!     .field("email", InputType::Email)
17//!         .label("Email Address")
18//!         .required()
19//!         .placeholder("you@example.com")
20//!         .done()
21//!     .field("password", InputType::Password)
22//!         .label("Password")
23//!         .required()
24//!         .min_length(8)
25//!         .done()
26//!     .submit("Sign Up")
27//!     .htmx_post("/users")
28//!     .htmx_target("#result")
29//!     .htmx_swap("innerHTML")
30//!     .build();
31//!
32//! println!("{form}");
33//! ```
34//!
35//! # HTMX Integration
36//!
37//! Forms can be enhanced with HTMX attributes for seamless partial updates:
38//!
39//! ```rust
40//! use acton_htmx::forms::FormBuilder;
41//!
42//! let form = FormBuilder::new("/search", "GET")
43//!     .htmx_get("/search")
44//!     .htmx_trigger("keyup changed delay:500ms")
45//!     .htmx_target("#results")
46//!     .htmx_swap("innerHTML")
47//!     .htmx_indicator("#spinner")
48//!     .build();
49//! ```
50//!
51//! # Validation Errors
52//!
53//! Display validation errors alongside fields:
54//!
55//! ```rust
56//! use acton_htmx::forms::{FormBuilder, InputType, ValidationErrors};
57//!
58//! let mut errors = ValidationErrors::new();
59//! errors.add("email", "Invalid email address");
60//!
61//! let form = FormBuilder::new("/users", "POST")
62//!     .errors(&errors)
63//!     .field("email", InputType::Email)
64//!         .label("Email")
65//!         .done()
66//!     .build();
67//!
68//! // Errors are automatically rendered next to the field
69//! ```
70
71mod builder;
72mod error;
73mod field;
74mod render;
75mod template_render;
76
77pub use builder::{FieldBuilder, FileFieldBuilder, FormBuilder};
78pub use error::{FieldError, ValidationErrors};
79pub use field::{FileFieldAttrs, FormField, InputType, SelectOption};
80pub use render::{FormRenderOptions, FormRenderer};
81pub use template_render::{FormRenderError, TemplateFormRenderer};
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn test_simple_form() {
89        let form = FormBuilder::new("/login", "POST")
90            .field("email", InputType::Email)
91            .label("Email")
92            .required()
93            .done()
94            .field("password", InputType::Password)
95            .label("Password")
96            .required()
97            .done()
98            .submit("Login")
99            .build();
100
101        assert!(form.contains(r#"action="/login""#));
102        assert!(form.contains(r#"method="POST""#));
103        assert!(form.contains(r#"type="email""#));
104        assert!(form.contains(r#"type="password""#));
105        assert!(form.contains("Login"));
106    }
107
108    #[test]
109    fn test_csrf_injection() {
110        let form = FormBuilder::new("/users", "POST")
111            .csrf_token("test_token_123")
112            .build();
113
114        assert!(form.contains(r#"name="_csrf_token""#));
115        assert!(form.contains(r#"value="test_token_123""#));
116    }
117
118    #[test]
119    fn test_htmx_attributes() {
120        let form = FormBuilder::new("/search", "GET")
121            .htmx_get("/api/search")
122            .htmx_target("#results")
123            .htmx_swap("innerHTML")
124            .htmx_indicator("#spinner")
125            .build();
126
127        assert!(form.contains(r#"hx-get="/api/search""#));
128        assert!(form.contains(r##"hx-target="#results""##));
129        assert!(form.contains(r#"hx-swap="innerHTML""#));
130        assert!(form.contains(r##"hx-indicator="#spinner""##));
131    }
132
133    #[test]
134    fn test_validation_errors() {
135        let mut errors = ValidationErrors::new();
136        errors.add("email", "is required");
137
138        let form = FormBuilder::new("/users", "POST")
139            .errors(&errors)
140            .field("email", InputType::Email)
141            .label("Email")
142            .done()
143            .build();
144
145        assert!(form.contains("is required"));
146        assert!(form.contains("form-error"));
147    }
148
149    #[test]
150    fn test_field_attributes() {
151        let form = FormBuilder::new("/test", "POST")
152            .field("name", InputType::Text)
153            .label("Full Name")
154            .placeholder("John Doe")
155            .required()
156            .min_length(2)
157            .max_length(100)
158            .pattern(r"[A-Za-z\s]+")
159            .done()
160            .build();
161
162        assert!(form.contains(r#"placeholder="John Doe""#));
163        assert!(form.contains("required"));
164        assert!(form.contains(r#"minlength="2""#));
165        assert!(form.contains(r#"maxlength="100""#));
166        assert!(form.contains("pattern="));
167    }
168
169    #[test]
170    fn test_select_field() {
171        let form = FormBuilder::new("/test", "POST")
172            .select("country")
173            .label("Country")
174            .option("us", "United States")
175            .option("ca", "Canada")
176            .option("mx", "Mexico")
177            .selected("us")
178            .done()
179            .build();
180
181        assert!(form.contains("<select"));
182        assert!(form.contains(r#"value="us""#));
183        assert!(form.contains("United States"));
184        assert!(form.contains("selected"));
185    }
186
187    #[test]
188    fn test_textarea_field() {
189        let form = FormBuilder::new("/test", "POST")
190            .textarea("bio")
191            .label("Biography")
192            .placeholder("Tell us about yourself...")
193            .rows(5)
194            .cols(40)
195            .done()
196            .build();
197
198        assert!(form.contains("<textarea"));
199        assert!(form.contains(r#"rows="5""#));
200        assert!(form.contains(r#"cols="40""#));
201    }
202
203    #[test]
204    fn test_checkbox_field() {
205        let form = FormBuilder::new("/test", "POST")
206            .checkbox("terms")
207            .label("I agree to the terms")
208            .checked()
209            .done()
210            .build();
211
212        assert!(form.contains(r#"type="checkbox""#));
213        assert!(form.contains("checked"));
214    }
215
216    #[test]
217    fn test_form_id_and_class() {
218        let form = FormBuilder::new("/test", "POST")
219            .id("my-form")
220            .class("form-styled")
221            .build();
222
223        assert!(form.contains(r#"id="my-form""#));
224        assert!(form.contains(r#"class="form-styled""#));
225    }
226
227    #[test]
228    fn test_file_upload_field() {
229        let form = FormBuilder::new("/upload", "POST")
230            .file("avatar")
231            .label("Profile Picture")
232            .accept("image/png,image/jpeg")
233            .max_size_mb(5)
234            .required()
235            .done()
236            .build();
237
238        assert!(form.contains(r#"enctype="multipart/form-data""#));
239        assert!(form.contains(r#"type="file""#));
240        assert!(form.contains(r#"accept="image/png,image/jpeg""#));
241        assert!(form.contains(r#"data-max-size-mb="5""#));
242        assert!(form.contains("required"));
243    }
244
245    #[test]
246    fn test_file_upload_multiple() {
247        let form = FormBuilder::new("/upload", "POST")
248            .file("attachments")
249            .label("Attachments")
250            .multiple()
251            .done()
252            .build();
253
254        assert!(form.contains(r#"enctype="multipart/form-data""#));
255        assert!(form.contains("multiple"));
256    }
257
258    #[test]
259    fn test_file_upload_with_preview() {
260        let form = FormBuilder::new("/upload", "POST")
261            .file("image")
262            .label("Image")
263            .show_preview()
264            .drag_drop()
265            .done()
266            .build();
267
268        assert!(form.contains(r#"data-preview="true""#));
269        assert!(form.contains(r#"data-drag-drop="true""#));
270    }
271
272    #[test]
273    fn test_file_upload_with_progress_endpoint() {
274        let form = FormBuilder::new("/upload", "POST")
275            .file("large_file")
276            .label("Large File")
277            .progress_endpoint("/upload/progress")
278            .done()
279            .build();
280
281        assert!(form.contains(r#"data-progress-endpoint="/upload/progress""#));
282    }
283
284    #[test]
285    fn test_multipart_auto_set() {
286        let form = FormBuilder::new("/upload", "POST")
287            .file("file1")
288            .done()
289            .build();
290
291        // Enctype should be automatically set when file() is called
292        assert!(form.contains(r#"enctype="multipart/form-data""#));
293    }
294
295    #[test]
296    fn test_file_upload_with_help_text() {
297        let form = FormBuilder::new("/upload", "POST")
298            .file("avatar")
299            .label("Avatar")
300            .help("Maximum size: 5MB. Accepted formats: PNG, JPEG")
301            .done()
302            .build();
303
304        assert!(form.contains("Maximum size: 5MB"));
305        assert!(form.contains("Accepted formats: PNG, JPEG"));
306    }
307}