Skip to main content

reinhardt_forms/
model_form.rs

1//! ModelForm implementation for ORM integration
2//!
3//! ModelForms automatically generate forms from ORM models, handling field
4//! inference, validation, and saving.
5
6use crate::{CharField, EmailField, FloatField, Form, FormError, FormField, IntegerField, Widget};
7use serde_json::Value;
8use std::collections::HashMap;
9use std::marker::PhantomData;
10
11/// Field type metadata for ModelForm field inference
12#[derive(Debug, Clone)]
13pub enum FieldType {
14	/// Character field with optional maximum length constraint.
15	Char {
16		/// Maximum character length, if any.
17		max_length: Option<usize>,
18	},
19	/// Multi-line text field rendered as a textarea.
20	Text,
21	/// Integer number field.
22	Integer,
23	/// Floating-point number field.
24	Float,
25	/// Boolean field rendered as a checkbox.
26	Boolean,
27	/// Date and time field.
28	DateTime,
29	/// Date-only field.
30	Date,
31	/// Time-only field.
32	Time,
33	/// Email address field with format validation.
34	Email,
35	/// URL field with format validation.
36	Url,
37	/// JSON data field.
38	Json,
39}
40
41/// Trait for models that can be used with ModelForm
42///
43/// This trait is specifically for form models. For ORM models, use `reinhardt_db::orm::Model`.
44pub trait FormModel: Send + Sync {
45	/// Get the model's field names
46	fn field_names() -> Vec<String>;
47
48	/// Get field type metadata for form field inference
49	///
50	/// # Examples
51	///
52	/// ```ignore
53	/// # use reinhardt_forms::model_form::FieldType;
54	/// fn field_type(name: &str) -> Option<FieldType> {
55	///     match name {
56	///         "name" => Some(FieldType::Char { max_length: Some(100) }),
57	///         "email" => Some(FieldType::Email),
58	///         "age" => Some(FieldType::Integer),
59	///         _ => None,
60	///     }
61	/// }
62	/// ```
63	fn field_type(_name: &str) -> Option<FieldType> {
64		None
65	}
66
67	/// Get a field value by name
68	fn get_field(&self, name: &str) -> Option<Value>;
69
70	/// Set a field value by name
71	fn set_field(&mut self, name: &str, value: Value) -> Result<(), String>;
72
73	/// Save the model to the database
74	fn save(&mut self) -> Result<(), String>;
75
76	/// Run model-level (cross-field) validation hook.
77	///
78	/// This is an opt-in extension point invoked by [`ModelForm::is_valid`]
79	/// *after* per-field validation has already succeeded. The default
80	/// implementation intentionally performs no extra validation and returns
81	/// `Ok(())`; per-field validation is fully handled by the form's
82	/// [`crate::Form::is_valid`] pipeline.
83	///
84	/// Implementers SHOULD override this method when they need cross-field
85	/// invariants (e.g. "end_date must be after start_date") that cannot be
86	/// expressed at the individual field level.
87	///
88	/// Returns `Ok(())` when the model passes all cross-field invariants, or
89	/// `Err(messages)` with one or more human-readable error messages.
90	fn validate(&self) -> Result<(), Vec<String>> {
91		Ok(())
92	}
93
94	/// Convert model instance to a choice label for display in forms
95	///
96	/// Default implementation returns the string representation of the primary key.
97	/// Override this method to provide custom display labels.
98	///
99	/// # Examples
100	///
101	/// ```ignore
102	/// # struct Example { id: i32, name: String }
103	/// # impl Example {
104	/// fn to_choice_label(&self) -> String {
105	///     format!("{} - {}", self.id, self.name)
106	/// }
107	/// # }
108	/// ```
109	fn to_choice_label(&self) -> String {
110		// Default: use the "id" field or empty string
111		self.get_field("id")
112			.and_then(|v| v.as_i64().map(|i| i.to_string()))
113			.or_else(|| {
114				self.get_field("id")
115					.and_then(|v| v.as_str().map(|s| s.to_string()))
116			})
117			.unwrap_or_default()
118	}
119
120	/// Get the primary key value as a string for form field validation
121	///
122	/// Default implementation uses the "id" field.
123	///
124	/// # Examples
125	///
126	/// ```ignore
127	/// # struct Example { id: i32 }
128	/// # impl Example {
129	/// fn to_choice_value(&self) -> String {
130	///     self.id.to_string()
131	/// }
132	/// # }
133	/// ```
134	fn to_choice_value(&self) -> String {
135		self.get_field("id")
136			.and_then(|v| v.as_i64().map(|i| i.to_string()))
137			.or_else(|| {
138				self.get_field("id")
139					.and_then(|v| v.as_str().map(|s| s.to_string()))
140			})
141			.unwrap_or_default()
142	}
143}
144
145/// ModelForm configuration
146#[derive(Debug, Clone, Default)]
147pub struct ModelFormConfig {
148	/// Fields to include in the form (None = all fields)
149	pub fields: Option<Vec<String>>,
150	/// Fields to exclude from the form
151	pub exclude: Vec<String>,
152	/// Custom widgets for specific fields
153	pub widgets: HashMap<String, crate::Widget>,
154	/// Custom labels for specific fields
155	pub labels: HashMap<String, String>,
156	/// Custom help text for specific fields
157	pub help_texts: HashMap<String, String>,
158}
159
160impl ModelFormConfig {
161	/// Creates a new default configuration.
162	pub fn new() -> Self {
163		Self::default()
164	}
165	/// Sets the list of fields to include in the generated form.
166	pub fn fields(mut self, fields: Vec<String>) -> Self {
167		self.fields = Some(fields);
168		self
169	}
170	/// Sets the list of fields to exclude from the generated form.
171	pub fn exclude(mut self, exclude: Vec<String>) -> Self {
172		self.exclude = exclude;
173		self
174	}
175	/// Overrides the widget for a specific field.
176	pub fn widget(mut self, field: String, widget: crate::Widget) -> Self {
177		self.widgets.insert(field, widget);
178		self
179	}
180	/// Sets a custom label for a specific field.
181	pub fn label(mut self, field: String, label: String) -> Self {
182		self.labels.insert(field, label);
183		self
184	}
185	/// Sets custom help text for a specific field.
186	pub fn help_text(mut self, field: String, text: String) -> Self {
187		self.help_texts.insert(field, text);
188		self
189	}
190}
191
192/// A form that is automatically generated from a Model
193pub struct ModelForm<T: FormModel> {
194	form: Form,
195	instance: Option<T>,
196	// Allow dead_code: config stored for future form rendering customization
197	#[allow(dead_code)]
198	config: ModelFormConfig,
199	_phantom: PhantomData<T>,
200}
201
202impl<T: FormModel> ModelForm<T> {
203	/// Create a form field from field type metadata
204	fn create_form_field(
205		name: &str,
206		field_type: FieldType,
207		config: &ModelFormConfig,
208	) -> Box<dyn FormField> {
209		let label = config.labels.get(name).cloned();
210		let help_text = config.help_texts.get(name).cloned();
211		let widget = config.widgets.get(name).cloned();
212
213		match field_type {
214			FieldType::Char { max_length } => {
215				let mut field = CharField::new(name.to_string());
216				if let Some(label) = label {
217					field.label = Some(label);
218				}
219				if let Some(help) = help_text {
220					field.help_text = Some(help);
221				}
222				if let Some(w) = widget {
223					field.widget = w;
224				}
225				field.max_length = max_length;
226				Box::new(field)
227			}
228			FieldType::Text => {
229				let mut field = CharField::new(name.to_string());
230				if let Some(label) = label {
231					field.label = Some(label);
232				}
233				if let Some(help) = help_text {
234					field.help_text = Some(help);
235				}
236				if let Some(w) = widget {
237					field.widget = w;
238				} else {
239					field.widget = Widget::TextArea;
240				}
241				Box::new(field)
242			}
243			FieldType::Email => {
244				let mut field = EmailField::new(name.to_string());
245				if let Some(label) = label {
246					field.label = Some(label);
247				}
248				if let Some(help) = help_text {
249					field.help_text = Some(help);
250				}
251				if let Some(w) = widget {
252					field.widget = w;
253				}
254				Box::new(field)
255			}
256			FieldType::Integer => {
257				let mut field = IntegerField::new(name.to_string());
258				if let Some(label) = label {
259					field.label = Some(label);
260				}
261				if let Some(help) = help_text {
262					field.help_text = Some(help);
263				}
264				if let Some(w) = widget {
265					field.widget = w;
266				}
267				Box::new(field)
268			}
269			FieldType::Float => {
270				let mut field = FloatField::new(name.to_string());
271				if let Some(label) = label {
272					field.label = Some(label);
273				}
274				if let Some(help) = help_text {
275					field.help_text = Some(help);
276				}
277				if let Some(w) = widget {
278					field.widget = w;
279				}
280				Box::new(field)
281			}
282			// For unsupported types, default to CharField
283			_ => {
284				let mut field = CharField::new(name.to_string());
285				if let Some(label) = label {
286					field.label = Some(label);
287				}
288				if let Some(help) = help_text {
289					field.help_text = Some(help);
290				}
291				if let Some(w) = widget {
292					field.widget = w;
293				}
294				Box::new(field)
295			}
296		}
297	}
298
299	/// Create a new ModelForm from a model instance
300	///
301	/// # Examples
302	///
303	/// ```ignore
304	/// use reinhardt_forms::{ModelForm, ModelFormConfig};
305	///
306	/// // Assuming we have a model that implements the Model trait
307	/// let config = ModelFormConfig::new();
308	/// let form = ModelForm::new(Some(instance), config);
309	/// ```
310	pub fn new(instance: Option<T>, config: ModelFormConfig) -> Self {
311		let mut form = Form::new();
312
313		// Get field names from model
314		let all_fields = T::field_names();
315
316		// Filter fields based on config
317		let fields_to_include: Vec<String> = if let Some(ref include) = config.fields {
318			include
319				.iter()
320				.filter(|f| !config.exclude.contains(f))
321				.cloned()
322				.collect()
323		} else {
324			all_fields
325				.iter()
326				.filter(|f| !config.exclude.contains(f))
327				.cloned()
328				.collect()
329		};
330
331		// Infer field types from model metadata and add to form
332		for field_name in &fields_to_include {
333			if let Some(field_type) = T::field_type(field_name) {
334				let form_field = Self::create_form_field(field_name, field_type, &config);
335				form.add_field(form_field);
336			}
337		}
338
339		// If instance exists, populate initial data from the instance
340		if let Some(ref inst) = instance {
341			let mut initial = HashMap::new();
342			for field_name in &fields_to_include {
343				if let Some(value) = inst.get_field(field_name) {
344					initial.insert(field_name.clone(), value);
345				}
346			}
347			form.bind(initial);
348		}
349
350		Self {
351			form,
352			instance,
353			config,
354			_phantom: PhantomData,
355		}
356	}
357	/// Create a new ModelForm without an instance (for creation)
358	///
359	/// # Examples
360	///
361	/// ```ignore
362	/// use reinhardt_forms::{ModelForm, ModelFormConfig};
363	///
364	/// let config = ModelFormConfig::new();
365	/// let form = ModelForm::<MyModel>::empty(config);
366	/// ```
367	pub fn empty(config: ModelFormConfig) -> Self {
368		Self::new(None, config)
369	}
370	/// Bind data to the form
371	///
372	/// # Examples
373	///
374	/// ```ignore
375	/// use reinhardt_forms::{ModelForm, ModelFormConfig};
376	/// use std::collections::HashMap;
377	/// use serde_json::json;
378	///
379	/// let config = ModelFormConfig::new();
380	/// let mut form = ModelForm::<MyModel>::empty(config);
381	/// let mut data = HashMap::new();
382	/// data.insert("field".to_string(), json!("value"));
383	/// form.bind(data);
384	/// ```
385	pub fn bind(&mut self, data: HashMap<String, Value>) -> &mut Self {
386		// Bind data to the underlying form
387		self.form.bind(data);
388		self
389	}
390	/// Check if the form is valid
391	///
392	/// # Examples
393	///
394	/// ```ignore
395	/// use reinhardt_forms::{ModelForm, ModelFormConfig};
396	///
397	/// let config = ModelFormConfig::new();
398	/// let mut form = ModelForm::<MyModel>::empty(config);
399	/// let is_valid = form.is_valid();
400	/// ```
401	pub fn is_valid(&mut self) -> bool {
402		// Step 1: per-field validation via the underlying `Form` pipeline.
403		// This runs `clean`, custom field-clean callbacks, CSRF checks, and
404		// populates `cleaned_data()`. Without this call, ModelForm would
405		// accept any bound data unconditionally (skeleton always-Ok).
406		if !self.form.is_valid() {
407			return false;
408		}
409
410		// Step 2: optional cross-field validation hook on the model.
411		// Capture the messages returned by `FormModel::validate` into the
412		// underlying `Form`'s non-field error bucket (keyed by `ALL_FIELDS_KEY`)
413		// so callers can retrieve them through `form().errors()` instead of
414		// only learning that validation failed.
415		if let Some(ref instance) = self.instance
416			&& let Err(messages) = instance.validate()
417		{
418			for message in messages {
419				self.form.add_error(crate::form::ALL_FIELDS_KEY, message);
420			}
421			return false;
422		}
423
424		true
425	}
426	/// Save the form data to the model instance
427	///
428	/// Returns `FormError::NoInstance` if no model instance is available.
429	///
430	/// # Examples
431	///
432	/// ```ignore
433	/// use reinhardt_forms::{ModelForm, ModelFormConfig};
434	///
435	/// let config = ModelFormConfig::new();
436	/// let mut form = ModelForm::<MyModel>::empty(config);
437	/// // Returns Err(FormError::NoInstance) without an instance
438	/// assert!(form.save().is_err());
439	/// ```
440	pub fn save(&mut self) -> Result<T, FormError> {
441		// Surface the missing-instance error first so callers can distinguish
442		// "no model to save into" from "data failed validation".
443		if self.instance.is_none() {
444			return Err(FormError::NoInstance);
445		}
446
447		if !self.is_valid() {
448			return Err(FormError::Validation("Form is not valid".to_string()));
449		}
450
451		// Keep this path non-panicking even though presence was checked above:
452		// if a future refactor breaks the invariant, surface a typed error
453		// rather than aborting the process.
454		let mut instance = self.instance.take().ok_or(FormError::NoInstance)?;
455
456		// Set field values from form's cleaned_data
457		let cleaned_data = self.form.cleaned_data();
458		for (field_name, value) in cleaned_data.iter() {
459			if let Err(e) = instance.set_field(field_name, value.clone()) {
460				return Err(FormError::Validation(format!(
461					"Failed to set field {}: {}",
462					field_name, e
463				)));
464			}
465		}
466
467		// Save the instance
468		if let Err(e) = instance.save() {
469			return Err(FormError::Validation(format!("Failed to save: {}", e)));
470		}
471
472		Ok(instance)
473	}
474	/// Set a field value directly on the model instance.
475	///
476	/// This is used by `InlineFormSet` to set foreign key values on child
477	/// instances before saving.
478	///
479	/// If no instance exists, this method is a no-op.
480	pub fn set_field_value(&mut self, field_name: &str, value: Value) {
481		if let Some(ref mut instance) = self.instance {
482			// Silently ignore errors from set_field, as the field may not exist
483			// on all model types (defensive approach for inline formsets)
484			let _ = instance.set_field(field_name, value);
485		}
486	}
487
488	/// Returns a reference to the underlying form.
489	pub fn form(&self) -> &Form {
490		&self.form
491	}
492	/// Returns a mutable reference to the underlying form.
493	pub fn form_mut(&mut self) -> &mut Form {
494		&mut self.form
495	}
496	/// Returns a reference to the model instance, if one exists.
497	pub fn instance(&self) -> Option<&T> {
498		self.instance.as_ref()
499	}
500}
501
502/// Builder for creating ModelForm instances
503pub struct ModelFormBuilder<T: FormModel> {
504	config: ModelFormConfig,
505	_phantom: PhantomData<T>,
506}
507
508impl<T: FormModel> ModelFormBuilder<T> {
509	/// Creates a new `ModelFormBuilder` with default configuration.
510	pub fn new() -> Self {
511		Self {
512			config: ModelFormConfig::default(),
513			_phantom: PhantomData,
514		}
515	}
516	/// Sets the list of fields to include in the form.
517	pub fn fields(mut self, fields: Vec<String>) -> Self {
518		self.config.fields = Some(fields);
519		self
520	}
521	/// Sets the list of fields to exclude from the form.
522	pub fn exclude(mut self, exclude: Vec<String>) -> Self {
523		self.config.exclude = exclude;
524		self
525	}
526	/// Overrides the widget for a specific field.
527	pub fn widget(mut self, field: String, widget: crate::Widget) -> Self {
528		self.config.widgets.insert(field, widget);
529		self
530	}
531	/// Overrides the label for a specific field.
532	pub fn label(mut self, field: String, label: String) -> Self {
533		self.config.labels.insert(field, label);
534		self
535	}
536	/// Overrides the help text for a specific field.
537	pub fn help_text(mut self, field: String, text: String) -> Self {
538		self.config.help_texts.insert(field, text);
539		self
540	}
541	/// Build the ModelForm with the configured settings
542	///
543	/// # Examples
544	///
545	/// ```ignore
546	/// use reinhardt_forms::{ModelFormBuilder, ModelFormConfig};
547	///
548	/// let config = ModelFormConfig::new();
549	/// let builder = ModelFormBuilder::<MyModel>::new();
550	/// let form = builder.build(None);
551	/// ```
552	pub fn build(self, instance: Option<T>) -> ModelForm<T> {
553		ModelForm::new(instance, self.config)
554	}
555}
556
557impl<T: FormModel> Default for ModelFormBuilder<T> {
558	fn default() -> Self {
559		Self::new()
560	}
561}
562
563#[cfg(test)]
564mod tests {
565	use super::*;
566	use rstest::rstest;
567
568	// Mock model for testing
569	#[derive(Debug)]
570	struct TestModel {
571		id: i32,
572		name: String,
573		email: String,
574	}
575
576	impl FormModel for TestModel {
577		fn field_names() -> Vec<String> {
578			vec!["id".to_string(), "name".to_string(), "email".to_string()]
579		}
580
581		fn field_type(name: &str) -> Option<FieldType> {
582			match name {
583				"id" => Some(FieldType::Integer),
584				"name" => Some(FieldType::Char {
585					max_length: Some(100),
586				}),
587				"email" => Some(FieldType::Email),
588				_ => None,
589			}
590		}
591
592		fn get_field(&self, name: &str) -> Option<Value> {
593			match name {
594				"id" => Some(Value::Number(self.id.into())),
595				"name" => Some(Value::String(self.name.clone())),
596				"email" => Some(Value::String(self.email.clone())),
597				_ => None,
598			}
599		}
600
601		fn set_field(&mut self, name: &str, value: Value) -> Result<(), String> {
602			match name {
603				"id" => {
604					if let Value::Number(n) = value {
605						self.id = n.as_i64().unwrap() as i32;
606						Ok(())
607					} else {
608						Err("Invalid type for id".to_string())
609					}
610				}
611				"name" => {
612					if let Value::String(s) = value {
613						self.name = s;
614						Ok(())
615					} else {
616						Err("Invalid type for name".to_string())
617					}
618				}
619				"email" => {
620					if let Value::String(s) = value {
621						self.email = s;
622						Ok(())
623					} else {
624						Err("Invalid type for email".to_string())
625					}
626				}
627				_ => Err(format!("Unknown field: {}", name)),
628			}
629		}
630
631		fn save(&mut self) -> Result<(), String> {
632			// Mock save
633			Ok(())
634		}
635	}
636
637	#[rstest]
638	fn test_model_form_config() {
639		// Arrange
640		let config = ModelFormConfig::new()
641			.fields(vec!["name".to_string(), "email".to_string()])
642			.exclude(vec!["id".to_string()]);
643
644		// Assert
645		assert_eq!(
646			config.fields,
647			Some(vec!["name".to_string(), "email".to_string()])
648		);
649		assert_eq!(config.exclude, vec!["id".to_string()]);
650	}
651
652	#[rstest]
653	fn test_model_form_builder() {
654		// Arrange
655		let instance = TestModel {
656			id: 1,
657			name: "John".to_string(),
658			email: "john@example.com".to_string(),
659		};
660
661		// Act
662		let form = ModelFormBuilder::<TestModel>::new()
663			.fields(vec!["name".to_string(), "email".to_string()])
664			.build(Some(instance));
665
666		// Assert
667		assert!(form.instance().is_some());
668	}
669
670	#[rstest]
671	fn test_model_field_names() {
672		// Act
673		let fields = TestModel::field_names();
674
675		// Assert
676		assert_eq!(
677			fields,
678			vec!["id".to_string(), "name".to_string(), "email".to_string()]
679		);
680	}
681
682	// Model that always fails the cross-field validation hook, used to prove
683	// that `ModelForm::is_valid` actually consults `FormModel::validate`.
684	#[derive(Debug)]
685	struct AlwaysInvalidModel;
686
687	impl FormModel for AlwaysInvalidModel {
688		fn field_names() -> Vec<String> {
689			vec![]
690		}
691
692		fn get_field(&self, _name: &str) -> Option<Value> {
693			None
694		}
695
696		fn set_field(&mut self, _name: &str, _value: Value) -> Result<(), String> {
697			Ok(())
698		}
699
700		fn save(&mut self) -> Result<(), String> {
701			Ok(())
702		}
703
704		fn validate(&self) -> Result<(), Vec<String>> {
705			Err(vec!["cross-field invariant violated".to_string()])
706		}
707	}
708
709	#[rstest]
710	fn test_is_valid_rejects_unbound_form() {
711		// Arrange: an empty form has no bound data, so the underlying
712		// `Form::is_valid` must return false. Previously `ModelForm::is_valid`
713		// was a skeleton that returned `true` unconditionally for unbound
714		// forms — this test guards against regression.
715		let mut form = ModelForm::<TestModel>::empty(ModelFormConfig::new());
716
717		// Act
718		let actual = form.is_valid();
719
720		// Assert
721		assert!(!actual, "Unbound ModelForm must not be valid");
722	}
723
724	#[rstest]
725	fn test_is_valid_rejects_invalid_email_field() {
726		// Arrange: bind syntactically invalid data to a field-typed form.
727		let instance = TestModel {
728			id: 1,
729			name: "John".to_string(),
730			email: "john@example.com".to_string(),
731		};
732		let mut form = ModelForm::new(Some(instance), ModelFormConfig::new());
733		let mut data = HashMap::new();
734		data.insert("id".to_string(), Value::Number(1.into()));
735		data.insert("name".to_string(), Value::String("John".to_string()));
736		data.insert(
737			"email".to_string(),
738			Value::String("not-an-email".to_string()),
739		);
740		form.bind(data);
741
742		// Act
743		let actual = form.is_valid();
744
745		// Assert
746		assert!(
747			!actual,
748			"ModelForm::is_valid must reject malformed email values"
749		);
750	}
751
752	#[rstest]
753	fn test_is_valid_accepts_valid_bound_data() {
754		// Arrange
755		let instance = TestModel {
756			id: 1,
757			name: "John".to_string(),
758			email: "john@example.com".to_string(),
759		};
760		let mut form = ModelForm::new(Some(instance), ModelFormConfig::new());
761		let mut data = HashMap::new();
762		data.insert("id".to_string(), Value::Number(2.into()));
763		data.insert("name".to_string(), Value::String("Jane".to_string()));
764		data.insert(
765			"email".to_string(),
766			Value::String("jane@example.com".to_string()),
767		);
768		form.bind(data);
769
770		// Act
771		let actual = form.is_valid();
772
773		// Assert
774		assert!(actual, "Valid bound data must pass validation");
775	}
776
777	#[rstest]
778	fn test_is_valid_consults_model_validate_hook() {
779		// Arrange: a model whose cross-field validator always fails.
780		// The underlying Form has no fields, so per-field validation
781		// trivially passes once we bind empty data — meaning the only
782		// way the form can be invalid is via the model-level hook.
783		let mut form = ModelForm::new(Some(AlwaysInvalidModel), ModelFormConfig::new());
784		form.bind(HashMap::new());
785
786		// Act
787		let actual = form.is_valid();
788
789		// Assert
790		assert!(
791			!actual,
792			"ModelForm::is_valid must propagate FormModel::validate errors"
793		);
794		let non_field_errors = form
795			.form()
796			.errors()
797			.get(crate::form::ALL_FIELDS_KEY)
798			.expect("model validate errors must be captured under ALL_FIELDS_KEY");
799		assert_eq!(
800			non_field_errors,
801			&vec!["cross-field invariant violated".to_string()],
802			"FormModel::validate messages must be attached to the form's non-field error bucket"
803		);
804	}
805
806	#[rstest]
807	fn test_save_without_instance_returns_no_instance_error() {
808		// Arrange
809		let config = ModelFormConfig::new();
810		let mut form = ModelForm::<TestModel>::empty(config);
811
812		// Act
813		let result = form.save();
814
815		// Assert
816		assert!(result.is_err());
817		let err = result.unwrap_err();
818		assert!(
819			matches!(err, FormError::NoInstance),
820			"Expected FormError::NoInstance, got: {err}"
821		);
822	}
823}