diff --git a/Cargo.lock b/Cargo.lock index 29b1474b4..a877b2d5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -845,6 +845,7 @@ dependencies = [ "png", "pollster", "rand", + "regex", "rfd", "serde", "sys-locale", diff --git a/Cargo.toml b/Cargo.toml index 431effe25..2d59c9f77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,7 @@ nominals = "0.3.0" parking_lot = "0.12.1" easing-function = "0.1.1" serde = { version = "1.0.210", features = ["derive"], optional = true } +regex = "1.11.1" # [patch.crates-io] diff --git a/examples/forms-signup.rs b/examples/forms-signup.rs index 153b2d586..c19a1a152 100644 --- a/examples/forms-signup.rs +++ b/examples/forms-signup.rs @@ -11,6 +11,7 @@ use cushy::widgets::layers::{Modal, OverlayLayer}; use cushy::widgets::{Expand, ProgressBar, Validated}; use cushy::Run; use kempt::Map; +use regex::Regex; #[derive(Default, PartialEq)] enum AppState { @@ -35,7 +36,7 @@ fn main() -> cushy::Result { let tooltips = tooltips.clone(); let modals = modals.clone(); move |current_state, app_state| match current_state { - AppState::NewUser => signup_form(&tooltips, &modals, app_state, &api).make_widget(), + AppState::NewUser => SignupForm::default().build(&tooltips, &modals, app_state, &api).make_widget(), AppState::LoggedIn { username } => logged_in(username, app_state).make_widget(), } }); @@ -51,68 +52,101 @@ enum NewUserState { Done, } -fn signup_form( - tooltips: &OverlayLayer, - modals: &Modal, - app_state: &Dynamic, - api: &channel::Sender, -) -> impl MakeWidget { - let form_state = Dynamic::::default(); - let username = Dynamic::::default(); - let password = Dynamic::::default(); - let password_confirmation = Dynamic::::default(); - let validations = Validations::default(); - - // A network request can take time, so rather than waiting on the API call - // once we are ready to submit the form, we delegate the login process to a - // background task using a channel. - let api_errors = Dynamic::default(); - let login_handler = channel::build() - .on_receive({ - let form_state = form_state.clone(); - let app_state = app_state.clone(); - let api = api.clone(); - let api_errors = api_errors.clone(); - move |(username, password)| { - handle_login( - username, - password, - &api, - &app_state, - &form_state, - &api_errors, - ); - } - }) - .finish(); - - // When we are processing a signup request, we should display a modal with a - // spinner so that the user can't edit the form or click the sign in button - // again. - let signup_modal = modals.new_handle(); - form_state - .for_each(move |state| match state { - NewUserState::FormEntry { .. } | NewUserState::Done => signup_modal.dismiss(), - NewUserState::SigningUp => { - signup_modal.present( - "SIgning Up" - .and(ProgressBar::indeterminant().spinner().centered()) - .into_rows() - .pad() - .centered() - .contain(), - ); - } - }) - .persist(); +#[derive(Default)] +struct SignupFormFieldState { + username: Dynamic::, + password: Dynamic::, +} + +impl SignupFormFieldState { + pub fn result(&self) -> LoginArgs { + LoginArgs { + username: self.username.get(), + password: self.password.get(), + } + } +} + +#[derive(Debug)] +struct LoginArgs { + username: String, + password: MaskedString, +} + +#[derive(Default)] +struct SignupForm { + state: Dynamic::, + fields: SignupFormFieldState, +} - // We use a helper in this file `validated_field` to combine our validation - // callback and any error returned from the API for this field. - let username_field = "Username" - .and( - validated_field(SignupField::Username, username - .to_input() - .placeholder("Username"), &username, &validations, &api_errors, |username| { +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum SignupFormField { + Username, + Password, +} + +impl SignupForm { + fn build(self, + tooltips: &OverlayLayer, + modals: &Modal, + app_state: &Dynamic, + api: &channel::Sender, + ) -> impl MakeWidget { + let form_fields = self.fields; + + let password_confirmation = Dynamic::::default(); + let validations = Validations::default(); + + // A network request can take time, so rather than waiting on the API call + // once we are ready to submit the form, we delegate the login process to a + // background task using a channel. + let field_errors: Dynamic> = Dynamic::default(); + + let login_handler = channel::build() + .on_receive({ + let form_state = self.state.clone(); + let app_state = app_state.clone(); + let api = api.clone(); + let form_errors = field_errors.clone(); + move |login_args: LoginArgs| { + handle_login( + login_args, + &api, + &app_state, + &form_state, + &form_errors, + ); + } + }) + .finish(); + + // When we are processing a signup request, we should display a modal with a + // spinner so that the user can't edit the form or click the sign-in button + // again. + let signup_modal = modals.new_handle(); + self.state + .for_each(move |state| match state { + NewUserState::FormEntry { .. } | NewUserState::Done => signup_modal.dismiss(), + NewUserState::SigningUp => { + signup_modal.present( + "Signing-up" + .and(ProgressBar::indeterminant().spinner().centered()) + .into_rows() + .pad() + .centered() + .contain(), + ); + } + }) + .persist(); + + // We use a helper in this file `validated_field` to combine our validation + // callback and any error returned from the API for this field. + let username_field = "Username" + .and( + validated_field(SignupFormField::Username, form_fields.username + .to_input() + .placeholder("Username"), &form_fields.username, &validations, &field_errors, |username| { if username.is_empty() { Err(String::from( "usernames must contain at least one character", @@ -123,107 +157,109 @@ fn signup_form( Ok(()) } }) - .hint("* required") - .tooltip( - tooltips, - "Your username uniquely identifies your account. It must only contain ascii letters and digits.", - ), - ) - .into_rows(); - - let password_field = "Password" - .and( - validated_field( - SignupField::Password, - password.to_input().placeholder("Password"), - &password, - &validations, - &api_errors, - |password| { - if password.len() < 8 { - Err(String::from("passwords must be at least 8 characters long")) - } else { - Ok(()) - } - }, + .hint("* required") + .tooltip( + tooltips, + "Your username uniquely identifies your account. It must only contain ascii letters and digits.", + ), ) - .hint("* required, 8 characters min") - .tooltip(tooltips, "Passwords are always at least 8 bytes long."), - ) - .into_rows(); - - // The password confirmation validation simply checks that the password and - // confirm password match. - let password_confirmation_result = - (&password, &password_confirmation).map_each(|(password, confirmation)| { - if password == confirmation { - Ok(()) - } else { - Err("Passwords must match") - } - }); - - let password_confirmation_field = "Confirm Password" - .and( - password_confirmation - .to_input() - .placeholder("Password") - .validation(validations.validate_result(password_confirmation_result)), - ) - .into_rows(); - - let buttons = "Cancel" - .into_button() - .on_click(|_| { - eprintln!("Sign Up cancelled"); - exit(0) - }) - .into_escape() - .tooltip(tooltips, "This button quits the program") - .and(Expand::empty_horizontally()) - .and( - "Sign Up" - .into_button() - .on_click(validations.when_valid(move |_| { - // The form is valid and the sign up button was clickeed. - // Send the request to our login handler background task - // after setting the state to show the indeterminant - // progress modal. - form_state.set(NewUserState::SigningUp); - login_handler - .send((username.get(), password.get())) - .unwrap(); - })) - .into_default(), - ) - .into_columns(); - - username_field - .and(password_field) - .and(password_confirmation_field) - .and(buttons) - .into_rows() - .contain() - .width(Lp::inches(3)..Lp::inches(6)) - .pad() - .scroll() - .centered() + .into_rows(); + + let password_field = "Password" + .and( + validated_field( + SignupFormField::Password, + form_fields.password.to_input().placeholder("Password"), + &form_fields.password, + &validations, + &field_errors, + |password| { + if password.len() < 8 { + Err(String::from("passwords must be at least 8 characters long")) + } else { + Ok(()) + } + }, + ) + .hint("* required, 8 characters min") + .tooltip(tooltips, "Passwords are always at least 8 bytes long."), + ) + .into_rows(); + + // The password confirmation validation simply checks that the password and + // confirm password match. + let password_confirmation_result = + (&form_fields.password, &password_confirmation).map_each(|(password, confirmation)| { + if password == confirmation { + Ok(()) + } else { + Err("Passwords must match") + } + }); + + let password_confirmation_field = "Confirm Password" + .and( + password_confirmation + .to_input() + .placeholder("Password") + .validation(validations.validate_result(password_confirmation_result)), + ) + .into_rows(); + + let buttons = "Cancel" + .into_button() + .on_click(|_| { + eprintln!("Sign Up cancelled"); + exit(0) + }) + .into_escape() + .tooltip(tooltips, "This button quits the program") + .and(Expand::empty_horizontally()) + .and( + "Sign Up" + .into_button() + .on_click(validations.when_valid(move |_| { + // The form is valid and the sign-up button was clicked. + // Send the request to our login handler background task + // after setting the state to show the indeterminate + // progress modal. + self.state.set(NewUserState::SigningUp); + login_handler + .send(form_fields.result()) + .unwrap(); + })) + .into_default(), + ) + .into_columns(); + + username_field + .and(password_field) + .and(password_confirmation_field) + .and(buttons) + .into_rows() + .contain() + .width(Lp::inches(3)..Lp::inches(6)) + .pad() + .scroll() + .centered() + } } + /// Returns `widget` that is validated using `validate` and `api_errors`. fn validated_field( - field: SignupField, + form_field: SignupFormField, widget: impl MakeWidget, value: &Dynamic, validations: &Validations, - api_errors: &Dynamic>, + form_errors: &Dynamic>, mut validate: impl FnMut(&T) -> Result<(), String> + Send + 'static, ) -> Validated where T: Send + 'static, { // Create a dynamic that contains the error for this field, or None. - let api_error = api_errors.map_each(move |errors| errors.get(&field).cloned()); + let api_error = form_errors.map_each(move |errors| errors.get(&form_field).cloned()); // When the underlying value has been changed, we should invalidate the API // error since the edited value needs to be re-checked by the API. value @@ -261,26 +297,54 @@ fn logged_in(username: &str, app_state: &Dynamic) -> impl MakeWidget { } fn handle_login( - username: String, - password: MaskedString, + login_args: LoginArgs, api: &channel::Sender, app_state: &Dynamic, form_state: &Dynamic, - api_errors: &Dynamic>, + form_errors: &Dynamic>, ) { - let response = FakeApiRequestKind::SignUp { - username: username.clone(), - password, - } - .send_to(api); + let request = FakeApiRequestKind::SignUp { + username: login_args.username.clone(), + password: login_args.password, + }; + + let response = request + .send_to(api); + match response { FakeApiResponse::SignUpSuccess => { - app_state.set(AppState::LoggedIn { username }); + app_state.set(AppState::LoggedIn { username: login_args.username }); form_state.set(NewUserState::Done); } - FakeApiResponse::SignUpFailure(errors) => { + FakeApiResponse::SignUpFailure(mut errors) => { form_state.set(NewUserState::FormEntry); - api_errors.set(errors); + + // match up the API errors to form errors, there may not be a 1:1 relationship with form fields and api errors + let mut mapped_errors: Map = Default::default(); + + for code in errors.drain(..).into_iter() { + match code.try_into() { + Ok(FakeApiSignupErrorCode::UsernameReserved) | + Ok(FakeApiSignupErrorCode::UsernameUnavailable) + => { + // handle the two cases with the same error message + mapped_errors.insert(SignupFormField::Username, String::from("Username is a unavailable")); + }, + Ok(FakeApiSignupErrorCode::UsernameInvalid) + => { + mapped_errors.insert(SignupFormField::Username, String::from("Username is invalid")); + }, + Ok(FakeApiSignupErrorCode::PasswordInsecure) => { + mapped_errors.insert(SignupFormField::Password, String::from("Password is insecure")); + }, + Err(_) => { + // another error occurred with the API, but this implementation doesn't know how to handle it + } + } + } + + // Using `force_set` here, not `set`, in case the resulting `mapped_errors` were the same as the last time the API was used. + form_errors.force_set(mapped_errors); } } } @@ -313,14 +377,37 @@ struct FakeApiRequest { #[derive(Debug)] enum FakeApiResponse { - SignUpFailure(Map), + // the API returns numbers, which needs to be mapped to a specific error message + SignUpFailure(Vec), SignUpSuccess, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -enum SignupField { - Username, - Password, +#[repr(u32)] +enum FakeApiSignupErrorCode { + UsernameInvalid = 2, + UsernameUnavailable = 3, + UsernameReserved = 42, + PasswordInsecure = 69, +} + +impl TryFrom for FakeApiSignupErrorCode { + type Error = (); + + fn try_from(value: u32) -> Result { + match value { + 2 => Ok(FakeApiSignupErrorCode::UsernameInvalid), + 3 => Ok(FakeApiSignupErrorCode::UsernameUnavailable), + 42 => Ok(FakeApiSignupErrorCode::UsernameReserved), + 69 => Ok(FakeApiSignupErrorCode::PasswordInsecure), + _ => Err(()), + } + } +} + +impl Into for FakeApiSignupErrorCode { + fn into(self) -> u32 { + self as u32 + } } fn fake_service(request: FakeApiRequest) { @@ -329,17 +416,37 @@ fn fake_service(request: FakeApiRequest) { // Simulate this api taking a while thread::sleep(Duration::from_secs(1)); - let mut errors = Map::new(); + // Simulate some arbitrary server-side validation rules not-known to the form + fn has_more_than_four_digits(s: &str) -> bool { + s.chars().filter(|c| c.is_digit(10)).count() > 4 + } + + fn contains_year(s: &str) -> bool { + let re = Regex::new(r"(?:^|\D)(19[0-9]{2}|20[0-9]{2})(?:\D|$)").unwrap(); + re.is_match(s) + } + + let mut errors: Vec = Vec::default(); if username == "admin" { - errors.insert( - SignupField::Username, - String::from("admin is a reserved username"), + errors.push( + FakeApiSignupErrorCode::UsernameReserved.into(), + ); + } + if username == "user" { + errors.push( + FakeApiSignupErrorCode::UsernameUnavailable.into(), ); } - if *password == "password" { - errors.insert( - SignupField::Password, - String::from("'password' is not a strong password"), + + if has_more_than_four_digits(&username) || contains_year(&username) { + errors.push( + FakeApiSignupErrorCode::UsernameInvalid.into(), + ); + } + + if *password == "password" || contains_year(&password) || password.eq(&username) { + errors.push( + FakeApiSignupErrorCode::PasswordInsecure.into(), ); } diff --git a/src/reactive/value.rs b/src/reactive/value.rs index f9be2b970..4bd5608a1 100644 --- a/src/reactive/value.rs +++ b/src/reactive/value.rs @@ -636,9 +636,7 @@ pub trait Destination { /// "noisy". Cushy attempts to minimize noise by only invoking callbacks /// when the value has changed, and it detects this by using `PartialEq`. /// - /// However, not all types implement `PartialEq`. - /// [`map_mut()`](Self::map_mut) does not require `PartialEq`, and will - /// invoke change callbacks after accessing exclusively. + /// However, not all types implement `PartialEq`. See [`force_set()`](Self::force_set). fn set(&self, new_value: T) where T: PartialEq, @@ -646,6 +644,17 @@ pub trait Destination { let _old = self.replace(new_value); } + /// Stores `new_value` in this dynamic without checking for equality. + /// + /// Before returning from this function, all observers will be notified + /// that the contents have been updated. + fn force_set(&self, new_value: T) { + self.map_mut(|mut old_value|{ + let _old_value = std::mem::replace(&mut *old_value, new_value); + }); + } + + /// Replaces the current value with `new_value` if the current value is /// equal to `expected_current`. ///