Skip to main content

reinhardt_forms/fields/
time_field.rs

1use crate::field::{FieldError, FieldResult, FormField, Widget};
2use chrono::NaiveTime;
3
4/// TimeField for time input
5pub struct TimeField {
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 time format strings (strftime patterns).
19	pub input_formats: Vec<String>,
20}
21
22impl TimeField {
23	/// Create a new TimeField
24	///
25	/// # Examples
26	///
27	/// ```
28	/// use reinhardt_forms::fields::TimeField;
29	///
30	/// let field = TimeField::new("start_time".to_string());
31	/// assert_eq!(field.name, "start_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				"%H:%M:%S".to_string(),
43				"%H:%M".to_string(),
44				"%I:%M:%S %p".to_string(),
45				"%I:%M %p".to_string(),
46			],
47		}
48	}
49
50	fn parse_time(&self, s: &str) -> Result<NaiveTime, String> {
51		for fmt in &self.input_formats {
52			if let Ok(time) = NaiveTime::parse_from_str(s, fmt) {
53				return Ok(time);
54			}
55		}
56		Err("Enter a valid time".to_string())
57	}
58}
59
60impl FormField for TimeField {
61	fn name(&self) -> &str {
62		&self.name
63	}
64
65	fn label(&self) -> Option<&str> {
66		self.label.as_deref()
67	}
68
69	fn required(&self) -> bool {
70		self.required
71	}
72
73	fn help_text(&self) -> Option<&str> {
74		self.help_text.as_deref()
75	}
76
77	fn widget(&self) -> &Widget {
78		&self.widget
79	}
80
81	fn initial(&self) -> Option<&serde_json::Value> {
82		self.initial.as_ref()
83	}
84
85	fn clean(&self, value: Option<&serde_json::Value>) -> FieldResult<serde_json::Value> {
86		match value {
87			None if self.required => Err(FieldError::required(None)),
88			None => Ok(serde_json::Value::Null),
89			Some(v) => {
90				let s = v
91					.as_str()
92					.ok_or_else(|| FieldError::Invalid("Expected string".to_string()))?;
93
94				let s = s.trim();
95
96				if s.is_empty() {
97					if self.required {
98						return Err(FieldError::required(None));
99					}
100					return Ok(serde_json::Value::Null);
101				}
102
103				let time = self.parse_time(s).map_err(FieldError::Validation)?;
104
105				Ok(serde_json::Value::String(
106					time.format("%H:%M:%S").to_string(),
107				))
108			}
109		}
110	}
111}
112
113#[cfg(test)]
114mod tests {
115	use super::*;
116
117	#[test]
118	fn test_timefield_valid() {
119		let field = TimeField::new("start_time".to_string());
120
121		assert_eq!(
122			field.clean(Some(&serde_json::json!("14:30:00"))).unwrap(),
123			serde_json::json!("14:30:00")
124		);
125		assert_eq!(
126			field.clean(Some(&serde_json::json!("14:30"))).unwrap(),
127			serde_json::json!("14:30:00")
128		);
129		assert_eq!(
130			field
131				.clean(Some(&serde_json::json!("02:30:00 PM")))
132				.unwrap(),
133			serde_json::json!("14:30:00")
134		);
135	}
136
137	#[test]
138	fn test_timefield_invalid() {
139		let field = TimeField::new("start_time".to_string());
140
141		assert!(matches!(
142			field.clean(Some(&serde_json::json!("not a time"))),
143			Err(FieldError::Validation(_))
144		));
145		assert!(matches!(
146			field.clean(Some(&serde_json::json!("25:00:00"))),
147			Err(FieldError::Validation(_))
148		));
149	}
150
151	#[test]
152	fn test_timefield_optional() {
153		let mut field = TimeField::new("start_time".to_string());
154		field.required = false;
155
156		assert_eq!(field.clean(None).unwrap(), serde_json::Value::Null);
157		assert_eq!(
158			field.clean(Some(&serde_json::json!(""))).unwrap(),
159			serde_json::Value::Null
160		);
161	}
162
163	#[test]
164	fn test_timefield_required() {
165		let field = TimeField::new("start_time".to_string());
166
167		// Required field rejects None
168		assert!(field.clean(None).is_err());
169
170		// Required field rejects empty string
171		assert!(field.clean(Some(&serde_json::json!(""))).is_err());
172	}
173
174	#[test]
175	fn test_timefield_24hour_format_with_seconds() {
176		let field = TimeField::new("start_time".to_string());
177
178		let result = field.clean(Some(&serde_json::json!("14:30:00"))).unwrap();
179		assert_eq!(result, serde_json::json!("14:30:00"));
180
181		let result = field.clean(Some(&serde_json::json!("09:15:30"))).unwrap();
182		assert_eq!(result, serde_json::json!("09:15:30"));
183	}
184
185	#[test]
186	fn test_timefield_24hour_format_without_seconds() {
187		let field = TimeField::new("start_time".to_string());
188
189		// Without seconds defaults to :00
190		let result = field.clean(Some(&serde_json::json!("14:30"))).unwrap();
191		assert_eq!(result, serde_json::json!("14:30:00"));
192
193		let result = field.clean(Some(&serde_json::json!("09:15"))).unwrap();
194		assert_eq!(result, serde_json::json!("09:15:00"));
195	}
196
197	#[test]
198	fn test_timefield_12hour_format_pm() {
199		let field = TimeField::new("start_time".to_string());
200
201		// PM times
202		let result = field
203			.clean(Some(&serde_json::json!("02:30:00 PM")))
204			.unwrap();
205		assert_eq!(result, serde_json::json!("14:30:00"));
206
207		let result = field.clean(Some(&serde_json::json!("02:30 PM"))).unwrap();
208		assert_eq!(result, serde_json::json!("14:30:00"));
209
210		let result = field
211			.clean(Some(&serde_json::json!("11:59:59 PM")))
212			.unwrap();
213		assert_eq!(result, serde_json::json!("23:59:59"));
214	}
215
216	#[test]
217	fn test_timefield_12hour_format_am() {
218		let field = TimeField::new("start_time".to_string());
219
220		// AM times
221		let result = field
222			.clean(Some(&serde_json::json!("09:30:00 AM")))
223			.unwrap();
224		assert_eq!(result, serde_json::json!("09:30:00"));
225
226		let result = field.clean(Some(&serde_json::json!("09:30 AM"))).unwrap();
227		assert_eq!(result, serde_json::json!("09:30:00"));
228
229		let result = field
230			.clean(Some(&serde_json::json!("11:59:59 AM")))
231			.unwrap();
232		assert_eq!(result, serde_json::json!("11:59:59"));
233	}
234
235	#[test]
236	fn test_timefield_midnight() {
237		let field = TimeField::new("start_time".to_string());
238
239		// Midnight as 00:00:00
240		let result = field.clean(Some(&serde_json::json!("00:00:00"))).unwrap();
241		assert_eq!(result, serde_json::json!("00:00:00"));
242
243		// Midnight as 12:00 AM
244		let result = field.clean(Some(&serde_json::json!("12:00 AM"))).unwrap();
245		assert_eq!(result, serde_json::json!("00:00:00"));
246	}
247
248	#[test]
249	fn test_timefield_noon() {
250		let field = TimeField::new("start_time".to_string());
251
252		// Noon as 12:00:00
253		let result = field.clean(Some(&serde_json::json!("12:00:00"))).unwrap();
254		assert_eq!(result, serde_json::json!("12:00:00"));
255
256		// Noon as 12:00 PM
257		let result = field.clean(Some(&serde_json::json!("12:00 PM"))).unwrap();
258		assert_eq!(result, serde_json::json!("12:00:00"));
259	}
260
261	#[test]
262	fn test_timefield_end_of_day() {
263		let field = TimeField::new("start_time".to_string());
264
265		let result = field.clean(Some(&serde_json::json!("23:59:59"))).unwrap();
266		assert_eq!(result, serde_json::json!("23:59:59"));
267	}
268
269	#[test]
270	fn test_timefield_whitespace_trimming() {
271		let field = TimeField::new("start_time".to_string());
272
273		let result = field
274			.clean(Some(&serde_json::json!("  14:30:00  ")))
275			.unwrap();
276		assert_eq!(result, serde_json::json!("14:30:00"));
277	}
278
279	#[test]
280	fn test_timefield_invalid_hour() {
281		let field = TimeField::new("start_time".to_string());
282
283		// Hour 25 is invalid
284		assert!(matches!(
285			field.clean(Some(&serde_json::json!("25:00:00"))),
286			Err(FieldError::Validation(_))
287		));
288
289		// Hour 24 is invalid (valid range is 00-23)
290		assert!(matches!(
291			field.clean(Some(&serde_json::json!("24:00:00"))),
292			Err(FieldError::Validation(_))
293		));
294	}
295
296	#[test]
297	fn test_timefield_invalid_minute() {
298		let field = TimeField::new("start_time".to_string());
299
300		// Minute 60 is invalid
301		assert!(matches!(
302			field.clean(Some(&serde_json::json!("14:60:00"))),
303			Err(FieldError::Validation(_))
304		));
305
306		// Minute 99 is invalid
307		assert!(matches!(
308			field.clean(Some(&serde_json::json!("14:99:00"))),
309			Err(FieldError::Validation(_))
310		));
311	}
312
313	#[test]
314	fn test_timefield_invalid_second() {
315		let field = TimeField::new("start_time".to_string());
316
317		// Second 99 is invalid
318		assert!(matches!(
319			field.clean(Some(&serde_json::json!("14:30:99"))),
320			Err(FieldError::Validation(_))
321		));
322
323		// Note: Second 60 is accepted by chrono as leap second
324		// So we test with a clearly invalid value like 99 instead
325	}
326
327	#[test]
328	fn test_timefield_invalid_format() {
329		let field = TimeField::new("start_time".to_string());
330
331		// Missing colon
332		assert!(matches!(
333			field.clean(Some(&serde_json::json!("1430"))),
334			Err(FieldError::Validation(_))
335		));
336
337		// Invalid text
338		assert!(matches!(
339			field.clean(Some(&serde_json::json!("not a time"))),
340			Err(FieldError::Validation(_))
341		));
342	}
343
344	#[test]
345	fn test_timefield_widget_type() {
346		let field = TimeField::new("start_time".to_string());
347		assert!(matches!(field.widget(), &Widget::TextInput));
348	}
349
350	#[test]
351	fn test_timefield_custom_format() {
352		let mut field = TimeField::new("start_time".to_string());
353		// Replace with custom 24-hour format using period
354		field.input_formats.clear();
355		field.input_formats.push("%H.%M.%S".to_string());
356
357		// Custom format with periods should work
358		let result = field.clean(Some(&serde_json::json!("14.30.00"))).unwrap();
359		assert_eq!(result, serde_json::json!("14:30:00"));
360	}
361
362	#[test]
363	fn test_timefield_format_precedence() {
364		let field = TimeField::new("start_time".to_string());
365
366		// When multiple formats could match, first matching format is used
367		// "14:30:00" matches "%H:%M:%S" (first format)
368		let result = field.clean(Some(&serde_json::json!("14:30:00"))).unwrap();
369		assert_eq!(result, serde_json::json!("14:30:00"));
370	}
371}