diff --git a/cot-core/src/html.rs b/cot-core/src/html.rs index 10d5c2dda..a73709408 100644 --- a/cot-core/src/html.rs +++ b/cot-core/src/html.rs @@ -191,7 +191,7 @@ impl HtmlTag { } } - /// Creates a new `HtmlTag` instance for an input element. + /// Creates a new `HtmlTag` instance for an [input] element. /// /// # Examples /// @@ -201,6 +201,8 @@ impl HtmlTag { /// let input = HtmlTag::input("text"); /// assert_eq!(input.render().as_str(), ""); /// ``` + /// + /// [input]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input #[must_use] pub fn input(input_type: &str) -> Self { let mut input = Self::new("input"); @@ -208,6 +210,38 @@ impl HtmlTag { input } + /// Creates a new `HtmlTag` instance for a [datalist] element. + /// + /// # Examples + /// ``` + /// use cot::html::HtmlTag; + /// let data_list = HtmlTag::data_list(vec!["Option 1", "Option 2"], "my-datalist"); + /// let rendered = data_list.render(); + /// ``` + /// + /// [datalist]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/datalist + #[must_use] + pub fn data_list(list: I, id: &str) -> Self + where + I: IntoIterator, + S: AsRef, + { + let mut data_list = Self::new("datalist"); + data_list.attr("id", id); + + let mut options: Vec = Vec::new(); + + for item in list { + let l = item.as_ref(); + let mut option = HtmlTag::new("option"); + option.attr("value", l); + options.push(HtmlNode::Tag(option)); + } + + data_list.children = options; + data_list + } + /// Adds an attribute to the HTML tag. /// /// # Safety diff --git a/cot/src/form/fields.rs b/cot/src/form/fields.rs index 1fcb4f14f..fea8882f1 100644 --- a/cot/src/form/fields.rs +++ b/cot/src/form/fields.rs @@ -10,16 +10,18 @@ use std::num::{ }; use askama::filters::HtmlSafe; -pub use attrs::Step; +pub use attrs::{AutoCapitalize, AutoComplete, Capture, Dir, List, Step}; pub use chrono::{ - DateField, DateFieldOptions, DateTimeField, DateTimeFieldOptions, DateTimeWithTimezoneField, - DateTimeWithTimezoneFieldOptions, TimeField, TimeFieldOptions, + DateField, DateFieldOptions, DateFieldOptionsBuilder, DateTimeField, DateTimeFieldOptions, + DateTimeFieldOptionsBuilder, DateTimeWithTimezoneField, DateTimeWithTimezoneFieldOptions, + DateTimeWithTimezoneFieldOptionsBuilder, TimeField, TimeFieldOptions, TimeFieldOptionsBuilder, }; -pub use files::{FileField, FileFieldOptions, InMemoryUploadedFile}; +use derive_builder::Builder; +pub use files::{FileField, FileFieldOptions, FileFieldOptionsBuilder, InMemoryUploadedFile}; pub(crate) use select::check_required_multiple; pub use select::{ - SelectAsFormField, SelectChoice, SelectField, SelectFieldOptions, SelectMultipleField, - SelectMultipleFieldOptions, + SelectAsFormField, SelectChoice, SelectField, SelectFieldOptions, SelectFieldOptionsBuilder, + SelectMultipleField, SelectMultipleFieldOptions, SelectMultipleFieldOptionsBuilder, }; use crate::auth::PasswordHash; @@ -68,16 +70,127 @@ macro_rules! impl_form_field { } }; } + +macro_rules! impl_field_options_builder { + ( + $ty:ident < $($gen:ident),+ >, + $builder:ident < $($gen2:ident),+ > + { $($field:ident),+ $(,)? } + ) => { + impl<$($gen: Clone),+> $ty<$($gen),+> { + #[doc = concat!( + "Creates a new [`", stringify!($builder), "`] to build a ", + "[`", stringify!($ty), "`].\n\n", + "# Examples\n\n", + "```\n", + "# use cot::form::fields::", stringify!($ty), ";\n", + "let options = ", stringify!($ty), "::::builder().build();\n", + "```", + )] + #[must_use] + pub fn builder() -> $builder<$($gen),+> { + $builder::default() + } + } + + impl<$($gen2: Clone),+> $builder<$($gen2),+> { + #[doc = concat!( + "Builds the [`", stringify!($ty), "`], falling back to ", + "defaults for any field that wasn't explicitly set.\n\n", + "# Examples\n\n", + "```\n", + "# use cot::form::fields::", stringify!($ty), ";\n", + "let options = ", stringify!($ty), "::::builder().build();\n", + "```", + )] + #[must_use] + pub fn build(&self) -> $ty<$($gen2),+> { + $ty { + $( $field: self.$field.clone().unwrap_or_default(), )+ + } + } + } + }; + + ($ty:ident, $builder:ident { $($field:ident),+ $(,)? }) => { + impl $ty { + #[doc = concat!( + "Creates a new [`", stringify!($builder), "`] to build a ", + "[`", stringify!($ty), "`].\n\n", + "# Examples\n\n", + "```\n", + "# use cot::form::fields::", stringify!($ty), ";\n", + "let options = ", stringify!($ty), "::builder().build();\n", + "```", + )] + #[must_use] + pub fn builder() -> $builder { + $builder::default() + } + } + + impl $builder { + #[doc = concat!( + "Builds the [`", stringify!($ty), "`], falling back to ", + "defaults for any field that wasn't explicitly set.\n\n", + "# Examples\n\n", + "```\n", + "# use cot::form::fields::", stringify!($ty), ";\n", + "let options = ", stringify!($ty), "::builder().build();\n", + "```", + )] + #[must_use] + pub fn build(&self) -> $ty { + $ty { + $( $field: self.$field.clone().unwrap_or_default(), )+ + } + } + } + }; +} + +pub(crate) use impl_field_options_builder; pub(crate) use impl_form_field; impl_form_field!(StringField, StringFieldOptions, "a string"); /// Custom options for a [`StringField`]. -#[derive(Debug, Default, Copy, Clone)] +#[derive(Debug, Default, Clone, Builder)] +#[builder(build_fn(skip, error = std::convert::Infallible))] +#[builder(setter(strip_option))] +#[non_exhaustive] pub struct StringFieldOptions { /// The maximum length of the field. Used to set the `maxlength` attribute /// in the HTML input element. pub max_length: Option, + /// The minimum length of the field. Used to set the `minlength` attribute + /// in the HTML input element. + pub min_length: Option, + /// The size of the field. Used to set the `size` attribute in the HTML + /// input element. + pub size: Option, + /// Corresponds to the [`AutoCapitalize`] attribute in the HTML input + /// element. + pub autocapitalize: Option, + /// Corresponds to the [`AutoComplete`] attribute in the HTML input element. + pub autocomplete: Option, + /// The direction of the text input, which can be set to `ltr` + /// (left-to-right) or `rtl` (right-to-left). This corresponds to the + /// [`Dir`] attribute in the HTML input element. + pub dir: Option, + /// The [`dirname`] attribute in the HTML input element, which is used to + /// specify the direction of the text input. + /// + /// [`dirname`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#dirname + pub dirname: Option, + /// A [`List`] of options for the `datalist` element, which can be used to + /// provide predefined options for the input. + pub list: Option, + /// The placeholder text for the input field, which is displayed when the + /// field is empty. + pub placeholder: Option, + /// If `true`, the field is read-only and cannot be modified by the user. + pub readonly: Option, } impl Display for StringField { @@ -91,6 +204,48 @@ impl Display for StringField { if let Some(max_length) = self.custom_options.max_length { tag.attr("maxlength", max_length.to_string()); } + if let Some(min_length) = self.custom_options.min_length { + tag.attr("minlength", min_length.to_string()); + } + + if let Some(size) = self.custom_options.size { + tag.attr("size", size.to_string()); + } + + if let Some(autocapitalize) = &self.custom_options.autocapitalize { + tag.attr("autocapitalize", autocapitalize.to_string()); + } + + if let Some(autocomplete) = &self.custom_options.autocomplete { + tag.attr("autocomplete", autocomplete.to_string()); + } + + if let Some(dir) = &self.custom_options.dir { + tag.attr("dir", dir.as_str()); + } + + if let Some(dirname) = &self.custom_options.dirname { + tag.attr("dirname", dirname); + } + + if let Some(list) = &self.custom_options.list { + let list_id = format!("__{}_datalist", self.id()); + tag.attr("list", &list_id); + + let data_list = HtmlTag::data_list(list.clone(), &list_id); + tag.push_tag(data_list); + } + + if let Some(placeholder) = &self.custom_options.placeholder { + tag.attr("placeholder", placeholder); + } + + if let Some(readonly) = self.custom_options.readonly + && readonly + { + tag.bool_attr("readonly"); + } + if let Some(value) = &self.value { tag.attr("value", value); } @@ -143,11 +298,29 @@ impl AsFormField for LimitedString { impl_form_field!(PasswordField, PasswordFieldOptions, "a password"); /// Custom options for a [`PasswordField`]. -#[derive(Debug, Default, Copy, Clone)] +#[derive(Debug, Default, Clone, Builder)] +#[builder(build_fn(skip, error = std::convert::Infallible))] +#[builder(setter(strip_option))] +#[non_exhaustive] pub struct PasswordFieldOptions { /// The maximum length of the field. Used to set the `maxlength` attribute /// in the HTML input element. pub max_length: Option, + /// The minimum length of the field. Used to set the `minlength` attribute + /// in the HTML input element. + pub min_length: Option, + /// The size of the field. Used to set the [`size`] attribute in the HTML + /// input element. + /// + /// [`size`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#size + pub size: Option, + /// Corresponds to the [`AutoComplete`] attribute in the HTML input element. + pub autocomplete: Option, + /// The placeholder text for the input field, which is displayed when the + /// field is empty. + pub placeholder: Option, + /// If `true`, the field is read-only and cannot be modified by the user. + pub readonly: Option, } impl Display for PasswordField { @@ -161,6 +334,28 @@ impl Display for PasswordField { if let Some(max_length) = self.custom_options.max_length { tag.attr("maxlength", max_length.to_string()); } + + if let Some(min_length) = self.custom_options.min_length { + tag.attr("minlength", min_length.to_string()); + } + + if let Some(size) = self.custom_options.size { + tag.attr("size", size.to_string()); + } + + if let Some(autocomplete) = &self.custom_options.autocomplete { + tag.attr("autocomplete", autocomplete.to_string()); + } + + if let Some(placeholder) = &self.custom_options.placeholder { + tag.attr("placeholder", placeholder); + } + if let Some(readonly) = self.custom_options.readonly + && readonly + { + tag.bool_attr("readonly"); + } + // we don't set the value attribute for password fields // to avoid leaking the password in the HTML @@ -218,7 +413,10 @@ impl AsFormField for PasswordHash { impl_form_field!(EmailField, EmailFieldOptions, "an email"); /// Custom options for [`EmailField`] -#[derive(Debug, Default, Copy, Clone)] +#[derive(Debug, Default, Clone, Builder)] +#[builder(build_fn(skip, error = std::convert::Infallible))] +#[builder(setter(strip_option))] +#[non_exhaustive] pub struct EmailFieldOptions { /// The maximum length of the field used to set the `maxlength` attribute /// in the HTML input element. @@ -226,11 +424,38 @@ pub struct EmailFieldOptions { /// The minimum length of the field used to set the `minlength` attribute /// in the HTML input element. pub min_length: Option, + /// The size of the field used to set the [`size`] attribute in the HTML + /// input element. + /// + /// [`size`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#size + pub size: Option, + /// Corresponds to the [`AutoCapitalize`] attribute in the HTML input + /// element. + pub autocomplete: Option, + /// The direction of the text input, which can be set to `ltr` + /// (left-to-right) or `rtl` (right-to-left). This corresponds to the + /// [`Dir`] attribute in the HTML input element. + pub dir: Option, + /// The [`dirname`] attribute in the HTML input element, which is used to + /// specify the direction of the text input. + /// + /// [`dirname`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#dirname + pub dirname: Option, + /// A [`List`] of options for the `datalist` element, which can be used to + /// provide predefined options for the input. + pub list: Option, + /// The placeholder text for the input field, which is displayed when the + /// field is empty. + pub placeholder: Option, + /// If `true`, the field is read-only and cannot be modified by the user. + pub readonly: Option, } impl Display for EmailField { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { let mut tag = HtmlTag::input("email"); + let mut data_list: Option = None; + tag.attr("name", self.id()); tag.attr("id", self.id()); if self.options.required { @@ -242,10 +467,51 @@ impl Display for EmailField { if let Some(min_length) = self.custom_options.min_length { tag.attr("minlength", min_length.to_string()); } + + if let Some(size) = self.custom_options.size { + tag.attr("size", size.to_string()); + } + + if let Some(autocomplete) = &self.custom_options.autocomplete { + tag.attr("autocomplete", autocomplete.to_string()); + } + + if let Some(dir) = &self.custom_options.dir { + tag.attr("dir", dir.as_str()); + } + + if let Some(dirname) = &self.custom_options.dirname { + tag.attr("dirname", dirname); + } + + if let Some(list) = &self.custom_options.list { + let list_id = format!("__{}_datalist", self.id()); + tag.attr("list", &list_id); + + data_list = Some(HtmlTag::data_list(list.clone(), &list_id)); + } + + if let Some(placeholder) = &self.custom_options.placeholder { + tag.attr("placeholder", placeholder); + } + if let Some(readonly) = self.custom_options.readonly + && readonly + { + tag.bool_attr("readonly"); + } + if let Some(value) = &self.value { tag.attr("value", value); } + if let Some(data_list) = data_list { + let mut wrapper = HtmlTag::new("div"); + wrapper + .attr("id", format!("__{}_datalist_wrapper", self.id())) + .push_tag(tag) + .push_tag(data_list); + return write!(f, "{}", wrapper.render()); + } write!(f, "{}", tag.render()) } } @@ -290,10 +556,13 @@ impl AsFormField for Email { impl HtmlSafe for EmailField {} -impl_form_field!(IntegerField, IntegerFieldOptions, "an integer", T: Integer); +impl_form_field!(IntegerField, IntegerFieldOptions, "an integer", T: Integer + Display); /// Custom options for a [`IntegerField`]. -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Clone, Builder)] +#[builder(build_fn(skip, error = std::convert::Infallible))] +#[builder(setter(strip_option))] +#[non_exhaustive] pub struct IntegerFieldOptions { /// The minimum value of the field. Used to set the `min` attribute in the /// HTML input element. @@ -301,6 +570,14 @@ pub struct IntegerFieldOptions { /// The maximum value of the field. Used to set the `max` attribute in the /// HTML input element. pub max: Option, + /// The placeholder text for the input field, which is displayed when the + /// field is empty. + pub placeholder: Option, + /// If `true`, the field is read-only and cannot be modified by the user. + pub readonly: Option, + /// The step size for the field. Used to set the [`Step`] attribute in the + /// HTML input element. + pub step: Option>, } impl Default for IntegerFieldOptions { @@ -308,11 +585,14 @@ impl Default for IntegerFieldOptions { Self { min: T::MIN, max: T::MAX, + placeholder: None, + readonly: None, + step: None, } } } -impl Display for IntegerField { +impl Display for IntegerField { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { let mut tag = HtmlTag::input("number"); tag.attr("name", self.id()); @@ -326,6 +606,19 @@ impl Display for IntegerField { if let Some(max) = &self.custom_options.max { tag.attr("max", max.to_string()); } + + if let Some(placeholder) = &self.custom_options.placeholder { + tag.attr("placeholder", placeholder); + } + if let Some(readonly) = self.custom_options.readonly + && readonly + { + tag.bool_attr("readonly"); + } + if let Some(step) = &self.custom_options.step { + tag.attr("step", step.to_string()); + } + if let Some(value) = &self.value { tag.attr("value", value); } @@ -334,7 +627,7 @@ impl Display for IntegerField { } } -impl HtmlSafe for IntegerField {} +impl HtmlSafe for IntegerField {} /// A trait for numerical types that optionally have minimum and maximum values. /// @@ -465,7 +758,10 @@ impl_integer_as_form_field!(NonZeroUsize); impl_form_field!(BoolField, BoolFieldOptions, "a boolean"); /// Custom options for a [`BoolField`]. -#[derive(Debug, Default, Copy, Clone)] +#[derive(Debug, Default, Copy, Clone, Builder)] +#[builder(build_fn(skip, error = std::convert::Infallible))] +#[builder(setter(strip_option))] +#[non_exhaustive] pub struct BoolFieldOptions { /// If `true`, the field must be checked to be considered valid. pub must_be_true: Option, @@ -647,10 +943,13 @@ pub(crate) fn check_required(field: &T) -> Result<&str, FormFieldV } } -impl_form_field!(FloatField, FloatFieldOptions, "a float", T: Float); +impl_form_field!(FloatField, FloatFieldOptions, "a float", T: Float + Display); /// Custom options for a [`FloatField`]. -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Clone, Builder)] +#[builder(build_fn(skip, error = std::convert::Infallible))] +#[builder(setter(strip_option))] +#[non_exhaustive] pub struct FloatFieldOptions { /// The minimum value of the field. Used to set the `min` attribute in the /// HTML input element. @@ -658,6 +957,14 @@ pub struct FloatFieldOptions { /// The maximum value of the field. Used to set the `max` attribute in the /// HTML input element. pub max: Option, + /// The placeholder text for the input field, which is displayed when the + /// field is empty. + pub placeholder: Option, + /// If `true`, the field is read-only and cannot be modified by the user. + pub readonly: Option, + /// The step size for the field. Used to set the [`Step`] attribute in the + /// HTML input element. + pub step: Option>, } impl Default for FloatFieldOptions { @@ -665,11 +972,14 @@ impl Default for FloatFieldOptions { Self { min: T::MIN, max: T::MAX, + placeholder: None, + readonly: None, + step: None, } } } -impl Display for FloatField { +impl Display for FloatField { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { let mut tag: HtmlTag = HtmlTag::input("number"); tag.attr("name", self.id()); @@ -684,6 +994,17 @@ impl Display for FloatField { if let Some(max) = &self.custom_options.max { tag.attr("max", max.to_string()); } + if let Some(placeholder) = &self.custom_options.placeholder { + tag.attr("placeholder", placeholder); + } + if let Some(readonly) = self.custom_options.readonly + && readonly + { + tag.bool_attr("readonly"); + } + if let Some(step) = &self.custom_options.step { + tag.attr("step", step.to_string()); + } if let Some(value) = &self.value { tag.attr("value", value); } @@ -692,7 +1013,7 @@ impl Display for FloatField { } } -impl HtmlSafe for FloatField {} +impl HtmlSafe for FloatField {} /// A trait for types that can be represented as a float. /// @@ -777,19 +1098,92 @@ impl_float_as_form_field!(f64); impl_form_field!(UrlField, UrlFieldOptions, "a URL"); /// Custom options for a [`UrlField`]. -#[derive(Debug, Default, Copy, Clone)] -pub struct UrlFieldOptions; +#[derive(Debug, Default, Clone, Builder)] +#[builder(build_fn(skip, error = std::convert::Infallible))] +#[builder(setter(strip_option))] +#[non_exhaustive] +pub struct UrlFieldOptions { + /// The maximum length of the field. Used to set the `maxlength` attribute + /// in the HTML input element. + pub max_length: Option, + /// The minimum length of the field. Used to set the `minlength` attribute + /// in the HTML input element. + pub min_length: Option, + /// The size of the field. Used to set the [`size`]attribute in the HTML + /// input element. + /// + /// [`size`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#size + pub size: Option, + /// The [`AutoComplete`] attribute in the HTML input element, which is used + /// to specify how the browser should handle autocomplete for the input. + pub autocomplete: Option, + /// The direction of the text input, which can be set to `ltr` + /// (left-to-right) or `rtl` (right-to-left). This corresponds to the + /// [`Dir`] attribute in the HTML input element. + pub dir: Option, + /// The [`dirname`] attribute in the HTML input element, which is used to + /// specify the direction of the text input. + /// + /// [`dirname`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#dirname + pub dirname: Option, + /// The [`List`] of options for the `datalist` element, which can be used to + /// provide predefined options for the input. + pub list: Option, + /// The placeholder text for the input field, which is displayed when the + /// field is empty. + pub placeholder: Option, + /// If `true`, the field is read-only and cannot be modified by the user. + pub readonly: Option, +} impl Display for UrlField { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - // no custom options - let _ = self.custom_options; let mut tag = HtmlTag::input("url"); tag.attr("name", self.id()); tag.attr("id", self.id()); if self.options.required { tag.bool_attr("required"); } + if let Some(max_length) = self.custom_options.max_length { + tag.attr("maxlength", max_length.to_string()); + } + if let Some(min_length) = self.custom_options.min_length { + tag.attr("minlength", min_length.to_string()); + } + + if let Some(size) = self.custom_options.size { + tag.attr("size", size.to_string()); + } + + if let Some(autocomplete) = &self.custom_options.autocomplete { + tag.attr("autocomplete", autocomplete.to_string()); + } + + if let Some(dir) = &self.custom_options.dir { + tag.attr("dir", dir.as_str()); + } + + if let Some(dirname) = &self.custom_options.dirname { + tag.attr("dirname", dirname); + } + + if let Some(list) = &self.custom_options.list { + let list_id = format!("__{}_datalist", self.id()); + tag.attr("list", &list_id); + + let data_list = HtmlTag::data_list(list, &list_id); + tag.push_tag(data_list); + } + + if let Some(placeholder) = &self.custom_options.placeholder { + tag.attr("placeholder", placeholder); + } + if let Some(readonly) = self.custom_options.readonly + && readonly + { + tag.bool_attr("readonly"); + } + if let Some(value) = &self.value { tag.attr("value", value); } @@ -817,6 +1211,64 @@ impl AsFormField for Url { } } +impl_field_options_builder!( + StringFieldOptions, + StringFieldOptionsBuilder { + max_length, + min_length, + size, + autocapitalize, + autocomplete, + dir, + dirname, + list, + placeholder, + readonly + } +); +impl_field_options_builder!( + PasswordFieldOptions, + PasswordFieldOptionsBuilder { + max_length, + min_length, + size, + autocomplete, + placeholder, + readonly + } +); +impl_field_options_builder!( + EmailFieldOptions, + EmailFieldOptionsBuilder { + max_length, + min_length, + size, + autocomplete, + dir, + dirname, + list, + placeholder, + readonly + } +); +impl_field_options_builder!(IntegerFieldOptions, IntegerFieldOptionsBuilder{min, max, placeholder, readonly, step}); +impl_field_options_builder!(FloatFieldOptions, FloatFieldOptionsBuilder{min, max, placeholder, readonly, step}); +impl_field_options_builder!( + UrlFieldOptions, + UrlFieldOptionsBuilder { + max_length, + min_length, + size, + autocomplete, + dir, + dirname, + list, + placeholder, + readonly + } +); +impl_field_options_builder!(BoolFieldOptions, BoolFieldOptionsBuilder { must_be_true }); + #[cfg(test)] mod tests { use super::*; @@ -830,14 +1282,33 @@ mod tests { name: "test".to_owned(), required: true, }, - StringFieldOptions { - max_length: Some(10), - }, + StringFieldOptions::builder() + .max_length(10) + .min_length(5) + .size(15) + .autocapitalize(AutoCapitalize::Words) + .autocomplete(AutoComplete::Value("foo bar".to_string())) + .dir(Dir::Ltr) + .dirname("dir".to_string()) + .list(List::new(["bar", "baz"])) + .placeholder("Enter text".to_string()) + .readonly(true) + .build(), ); let html = field.to_string(); assert!(html.contains("type=\"text\"")); + assert!(html.contains("name=\"test\"")); + assert!(html.contains("id=\"test\"")); assert!(html.contains("required")); assert!(html.contains("maxlength=\"10\"")); + assert!(html.contains("minlength=\"5\"")); + assert!(html.contains("size=\"15\"")); + assert!(html.contains("autocomplete=\"foo bar\"")); + assert!(html.contains("dir=\"ltr\"")); + assert!(html.contains("dirname=\"dir\"")); + assert!(html.contains("placeholder=\"Enter text\"")); + assert!(html.contains("readonly")); + assert!(html.contains("")); } #[cot::test] @@ -848,9 +1319,7 @@ mod tests { name: "test".to_owned(), required: true, }, - StringFieldOptions { - max_length: Some(10), - }, + StringFieldOptions::builder().max_length(10).build(), ); field .set_value(FormFieldValue::new_text("test")) @@ -868,9 +1337,7 @@ mod tests { name: "test".to_owned(), required: true, }, - StringFieldOptions { - max_length: Some(10), - }, + StringFieldOptions::builder().max_length(10).build(), ); field.set_value(FormFieldValue::new_text("")).await.unwrap(); let value = String::clean_value(&field); @@ -885,15 +1352,24 @@ mod tests { name: "test".to_owned(), required: true, }, - PasswordFieldOptions { - max_length: Some(10), - }, + PasswordFieldOptions::builder() + .max_length(10) + .min_length(5) + .size(15) + .autocomplete(AutoComplete::Value("foo bar".to_string())) + .placeholder("Enter password".to_string()) + .readonly(false) + .build(), ); let html = field.to_string(); assert!(html.contains("type=\"password\"")); assert!(html.contains("required")); assert!(html.contains("maxlength=\"10\"")); + assert!(html.contains("minlength=\"5\"")); + assert!(html.contains("autocomplete=\"foo bar\"")); + assert!(html.contains("placeholder=\"Enter password\"")); } + #[cot::test] async fn password_field_clean_value() { let mut field = PasswordField::with_options( @@ -902,9 +1378,7 @@ mod tests { name: "test".to_owned(), required: true, }, - PasswordFieldOptions { - max_length: Some(10), - }, + PasswordFieldOptions::builder().max_length(10).build(), ); field .set_value(FormFieldValue::new_text("password")) @@ -922,19 +1396,34 @@ mod tests { name: "test_name".to_owned(), required: true, }, - EmailFieldOptions { - min_length: Some(10), - max_length: Some(50), - }, + EmailFieldOptions::builder() + .max_length(10) + .min_length(5) + .size(15) + .autocomplete(AutoComplete::Value("foo bar".to_string())) + .dir(Dir::Ltr) + .dirname("dir".to_string()) + .list(List::new(["foo@example.com", "baz@example.com"])) + .placeholder("Enter text".to_string()) + .readonly(true) + .build(), ); let html = field.to_string(); + assert!(html.contains("type=\"email\"")); assert!(html.contains("required")); - assert!(html.contains("minlength=\"10\"")); - assert!(html.contains("maxlength=\"50\"")); + assert!(html.contains("minlength=\"5\"")); + assert!(html.contains("maxlength=\"10\"")); assert!(html.contains("name=\"test_id\"")); assert!(html.contains("id=\"test_id\"")); + assert!(html.contains("placeholder=\"Enter text\"")); + assert!(html.contains("readonly")); + assert!(html.contains("autocomplete=\"foo bar\"")); + assert!(html.contains("dir=\"ltr\"")); + assert!(html.contains("dirname=\"dir\"")); + assert!(html.contains("list=\"__test_id_datalist\"")); + assert!(html.contains(r#""#)); } #[cot::test] @@ -945,10 +1434,10 @@ mod tests { name: "email_test".to_owned(), required: true, }, - EmailFieldOptions { - min_length: Some(10), - max_length: Some(50), - }, + EmailFieldOptions::builder() + .max_length(50) + .min_length(10) + .build(), ); field @@ -968,10 +1457,10 @@ mod tests { name: "email_test".to_owned(), required: true, }, - EmailFieldOptions { - min_length: Some(10), - max_length: Some(50), - }, + EmailFieldOptions::builder() + .max_length(50) + .min_length(10) + .build(), ); field @@ -991,10 +1480,10 @@ mod tests { name: "email_test".to_owned(), required: true, }, - EmailFieldOptions { - min_length: Some(5), - max_length: Some(10), - }, + EmailFieldOptions::builder() + .max_length(10) + .min_length(5) + .build(), ); field @@ -1017,10 +1506,10 @@ mod tests { name: "email_test".to_owned(), required: true, }, - EmailFieldOptions { - min_length: Some(5), - max_length: Some(10), - }, + EmailFieldOptions::builder() + .max_length(50) + .min_length(10) + .build(), ); field @@ -1043,10 +1532,10 @@ mod tests { name: "email_test".to_owned(), required: true, }, - EmailFieldOptions { - min_length: Some(50), - max_length: Some(10), - }, + EmailFieldOptions::builder() + .max_length(10) + .min_length(50) + .build(), ); field @@ -1070,16 +1559,25 @@ mod tests { name: "test".to_owned(), required: true, }, - IntegerFieldOptions { - min: Some(1), - max: Some(10), - }, + IntegerFieldOptions::builder() + .max(10) + .min(1) + .placeholder("Enter text".to_string()) + .readonly(false) + .step(Step::Value(10)) + .build(), ); let html = field.to_string(); + assert!(html.contains("type=\"number\"")); assert!(html.contains("required")); assert!(html.contains("min=\"1\"")); assert!(html.contains("max=\"10\"")); + assert!(html.contains("step=\"10\"")); + assert!(html.contains("placeholder=\"Enter text\"")); + assert!(html.contains("name=\"test\"")); + assert!(html.contains("id=\"test\"")); + assert!(!html.contains("readonly")); } #[cot::test] @@ -1090,10 +1588,7 @@ mod tests { name: "test".to_owned(), required: true, }, - IntegerFieldOptions { - min: Some(1), - max: Some(10), - }, + IntegerFieldOptions::builder().max(10).min(1).build(), ); field .set_value(FormFieldValue::new_text("5")) @@ -1111,10 +1606,7 @@ mod tests { name: "test".to_owned(), required: true, }, - IntegerFieldOptions { - min: Some(10), - max: Some(50), - }, + IntegerFieldOptions::builder().min(10).max(50).build(), ); field .set_value(FormFieldValue::new_text("5")) @@ -1135,10 +1627,7 @@ mod tests { name: "test".to_owned(), required: true, }, - IntegerFieldOptions { - min: Some(10), - max: Some(50), - }, + IntegerFieldOptions::builder().min(10).max(50).build(), ); field .set_value(FormFieldValue::new_text("100")) @@ -1159,9 +1648,7 @@ mod tests { name: "test".to_owned(), required: true, }, - BoolFieldOptions { - must_be_true: Some(false), - }, + BoolFieldOptions::builder().must_be_true(false).build(), ); let html = field.to_string(); assert!(html.contains("type=\"checkbox\"")); @@ -1177,9 +1664,7 @@ mod tests { name: "test".to_owned(), required: true, }, - BoolFieldOptions { - must_be_true: Some(true), - }, + BoolFieldOptions::builder().must_be_true(true).build(), ); let html = field.to_string(); assert!(html.contains("type=\"checkbox\"")); @@ -1195,9 +1680,7 @@ mod tests { name: "test".to_owned(), required: true, }, - BoolFieldOptions { - must_be_true: Some(true), - }, + BoolFieldOptions::builder().must_be_true(false).build(), ); field .set_value(FormFieldValue::new_text("true")) @@ -1215,16 +1698,25 @@ mod tests { name: "test".to_owned(), required: true, }, - FloatFieldOptions { - min: Some(1.5), - max: Some(10.7), - }, + FloatFieldOptions::builder() + .min(1.5) + .max(10.7) + .step(Step::Any) + .placeholder("Enter text".to_string()) + .readonly(true) + .build(), ); let html = field.to_string(); + assert!(html.contains("type=\"number\"")); assert!(html.contains("required")); assert!(html.contains("min=\"1.5\"")); assert!(html.contains("max=\"10.7\"")); + assert!(html.contains("step=\"any\"")); + assert!(html.contains("placeholder=\"Enter text\"")); + assert!(html.contains("name=\"test\"")); + assert!(html.contains("id=\"test\"")); + assert!(html.contains("readonly")); } #[cot::test] @@ -1236,10 +1728,7 @@ mod tests { name: "test".to_owned(), required: true, }, - FloatFieldOptions { - min: Some(1.0), - max: Some(10.0), - }, + FloatFieldOptions::builder().min(1.0).max(10.0).build(), ); field .set_value(FormFieldValue::new_text("5.0")) @@ -1257,10 +1746,7 @@ mod tests { name: "test".to_owned(), required: true, }, - FloatFieldOptions { - min: Some(5.0), - max: Some(10.0), - }, + FloatFieldOptions::builder().min(5.0).max(10.0).build(), ); field .set_value(FormFieldValue::new_text("2.0")) @@ -1281,10 +1767,7 @@ mod tests { name: "test".to_owned(), required: true, }, - FloatFieldOptions { - min: Some(5.0), - max: Some(10.0), - }, + FloatFieldOptions::builder().min(5.0).max(10.0).build(), ); field .set_value(FormFieldValue::new_text("20.0")) @@ -1305,10 +1788,7 @@ mod tests { name: "test".to_owned(), required: true, }, - FloatFieldOptions { - min: Some(1.0), - max: Some(10.0), - }, + FloatFieldOptions::builder().min(1.0).max(10.0).build(), ); let bad_inputs = ["NaN", "inf"]; @@ -1335,10 +1815,7 @@ mod tests { name: "test".to_owned(), required: true, }, - FloatFieldOptions { - min: Some(1.0), - max: Some(10.0), - }, + FloatFieldOptions::builder().min(1.0).max(10.0).build(), ); field.set_value(FormFieldValue::new_text("")).await.unwrap(); let value = f32::clean_value(&field); @@ -1353,12 +1830,24 @@ mod tests { name: "test".to_owned(), required: true, }, - UrlFieldOptions, + UrlFieldOptions::builder() + .max_length(100) + .min_length(5) + .size(30) + .autocomplete(AutoComplete::Value("url".to_string())) + .dir(Dir::Ltr) + .dirname("dir".to_string()) + .list(List::new(["http:://example.com"])) + .placeholder("Enter URL".to_string()) + .readonly(true) + .build(), ); + field .set_value(FormFieldValue::new_text("https://example.com")) .await .unwrap(); + let value = Url::clean_value(&field).unwrap(); assert_eq!( value.as_str(), @@ -1374,16 +1863,40 @@ mod tests { name: "url".to_owned(), required: true, }, - UrlFieldOptions, + UrlFieldOptions::builder() + .max_length(120) + .min_length(10) + .size(40) + .list(List::new(["http://example.com", "https://example.org"])) + .dir(Dir::Ltr) + .dirname("lang".to_owned()) + .autocomplete(AutoComplete::Value("url".to_owned())) + .placeholder("Paste link".to_owned()) + .readonly(true) + .build(), ); + field .set_value(FormFieldValue::new_text("http://example.com")) .await .unwrap(); + let html = field.to_string(); + assert!(html.contains("type=\"url\"")); + assert!(html.contains("id=\"id_url\"")); + assert!(html.contains("name=\"id_url\"")); assert!(html.contains("required")); assert!(html.contains("value=\"http://example.com\"")); + assert!(html.contains("maxlength=\"120\"")); + assert!(html.contains("minlength=\"10\"")); + assert!(html.contains("size=\"40\"")); + assert!(html.contains("placeholder=\"Paste link\"")); + assert!(html.contains("autocomplete=\"url\"")); + assert!(html.contains("dir=\"ltr\"")); + assert!(html.contains("dirname=\"lang\"")); + assert!(html.contains("readonly")); + assert!(html.contains("` elements: +/// Represents the HTML [`step`] attribute for `` elements: /// - `Any` → `step="any"` /// - `Value(T)` → `step=""` where `T` is converted appropriately +/// +/// [`step`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/step #[derive(Debug, Copy, Clone)] +#[non_exhaustive] pub enum Step { /// Indicates that the user may enter any value (no fixed “step” interval). /// @@ -26,3 +29,238 @@ impl Display for Step { } } } + +/// Represents the HTML [`list`] attribute for `` elements. +/// Used to provide a set of predefined options for the input. +/// +/// [`list`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#list +#[derive(Debug, Clone, Default)] +pub struct List(Vec); + +impl List { + /// Creates a new `List` from any iterator of string-like items. + /// + /// # Examples + /// ``` + /// use cot::form::fields::List; + /// let list = List::new(vec!["Option 1", "Option 2", "Option 3"]); + /// ``` + pub fn new(iter: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + let v = iter.into_iter().map(|s| s.as_ref().to_string()).collect(); + Self(v) + } + + /// Returns an iterator over the items in the list. + /// + /// # Examples + /// ``` + /// use cot::form::fields::List; + /// let list = List::new(vec!["Option 1", "Option 2", "Option 3"]); + /// for item in list.iter() { + /// println!("{item:?}"); + /// } + /// ``` + pub fn iter(&self) -> std::slice::Iter<'_, String> { + self.0.iter() + } +} + +impl IntoIterator for List { + type Item = String; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl<'a> IntoIterator for &'a List { + type Item = &'a String; + type IntoIter = std::slice::Iter<'a, String>; + + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } +} + +/// Represents the HTML [`autocomplete`] attribute for form fields. +/// +/// [`autocomplete`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/autocomplete +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum AutoComplete { + /// Enables autocomplete. + On, + /// Disables autocomplete. + Off, + /// Custom autocomplete value. + Value(String), +} + +impl AutoComplete { + /// Returns the string representation for use in HTML. + #[must_use] + pub fn as_str(&self) -> &str { + match self { + Self::Off => "off", + Self::On => "on", + Self::Value(value) => value.as_str(), + } + } +} + +impl Display for AutoComplete { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Represents the HTML [`autocapitalize`] attribute for form fields. +/// +/// [`autocapitalize`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/autocapitalize +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub enum AutoCapitalize { + /// No capitalization. + Off, + /// Capitalize all letters. + On, + /// Capitalize the first letter of each word. + Words, + /// Capitalize all characters. + Characters, +} + +impl AutoCapitalize { + /// Returns the string representation for use in HTML. + fn as_str(self) -> &'static str { + match self { + AutoCapitalize::Off => "off", + AutoCapitalize::On => "on", + AutoCapitalize::Words => "words", + AutoCapitalize::Characters => "characters", + } + } +} + +impl Display for AutoCapitalize { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Represents the HTML [`dir`] attribute for text direction. +/// +/// [`dir`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub enum Dir { + /// Right-to-left text direction. + Rtl, + /// Left-to-right text direction. + Ltr, + /// User agent auto-detects the text direction. + Auto, +} + +impl Dir { + /// Returns the string representation for use in HTML. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Rtl => "rtl", + Self::Ltr => "ltr", + Self::Auto => "auto", + } + } +} + +impl Display for Dir { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Represents the HTML [`capture`] attribute for file inputs. +/// Used to specify the preferred source for file capture. +/// +/// [`capture`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/file#capture +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub enum Capture { + /// Use the user-facing camera or microphone. + User, + /// Use the environment-facing camera or microphone. + Environment, +} + +impl Capture { + /// Returns the string representation for use in HTML. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::User => "user", + Self::Environment => "environment", + } + } +} + +impl Display for Capture { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn autocapitalize_as_str() { + assert_eq!(AutoCapitalize::Off.as_str(), "off"); + assert_eq!(AutoCapitalize::On.as_str(), "on"); + assert_eq!(AutoCapitalize::Words.as_str(), "words"); + assert_eq!(AutoCapitalize::Characters.as_str(), "characters"); + } + + #[test] + fn autocapitalize_to_string() { + assert_eq!(AutoCapitalize::Off.to_string(), "off"); + assert_eq!(AutoCapitalize::On.to_string(), "on"); + assert_eq!(AutoCapitalize::Words.to_string(), "words"); + assert_eq!(AutoCapitalize::Characters.to_string(), "characters"); + } + + #[test] + fn autocomplete_as_str() { + assert_eq!(AutoComplete::Off.as_str(), "off"); + assert_eq!(AutoComplete::On.as_str(), "on"); + let custom = AutoComplete::Value("email".to_string()); + assert_eq!(custom.as_str(), "email"); + } + + #[test] + fn dir_as_str() { + assert_eq!(Dir::Rtl.as_str(), "rtl"); + assert_eq!(Dir::Ltr.as_str(), "ltr"); + assert_eq!(Dir::Auto.as_str(), "auto"); + } + + #[test] + fn dir_to_string() { + assert_eq!(Dir::Rtl.to_string(), "rtl"); + assert_eq!(Dir::Ltr.to_string(), "ltr"); + assert_eq!(Dir::Auto.to_string(), "auto"); + } + + #[test] + fn list_iter() { + let list = List::new(["Option 1", "Option 2", "Option 3"]); + let collected: Vec<&str> = list.iter().map(String::as_str).collect(); + assert_eq!(collected, vec!["Option 1", "Option 2", "Option 3"]); + } +} diff --git a/cot/src/form/fields/chrono.rs b/cot/src/form/fields/chrono.rs index 7f1214e6e..efaf52fb7 100644 --- a/cot/src/form/fields/chrono.rs +++ b/cot/src/form/fields/chrono.rs @@ -9,8 +9,11 @@ use chrono_tz::Tz; use cot::form::FormField; use cot::form::fields::impl_form_field; use cot::html::HtmlTag; +use derive_builder::Builder; -use crate::form::fields::{SelectChoice, SelectField, Step, check_required}; +use crate::form::fields::{ + SelectChoice, SelectField, Step, check_required, impl_field_options_builder, +}; use crate::form::{AsFormField, FormFieldValidationError}; impl AsFormField for Weekday { @@ -126,12 +129,12 @@ impl_form_field!(DateTimeField, DateTimeFieldOptions, "a datetime"); /// let now = chrono::Local::now().naive_local(); /// let in_two_days = now + Duration::hours(48); /// -/// let options = DateTimeFieldOptions { -/// min: Some(now), -/// max: Some(in_two_days), -/// readonly: Some(true), -/// step: Some(Step::Value(Duration::seconds(300))), -/// }; +/// let options = DateTimeFieldOptions::builder() +/// .min(now) +/// .max(in_two_days) +/// .readonly(true) +/// .step(Step::Value(Duration::seconds(300))) +/// .build(); /// /// let field = DateTimeField::with_options( /// FormFieldOptions { @@ -142,7 +145,10 @@ impl_form_field!(DateTimeField, DateTimeFieldOptions, "a datetime"); /// options, /// ); /// ``` -#[derive(Debug, Default, Clone, Copy)] +#[derive(Debug, Default, Clone, Copy, Builder)] +#[builder(build_fn(skip, error = std::convert::Infallible))] +#[builder(setter(strip_option))] +#[non_exhaustive] pub struct DateTimeFieldOptions { /// The maximum datetime value of the field used to set the `max` attribute /// in the HTML input element. @@ -271,15 +277,11 @@ impl From for FormFieldValidationError { /// // we choose the later offset (i.e. `prefer_latest = true`). /// let tz: Tz = "America/New_York".parse().unwrap(); /// -/// let options = DateTimeWithTimezoneFieldOptions { -/// min: None, -/// max: None, -/// readonly: Some(false), -/// step: Some(Step::Value(Duration::seconds(60))), -/// timezone: Some(tz), +/// let options = DateTimeWithTimezoneFieldOptions::builder() +/// .timezone(tz) /// // If the given local time is ambiguous (DST fall‐back), pick the later of the two possibilities. -/// prefer_latest: Some(true), -/// }; +/// .prefer_latest(true) +/// .build(); /// /// let field = DateTimeWithTimezoneField::with_options( /// FormFieldOptions { @@ -290,7 +292,10 @@ impl From for FormFieldValidationError { /// options, /// ); /// ``` -#[derive(Debug, Default, Clone, Copy)] +#[derive(Debug, Default, Clone, Copy, Builder)] +#[builder(build_fn(skip, error= std::convert::Infallible))] +#[builder(setter(strip_option))] +#[non_exhaustive] pub struct DateTimeWithTimezoneFieldOptions { /// The maximum allowed datetime (with offset) for this field. /// @@ -459,12 +464,12 @@ impl_form_field!(TimeField, TimeFieldOptions, "a time"); /// use cot::form::fields::{Step, TimeField, TimeFieldOptions}; /// use cot::form::{FormField, FormFieldOptions}; /// -/// let options = TimeFieldOptions { -/// min: Some(NaiveTime::from_hms_opt(9, 0, 0).unwrap()), -/// max: Some(NaiveTime::from_hms_opt(17, 0, 0).unwrap()), -/// readonly: Some(true), -/// step: Some(Step::Value(Duration::seconds(900))), -/// }; +/// let options = TimeFieldOptions::builder() +/// .min(NaiveTime::from_hms_opt(9, 0, 0).unwrap()) +/// .max(NaiveTime::from_hms_opt(17, 0, 0).unwrap()) +/// .readonly(true) +/// .step(Step::Value(Duration::seconds(900))) +/// .build(); /// /// let field = TimeField::with_options( /// FormFieldOptions { @@ -475,7 +480,10 @@ impl_form_field!(TimeField, TimeFieldOptions, "a time"); /// options, /// ); /// ``` -#[derive(Debug, Default, Clone, Copy)] +#[derive(Debug, Default, Clone, Copy, Builder)] +#[builder(build_fn(skip, error= std::convert::Infallible))] +#[builder(setter(strip_option))] +#[non_exhaustive] pub struct TimeFieldOptions { /// The maximum time value of the field used to set the `max` attribute /// in the HTML input element. @@ -580,12 +588,12 @@ impl_form_field!(DateField, DateFieldOptions, "a date"); /// use cot::form::fields::{DateField, DateFieldOptions, Step}; /// use cot::form::{FormField, FormFieldOptions}; /// -/// let options = DateFieldOptions { -/// min: Some(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()), -/// max: Some(NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()), -/// readonly: None, -/// step: Some(Step::Value(Duration::days(7))), -/// }; +/// let options = DateFieldOptions::builder() +/// .min(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()) +/// .max(NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()) +/// .readonly(true) +/// .step(Step::Value(Duration::days(7))) +/// .build(); /// /// let field = DateField::with_options( /// FormFieldOptions { @@ -596,7 +604,10 @@ impl_form_field!(DateField, DateFieldOptions, "a date"); /// options, /// ); /// ``` -#[derive(Debug, Default, Clone, Copy)] +#[derive(Debug, Default, Clone, Copy, Builder)] +#[builder(build_fn(skip, error= std::convert::Infallible))] +#[builder(setter(strip_option))] +#[non_exhaustive] pub struct DateFieldOptions { /// The maximum date value of the field used to set the `max` attribute /// in the HTML input element. @@ -688,6 +699,45 @@ impl AsFormField for NaiveDate { impl HtmlSafe for DateField {} +impl_field_options_builder!( + DateTimeFieldOptions, + DateTimeFieldOptionsBuilder { + max, + min, + readonly, + step + } +); +impl_field_options_builder!( + DateTimeWithTimezoneFieldOptions, + DateTimeWithTimezoneFieldOptionsBuilder { + max, + min, + readonly, + step, + timezone, + prefer_latest + } +); +impl_field_options_builder!( + TimeFieldOptions, + TimeFieldOptionsBuilder { + max, + min, + readonly, + step + } +); +impl_field_options_builder!( + DateFieldOptions, + DateFieldOptionsBuilder { + max, + min, + readonly, + step + } +); + #[cfg(test)] mod tests { use std::collections::{HashSet, LinkedList, VecDeque}; @@ -1122,18 +1172,18 @@ mod tests { name: "dt".into(), required: true, }, - DateTimeFieldOptions { - min: Some( - NaiveDateTime::parse_from_str("2025-05-27T00:00:00", "%Y-%m-%dT%H:%M:%S") - .unwrap(), - ), - max: Some( + DateTimeFieldOptions::builder() + .max( NaiveDateTime::parse_from_str("2025-05-28T00:00:00", "%Y-%m-%dT%H:%M:%S") .unwrap(), - ), - readonly: Some(true), - step: Some(Step::Value(Duration::seconds(60))), - }, + ) + .min( + NaiveDateTime::parse_from_str("2025-05-27T00:00:00", "%Y-%m-%dT%H:%M:%S") + .unwrap(), + ) + .readonly(true) + .step(Step::Value(Duration::seconds(60))) + .build(), ); let html = field.to_string(); assert!(html.contains("type=\"datetime-local\"")); @@ -1153,18 +1203,17 @@ mod tests { name: "dt".into(), required: true, }, - DateTimeFieldOptions { - min: Some( + DateTimeFieldOptions::builder() + .min( NaiveDateTime::parse_from_str("2025-05-27T00:00:00", "%Y-%m-%dT%H:%M:%S") .unwrap(), - ), - max: Some( + ) + .max( NaiveDateTime::parse_from_str("2025-05-28T00:00:00", "%Y-%m-%dT%H:%M:%S") .unwrap(), - ), - readonly: None, - step: Some(Step::Value(Duration::seconds(60))), - }, + ) + .step(Step::Value(Duration::seconds(60))) + .build(), ); for &dt in &["2025-05-27T12:34", "2025-05-27T12:34:00"] { @@ -1182,15 +1231,13 @@ mod tests { name: "dt".into(), required: true, }, - DateTimeFieldOptions { - min: Some( + DateTimeFieldOptions::builder() + .min( NaiveDateTime::parse_from_str("2025-05-27T10:00:00", "%Y-%m-%dT%H:%M:%S") .unwrap(), - ), - max: None, - readonly: None, - step: Some(Step::Value(Duration::seconds(60))), - }, + ) + .step(Step::Value(Duration::seconds(60))) + .build(), ); for &dt in &["2025-05-27T09:59", "2025-05-27T09:59:00"] { field.set_value(FormFieldValue::new_text(dt)).await.unwrap(); @@ -1210,15 +1257,13 @@ mod tests { name: "dt".into(), required: true, }, - DateTimeFieldOptions { - min: None, - max: Some( + DateTimeFieldOptions::builder() + .max( NaiveDateTime::parse_from_str("2025-05-27T10:00:00", "%Y-%m-%dT%H:%M:%S") .unwrap(), - ), - readonly: None, - step: Some(Step::Value(Duration::seconds(60))), - }, + ) + .step(Step::Value(Duration::seconds(60))) + .build(), ); for &dt in &["2025-05-27T10:01", "2025-05-27T10:01:00"] { field.set_value(FormFieldValue::new_text(dt)).await.unwrap(); @@ -1238,20 +1283,24 @@ mod tests { name: "dt".into(), required: true, }, - DateTimeWithTimezoneFieldOptions { - min: Some( - DateTime::parse_from_str("2025-05-27T00:00:00 +0000", "%Y-%m-%dT%H:%M:%S %z") - .unwrap(), - ), - max: Some( - DateTime::parse_from_str("2025-05-28T00:00:00 +0000", "%Y-%m-%dT%H:%M:%S %z") - .unwrap(), - ), - readonly: Some(true), - step: Some(Step::Value(Duration::seconds(60))), - timezone: None, - prefer_latest: None, - }, + DateTimeWithTimezoneFieldOptions::builder() + .min( + DateTime::::parse_from_str( + "2025-05-27T00:00:00 +0000", + "%Y-%m-%dT%H:%M:%S %z", + ) + .unwrap(), + ) + .max( + DateTime::::parse_from_str( + "2025-05-28T00:00:00 +0000", + "%Y-%m-%dT%H:%M:%S %z", + ) + .unwrap(), + ) + .readonly(true) + .step(Step::Value(Duration::seconds(60))) + .build(), ); let html = field.to_string(); assert_eq!( @@ -1268,14 +1317,7 @@ mod tests { name: "dt".into(), required: true, }, - DateTimeWithTimezoneFieldOptions { - min: None, - max: None, - readonly: None, - step: None, - timezone: None, - prefer_latest: None, - }, + DateTimeWithTimezoneFieldOptions::builder().build(), ); field .set_value(FormFieldValue::new_text("2025-05-27T12:34")) @@ -1295,14 +1337,9 @@ mod tests { name: "dt".into(), required: true, }, - DateTimeWithTimezoneFieldOptions { - min: None, - max: None, - readonly: None, - step: None, - timezone: Some(offset), - prefer_latest: None, - }, + DateTimeWithTimezoneFieldOptions::builder() + .timezone(offset) + .build(), ); field .set_value(FormFieldValue::new_text("2025-05-27T01:23")) @@ -1322,14 +1359,10 @@ mod tests { name: "dt".into(), required: true, }, - DateTimeWithTimezoneFieldOptions { - min: None, - max: None, - readonly: None, - step: None, - timezone: Some(offset), - prefer_latest: Some(false), - }, + DateTimeWithTimezoneFieldOptions::builder() + .timezone(offset) + .prefer_latest(false) + .build(), ); field .set_value(FormFieldValue::new_text("2024-11-03T01:30")) @@ -1349,14 +1382,10 @@ mod tests { name: "dt".into(), required: true, }, - DateTimeWithTimezoneFieldOptions { - min: None, - max: None, - readonly: None, - step: None, - timezone: Some(offset), - prefer_latest: Some(true), - }, + DateTimeWithTimezoneFieldOptions::builder() + .timezone(offset) + .prefer_latest(true) + .build(), ); field .set_value(FormFieldValue::new_text("2024-11-03T01:30")) @@ -1376,14 +1405,9 @@ mod tests { name: "dt".into(), required: true, }, - DateTimeWithTimezoneFieldOptions { - min: None, - max: None, - readonly: None, - step: None, - timezone: Some(offset), - prefer_latest: None, - }, + DateTimeWithTimezoneFieldOptions::builder() + .timezone(offset) + .build(), ); field .set_value(FormFieldValue::new_text("2024-11-03T01:30")) @@ -1405,14 +1429,9 @@ mod tests { name: "dt".into(), required: true, }, - DateTimeWithTimezoneFieldOptions { - min: None, - max: None, - readonly: None, - step: None, - timezone: Some(offset), - prefer_latest: None, - }, + DateTimeWithTimezoneFieldOptions::builder() + .timezone(offset) + .build(), ); field .set_value(FormFieldValue::new_text("2024-03-10T02:30")) @@ -1436,14 +1455,9 @@ mod tests { name: "dt".into(), required: true, }, - DateTimeWithTimezoneFieldOptions { - min: Some(min_dt), - max: None, - readonly: None, - step: None, - timezone: None, - prefer_latest: None, - }, + DateTimeWithTimezoneFieldOptions::builder() + .min(min_dt) + .build(), ); field .set_value(FormFieldValue::new_text("2025-05-27T09:59")) @@ -1466,14 +1480,9 @@ mod tests { name: "dt".into(), required: true, }, - DateTimeWithTimezoneFieldOptions { - min: None, - max: Some(max_dt), - readonly: None, - step: None, - timezone: None, - prefer_latest: None, - }, + DateTimeWithTimezoneFieldOptions::builder() + .max(max_dt) + .build(), ); field .set_value(FormFieldValue::new_text("2025-05-27T10:01")) @@ -1494,14 +1503,7 @@ mod tests { name: "dt".into(), required: true, }, - DateTimeWithTimezoneFieldOptions { - min: None, - max: None, - readonly: None, - step: None, - timezone: None, - prefer_latest: None, - }, + DateTimeWithTimezoneFieldOptions::builder().build(), ); field .set_value(FormFieldValue::new_text("not-a-valid-datetime")) @@ -1520,14 +1522,7 @@ mod tests { name: "dt".into(), required: true, }, - DateTimeWithTimezoneFieldOptions { - min: None, - max: None, - readonly: None, - step: None, - timezone: None, - prefer_latest: None, - }, + DateTimeWithTimezoneFieldOptions::builder().build(), ); field.set_value(FormFieldValue::new_text("")).await.unwrap(); @@ -1543,12 +1538,12 @@ mod tests { name: "time".into(), required: true, }, - TimeFieldOptions { - min: Some(NaiveTime::parse_from_str("09:00:00", "%H:%M:%S").unwrap()), - max: Some(NaiveTime::parse_from_str("17:00:00", "%H:%M:%S").unwrap()), - readonly: Some(false), - step: Some(Step::Value(Duration::seconds(60))), - }, + TimeFieldOptions::builder() + .min(NaiveTime::parse_from_str("09:00:00", "%H:%M:%S").unwrap()) + .max(NaiveTime::parse_from_str("17:00:00", "%H:%M:%S").unwrap()) + .step(Step::Value(Duration::seconds(60))) + .readonly(false) + .build(), ); let html = field.to_string(); assert!(html.contains("type=\"time\"")); @@ -1567,12 +1562,12 @@ mod tests { name: "t".into(), required: true, }, - TimeFieldOptions { - min: Some(NaiveTime::parse_from_str("09:00:00", "%H:%M:%S").unwrap()), - max: Some(NaiveTime::parse_from_str("17:00:00", "%H:%M:%S").unwrap()), - readonly: Some(false), - step: Some(Step::Value(Duration::seconds(60))), - }, + TimeFieldOptions::builder() + .min(NaiveTime::parse_from_str("09:00:00", "%H:%M:%S").unwrap()) + .max(NaiveTime::parse_from_str("17:00:00", "%H:%M:%S").unwrap()) + .step(Step::Value(Duration::seconds(60))) + .readonly(false) + .build(), ); field .set_value(FormFieldValue::new_text("12:30")) @@ -1590,12 +1585,11 @@ mod tests { name: "t".into(), required: true, }, - TimeFieldOptions { - min: Some(NaiveTime::parse_from_str("09:00:00", "%H:%M:%S").unwrap()), - max: None, - readonly: Some(false), - step: Some(Step::Value(Duration::seconds(60))), - }, + TimeFieldOptions::builder() + .min(NaiveTime::parse_from_str("09:00:00", "%H:%M:%S").unwrap()) + .step(Step::Value(Duration::seconds(60))) + .readonly(false) + .build(), ); for &time in &["08:59:00", "08:59"] { field @@ -1618,12 +1612,11 @@ mod tests { name: "t".into(), required: true, }, - TimeFieldOptions { - min: None, - max: Some(NaiveTime::parse_from_str("17:00:00", "%H:%M:%S").unwrap()), - readonly: Some(false), - step: Some(Step::Value(Duration::seconds(60))), - }, + TimeFieldOptions::builder() + .max(NaiveTime::parse_from_str("17:00:00", "%H:%M:%S").unwrap()) + .step(Step::Value(Duration::seconds(60))) + .readonly(false) + .build(), ); for &time in &["17:01:00", "17:01"] { @@ -1647,12 +1640,11 @@ mod tests { name: "d".into(), required: true, }, - DateFieldOptions { - min: Some(NaiveDate::parse_from_str("2025-05-27", "%Y-%m-%d").unwrap()), - max: Some(NaiveDate::parse_from_str("2025-05-28", "%Y-%m-%d").unwrap()), - readonly: None, - step: Some(Step::Value(Duration::days(1))), - }, + DateFieldOptions::builder() + .min(NaiveDate::parse_from_str("2025-05-27", "%Y-%m-%d").unwrap()) + .max(NaiveDate::parse_from_str("2025-05-28", "%Y-%m-%d").unwrap()) + .step(Step::Value(Duration::days(1))) + .build(), ); let html = field.to_string(); assert!(html.contains("type=\"date\"")); @@ -1669,12 +1661,11 @@ mod tests { name: "d".into(), required: true, }, - DateFieldOptions { - min: Some(NaiveDate::parse_from_str("2025-05-27", "%Y-%m-%d").unwrap()), - max: Some(NaiveDate::parse_from_str("2025-05-28", "%Y-%m-%d").unwrap()), - readonly: None, - step: Some(Step::Value(Duration::days(1))), - }, + DateFieldOptions::builder() + .min(NaiveDate::parse_from_str("2025-05-27", "%Y-%m-%d").unwrap()) + .max(NaiveDate::parse_from_str("2025-05-28", "%Y-%m-%d").unwrap()) + .step(Step::Value(Duration::days(1))) + .build(), ); field .set_value(FormFieldValue::new_text("2025-05-27")) @@ -1692,12 +1683,10 @@ mod tests { name: "d".into(), required: true, }, - DateFieldOptions { - min: Some(NaiveDate::parse_from_str("2025-05-27", "%Y-%m-%d").unwrap()), - max: None, - readonly: None, - step: Some(Step::Value(Duration::days(1))), - }, + DateFieldOptions::builder() + .min(NaiveDate::parse_from_str("2025-05-27", "%Y-%m-%d").unwrap()) + .step(Step::Value(Duration::days(1))) + .build(), ); field .set_value(FormFieldValue::new_text("2025-05-26")) @@ -1718,12 +1707,10 @@ mod tests { name: "d".into(), required: true, }, - DateFieldOptions { - min: None, - max: Some(NaiveDate::parse_from_str("2025-05-27", "%Y-%m-%d").unwrap()), - readonly: None, - step: Some(Step::Value(Duration::days(1))), - }, + DateFieldOptions::builder() + .max(NaiveDate::parse_from_str("2025-05-27", "%Y-%m-%d").unwrap()) + .step(Step::Value(Duration::days(1))) + .build(), ); field .set_value(FormFieldValue::new_text("2025-05-28")) diff --git a/cot/src/form/fields/files.rs b/cot/src/form/fields/files.rs index fb1072803..3dedb9779 100644 --- a/cot/src/form/fields/files.rs +++ b/cot/src/form/fields/files.rs @@ -2,9 +2,12 @@ use std::fmt::{Display, Formatter}; use askama::filters::HtmlSafe; use bytes::Bytes; +use cot::form::fields::impl_field_options_builder; use cot::form::{AsFormField, FormFieldValidationError}; use cot::html::HtmlTag; +use derive_builder::Builder; +use crate::form::fields::attrs::Capture; use crate::form::{FormField, FormFieldOptions, FormFieldValue, FormFieldValueError}; #[derive(Debug)] @@ -51,7 +54,10 @@ impl FormField for FileField { } /// Custom options for a [`FileField`]. -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default, Clone, Builder)] +#[builder(build_fn(skip, error = std::convert::Infallible))] +#[builder(setter(strip_option))] +#[non_exhaustive] pub struct FileFieldOptions { /// The accepted file types. Used to set the [`accept` attribute] in the /// HTML input element. Each string in the vector represents a file type @@ -64,6 +70,8 @@ pub struct FileFieldOptions { /// /// [`accept` attribute]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/file#limiting_accepted_file_types pub accept: Option>, + /// The [`Capture`] attribute specifies the source of the file input. + pub capture: Option, } impl Display for FileField { @@ -78,6 +86,10 @@ impl Display for FileField { tag.attr("accept", accept.join(",")); } + if let Some(capture) = self.custom_options.capture { + tag.attr("capture", capture.to_string()); + } + write!(f, "{}", tag.render()) } } @@ -142,6 +154,11 @@ impl InMemoryUploadedFile { } } +impl_field_options_builder!( + FileFieldOptions, + FileFieldOptionsBuilder { accept, capture } +); + #[cfg(test)] mod tests { use bytes::Bytes; @@ -159,9 +176,10 @@ mod tests { name: "test".to_owned(), required: true, }, - FileFieldOptions { - accept: Some(vec!["image/*".to_string(), ".pdf".to_string()]), - }, + FileFieldOptions::builder() + .accept(vec!["image/*".to_string(), ".pdf".to_string()]) + .capture(Capture::Environment) + .build(), ); let html = field.to_string(); @@ -169,6 +187,7 @@ mod tests { assert!(html.contains("type=\"file\"")); assert!(html.contains("required")); assert!(html.contains("accept=\"image/*,.pdf\"")); + assert!(html.contains("capture=\"environment\"")); } #[test] @@ -179,7 +198,7 @@ mod tests { name: "test".to_owned(), required: true, }, - FileFieldOptions { accept: None }, + FileFieldOptions::builder().capture(Capture::User).build(), ); let html = field.to_string(); @@ -187,6 +206,7 @@ mod tests { assert!(html.contains("type=\"file\"")); assert!(html.contains("required")); assert!(!html.contains("accept=")); + assert!(html.contains("capture=\"user\"")); } #[cot::test] @@ -197,7 +217,7 @@ mod tests { name: "test".to_owned(), required: true, }, - FileFieldOptions { accept: None }, + FileFieldOptions::builder().build(), ); let boundary = "boundary"; @@ -232,7 +252,7 @@ mod tests { name: "test".to_owned(), required: true, }, - FileFieldOptions { accept: None }, + FileFieldOptions::builder().build(), ); let value = InMemoryUploadedFile::clean_value(&field); diff --git a/cot/src/form/fields/select.rs b/cot/src/form/fields/select.rs index 831af0bcc..9528b855f 100644 --- a/cot/src/form/fields/select.rs +++ b/cot/src/form/fields/select.rs @@ -1,6 +1,7 @@ use std::fmt::{Debug, Display, Formatter}; use askama::filters::HtmlSafe; +use cot::form::fields::impl_field_options_builder; /// Derive helper that implements `AsFormField` for select-like enums and common /// collections. /// @@ -131,6 +132,7 @@ pub use cot_macros::SelectAsFormField; /// [`SelectField`]: cot::form::fields::SelectField /// [`SelectMultipleField`]: cot::form::fields::SelectMultipleField pub use cot_macros::SelectChoice; +use derive_builder::Builder; use indexmap::IndexSet; use crate::form::fields::impl_form_field; @@ -183,7 +185,10 @@ pub(crate) use impl_as_form_field_mult_collection; impl_form_field!(SelectField, SelectFieldOptions, "a dropdown list", T: SelectChoice + Send); /// Custom options for a [`SelectField`]. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Builder)] +#[builder(build_fn(skip, error = std::convert::Infallible))] +#[builder(setter(strip_option))] +#[non_exhaustive] pub struct SelectFieldOptions { /// The list of available choices for the select field. /// If not set, the default choices from [`SelectChoice::default_choices`] @@ -281,7 +286,10 @@ impl FormField for SelectMultipleField { } /// Custom options for a [`SelectMultipleField`]. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Builder)] +#[builder(build_fn(skip, error = std::convert::Infallible))] +#[builder(setter(strip_option))] +#[non_exhaustive] pub struct SelectMultipleFieldOptions { /// The list of available choices for the multi-select field. /// If not set, the default choices from [`SelectChoice::default_choices`] @@ -621,6 +629,8 @@ pub trait SelectChoice { fn to_string(&self) -> String; } +impl_field_options_builder!(SelectFieldOptions, SelectFieldOptionsBuilder{choices, none_option}); +impl_field_options_builder!(SelectMultipleFieldOptions, SelectMultipleFieldOptionsBuilder{choices, size}); #[cfg(test)] mod tests { use std::collections::{HashSet, LinkedList, VecDeque}; @@ -717,10 +727,9 @@ mod tests { name: "test_select".to_owned(), required: false, }, - SelectFieldOptions { - choices: None, - none_option: Some("Please select...".to_string()), - }, + SelectFieldOptions::builder() + .none_option("Please select...".to_owned()) + .build(), ); let html = field.to_string(); @@ -736,10 +745,9 @@ mod tests { name: "test_select".to_owned(), required: false, }, - SelectFieldOptions { - choices: Some(vec![TestChoice::Option1, TestChoice::Option3]), - none_option: None, - }, + SelectFieldOptions::builder() + .choices(vec![TestChoice::Option1, TestChoice::Option3]) + .build(), ); let html = field.to_string(); @@ -798,10 +806,7 @@ mod tests { name: "test_multi".to_owned(), required: false, }, - SelectMultipleFieldOptions { - choices: None, - size: Some(5), - }, + SelectMultipleFieldOptions::builder().size(5).build(), ); let html = field.to_string();