diff --git a/cot-cli/src/migration_generator.rs b/cot-cli/src/migration_generator.rs index 2053453ea..b5010bd37 100644 --- a/cot-cli/src/migration_generator.rs +++ b/cot-cli/src/migration_generator.rs @@ -770,7 +770,7 @@ impl MigrationOperationGenerator { fn make_remove_model_operation(migration_model: &ModelInSource) -> DynOperation { print_status_msg( StatusType::Removing, - &format!("Model '{}'", &migration_model.model.name), + &format!("Model '{}'", migration_model.model.name), ); let op = DynOperation::RemoveModel { @@ -781,7 +781,7 @@ impl MigrationOperationGenerator { print_status_msg( StatusType::Removed, - &format!("Model '{}'", &migration_model.model.name), + &format!("Model '{}'", migration_model.model.name), ); op @@ -791,10 +791,7 @@ impl MigrationOperationGenerator { fn make_add_field_operation(app_model: &ModelInSource, field: &Field) -> DynOperation { print_status_msg( StatusType::Adding, - &format!( - "Field '{}' to Model '{}'", - &field.name, app_model.model.name - ), + &format!("Field '{}' to Model '{}'", field.name, app_model.model.name), ); let op = DynOperation::AddField { @@ -805,10 +802,7 @@ impl MigrationOperationGenerator { print_status_msg( StatusType::Added, - &format!( - "Field '{}' to Model '{}'", - &field.name, app_model.model.name - ), + &format!("Field '{}' to Model '{}'", field.name, app_model.model.name), ); op @@ -828,7 +822,7 @@ impl MigrationOperationGenerator { StatusType::Modifying, &format!( "Field '{}' from Model '{}'", - &migration_field.name, migration_model.model.name + migration_field.name, migration_model.model.name ), ); @@ -839,7 +833,7 @@ impl MigrationOperationGenerator { StatusType::Modified, &format!( "Field '{}' from Model '{}'", - &migration_field.name, migration_model.model.name + migration_field.name, migration_model.model.name ), ); } @@ -853,7 +847,7 @@ impl MigrationOperationGenerator { StatusType::Removing, &format!( "Field '{}' from Model '{}'", - &migration_field.name, migration_model.model.name + migration_field.name, migration_model.model.name ), ); @@ -867,7 +861,7 @@ impl MigrationOperationGenerator { StatusType::Removed, &format!( "Field '{}' from Model '{}'", - &migration_field.name, migration_model.model.name + migration_field.name, migration_model.model.name ), ); 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-macros/src/form.rs b/cot-macros/src/form.rs index 37cdfcaf6..7c2a77fb4 100644 --- a/cot-macros/src/form.rs +++ b/cot-macros/src/form.rs @@ -135,11 +135,12 @@ impl FormDeriveBuilder { Vec::new() }; quote!(#field_ident: { - let options = #crate_ident::form::FormFieldOptions { - id: stringify!(#field_ident).to_owned(), - name: #name.to_owned(), - required: true, - }; + let options = #crate_ident::form::FormFieldOptions::builder() + .id(stringify!(#field_ident).to_owned()) + .name(#name.to_owned()) + .required(true) + .build(); + type Field = <#ty as #crate_ident::form::AsFormField>::Type; type CustomOptions = ::CustomOptions; let mut custom_options: CustomOptions = ::core::default::Default::default(); diff --git a/cot/src/auth.rs b/cot/src/auth.rs index 550bf1d2b..d69d3c335 100644 --- a/cot/src/auth.rs +++ b/cot/src/auth.rs @@ -1307,7 +1307,7 @@ mod tests { let id_2 = session.id(); assert!(id_2.is_some()); - assert!(id_1 != id_2); + assert_ne!(id_1, id_2); } /// Test that the user is logged out when there is an invalid user ID in the diff --git a/cot/src/form.rs b/cot/src/form.rs index fcfb62fad..499045e88 100644 --- a/cot/src/form.rs +++ b/cot/src/form.rs @@ -58,6 +58,7 @@ use cot_core::headers::{MULTIPART_FORM_CONTENT_TYPE, URLENCODED_FORM_CONTENT_TYP /// fields. If the form fields are not safe to render as HTML, the form context /// will not be safe to render as HTML either. pub use cot_macros::Form; +use derive_builder::Builder; use derive_more::with_trait::Debug; pub use field_value::{FormFieldValue, FormFieldValueError}; use http_body_util::BodyExt; @@ -508,7 +509,9 @@ pub trait FormContext: Debug { } /// Generic options valid for all types of form fields. -#[derive(Debug)] +#[derive(Debug, Default, Clone, Builder)] +#[builder(build_fn(skip, error = std::convert::Infallible))] +#[non_exhaustive] pub struct FormFieldOptions { /// The HTML ID of the form field. pub id: String, @@ -521,6 +524,51 @@ pub struct FormFieldOptions { pub required: bool, } +impl FormFieldOptions { + /// Creates a new [`FormFieldOptionsBuilder`] to build a + /// [`FormFieldOptions`]. + /// + /// # Examples + /// + /// ``` + /// use cot::form::FormFieldOptions; + /// + /// let options = FormFieldOptions::builder() + /// .name("field_name".to_owned()) + /// .id("field_id".to_owned()) + /// .required(true) + /// .build(); + /// ``` + #[must_use] + pub fn builder() -> FormFieldOptionsBuilder { + FormFieldOptionsBuilder::default() + } +} +impl FormFieldOptionsBuilder { + /// Builds the [`FormFieldOptions`], falling back to defaults for any field + /// that wasn't explicitly set. + /// + /// # Examples + /// + /// ``` + /// use cot::form::FormFieldOptions; + /// + /// let options = FormFieldOptions::builder() + /// .name("field_name".to_owned()) + /// .id("field_id".to_owned()) + /// .required(true) + /// .build(); + /// ``` + #[must_use] + pub fn build(&self) -> FormFieldOptions { + FormFieldOptions { + id: self.id.clone().unwrap_or_default(), + name: self.name.clone().unwrap_or_default(), + required: self.required.unwrap_or_default(), + } + } +} + /// A form field. /// /// This trait is used to define a type of field that can be used in a form. It diff --git a/cot/src/form/fields.rs b/cot/src/form/fields.rs index 1fcb4f14f..b30c73c0e 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::*; @@ -825,32 +1277,49 @@ mod tests { #[test] fn string_field_render() { let field = StringField::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - StringFieldOptions { - max_length: Some(10), - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), + 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] async fn string_field_clean_value() { let mut field = StringField::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - StringFieldOptions { - max_length: Some(10), - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), + StringFieldOptions::builder().max_length(10).build(), ); field .set_value(FormFieldValue::new_text("test")) @@ -863,14 +1332,12 @@ mod tests { #[cot::test] async fn string_field_clean_required() { let mut field = StringField::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - StringFieldOptions { - max_length: Some(10), - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), + StringFieldOptions::builder().max_length(10).build(), ); field.set_value(FormFieldValue::new_text("")).await.unwrap(); let value = String::clean_value(&field); @@ -880,31 +1347,38 @@ mod tests { #[test] fn password_field_render() { let field = PasswordField::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - PasswordFieldOptions { - max_length: Some(10), - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), + 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( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - PasswordFieldOptions { - max_length: Some(10), - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), + PasswordFieldOptions::builder().max_length(10).build(), ); field .set_value(FormFieldValue::new_text("password")) @@ -917,38 +1391,53 @@ mod tests { #[test] fn email_field_render() { let field = EmailField::with_options( - FormFieldOptions { - id: "test_id".to_owned(), - name: "test_name".to_owned(), - required: true, - }, - EmailFieldOptions { - min_length: Some(10), - max_length: Some(50), - }, + FormFieldOptions::builder() + .id("test_id".to_owned()) + .name("test_name".to_owned()) + .required(true) + .build(), + 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] async fn email_field_clean_valid() { let mut field = EmailField::with_options( - FormFieldOptions { - id: "email_test".to_owned(), - name: "email_test".to_owned(), - required: true, - }, - EmailFieldOptions { - min_length: Some(10), - max_length: Some(50), - }, + FormFieldOptions::builder() + .id("email_test".to_owned()) + .name("email_test".to_owned()) + .required(true) + .build(), + EmailFieldOptions::builder() + .max_length(50) + .min_length(10) + .build(), ); field @@ -963,15 +1452,15 @@ mod tests { #[cot::test] async fn email_field_clean_invalid_format() { let mut field = EmailField::with_options( - FormFieldOptions { - id: "email_test".to_owned(), - name: "email_test".to_owned(), - required: true, - }, - EmailFieldOptions { - min_length: Some(10), - max_length: Some(50), - }, + FormFieldOptions::builder() + .id("email_test".to_owned()) + .name("email_test".to_owned()) + .required(true) + .build(), + EmailFieldOptions::builder() + .max_length(50) + .min_length(10) + .build(), ); field @@ -986,15 +1475,15 @@ mod tests { #[cot::test] async fn email_field_clean_exceeds_max_length() { let mut field = EmailField::with_options( - FormFieldOptions { - id: "email_test".to_owned(), - name: "email_test".to_owned(), - required: true, - }, - EmailFieldOptions { - min_length: Some(5), - max_length: Some(10), - }, + FormFieldOptions::builder() + .id("email_test".to_owned()) + .name("email_test".to_owned()) + .required(true) + .build(), + EmailFieldOptions::builder() + .max_length(10) + .min_length(5) + .build(), ); field @@ -1012,15 +1501,15 @@ mod tests { #[cot::test] async fn email_field_clean_below_min_length() { let mut field = EmailField::with_options( - FormFieldOptions { - id: "email_test".to_owned(), - name: "email_test".to_owned(), - required: true, - }, - EmailFieldOptions { - min_length: Some(5), - max_length: Some(10), - }, + FormFieldOptions::builder() + .id("email_test".to_owned()) + .name("email_test".to_owned()) + .required(true) + .build(), + EmailFieldOptions::builder() + .max_length(10) + .min_length(5) + .build(), ); field @@ -1038,15 +1527,15 @@ mod tests { #[cot::test] async fn email_field_clean_invalid_length_options() { let mut field = EmailField::with_options( - FormFieldOptions { - id: "email_test".to_owned(), - name: "email_test".to_owned(), - required: true, - }, - EmailFieldOptions { - min_length: Some(50), - max_length: Some(10), - }, + FormFieldOptions::builder() + .id("email_test".to_owned()) + .name("email_test".to_owned()) + .required(true) + .build(), + EmailFieldOptions::builder() + .max_length(10) + .min_length(50) + .build(), ); field @@ -1065,35 +1554,41 @@ mod tests { #[test] fn integer_field_render() { let field = IntegerField::::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - IntegerFieldOptions { - min: Some(1), - max: Some(10), - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), + 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] async fn integer_field_clean_value() { let mut field = IntegerField::::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - IntegerFieldOptions { - min: Some(1), - max: Some(10), - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), + IntegerFieldOptions::builder().max(10).min(1).build(), ); field .set_value(FormFieldValue::new_text("5")) @@ -1106,15 +1601,12 @@ mod tests { #[cot::test] async fn integer_field_clean_value_below_min_value() { let mut field = IntegerField::::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - IntegerFieldOptions { - min: Some(10), - max: Some(50), - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), + IntegerFieldOptions::builder().min(10).max(50).build(), ); field .set_value(FormFieldValue::new_text("5")) @@ -1130,15 +1622,12 @@ mod tests { #[cot::test] async fn integer_field_clean_value_above_max_value() { let mut field = IntegerField::::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - IntegerFieldOptions { - min: Some(10), - max: Some(50), - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), + IntegerFieldOptions::builder().min(10).max(50).build(), ); field .set_value(FormFieldValue::new_text("100")) @@ -1154,14 +1643,12 @@ mod tests { #[test] fn bool_field_render() { let field = BoolField::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - BoolFieldOptions { - must_be_true: Some(false), - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), + BoolFieldOptions::builder().must_be_true(false).build(), ); let html = field.to_string(); assert!(html.contains("type=\"checkbox\"")); @@ -1172,14 +1659,12 @@ mod tests { #[test] fn bool_field_render_must_be_true() { let field = BoolField::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - BoolFieldOptions { - must_be_true: Some(true), - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), + BoolFieldOptions::builder().must_be_true(true).build(), ); let html = field.to_string(); assert!(html.contains("type=\"checkbox\"")); @@ -1190,14 +1675,12 @@ mod tests { #[cot::test] async fn bool_field_clean_value() { let mut field = BoolField::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - BoolFieldOptions { - must_be_true: Some(true), - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), + BoolFieldOptions::builder().must_be_true(false).build(), ); field .set_value(FormFieldValue::new_text("true")) @@ -1210,36 +1693,42 @@ mod tests { #[test] fn float_field_render() { let field = FloatField::::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - FloatFieldOptions { - min: Some(1.5), - max: Some(10.7), - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), + 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] #[expect(clippy::float_cmp)] async fn float_field_clean_value() { let mut field = FloatField::::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - FloatFieldOptions { - min: Some(1.0), - max: Some(10.0), - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), + FloatFieldOptions::builder().min(1.0).max(10.0).build(), ); field .set_value(FormFieldValue::new_text("5.0")) @@ -1252,15 +1741,12 @@ mod tests { #[cot::test] async fn float_field_clean_value_min_value_not_met() { let mut field = FloatField::::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - FloatFieldOptions { - min: Some(5.0), - max: Some(10.0), - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), + FloatFieldOptions::builder().min(5.0).max(10.0).build(), ); field .set_value(FormFieldValue::new_text("2.0")) @@ -1276,15 +1762,12 @@ mod tests { #[cot::test] async fn float_field_clean_value_max_value_exceeded() { let mut field = FloatField::::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - FloatFieldOptions { - min: Some(5.0), - max: Some(10.0), - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), + FloatFieldOptions::builder().min(5.0).max(10.0).build(), ); field .set_value(FormFieldValue::new_text("20.0")) @@ -1300,15 +1783,12 @@ mod tests { #[cot::test] async fn float_field_clean_value_nan_and_inf() { let mut field = FloatField::::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - FloatFieldOptions { - min: Some(1.0), - max: Some(10.0), - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), + FloatFieldOptions::builder().min(1.0).max(10.0).build(), ); let bad_inputs = ["NaN", "inf"]; @@ -1330,15 +1810,12 @@ mod tests { #[cot::test] async fn float_field_clean_required() { let mut field = FloatField::::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - FloatFieldOptions { - min: Some(1.0), - max: Some(10.0), - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), + FloatFieldOptions::builder().min(1.0).max(10.0).build(), ); field.set_value(FormFieldValue::new_text("")).await.unwrap(); let value = f32::clean_value(&field); @@ -1348,17 +1825,29 @@ mod tests { #[cot::test] async fn url_field_clean_value() { let mut field = UrlField::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - UrlFieldOptions, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), + 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(), @@ -1369,34 +1858,69 @@ mod tests { #[cot::test] async fn url_field_render() { let mut field = UrlField::with_options( - FormFieldOptions { - id: "id_url".to_owned(), - name: "url".to_owned(), - required: true, - }, - UrlFieldOptions, + FormFieldOptions::builder() + .id("id_url".to_owned()) + .name("url".to_owned()) + .required(true) + .build(), + 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..d1dea40c4 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,23 +129,26 @@ 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 { -/// id: "event_time".into(), -/// name: "event_time".into(), -/// required: true, -/// }, +/// FormFieldOptions::builder() +/// .id("event_time".into()) +/// .name("event_time".into()) +/// .required(true) +/// .build(), /// 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,26 +277,25 @@ 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 { -/// id: "dt".into(), -/// name: "dt".into(), -/// required: true, -/// }, +/// FormFieldOptions::builder() +/// .id("dt".into()) +/// .name("dt".into()) +/// .required(true) +/// .build(), /// 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,23 +464,26 @@ 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 { -/// id: "event_time".into(), -/// name: "event_time".into(), -/// required: true, -/// }, +/// FormFieldOptions::builder() +/// .id("event_time".into()) +/// .name("event_time".into()) +/// .required(true) +/// .build(), /// 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,23 +588,26 @@ 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 { -/// id: "event_time".into(), -/// name: "event_time".into(), -/// required: true, -/// }, +/// FormFieldOptions::builder() +/// .id("event_time".into()) +/// .name("event_time".into()) +/// .required(true) +/// .build(), /// 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}; @@ -772,11 +822,11 @@ mod tests { #[cot::test] async fn weekday_as_form_field_clean_value() { let mut field = SelectField::::with_options( - FormFieldOptions { - id: "weekday".to_owned(), - name: "weekday".to_owned(), - required: true, - }, + FormFieldOptions::builder() + .id("weekday".to_owned()) + .name("weekday".to_owned()) + .required(true) + .build(), SelectFieldOptions::default(), ); @@ -792,11 +842,11 @@ mod tests { #[cot::test] async fn weekday_as_form_field_clean_value_invalid() { let mut field = SelectField::::with_options( - FormFieldOptions { - id: "weekday".to_owned(), - name: "weekday".to_owned(), - required: true, - }, + FormFieldOptions::builder() + .id("weekday".to_owned()) + .name("weekday".to_owned()) + .required(true) + .build(), SelectFieldOptions::default(), ); @@ -817,11 +867,11 @@ mod tests { #[cot::test] async fn weekday_as_form_field_clean_value_required_empty() { let mut field = SelectField::::with_options( - FormFieldOptions { - id: "weekday".to_owned(), - name: "weekday".to_owned(), - required: true, - }, + FormFieldOptions::builder() + .id("weekday".to_owned()) + .name("weekday".to_owned()) + .required(true) + .build(), SelectFieldOptions::default(), ); @@ -841,11 +891,11 @@ mod tests { #[cot::test] async fn weekday_vec_as_form_field_clean_value() { let mut field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "weekdays".to_owned(), - name: "weekdays".to_owned(), - required: true, - }, + FormFieldOptions::builder() + .id("weekdays".to_owned()) + .name("weekdays".to_owned()) + .required(true) + .build(), SelectMultipleFieldOptions::default(), ); @@ -872,11 +922,11 @@ mod tests { #[cot::test] async fn weekday_vec_as_form_field_clean_value_empty_required() { let field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "weekdays".to_owned(), - name: "weekdays".to_owned(), - required: true, - }, + FormFieldOptions::builder() + .id("weekdays".to_owned()) + .name("weekdays".to_owned()) + .required(true) + .build(), SelectMultipleFieldOptions::default(), ); @@ -887,11 +937,11 @@ mod tests { #[cot::test] async fn weekday_vec_as_form_field_clean_value_invalid() { let mut field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "weekdays".to_owned(), - name: "weekdays".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("weekdays".to_owned()) + .name("weekdays".to_owned()) + .required(false) + .build(), SelectMultipleFieldOptions::default(), ); @@ -922,11 +972,11 @@ mod tests { #[cot::test] async fn weekday_hash_set_as_form_field_clean_value() { let mut field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "weekdays".to_owned(), - name: "weekdays".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("weekdays".to_owned()) + .name("weekdays".to_owned()) + .required(false) + .build(), SelectMultipleFieldOptions::default(), ); @@ -948,11 +998,11 @@ mod tests { #[cot::test] async fn weekday_vec_deque_as_form_field_clean_value() { let mut field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "weekdays".to_owned(), - name: "weekdays".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("weekdays".to_owned()) + .name("weekdays".to_owned()) + .required(false) + .build(), SelectMultipleFieldOptions::default(), ); @@ -974,11 +1024,11 @@ mod tests { #[cot::test] async fn weekday_linked_list_as_form_field_clean_value() { let mut field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "weekdays".to_owned(), - name: "weekdays".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("weekdays".to_owned()) + .name("weekdays".to_owned()) + .required(false) + .build(), SelectMultipleFieldOptions::default(), ); @@ -995,11 +1045,11 @@ mod tests { #[cot::test] async fn weekday_index_set_as_form_field_clean_value() { let mut field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "weekdays".to_owned(), - name: "weekdays".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("weekdays".to_owned()) + .name("weekdays".to_owned()) + .required(false) + .build(), SelectMultipleFieldOptions::default(), ); @@ -1025,11 +1075,11 @@ mod tests { #[cot::test] async fn weekday_set_as_form_field_clean_value() { let mut field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "weekdays".to_owned(), - name: "weekdays".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("weekdays".to_owned()) + .name("weekdays".to_owned()) + .required(false) + .build(), SelectMultipleFieldOptions::default(), ); @@ -1065,11 +1115,11 @@ mod tests { #[test] fn weekday_select_field_render() { let field = SelectField::::with_options( - FormFieldOptions { - id: "weekday".to_owned(), - name: "weekday".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("weekday".to_owned()) + .name("weekday".to_owned()) + .required(false) + .build(), SelectFieldOptions::default(), ); @@ -1096,11 +1146,11 @@ mod tests { #[test] fn weekday_select_multiple_field_render() { let field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "weekdays".to_owned(), - name: "weekdays".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("weekdays".to_owned()) + .name("weekdays".to_owned()) + .required(false) + .build(), SelectMultipleFieldOptions::default(), ); @@ -1117,23 +1167,23 @@ mod tests { #[test] fn datetime_field_render() { let field = DateTimeField::with_options( - FormFieldOptions { - id: "dt".into(), - 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( + FormFieldOptions::builder() + .id("dt".into()) + .name("dt".into()) + .required(true) + .build(), + 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\"")); @@ -1148,23 +1198,22 @@ mod tests { #[cot::test] async fn datetime_field_clean_valid() { let mut field = DateTimeField::with_options( - FormFieldOptions { - id: "dt".into(), - name: "dt".into(), - required: true, - }, - DateTimeFieldOptions { - min: Some( + FormFieldOptions::builder() + .id("dt".into()) + .name("dt".into()) + .required(true) + .build(), + 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"] { @@ -1177,20 +1226,18 @@ mod tests { #[cot::test] async fn datetime_field_clean_below_min() { let mut field = DateTimeField::with_options( - FormFieldOptions { - id: "dt".into(), - name: "dt".into(), - required: true, - }, - DateTimeFieldOptions { - min: Some( + FormFieldOptions::builder() + .id("dt".into()) + .name("dt".into()) + .required(true) + .build(), + 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(); @@ -1205,20 +1252,18 @@ mod tests { #[cot::test] async fn datetime_field_clean_above_max() { let mut field = DateTimeField::with_options( - FormFieldOptions { - id: "dt".into(), - name: "dt".into(), - required: true, - }, - DateTimeFieldOptions { - min: None, - max: Some( + FormFieldOptions::builder() + .id("dt".into()) + .name("dt".into()) + .required(true) + .build(), + 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(); @@ -1233,25 +1278,29 @@ mod tests { #[test] fn datetime_with_tz_field_render() { let field = DateTimeWithTimezoneField::with_options( - FormFieldOptions { - id: "dt".into(), - 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, - }, + FormFieldOptions::builder() + .id("dt".into()) + .name("dt".into()) + .required(true) + .build(), + 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!( @@ -1263,19 +1312,12 @@ mod tests { #[cot::test] async fn datetime_with_tz_clean_valid_default_utc() { let mut field = DateTimeWithTimezoneField::with_options( - FormFieldOptions { - id: "dt".into(), - name: "dt".into(), - required: true, - }, - DateTimeWithTimezoneFieldOptions { - min: None, - max: None, - readonly: None, - step: None, - timezone: None, - prefer_latest: None, - }, + FormFieldOptions::builder() + .id("dt".into()) + .name("dt".into()) + .required(true) + .build(), + DateTimeWithTimezoneFieldOptions::builder().build(), ); field .set_value(FormFieldValue::new_text("2025-05-27T12:34")) @@ -1290,19 +1332,14 @@ mod tests { async fn datetime_with_tz_clean_valid_custom_offset() { let offset = Tz::America__New_York; let mut field = DateTimeWithTimezoneField::with_options( - FormFieldOptions { - id: "dt".into(), - name: "dt".into(), - required: true, - }, - DateTimeWithTimezoneFieldOptions { - min: None, - max: None, - readonly: None, - step: None, - timezone: Some(offset), - prefer_latest: None, - }, + FormFieldOptions::builder() + .id("dt".into()) + .name("dt".into()) + .required(true) + .build(), + DateTimeWithTimezoneFieldOptions::builder() + .timezone(offset) + .build(), ); field .set_value(FormFieldValue::new_text("2025-05-27T01:23")) @@ -1317,19 +1354,15 @@ mod tests { async fn datetime_with_tz_clean_ambiguous_time_prefer_earliest() { let offset = Tz::America__New_York; let mut field = DateTimeWithTimezoneField::with_options( - FormFieldOptions { - id: "dt".into(), - name: "dt".into(), - required: true, - }, - DateTimeWithTimezoneFieldOptions { - min: None, - max: None, - readonly: None, - step: None, - timezone: Some(offset), - prefer_latest: Some(false), - }, + FormFieldOptions::builder() + .id("dt".into()) + .name("dt".into()) + .required(true) + .build(), + DateTimeWithTimezoneFieldOptions::builder() + .timezone(offset) + .prefer_latest(false) + .build(), ); field .set_value(FormFieldValue::new_text("2024-11-03T01:30")) @@ -1344,19 +1377,15 @@ mod tests { async fn datetime_with_tz_clean_ambiguous_time_prefer_latest() { let offset = Tz::America__New_York; let mut field = DateTimeWithTimezoneField::with_options( - FormFieldOptions { - id: "dt".into(), - name: "dt".into(), - required: true, - }, - DateTimeWithTimezoneFieldOptions { - min: None, - max: None, - readonly: None, - step: None, - timezone: Some(offset), - prefer_latest: Some(true), - }, + FormFieldOptions::builder() + .id("dt".into()) + .name("dt".into()) + .required(true) + .build(), + DateTimeWithTimezoneFieldOptions::builder() + .timezone(offset) + .prefer_latest(true) + .build(), ); field .set_value(FormFieldValue::new_text("2024-11-03T01:30")) @@ -1371,19 +1400,14 @@ mod tests { async fn datetime_with_tz_clean_ambiguous_time_unhandled() { let offset = Tz::America__New_York; let mut field = DateTimeWithTimezoneField::with_options( - FormFieldOptions { - id: "dt".into(), - name: "dt".into(), - required: true, - }, - DateTimeWithTimezoneFieldOptions { - min: None, - max: None, - readonly: None, - step: None, - timezone: Some(offset), - prefer_latest: None, - }, + FormFieldOptions::builder() + .id("dt".into()) + .name("dt".into()) + .required(true) + .build(), + DateTimeWithTimezoneFieldOptions::builder() + .timezone(offset) + .build(), ); field .set_value(FormFieldValue::new_text("2024-11-03T01:30")) @@ -1400,19 +1424,14 @@ mod tests { async fn datetime_with_tz_clean_non_existent_local_time() { let offset = Tz::America__New_York; let mut field = DateTimeWithTimezoneField::with_options( - FormFieldOptions { - id: "dt".into(), - name: "dt".into(), - required: true, - }, - DateTimeWithTimezoneFieldOptions { - min: None, - max: None, - readonly: None, - step: None, - timezone: Some(offset), - prefer_latest: None, - }, + FormFieldOptions::builder() + .id("dt".into()) + .name("dt".into()) + .required(true) + .build(), + DateTimeWithTimezoneFieldOptions::builder() + .timezone(offset) + .build(), ); field .set_value(FormFieldValue::new_text("2024-03-10T02:30")) @@ -1431,19 +1450,14 @@ mod tests { let min_dt = DateTime::parse_from_str("2025-05-27T10:00:00 +0000", "%Y-%m-%dT%H:%M:%S %z").unwrap(); let mut field = DateTimeWithTimezoneField::with_options( - FormFieldOptions { - id: "dt".into(), - name: "dt".into(), - required: true, - }, - DateTimeWithTimezoneFieldOptions { - min: Some(min_dt), - max: None, - readonly: None, - step: None, - timezone: None, - prefer_latest: None, - }, + FormFieldOptions::builder() + .id("dt".into()) + .name("dt".into()) + .required(true) + .build(), + DateTimeWithTimezoneFieldOptions::builder() + .min(min_dt) + .build(), ); field .set_value(FormFieldValue::new_text("2025-05-27T09:59")) @@ -1461,19 +1475,14 @@ mod tests { let max_dt = DateTime::parse_from_str("2025-05-27T10:00:00 +0000", "%Y-%m-%dT%H:%M:%S %z").unwrap(); let mut field = DateTimeWithTimezoneField::with_options( - FormFieldOptions { - id: "dt".into(), - name: "dt".into(), - required: true, - }, - DateTimeWithTimezoneFieldOptions { - min: None, - max: Some(max_dt), - readonly: None, - step: None, - timezone: None, - prefer_latest: None, - }, + FormFieldOptions::builder() + .id("dt".into()) + .name("dt".into()) + .required(true) + .build(), + DateTimeWithTimezoneFieldOptions::builder() + .max(max_dt) + .build(), ); field .set_value(FormFieldValue::new_text("2025-05-27T10:01")) @@ -1489,19 +1498,12 @@ mod tests { #[cot::test] async fn datetime_with_tz_clean_invalid_format() { let mut field = DateTimeWithTimezoneField::with_options( - FormFieldOptions { - id: "dt".into(), - name: "dt".into(), - required: true, - }, - DateTimeWithTimezoneFieldOptions { - min: None, - max: None, - readonly: None, - step: None, - timezone: None, - prefer_latest: None, - }, + FormFieldOptions::builder() + .id("dt".into()) + .name("dt".into()) + .required(true) + .build(), + DateTimeWithTimezoneFieldOptions::builder().build(), ); field .set_value(FormFieldValue::new_text("not-a-valid-datetime")) @@ -1515,19 +1517,12 @@ mod tests { #[cot::test] async fn datetime_with_tz_clean_required() { let mut field = DateTimeWithTimezoneField::with_options( - FormFieldOptions { - id: "dt".into(), - name: "dt".into(), - required: true, - }, - DateTimeWithTimezoneFieldOptions { - min: None, - max: None, - readonly: None, - step: None, - timezone: None, - prefer_latest: None, - }, + FormFieldOptions::builder() + .id("dt".into()) + .name("dt".into()) + .required(true) + .build(), + DateTimeWithTimezoneFieldOptions::builder().build(), ); field.set_value(FormFieldValue::new_text("")).await.unwrap(); @@ -1538,17 +1533,17 @@ mod tests { #[test] fn time_field_render() { let field = TimeField::with_options( - FormFieldOptions { - id: "time".into(), - 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))), - }, + FormFieldOptions::builder() + .id("time".into()) + .name("time".into()) + .required(true) + .build(), + 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\"")); @@ -1562,17 +1557,17 @@ mod tests { #[cot::test] async fn time_field_clean_valid() { let mut field = TimeField::with_options( - FormFieldOptions { - id: "t".into(), - 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))), - }, + FormFieldOptions::builder() + .id("t".into()) + .name("t".into()) + .required(true) + .build(), + 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")) @@ -1585,17 +1580,16 @@ mod tests { #[cot::test] async fn time_field_clean_below_min() { let mut field = TimeField::with_options( - FormFieldOptions { - id: "t".into(), - 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))), - }, + FormFieldOptions::builder() + .id("t".into()) + .name("t".into()) + .required(true) + .build(), + 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 @@ -1613,17 +1607,16 @@ mod tests { #[cot::test] async fn time_field_clean_above_max() { let mut field = TimeField::with_options( - FormFieldOptions { - id: "t".into(), - 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))), - }, + FormFieldOptions::builder() + .id("t".into()) + .name("t".into()) + .required(true) + .build(), + 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"] { @@ -1642,17 +1635,16 @@ mod tests { #[test] fn date_field_render() { let field = DateField::with_options( - FormFieldOptions { - id: "d".into(), - 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))), - }, + FormFieldOptions::builder() + .id("d".into()) + .name("d".into()) + .required(true) + .build(), + 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\"")); @@ -1664,17 +1656,16 @@ mod tests { #[cot::test] async fn date_field_clean_valid() { let mut field = DateField::with_options( - FormFieldOptions { - id: "d".into(), - 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))), - }, + FormFieldOptions::builder() + .id("d".into()) + .name("d".into()) + .required(true) + .build(), + 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")) @@ -1687,17 +1678,15 @@ mod tests { #[cot::test] async fn date_field_clean_below_min() { let mut field = DateField::with_options( - FormFieldOptions { - id: "d".into(), - 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))), - }, + FormFieldOptions::builder() + .id("d".into()) + .name("d".into()) + .required(true) + .build(), + 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")) @@ -1713,17 +1702,15 @@ mod tests { #[cot::test] async fn date_field_clean_above_max() { let mut field = DateField::with_options( - FormFieldOptions { - id: "d".into(), - 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))), - }, + FormFieldOptions::builder() + .id("d".into()) + .name("d".into()) + .required(true) + .build(), + 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..1a7c78bc0 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; @@ -154,14 +171,15 @@ mod tests { #[test] fn file_field_render() { let field = FileField::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - FileFieldOptions { - accept: Some(vec!["image/*".to_string(), ".pdf".to_string()]), - }, + FormFieldOptions::builder() + .id("test".into()) + .name("test".into()) + .required(true) + .build(), + FileFieldOptions::builder() + .accept(vec!["image/*".to_string(), ".pdf".to_string()]) + .capture(Capture::Environment) + .build(), ); let html = field.to_string(); @@ -169,17 +187,18 @@ mod tests { assert!(html.contains("type=\"file\"")); assert!(html.contains("required")); assert!(html.contains("accept=\"image/*,.pdf\"")); + assert!(html.contains("capture=\"environment\"")); } #[test] fn file_field_render_no_accept() { let field = FileField::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - FileFieldOptions { accept: None }, + FormFieldOptions::builder() + .id("test".into()) + .name("test".into()) + .required(true) + .build(), + FileFieldOptions::builder().capture(Capture::User).build(), ); let html = field.to_string(); @@ -187,17 +206,18 @@ mod tests { assert!(html.contains("type=\"file\"")); assert!(html.contains("required")); assert!(!html.contains("accept=")); + assert!(html.contains("capture=\"user\"")); } #[cot::test] async fn file_field_clean_value() { let mut field = FileField::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - FileFieldOptions { accept: None }, + FormFieldOptions::builder() + .id("test".into()) + .name("test".into()) + .required(true) + .build(), + FileFieldOptions::builder().build(), ); let boundary = "boundary"; @@ -227,12 +247,12 @@ mod tests { #[cot::test] async fn file_field_clean_required() { let field = FileField::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, - FileFieldOptions { accept: None }, + FormFieldOptions::builder() + .id("test".into()) + .name("test".into()) + .required(true) + .build(), + 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..a5922f8ba 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}; @@ -671,11 +681,11 @@ mod tests { #[test] fn select_field_render_default() { let field = SelectField::::with_options( - FormFieldOptions { - id: "test_select".to_owned(), - name: "test_select".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("test_select".to_owned()) + .name("test_select".to_owned()) + .required(false) + .build(), SelectFieldOptions::default(), ); let html = field.to_string(); @@ -696,11 +706,11 @@ mod tests { #[test] fn select_field_render_required() { let field = SelectField::::with_options( - FormFieldOptions { - id: "test_select".to_owned(), - name: "test_select".to_owned(), - required: true, - }, + FormFieldOptions::builder() + .id("test_select".to_owned()) + .name("test_select".to_owned()) + .required(true) + .build(), SelectFieldOptions::default(), ); let html = field.to_string(); @@ -712,15 +722,14 @@ mod tests { #[test] fn select_field_render_custom_none_option() { let field = SelectField::::with_options( - FormFieldOptions { - id: "test_select".to_owned(), - name: "test_select".to_owned(), - required: false, - }, - SelectFieldOptions { - choices: None, - none_option: Some("Please select...".to_string()), - }, + FormFieldOptions::builder() + .id("test_select".to_owned()) + .name("test_select".to_owned()) + .required(false) + .build(), + SelectFieldOptions::builder() + .none_option("Please select...".to_owned()) + .build(), ); let html = field.to_string(); @@ -731,15 +740,14 @@ mod tests { #[test] fn select_field_render_custom_choices() { let field = SelectField::::with_options( - FormFieldOptions { - id: "test_select".to_owned(), - name: "test_select".to_owned(), - required: false, - }, - SelectFieldOptions { - choices: Some(vec![TestChoice::Option1, TestChoice::Option3]), - none_option: None, - }, + FormFieldOptions::builder() + .id("test_select".to_owned()) + .name("test_select".to_owned()) + .required(false) + .build(), + SelectFieldOptions::builder() + .choices(vec![TestChoice::Option1, TestChoice::Option3]) + .build(), ); let html = field.to_string(); @@ -751,11 +759,11 @@ mod tests { #[cot::test] async fn select_field_with_value() { let mut field = SelectField::::with_options( - FormFieldOptions { - id: "test_select".to_owned(), - name: "test_select".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("test_select".to_owned()) + .name("test_select".to_owned()) + .required(false) + .build(), SelectFieldOptions::default(), ); @@ -771,11 +779,11 @@ mod tests { #[test] fn select_multiple_field_render_default() { let field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "test_multi".to_owned(), - name: "test_multi".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("test_multi".to_owned()) + .name("test_multi".to_owned()) + .required(false) + .build(), SelectMultipleFieldOptions::default(), ); let html = field.to_string(); @@ -793,15 +801,12 @@ mod tests { #[test] fn select_multiple_field_render_with_size() { let field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "test_multi".to_owned(), - name: "test_multi".to_owned(), - required: false, - }, - SelectMultipleFieldOptions { - choices: None, - size: Some(5), - }, + FormFieldOptions::builder() + .id("test_multi".to_owned()) + .name("test_multi".to_owned()) + .required(false) + .build(), + SelectMultipleFieldOptions::builder().size(5).build(), ); let html = field.to_string(); @@ -811,11 +816,11 @@ mod tests { #[test] fn select_multiple_field_render_required() { let field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "test_multi".to_owned(), - name: "test_multi".to_owned(), - required: true, - }, + FormFieldOptions::builder() + .id("test_multi".to_owned()) + .name("test_multi".to_owned()) + .required(true) + .build(), SelectMultipleFieldOptions::default(), ); let html = field.to_string(); @@ -826,11 +831,11 @@ mod tests { #[cot::test] async fn select_multiple_field_with_values() { let mut field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "test_multi".to_owned(), - name: "test_multi".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("test_multi".to_owned()) + .name("test_multi".to_owned()) + .required(false) + .build(), SelectMultipleFieldOptions::default(), ); @@ -877,11 +882,11 @@ mod tests { #[test] fn check_required_multiple_empty() { let field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), SelectMultipleFieldOptions::default(), ); @@ -892,11 +897,11 @@ mod tests { #[cot::test] async fn check_required_multiple_with_values() { let mut field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: true, - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build(), SelectMultipleFieldOptions::default(), ); @@ -915,11 +920,11 @@ mod tests { #[cot::test] async fn select_multiple_field_values_iterator() { let mut field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "test".to_owned(), - name: "test".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(false) + .build(), SelectMultipleFieldOptions::default(), ); @@ -948,11 +953,11 @@ mod tests { #[cot::test] async fn vec_as_form_field_clean_value() { let mut field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "choices".to_owned(), - name: "choices".to_owned(), - required: true, - }, + FormFieldOptions::builder() + .id("choices".to_owned()) + .name("choices".to_owned()) + .required(true) + .build(), SelectMultipleFieldOptions::default(), ); @@ -972,11 +977,11 @@ mod tests { #[cot::test] async fn vec_as_form_field_required_empty() { let field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "choices".to_owned(), - name: "choices".to_owned(), - required: true, - }, + FormFieldOptions::builder() + .id("choices".to_owned()) + .name("choices".to_owned()) + .required(true) + .build(), SelectMultipleFieldOptions::default(), ); @@ -987,11 +992,11 @@ mod tests { #[cot::test] async fn vec_as_form_field_invalid_value() { let mut field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "choices".to_owned(), - name: "choices".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("choices".to_owned()) + .name("choices".to_owned()) + .required(false) + .build(), SelectMultipleFieldOptions::default(), ); @@ -1020,11 +1025,11 @@ mod tests { #[cot::test] async fn vec_deque_as_form_field_clean_value() { let mut field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "choices".to_owned(), - name: "choices".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("choices".to_owned()) + .name("choices".to_owned()) + .required(true) + .build(), SelectMultipleFieldOptions::default(), ); @@ -1045,11 +1050,11 @@ mod tests { #[cot::test] async fn linked_list_as_form_field_clean_value() { let mut field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "choices".to_owned(), - name: "choices".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("choices".to_owned()) + .name("choices".to_owned()) + .required(false) + .build(), SelectMultipleFieldOptions::default(), ); @@ -1066,11 +1071,11 @@ mod tests { #[cot::test] async fn hash_set_as_form_field_clean_value() { let mut field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "choices".to_owned(), - name: "choices".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("choices".to_owned()) + .name("choices".to_owned()) + .required(true) + .build(), SelectMultipleFieldOptions::default(), ); @@ -1096,11 +1101,11 @@ mod tests { #[cot::test] async fn index_set_as_form_field_preserves_order() { let mut field = SelectMultipleField::::with_options( - FormFieldOptions { - id: "choices".to_owned(), - name: "choices".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("choices".to_owned()) + .name("choices".to_owned()) + .required(true) + .build(), SelectMultipleFieldOptions::default(), ); @@ -1137,11 +1142,11 @@ mod tests { #[test] fn select_as_form_field_render() { let field = SelectField::::with_options( - FormFieldOptions { - id: "status".to_owned(), - name: "status".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("status".to_owned()) + .name("status".to_owned()) + .required(false) + .build(), SelectFieldOptions::default(), ); let html = field.to_string(); @@ -1160,11 +1165,11 @@ mod tests { #[cot::test] async fn select_as_form_field_clean_value_valid() { let mut field = SelectField::::with_options( - FormFieldOptions { - id: "status".to_owned(), - name: "status".to_owned(), - required: true, - }, + FormFieldOptions::builder() + .id("status".to_owned()) + .name("status".to_owned()) + .required(true) + .build(), SelectFieldOptions::default(), ); @@ -1180,11 +1185,11 @@ mod tests { #[cot::test] async fn select_as_form_field_clean_value_required_empty() { let mut field = SelectField::::with_options( - FormFieldOptions { - id: "status".to_owned(), - name: "status".to_owned(), - required: true, - }, + FormFieldOptions::builder() + .id("status".to_owned()) + .name("status".to_owned()) + .required(true) + .build(), SelectFieldOptions::default(), ); @@ -1197,11 +1202,11 @@ mod tests { #[cot::test] async fn select_as_form_field_clean_value_invalid() { let mut field = SelectField::::with_options( - FormFieldOptions { - id: "status".to_owned(), - name: "status".to_owned(), - required: false, - }, + FormFieldOptions::builder() + .id("status".to_owned()) + .name("status".to_owned()) + .required(false) + .build(), SelectFieldOptions::default(), ); diff --git a/cot/src/openapi.rs b/cot/src/openapi.rs index acc58bc23..f2b41c6b6 100644 --- a/cot/src/openapi.rs +++ b/cot/src/openapi.rs @@ -1360,7 +1360,7 @@ mod tests { panic!("Expected object schema"); } } else { - panic!("Expected request body: {:?}", &operation.request_body); + panic!("Expected request body: {:?}", operation.request_body); } } @@ -1430,7 +1430,7 @@ mod tests { panic!("Expected object schema"); } } else { - panic!("Expected request body: {:?}", &operation.request_body); + panic!("Expected request body: {:?}", operation.request_body); } } diff --git a/cot/src/session/store/file.rs b/cot/src/session/store/file.rs index 23af45dc3..5b45f4cbf 100644 --- a/cot/src/session/store/file.rs +++ b/cot/src/session/store/file.rs @@ -194,7 +194,7 @@ impl SessionStore for FileStore { if let Err(e) = res && e.kind() != io::ErrorKind::NotFound { - return Err(FileStoreError::Io(Box::new(e)))?; + Err(FileStoreError::Io(Box::new(e)))?; } Ok(()) }