use std::collections::{HashMap, HashSet};
use crate::db::models::fields::{FieldDescriptor, FieldType};
use crate::forms::fields::{
BooleanField, CharField, DateField, DateTimeField, DecimalField, DurationField, EmailField,
FileField, FilePathField, FloatField, FormField, GenericIPAddressField, ImageField,
IntegerField, JSONField, SlugField, TimeField, URLField,
};
use crate::forms::forms::Form;
#[derive(Debug, Clone)]
pub struct ModelFormConfig {
pub model_name: String,
pub fields: Vec<FieldDescriptor>,
pub include_fields: Option<Vec<String>>,
pub exclude_fields: Vec<String>,
}
impl ModelFormConfig {
#[must_use]
pub fn new(model_name: impl Into<String>, fields: Vec<FieldDescriptor>) -> Self {
Self {
model_name: model_name.into(),
fields,
include_fields: None,
exclude_fields: Vec::new(),
}
}
#[must_use]
pub fn include(mut self, fields: Vec<String>) -> Self {
self.include_fields = Some(fields);
self
}
#[must_use]
pub fn exclude(mut self, fields: Vec<String>) -> Self {
self.exclude_fields.extend(fields);
self
}
}
#[must_use]
pub fn field_type_to_form_field(field: &FieldDescriptor) -> Box<dyn FormField> {
let required = !field.blank;
let text_field = |max_length| -> Box<dyn FormField> {
Box::new(CharField {
max_length,
required,
strip: true,
..Default::default()
})
};
match &field.field_type {
FieldType::Char { max_length } => text_field(Some(*max_length)),
FieldType::Text => text_field(None),
FieldType::Integer | FieldType::BigInteger | FieldType::SmallInteger => {
Box::new(IntegerField {
required,
..Default::default()
})
}
FieldType::PositiveInteger
| FieldType::PositiveBigInteger
| FieldType::PositiveSmallInteger => Box::new(IntegerField {
min_value: Some(0),
required,
..Default::default()
}),
FieldType::CommaSeparatedInteger { max_length } => text_field(Some(*max_length)),
FieldType::Float => Box::new(FloatField {
required,
..Default::default()
}),
FieldType::Decimal {
max_digits,
decimal_places,
} => Box::new(DecimalField {
required,
max_digits: Some(*max_digits as usize),
decimal_places: Some(*decimal_places as usize),
}),
FieldType::Boolean => Box::new(BooleanField { required }),
FieldType::NullBoolean => Box::new(BooleanField { required: false }),
FieldType::DateTime { .. } => Box::new(DateTimeField { required }),
FieldType::Date { .. } => Box::new(DateField {
required,
..Default::default()
}),
FieldType::Time => Box::new(TimeField {
required,
..Default::default()
}),
FieldType::Duration => Box::new(DurationField { required }),
FieldType::Uuid => text_field(Some(36)),
FieldType::Json => Box::new(JSONField { required }),
FieldType::File { .. } => Box::new(FileField {
required,
max_length: field.max_length,
..Default::default()
}),
FieldType::Image { .. } => Box::new(ImageField {
required,
max_length: field.max_length,
..Default::default()
}),
FieldType::FilePath { path } => Box::new(FilePathField {
path: path.clone(),
required,
..Default::default()
}),
FieldType::Email => Box::new(EmailField { required }),
FieldType::Url => Box::new(URLField { required }),
FieldType::Slug { .. } => Box::new(SlugField { required }),
FieldType::Ip | FieldType::GenericIp => Box::new(GenericIPAddressField {
required,
..Default::default()
}),
FieldType::Auto
| FieldType::BigAuto
| FieldType::SmallAuto
| FieldType::Binary
| FieldType::ForeignKey { .. }
| FieldType::OneToOne { .. }
| FieldType::ManyToMany { .. } => text_field(None),
}
}
#[must_use]
pub fn modelform_factory(config: &ModelFormConfig) -> Form {
let include_fields = config
.include_fields
.as_ref()
.map(|fields| fields.iter().map(String::as_str).collect::<HashSet<&str>>());
let exclude_fields = config
.exclude_fields
.iter()
.map(String::as_str)
.collect::<HashSet<&str>>();
let mut form_fields: HashMap<String, Box<dyn FormField>> = HashMap::new();
for field_desc in &config.fields {
if matches!(
&field_desc.field_type,
FieldType::Auto | FieldType::BigAuto | FieldType::SmallAuto
) {
continue;
}
let field_name = field_desc.name.as_str();
if exclude_fields.contains(field_name) {
continue;
}
if let Some(include_fields) = &include_fields
&& !include_fields.contains(field_name)
{
continue;
}
form_fields.insert(
field_desc.name.clone(),
field_type_to_form_field(field_desc),
);
}
Form::new(form_fields)
}
#[must_use]
pub fn bind_instance(form: Form, instance: &HashMap<String, String>) -> Form {
form.bind(instance.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn descriptor(name: &str, field_type: FieldType) -> FieldDescriptor {
FieldDescriptor::new(name, field_type)
}
fn optional_descriptor(name: &str, field_type: FieldType) -> FieldDescriptor {
let mut descriptor = descriptor(name, field_type);
descriptor.blank = true;
descriptor
}
#[test]
fn modelform_factory_creates_fields_from_descriptors() {
let config = ModelFormConfig::new(
"Article",
vec![
descriptor("title", FieldType::Char { max_length: 255 }),
descriptor("email", FieldType::Email),
descriptor("rating", FieldType::Float),
optional_descriptor(
"published_at",
FieldType::DateTime {
auto_now: false,
auto_now_add: false,
},
),
],
);
let form = modelform_factory(&config);
assert_eq!(form.fields.len(), 4);
assert_eq!(form.fields["title"].widget_type(), "text");
assert!(form.fields["title"].required());
assert_eq!(form.fields["email"].widget_type(), "email");
assert_eq!(form.fields["rating"].widget_type(), "number");
assert_eq!(form.fields["published_at"].widget_type(), "datetime-local");
assert!(!form.fields["published_at"].required());
}
#[test]
fn modelform_factory_excludes_auto_fields() {
let config = ModelFormConfig::new(
"Article",
vec![
descriptor("id", FieldType::Auto),
descriptor("big_id", FieldType::BigAuto),
descriptor("small_id", FieldType::SmallAuto),
descriptor("title", FieldType::Char { max_length: 255 }),
],
);
let form = modelform_factory(&config);
assert_eq!(form.fields.len(), 1);
assert!(form.fields.contains_key("title"));
assert!(!form.fields.contains_key("id"));
assert!(!form.fields.contains_key("big_id"));
assert!(!form.fields.contains_key("small_id"));
}
#[test]
fn modelform_factory_respects_include() {
let config = ModelFormConfig::new(
"Article",
vec![
descriptor("title", FieldType::Char { max_length: 255 }),
descriptor("body", FieldType::Text),
descriptor("email", FieldType::Email),
],
)
.include(vec!["email".to_string(), "body".to_string()]);
let form = modelform_factory(&config);
assert_eq!(form.fields.len(), 2);
assert!(form.fields.contains_key("body"));
assert!(form.fields.contains_key("email"));
assert!(!form.fields.contains_key("title"));
}
#[test]
fn modelform_factory_respects_exclude() {
let config = ModelFormConfig::new(
"Article",
vec![
descriptor("title", FieldType::Char { max_length: 255 }),
descriptor("body", FieldType::Text),
descriptor("email", FieldType::Email),
],
)
.include(vec!["title".to_string(), "email".to_string()])
.exclude(vec!["email".to_string()]);
let form = modelform_factory(&config);
assert_eq!(form.fields.len(), 1);
assert!(form.fields.contains_key("title"));
assert!(!form.fields.contains_key("body"));
assert!(!form.fields.contains_key("email"));
}
#[test]
fn field_type_to_form_field_maps_correctly() {
let char_field =
field_type_to_form_field(&descriptor("title", FieldType::Char { max_length: 5 }));
assert_eq!(char_field.widget_type(), "text");
assert_eq!(
char_field.clean(" abc ").expect("char should clean"),
"abc"
);
assert!(char_field.clean("abcdef").is_err());
let integer_field = field_type_to_form_field(&descriptor("count", FieldType::Integer));
assert_eq!(integer_field.widget_type(), "number");
assert_eq!(integer_field.clean("42").expect("int should clean"), "42");
let boolean_field = field_type_to_form_field(&descriptor("active", FieldType::Boolean));
assert_eq!(boolean_field.widget_type(), "checkbox");
let email_field = field_type_to_form_field(&descriptor("email", FieldType::Email));
assert_eq!(email_field.widget_type(), "email");
assert!(email_field.clean("user@example.com").is_ok());
let url_field = field_type_to_form_field(&descriptor("site", FieldType::Url));
assert_eq!(url_field.widget_type(), "url");
assert!(url_field.clean("https://example.com").is_ok());
let slug_field =
field_type_to_form_field(&descriptor("slug", FieldType::Slug { max_length: 50 }));
assert_eq!(slug_field.widget_type(), "text");
assert!(slug_field.clean("valid-slug").is_ok());
assert!(slug_field.clean("not a slug").is_err());
let datetime_field = field_type_to_form_field(&descriptor(
"published_at",
FieldType::DateTime {
auto_now: false,
auto_now_add: false,
},
));
assert_eq!(datetime_field.widget_type(), "datetime-local");
let json_field = field_type_to_form_field(&descriptor("payload", FieldType::Json));
assert_eq!(json_field.widget_type(), "textarea");
let fallback_field = field_type_to_form_field(&descriptor("blob", FieldType::Binary));
assert_eq!(fallback_field.widget_type(), "text");
assert_eq!(
fallback_field
.clean("opaque")
.expect("fallback should clean"),
"opaque"
);
}
#[test]
fn bind_instance_populates_data() {
let config = ModelFormConfig::new(
"Article",
vec![
descriptor("title", FieldType::Char { max_length: 255 }),
descriptor("email", FieldType::Email),
],
);
let instance = HashMap::from([
("title".to_string(), " Hello world ".to_string()),
("email".to_string(), "editor@example.com".to_string()),
]);
let mut form = bind_instance(modelform_factory(&config), &instance);
assert!(form.is_bound);
assert_eq!(form.data.get("title"), Some(&" Hello world ".to_string()));
assert_eq!(
form.data.get("email"),
Some(&"editor@example.com".to_string())
);
assert!(form.is_valid());
assert_eq!(
form.cleaned_data().get("title"),
Some(&"Hello world".to_string())
);
assert_eq!(
form.cleaned_data().get("email"),
Some(&"editor@example.com".to_string())
);
}
}