Skip to main content

reinhardt_forms/fields/
datetime_field.rs

1use crate::field::{FieldError, FieldResult, FormField, Widget};
2use chrono::NaiveDateTime;
3
4/// DateTimeField for date and time input
5pub struct DateTimeField {
6	/// The field name used as the form data key.
7	pub name: String,
8	/// Optional human-readable label for display.
9	pub label: Option<String>,
10	/// Whether this field must be filled in.
11	pub required: bool,
12	/// Optional help text displayed alongside the field.
13	pub help_text: Option<String>,
14	/// The widget type used for rendering this field.
15	pub widget: Widget,
16	/// Optional initial (default) value for the field.
17	pub initial: Option<serde_json::Value>,
18	/// Accepted datetime format strings (strftime patterns).
19	pub input_formats: Vec<String>,
20}
21
22impl DateTimeField {
23	/// Create a new DateTimeField
24	///
25	/// # Examples
26	///
27	/// ```
28	/// use reinhardt_forms::fields::DateTimeField;
29	///
30	/// let field = DateTimeField::new("event_time".to_string());
31	/// assert_eq!(field.name, "event_time");
32	/// ```
33	pub fn new(name: String) -> Self {
34		Self {
35			name,
36			label: None,
37			required: true,
38			help_text: None,
39			widget: Widget::TextInput,
40			initial: None,
41			input_formats: vec![
42				"%Y-%m-%d %H:%M:%S".to_string(),
43				"%Y-%m-%d %H:%M".to_string(),
44				"%Y-%m-%dT%H:%M:%S".to_string(),
45				"%Y-%m-%dT%H:%M".to_string(),
46				"%m/%d/%Y %H:%M:%S".to_string(),
47				"%m/%d/%Y %H:%M".to_string(),
48				"%m/%d/%y %H:%M:%S".to_string(),
49				"%m/%d/%y %H:%M".to_string(),
50			],
51		}
52	}
53
54	fn parse_datetime(&self, s: &str) -> Result<NaiveDateTime, String> {
55		for fmt in &self.input_formats {
56			if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) {
57				return Ok(dt);
58			}
59		}
60		Err("Enter a valid date/time".to_string())
61	}
62}
63
64impl FormField for DateTimeField {
65	fn name(&self) -> &str {
66		&self.name
67	}
68
69	fn label(&self) -> Option<&str> {
70		self.label.as_deref()
71	}
72
73	fn required(&self) -> bool {
74		self.required
75	}
76
77	fn help_text(&self) -> Option<&str> {
78		self.help_text.as_deref()
79	}
80
81	fn widget(&self) -> &Widget {
82		&self.widget
83	}
84
85	fn initial(&self) -> Option<&serde_json::Value> {
86		self.initial.as_ref()
87	}
88
89	fn clean(&self, value: Option<&serde_json::Value>) -> FieldResult<serde_json::Value> {
90		match value {
91			None if self.required => Err(FieldError::required(None)),
92			None => Ok(serde_json::Value::Null),
93			Some(v) => {
94				let s = v
95					.as_str()
96					.ok_or_else(|| FieldError::Invalid("Expected string".to_string()))?;
97
98				let s = s.trim();
99
100				if s.is_empty() {
101					if self.required {
102						return Err(FieldError::required(None));
103					}
104					return Ok(serde_json::Value::Null);
105				}
106
107				let dt = self.parse_datetime(s).map_err(FieldError::Validation)?;
108
109				Ok(serde_json::Value::String(
110					dt.format("%Y-%m-%d %H:%M:%S").to_string(),
111				))
112			}
113		}
114	}
115}
116
117#[cfg(test)]
118mod tests {
119	use super::*;
120
121	#[test]
122	fn test_datetimefield_valid() {
123		let field = DateTimeField::new("created_at".to_string());
124
125		assert_eq!(
126			field
127				.clean(Some(&serde_json::json!("2025-01-15 14:30:00")))
128				.unwrap(),
129			serde_json::json!("2025-01-15 14:30:00")
130		);
131		assert_eq!(
132			field
133				.clean(Some(&serde_json::json!("2025-01-15T14:30:00")))
134				.unwrap(),
135			serde_json::json!("2025-01-15 14:30:00")
136		);
137		assert_eq!(
138			field
139				.clean(Some(&serde_json::json!("01/15/2025 14:30:00")))
140				.unwrap(),
141			serde_json::json!("2025-01-15 14:30:00")
142		);
143	}
144
145	#[test]
146	fn test_datetimefield_invalid() {
147		let field = DateTimeField::new("created_at".to_string());
148
149		assert!(matches!(
150			field.clean(Some(&serde_json::json!("not a datetime"))),
151			Err(FieldError::Validation(_))
152		));
153		assert!(matches!(
154			field.clean(Some(&serde_json::json!("2025-13-01 14:30:00"))),
155			Err(FieldError::Validation(_))
156		));
157	}
158
159	#[test]
160	fn test_datetimefield_optional() {
161		let mut field = DateTimeField::new("created_at".to_string());
162		field.required = false;
163
164		assert_eq!(field.clean(None).unwrap(), serde_json::Value::Null);
165		assert_eq!(
166			field.clean(Some(&serde_json::json!(""))).unwrap(),
167			serde_json::Value::Null
168		);
169	}
170
171	#[test]
172	fn test_datetimefield_required() {
173		let field = DateTimeField::new("created_at".to_string());
174
175		// Required field rejects None
176		assert!(field.clean(None).is_err());
177
178		// Required field rejects empty string
179		assert!(field.clean(Some(&serde_json::json!(""))).is_err());
180	}
181
182	#[test]
183	fn test_datetimefield_iso_format_with_seconds() {
184		let field = DateTimeField::new("created_at".to_string());
185
186		// ISO 8601 with space separator
187		let result = field
188			.clean(Some(&serde_json::json!("2025-01-15 14:30:00")))
189			.unwrap();
190		assert_eq!(result, serde_json::json!("2025-01-15 14:30:00"));
191
192		// ISO 8601 with T separator
193		let result = field
194			.clean(Some(&serde_json::json!("2025-01-15T14:30:00")))
195			.unwrap();
196		assert_eq!(result, serde_json::json!("2025-01-15 14:30:00"));
197	}
198
199	#[test]
200	fn test_datetimefield_iso_format_without_seconds() {
201		let field = DateTimeField::new("created_at".to_string());
202
203		// ISO 8601 with space separator (no seconds)
204		let result = field
205			.clean(Some(&serde_json::json!("2025-01-15 14:30")))
206			.unwrap();
207		assert_eq!(result, serde_json::json!("2025-01-15 14:30:00"));
208
209		// ISO 8601 with T separator (no seconds)
210		let result = field
211			.clean(Some(&serde_json::json!("2025-01-15T14:30")))
212			.unwrap();
213		assert_eq!(result, serde_json::json!("2025-01-15 14:30:00"));
214	}
215
216	#[test]
217	fn test_datetimefield_us_format_with_seconds() {
218		let field = DateTimeField::new("created_at".to_string());
219
220		// US format with 4-digit year
221		let result = field
222			.clean(Some(&serde_json::json!("01/15/2025 14:30:00")))
223			.unwrap();
224		assert_eq!(result, serde_json::json!("2025-01-15 14:30:00"));
225
226		// US format with 2-digit year (chrono interprets as 00-99 AD)
227		let result = field
228			.clean(Some(&serde_json::json!("01/15/25 14:30:00")))
229			.unwrap();
230		assert_eq!(result, serde_json::json!("0025-01-15 14:30:00"));
231	}
232
233	#[test]
234	fn test_datetimefield_us_format_without_seconds() {
235		let field = DateTimeField::new("created_at".to_string());
236
237		// US format with 4-digit year (no seconds)
238		let result = field
239			.clean(Some(&serde_json::json!("01/15/2025 14:30")))
240			.unwrap();
241		assert_eq!(result, serde_json::json!("2025-01-15 14:30:00"));
242
243		// US format with 2-digit year (no seconds)
244		let result = field
245			.clean(Some(&serde_json::json!("01/15/25 14:30")))
246			.unwrap();
247		assert_eq!(result, serde_json::json!("0025-01-15 14:30:00"));
248	}
249
250	#[test]
251	fn test_datetimefield_whitespace_trimming() {
252		let field = DateTimeField::new("created_at".to_string());
253
254		let result = field
255			.clean(Some(&serde_json::json!("  2025-01-15 14:30:00  ")))
256			.unwrap();
257		assert_eq!(result, serde_json::json!("2025-01-15 14:30:00"));
258	}
259
260	#[test]
261	fn test_datetimefield_invalid_date() {
262		let field = DateTimeField::new("created_at".to_string());
263
264		// Invalid month (13)
265		assert!(matches!(
266			field.clean(Some(&serde_json::json!("2025-13-01 14:30:00"))),
267			Err(FieldError::Validation(_))
268		));
269
270		// Invalid day (32)
271		assert!(matches!(
272			field.clean(Some(&serde_json::json!("2025-01-32 14:30:00"))),
273			Err(FieldError::Validation(_))
274		));
275
276		// Feb 30 (invalid)
277		assert!(matches!(
278			field.clean(Some(&serde_json::json!("2025-02-30 14:30:00"))),
279			Err(FieldError::Validation(_))
280		));
281	}
282
283	#[test]
284	fn test_datetimefield_invalid_time() {
285		let field = DateTimeField::new("created_at".to_string());
286
287		// Invalid hour (25)
288		assert!(matches!(
289			field.clean(Some(&serde_json::json!("2025-01-15 25:30:00"))),
290			Err(FieldError::Validation(_))
291		));
292
293		// Invalid minute (61)
294		assert!(matches!(
295			field.clean(Some(&serde_json::json!("2025-01-15 14:61:00"))),
296			Err(FieldError::Validation(_))
297		));
298
299		// Invalid second (61)
300		assert!(matches!(
301			field.clean(Some(&serde_json::json!("2025-01-15 14:30:61"))),
302			Err(FieldError::Validation(_))
303		));
304	}
305
306	#[test]
307	fn test_datetimefield_leap_year() {
308		let field = DateTimeField::new("created_at".to_string());
309
310		// Feb 29 in leap year 2024 should be valid
311		let result = field
312			.clean(Some(&serde_json::json!("2024-02-29 14:30:00")))
313			.unwrap();
314		assert_eq!(result, serde_json::json!("2024-02-29 14:30:00"));
315
316		// Feb 29 in non-leap year 2025 should fail
317		assert!(matches!(
318			field.clean(Some(&serde_json::json!("2025-02-29 14:30:00"))),
319			Err(FieldError::Validation(_))
320		));
321	}
322
323	#[test]
324	fn test_datetimefield_midnight() {
325		let field = DateTimeField::new("created_at".to_string());
326
327		let result = field
328			.clean(Some(&serde_json::json!("2025-01-15 00:00:00")))
329			.unwrap();
330		assert_eq!(result, serde_json::json!("2025-01-15 00:00:00"));
331	}
332
333	#[test]
334	fn test_datetimefield_end_of_day() {
335		let field = DateTimeField::new("created_at".to_string());
336
337		let result = field
338			.clean(Some(&serde_json::json!("2025-01-15 23:59:59")))
339			.unwrap();
340		assert_eq!(result, serde_json::json!("2025-01-15 23:59:59"));
341	}
342
343	#[test]
344	fn test_datetimefield_noon() {
345		let field = DateTimeField::new("created_at".to_string());
346
347		let result = field
348			.clean(Some(&serde_json::json!("2025-01-15 12:00:00")))
349			.unwrap();
350		assert_eq!(result, serde_json::json!("2025-01-15 12:00:00"));
351	}
352
353	#[test]
354	fn test_datetimefield_widget_type() {
355		let field = DateTimeField::new("created_at".to_string());
356		assert!(matches!(field.widget(), &Widget::TextInput));
357	}
358
359	#[test]
360	fn test_datetimefield_custom_formats() {
361		let mut field = DateTimeField::new("created_at".to_string());
362		// Add custom format
363		field.input_formats.push("%d-%m-%Y %H:%M:%S".to_string());
364
365		// Custom day-first format should work
366		let result = field
367			.clean(Some(&serde_json::json!("15-01-2025 14:30:00")))
368			.unwrap();
369		assert_eq!(result, serde_json::json!("2025-01-15 14:30:00"));
370	}
371
372	#[test]
373	fn test_datetimefield_format_precedence() {
374		let field = DateTimeField::new("created_at".to_string());
375
376		// When multiple formats could match, first matching format is used
377		// "2025-01-15 14:30:00" matches "%Y-%m-%d %H:%M:%S" (first format)
378		let result = field
379			.clean(Some(&serde_json::json!("2025-01-15 14:30:00")))
380			.unwrap();
381		assert_eq!(result, serde_json::json!("2025-01-15 14:30:00"));
382	}
383}