diff --git a/cot-macros/src/lib.rs b/cot-macros/src/lib.rs index ba041708..e3916341 100644 --- a/cot-macros/src/lib.rs +++ b/cot-macros/src/lib.rs @@ -6,6 +6,7 @@ mod from_request; mod main_fn; mod model; mod query; +mod select_as_form_field; mod select_choice; use darling::Error; @@ -23,6 +24,7 @@ use crate::from_request::impl_from_request_head_for_struct; use crate::main_fn::{fn_to_cot_e2e_test, fn_to_cot_main, fn_to_cot_test}; use crate::model::impl_model_for_struct; use crate::query::{Query, query_to_tokens}; +use crate::select_as_form_field::impl_select_as_form_field_for_enum; use crate::select_choice::impl_select_choice_for_enum; #[proc_macro_derive(Form, attributes(form))] @@ -213,6 +215,13 @@ pub fn derive_select_choice(input: TokenStream) -> TokenStream { token_stream.into() } +#[proc_macro_derive(SelectAsFormField)] +pub fn derive_select_as_form_field(input: TokenStream) -> TokenStream { + let ast = syn::parse_macro_input!(input as DeriveInput); + let token_stream = impl_select_as_form_field_for_enum(&ast); + token_stream.into() +} + #[proc_macro_derive(IntoResponse)] pub fn derive_into_response(input: TokenStream) -> TokenStream { let ast = parse_macro_input!(input as DeriveInput); diff --git a/cot-macros/src/select_as_form_field.rs b/cot-macros/src/select_as_form_field.rs new file mode 100644 index 00000000..dbe22a36 --- /dev/null +++ b/cot-macros/src/select_as_form_field.rs @@ -0,0 +1,42 @@ +use darling::Error; +use quote::quote; +use syn::{Data, DeriveInput}; + +use crate::cot_ident; + +pub(super) fn impl_select_as_form_field_for_enum(ast: &DeriveInput) -> proc_macro2::TokenStream { + let enum_name = &ast.ident; + let cot = cot_ident(); + + match &ast.data { + Data::Enum(_) => {} + _ => { + return Error::custom("`SelectAsFormField` can only be derived for enums") + .write_errors(); + } + } + + let impl_single = quote! { + #[automatically_derived] + impl #cot::form::AsFormField for #enum_name { + type Type = #cot::form::fields::SelectField; + + fn clean_value( + field: &Self::Type + ) -> ::core::result::Result { + match #cot::form::FormField::value(field) { + ::core::option::Option::Some(v) if !v.is_empty() => ::from_str(v), + _ => ::core::result::Result::Err(#cot::form::FormFieldValidationError::Required), + } + } + + fn to_field_value(&self) -> ::std::string::String { + ::to_string(self) + } + } + }; + + quote! { + #impl_single + } +} diff --git a/cot-macros/tests/compile_tests.rs b/cot-macros/tests/compile_tests.rs index 150c92db..053cd826 100644 --- a/cot-macros/tests/compile_tests.rs +++ b/cot-macros/tests/compile_tests.rs @@ -113,6 +113,21 @@ fn derive_select_choice() { t.compile_fail("tests/ui/derive_select_choice_empty_enum.rs"); } +#[rustversion::attr( + not(nightly), + ignore = "only test on nightly for consistent error messages" +)] +#[test] +#[cfg_attr( + miri, + ignore = "unsupported operation: extern static `pidfd_spawnp` is not supported by Miri" +)] +fn derive_select_as_form_field() { + let t = trybuild::TestCases::new(); + t.pass("tests/ui/derive_select_as_form_field.rs"); + t.compile_fail("tests/ui/derive_select_as_form_field_struct.rs"); +} + #[rustversion::attr( not(nightly), ignore = "only test on nightly for consistent error messages" diff --git a/cot-macros/tests/ui/derive_select_as_form_field.rs b/cot-macros/tests/ui/derive_select_as_form_field.rs new file mode 100644 index 00000000..788b874c --- /dev/null +++ b/cot-macros/tests/ui/derive_select_as_form_field.rs @@ -0,0 +1,10 @@ +use cot::form::fields::{SelectChoice, SelectAsFormField}; + +#[derive(SelectChoice, SelectAsFormField, Debug, Clone, PartialEq, Eq, Hash)] +enum Status { + Draft, + Published, + Archived, +} + +fn main() {} diff --git a/cot-macros/tests/ui/derive_select_as_form_field_struct.rs b/cot-macros/tests/ui/derive_select_as_form_field_struct.rs new file mode 100644 index 00000000..918ae266 --- /dev/null +++ b/cot-macros/tests/ui/derive_select_as_form_field_struct.rs @@ -0,0 +1,8 @@ +use cot_macros::SelectAsFormField; + +#[derive(SelectAsFormField)] +struct NotAnEnum { + x: u8, +} + +fn main() {} diff --git a/cot-macros/tests/ui/derive_select_as_form_field_struct.stderr b/cot-macros/tests/ui/derive_select_as_form_field_struct.stderr new file mode 100644 index 00000000..58745bc5 --- /dev/null +++ b/cot-macros/tests/ui/derive_select_as_form_field_struct.stderr @@ -0,0 +1,7 @@ +error: `SelectAsFormField` can only be derived for enums + --> tests/ui/derive_select_as_form_field_struct.rs:3:10 + | +3 | #[derive(SelectAsFormField)] + | ^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the derive macro `SelectAsFormField` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/cot/src/form/fields.rs b/cot/src/form/fields.rs index 920e19b1..a5486210 100644 --- a/cot/src/form/fields.rs +++ b/cot/src/form/fields.rs @@ -18,7 +18,8 @@ pub use chrono::{ pub use files::{FileField, FileFieldOptions, InMemoryUploadedFile}; pub(crate) use select::check_required_multiple; pub use select::{ - SelectChoice, SelectField, SelectFieldOptions, SelectMultipleField, SelectMultipleFieldOptions, + SelectAsFormField, SelectChoice, SelectField, SelectFieldOptions, SelectMultipleField, + SelectMultipleFieldOptions, }; use crate::auth::PasswordHash; diff --git a/cot/src/form/fields/chrono.rs b/cot/src/form/fields/chrono.rs index af02c144..af7dfd1b 100644 --- a/cot/src/form/fields/chrono.rs +++ b/cot/src/form/fields/chrono.rs @@ -10,9 +10,7 @@ use cot::form::FormField; use cot::form::fields::impl_form_field; use cot::html::HtmlTag; -use crate::form::fields::{ - SelectChoice, SelectField, SelectMultipleField, Step, check_required, check_required_multiple, -}; +use crate::form::fields::{SelectChoice, SelectField, Step, check_required}; use crate::form::{AsFormField, FormFieldValidationError}; impl AsFormField for Weekday { @@ -29,39 +27,7 @@ impl AsFormField for Weekday { } } -macro_rules! impl_as_form_field_mult { - ($field_type:ty) => { - impl_as_form_field_mult_collection!(::std::vec::Vec<$field_type>, $field_type); - impl_as_form_field_mult_collection!(::std::collections::VecDeque<$field_type>, $field_type); - impl_as_form_field_mult_collection!( - ::std::collections::LinkedList<$field_type>, - $field_type - ); - impl_as_form_field_mult_collection!(::std::collections::HashSet<$field_type>, $field_type); - impl_as_form_field_mult_collection!(::indexmap::IndexSet<$field_type>, $field_type); - }; -} - -macro_rules! impl_as_form_field_mult_collection { - ($collection_type:ty, $field_type:ty) => { - impl AsFormField for $collection_type { - type Type = SelectMultipleField<$field_type>; - - fn clean_value(field: &Self::Type) -> Result { - let value = check_required_multiple(field)?; - - value.iter().map(|id| <$field_type>::from_str(id)).collect() - } - - fn to_field_value(&self) -> String { - String::new() - } - } - }; -} - -impl_as_form_field_mult!(Weekday); -impl_as_form_field_mult_collection!(WeekdaySet, Weekday); +crate::form::fields::select::impl_as_form_field_mult_collection!(() => WeekdaySet, Weekday); const MONDAY_ID: &str = "mon"; const TUESDAY_ID: &str = "tue"; @@ -730,7 +696,9 @@ mod tests { use cot::form::FormFieldValue; use super::*; - use crate::form::fields::{SelectFieldOptions, SelectMultipleFieldOptions}; + use crate::form::fields::{ + SelectFieldOptions, SelectMultipleField, SelectMultipleFieldOptions, + }; use crate::form::{FormField, FormFieldOptions}; #[test] diff --git a/cot/src/form/fields/select.rs b/cot/src/form/fields/select.rs index 25b5d693..831af0bc 100644 --- a/cot/src/form/fields/select.rs +++ b/cot/src/form/fields/select.rs @@ -1,6 +1,29 @@ use std::fmt::{Debug, Display, Formatter}; use askama::filters::HtmlSafe; +/// Derive helper that implements `AsFormField` for select-like enums and common +/// collections. +/// +/// Apply this together with [`SelectChoice`] to your enum to enable using it +/// directly as a form field (`SelectField`) and as multi-select via common +/// collections (`Vec`, `VecDeque`, `LinkedList`, `HashSet`, and +/// `indexmap::IndexSet`). +/// +/// # Examples +/// +/// ``` +/// use cot::form::fields::{SelectAsFormField, SelectChoice, SelectField, SelectMultipleField}; +/// +/// #[derive(SelectChoice, SelectAsFormField, Debug, Clone, PartialEq, Eq, Hash)] +/// enum Status { +/// Draft, +/// Published, +/// Archived, +/// } +/// +/// // `Status` works with `SelectField` and `SelectMultipleField`. +/// ``` +pub use cot_macros::SelectAsFormField; /// Derive the [`SelectChoice`] trait for an enum. /// /// This macro automatically implements the [`SelectChoice`] trait for enums, @@ -116,6 +139,47 @@ use crate::form::{ }; use crate::html::HtmlTag; +macro_rules! impl_as_form_field_mult_collection { + (($($generics:tt)+) => $collection:ty, $element:ty $(where $($where_clause:tt)+)?) => { + impl<$($generics)+> crate::form::AsFormField for $collection + $(where $($where_clause)+)? + { + type Type = crate::form::fields::SelectMultipleField<$element>; + + fn clean_value( + field: &Self::Type, + ) -> Result { + let values = crate::form::fields::check_required_multiple(field)?; + values.iter().map(|id| <$element>::from_str(id)).collect() + } + + fn to_field_value(&self) -> String { + String::new() + } + } + }; + (() => $collection:ty, $element:ty $(where $($where_clause:tt)+)?) => { + impl crate::form::AsFormField for $collection + $(where $($where_clause)+)? + { + type Type = crate::form::fields::SelectMultipleField<$element>; + + fn clean_value( + field: &Self::Type, + ) -> Result { + let values = crate::form::fields::check_required_multiple(field)?; + values.iter().map(|id| <$element>::from_str(id)).collect() + } + + fn to_field_value(&self) -> String { + String::new() + } + } + }; +} + +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`]. @@ -316,6 +380,27 @@ pub(crate) fn check_required_multiple( } } +impl_as_form_field_mult_collection!((T: SelectChoice + Send) => ::std::vec::Vec, T); +impl_as_form_field_mult_collection!( + (T: SelectChoice + Send) => ::std::collections::VecDeque, + T +); +impl_as_form_field_mult_collection!( + (T: SelectChoice + Send) => ::std::collections::LinkedList, + T +); +impl_as_form_field_mult_collection!( + ( + T: SelectChoice + Eq + ::std::hash::Hash + Send, + S: ::std::hash::BuildHasher + Default + ) => ::std::collections::HashSet, + T +); +impl_as_form_field_mult_collection!( + (T: SelectChoice + Eq + ::std::hash::Hash + Send) => ::indexmap::IndexSet, + T +); + /// A trait for types that can be used as choices in select fields. /// /// This trait enables types to be used with [`SelectField`] and @@ -538,9 +623,14 @@ pub trait SelectChoice { #[cfg(test)] mod tests { + use std::collections::{HashSet, LinkedList, VecDeque}; + + use indexmap::IndexSet; + use super::*; + use crate::form::AsFormField; - #[derive(Debug, Clone, PartialEq)] + #[derive(Debug, Clone, PartialEq, Eq, Hash)] enum TestChoice { Option1, Option2, @@ -854,4 +944,282 @@ mod tests { assert!(values.contains(&"opt1")); assert!(values.contains(&"opt2")); } + + #[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, + }, + SelectMultipleFieldOptions::default(), + ); + + field + .set_value(FormFieldValue::new_text("opt1")) + .await + .unwrap(); + field + .set_value(FormFieldValue::new_text("opt3")) + .await + .unwrap(); + + let values = Vec::::clean_value(&field).unwrap(); + assert_eq!(values, vec![TestChoice::Option1, TestChoice::Option3]); + } + + #[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, + }, + SelectMultipleFieldOptions::default(), + ); + + let result = Vec::::clean_value(&field); + assert_eq!(result, Err(FormFieldValidationError::Required)); + } + + #[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, + }, + SelectMultipleFieldOptions::default(), + ); + + field + .set_value(FormFieldValue::new_text("opt1")) + .await + .unwrap(); + field + .set_value(FormFieldValue::new_text("bad")) + .await + .unwrap(); + + let result = Vec::::clean_value(&field); + assert!(matches!( + result, + Err(FormFieldValidationError::InvalidValue(value)) if value == "bad" + )); + } + + #[test] + fn vec_as_form_field_to_field_value() { + let items = vec![TestChoice::Option1, TestChoice::Option2]; + assert_eq!(items.to_field_value(), ""); + } + + #[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, + }, + SelectMultipleFieldOptions::default(), + ); + + field + .set_value(FormFieldValue::new_text("opt2")) + .await + .unwrap(); + field + .set_value(FormFieldValue::new_text("opt1")) + .await + .unwrap(); + + let mut values = VecDeque::::clean_value(&field).unwrap(); + assert_eq!(values.pop_front(), Some(TestChoice::Option2)); + assert_eq!(values.pop_back(), Some(TestChoice::Option1)); + } + + #[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, + }, + SelectMultipleFieldOptions::default(), + ); + + field + .set_value(FormFieldValue::new_text("opt3")) + .await + .unwrap(); + + let mut values = LinkedList::::clean_value(&field).unwrap(); + assert_eq!(values.pop_front(), Some(TestChoice::Option3)); + assert!(values.is_empty()); + } + + #[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, + }, + SelectMultipleFieldOptions::default(), + ); + + field + .set_value(FormFieldValue::new_text("opt1")) + .await + .unwrap(); + field + .set_value(FormFieldValue::new_text("opt1")) + .await + .unwrap(); + field + .set_value(FormFieldValue::new_text("opt2")) + .await + .unwrap(); + + let values = HashSet::::clean_value(&field).unwrap(); + assert_eq!(values.len(), 2); + assert!(values.contains(&TestChoice::Option1)); + assert!(values.contains(&TestChoice::Option2)); + } + + #[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, + }, + SelectMultipleFieldOptions::default(), + ); + + field + .set_value(FormFieldValue::new_text("opt2")) + .await + .unwrap(); + field + .set_value(FormFieldValue::new_text("opt3")) + .await + .unwrap(); + field + .set_value(FormFieldValue::new_text("opt2")) + .await + .unwrap(); + + let values = IndexSet::::clean_value(&field).unwrap(); + let mut iter = values.iter(); + assert_eq!(iter.next(), Some(&TestChoice::Option2)); + assert_eq!(iter.next(), Some(&TestChoice::Option3)); + assert_eq!(iter.next(), None); + } + + #[derive(SelectChoice, SelectAsFormField, Debug, Clone, PartialEq, Eq, Hash)] + enum DerivedStatus { + #[select_choice(id = "draft", name = "Draft")] + Draft, + #[select_choice(id = "published", name = "Published")] + Published, + #[select_choice(id = "archived", name = "Archived")] + Archived, + } + + #[test] + fn select_as_form_field_render() { + let field = SelectField::::with_options( + FormFieldOptions { + id: "status".to_owned(), + name: "status".to_owned(), + required: false, + }, + SelectFieldOptions::default(), + ); + let html = field.to_string(); + + assert!(html.contains("::with_options( + FormFieldOptions { + id: "status".to_owned(), + name: "status".to_owned(), + required: true, + }, + SelectFieldOptions::default(), + ); + + field + .set_value(FormFieldValue::new_text("published")) + .await + .unwrap(); + + let value = DerivedStatus::clean_value(&field).unwrap(); + assert_eq!(value, DerivedStatus::Published); + } + + #[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, + }, + SelectFieldOptions::default(), + ); + + field.set_value(FormFieldValue::new_text("")).await.unwrap(); + + let result = DerivedStatus::clean_value(&field); + assert_eq!(result, Err(FormFieldValidationError::Required)); + } + + #[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, + }, + SelectFieldOptions::default(), + ); + + field + .set_value(FormFieldValue::new_text("not-a-valid-id")) + .await + .unwrap(); + + let result = DerivedStatus::clean_value(&field); + assert!(matches!( + result, + Err(FormFieldValidationError::InvalidValue(value)) if value == "not-a-valid-id" + )); + } + + #[test] + fn select_as_form_field_to_field_value() { + assert_eq!(DerivedStatus::Draft.to_field_value(), "Draft"); + assert_eq!(DerivedStatus::Published.to_field_value(), "Published"); + } }