From 74e71118205e9f082da14647b3d4ec0b8e6c6210 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Mon, 29 Dec 2025 18:07:13 -0500 Subject: [PATCH] feat: implement new ValidatedForm and error handling for text fields Note that text fields only accept Region 15 email addresses right now. We should add extra domains in the future. --- Cargo.lock | 1 + Cargo.toml | 1 + src/common/form_schema.rs | 4 +- src/components/action_button.rs | 2 +- src/components/mod.rs | 3 +- src/components/text_field.rs | 174 +++++++++++++++++++++---------- src/components/validated_form.rs | 147 ++++++++++++++++++++++++++ src/pages/home_page.rs | 12 +-- src/pages/scholarship_info.rs | 8 +- 9 files changed, 285 insertions(+), 67 deletions(-) create mode 100644 src/components/validated_form.rs diff --git a/Cargo.lock b/Cargo.lock index 67bcabe..3f10111 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2947,6 +2947,7 @@ dependencies = [ "leptos_oidc", "leptos_router", "macros", + "regex", "serde", "serde_dynamo", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 690e781..3bd7f60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ uuid = { version = "1.17.0", features = ["serde", "v4", "js"] } leptos_oidc = "0.9.0" base64 = "0.22.1" chrono = "0.4.42" +regex = "1.11.2" macros = { path = "./macros" } traits = { path = "./traits" } diff --git a/src/common/form_schema.rs b/src/common/form_schema.rs index e5ec97e..0d59eaf 100644 --- a/src/common/form_schema.rs +++ b/src/common/form_schema.rs @@ -1,5 +1,5 @@ use crate::common::ValueType; -use crate::components::{CheckboxList, OutlinedTextField, RadioList, Row, Select}; +use crate::components::{CheckboxList, OutlinedTextField, RadioList, Row, Select, TextFieldType}; use leptos::either::EitherOf5; use leptos::prelude::RwSignal; use leptos::{IntoView, view}; @@ -49,7 +49,7 @@ impl InputType { placeholder = placeholder.clone() data_member = member.clone() data_map = data_map - input_type = "number" + input_type = TextFieldType::Number /> }), InputType::Checkbox(member, label, options) => diff --git a/src/components/action_button.rs b/src/components/action_button.rs index 8d9728e..9207d84 100644 --- a/src/components/action_button.rs +++ b/src/components/action_button.rs @@ -2,7 +2,7 @@ use leptos::prelude::*; #[component] pub fn ActionButton( - #[prop(default = String::from("button"))] button_type: String, + #[prop(default = String::from("button"), into)] button_type: String, #[prop(optional, into)] disabled: Signal, children: Children, ) -> impl IntoView { diff --git a/src/components/mod.rs b/src/components/mod.rs index a66f141..650f63e 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -11,8 +11,9 @@ mod row; mod select; mod text_field; mod utils; +mod validated_form; pub use self::{ action_button::*, banner::*, checkbox::*, chips::*, dashboard_button::*, loading::*, - multi_entry::*, panel::*, radio::*, row::*, select::*, text_field::*, + multi_entry::*, panel::*, radio::*, row::*, select::*, text_field::*, validated_form::* }; diff --git a/src/components/text_field.rs b/src/components/text_field.rs index 4dae16c..5c22009 100644 --- a/src/components/text_field.rs +++ b/src/components/text_field.rs @@ -1,69 +1,130 @@ use crate::common::ValueType; -use leptos::leptos_dom::logging::console_log; use leptos::prelude::*; use std::collections::HashMap; +use leptos::html::Input; +use leptos::logging::log; +use crate::components::InputState; +use crate::components::validated_form::{FormValidationRegistry, ValidationState}; + +/// Determines the available types of text fields. +#[derive(Default, Debug, Clone)] +pub enum TextFieldType { + /// A text field that accepts any string. + #[default] + Text, + /// A text field that accepts only numbers. + Number, + /// A text field that accepts only Region 15 email addresses. + /// TODO Accept more than just Region 15 addresses. + Email +} + +/// Entry point for validating a text field. +fn validate(required: bool, value: &str, input_type: &TextFieldType) -> ValidationState { + log!("Input information: required: {}, value: \"{}\", input_type: {:?}", required, value, input_type); + if required && value.is_empty() { + ValidationState::Invalid("This field is required.".to_string()) + } else { + match input_type { + TextFieldType::Email => validate_email(value), + TextFieldType::Number => validate_number(value), + TextFieldType::Text => ValidationState::Valid + } + } +} + +/// Validates an email address. +fn validate_email(input: &str) -> ValidationState { + // TODO Accept more than just Region 15 addresses. + log!("Validating email: {}", input); + let pattern = regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@region15\.org$").expect("Invalid regex pattern"); + + if pattern.is_match(input) { + ValidationState::Valid + } else { + ValidationState::Invalid("Please provide a valid Region 15 email address.".to_string()) + } +} + +/// Validates a number. +fn validate_number(input: &str) -> ValidationState { + log!("Validating number: {}", input); + if input.parse::().is_ok() { + ValidationState::Valid + } else { + ValidationState::Invalid("Value is not a valid number.".to_string()) + } +} #[component] pub fn OutlinedTextField( #[prop(optional, into)] placeholder: String, - // #[prop()] value: Subfield, #[prop(into)] data_member: String, // should this be an RwSignal?? #[prop()] data_map: RwSignal>, - #[prop(optional)] value: RwSignal, - #[prop(default = "text".to_owned(), into)] input_type: String, + #[prop(optional)] input_type: TextFieldType, #[prop(into, optional)] disabled: Signal, - #[prop(optional)] error: RwSignal, #[prop(optional, into)] name: String, #[prop(optional, into)] label: String, - #[prop(optional, into)] error_text: RwSignal, + #[prop(optional, into)] required: Signal ) -> impl IntoView { + let valid = RwSignal::new(true); + let input_ref = NodeRef::::new(); + + // Register this input's validation signal. + let validator_context = use_context::() + .expect("FormValidSignal was not found"); + + let raw_value = RwSignal::new(data_map.get_untracked() + .get(&data_member) + .unwrap_or(&ValueType::String(None)) + .to_string()); + + let dirty = RwSignal::new(false); + let error = RwSignal::new(validate(required.get_untracked(), &raw_value.get_untracked(), &input_type)); + let show_errors = Signal::derive(move || { + dirty.get() && matches!(error.get(), ValidationState::Invalid(_)) + }); + + let validator = RwSignal::new(InputState::new(data_member.clone(), error.clone(), dirty.clone())); + + validator_context.validators.update(|list| list.push(validator)); + + on_cleanup(move || { + validator_context.validators.update(|list| { + list.retain(|v| *v.get_untracked().input_name != *validator.get_untracked().input_name) + }); + }); + + let str_type = match input_type { + TextFieldType::Number => "number", + _ => "text" // This is fine even for emails, as we're doing our own validation. + }; + + let on_blur = move |_| { + // Check validity of input's current value. The value will be updated by on_input, + // and it should be a raw input (not parsed into a ValueType). + dirty.set(true); + }; + // This function parses the input into the correct type. It only accepts numbers or strings as // valid types, and parses them accordingly. let on_input = { - let input_type = input_type.clone(); let data_member = data_member.clone(); move |e| { let to_parse = event_target_value(&e); - match input_type.as_str() { - "text" => { - // String parse function. It will always parse, but make sure the error - // state is set properly anyway. - error.set(false); - value.set(ValueType::String(Some(to_parse.clone()))); - data_map.update(|map| { - map.insert(data_member.clone(), ValueType::String(Some(to_parse))); - }); - } - "number" => { - // Define number parse function. - // If the number parses, Some(num), else None, but we want to keep the value that's - // in the text field at this point, just set the error. - console_log(format!("Parsing {to_parse} to a number").as_str()); - match to_parse - .parse::() - .map(|n| ValueType::Number(Some(n.to_string()))) - { - Ok(value_type) => { - error.set(false); - value.set(value_type.clone()); - data_map.update(|map| { - map.insert(data_member.clone(), value_type); - }); - } - Err(_) => { - error.set(true); - error_text.set("Value is not a valid number.".to_owned()); - } - }; - } - _ => { - // Create some custom function that reports failure. - let msg = format!("Input {input_type:?} is not a valid type."); - console_log(msg.as_str()); - error.set(true); - error_text.set(msg); - } - } + raw_value.set(to_parse.clone()); + error.set(validate(required.get(), &to_parse, &input_type)); + let into_map = match input_type { + TextFieldType::Number => ValueType::Number(Some(to_parse)), + _ => ValueType::String(Some(to_parse)) + }; + + // Edit the data map. Note that this should be uncoupled in the future - we don't want + // to update the map inside the component definition, but inside the page/form that + // created this input. + data_map.update(|map| { + map.insert(data_member.clone(), into_map); + }); } }; @@ -72,11 +133,12 @@ pub fn OutlinedTextField( } diff --git a/src/components/validated_form.rs b/src/components/validated_form.rs new file mode 100644 index 0000000..5ae17bb --- /dev/null +++ b/src/components/validated_form.rs @@ -0,0 +1,147 @@ +use leptos::logging::log; +use leptos::prelude::*; +use leptos::web_sys::SubmitEvent; +use crate::components::ActionButton; + +/// Indicates the current state of an input. +#[derive(Default, Debug, Clone)] +pub enum ValidationState { + /// A valid input. + #[default] + Valid, + /// An invalid input with an error message. + Invalid(String) +} + +/// A struct that contains all information required for a form to make real-time validation +/// decisions, including display of error messages. +#[derive(Debug, Clone)] +pub struct InputState { + /// The name of the input. This is used to identify the input in the form. + pub input_name: String, + /// Indicates the current state of the input. + error: Signal, + /// Indicates whether the form has requested the error state. This means that the input + /// should show their error appearances immediately. + form_requested_validation: RwSignal, +} + +impl InputState { + /// Creates a new [`InputState`] with the given error and form requested validation signals. + pub fn new( + name: impl Into, + error: impl Into>, + form_requested_errors: RwSignal + ) -> Self { + Self { + input_name: name.into(), + error: error.into(), + form_requested_validation: form_requested_errors + } + } + + pub fn error(&self) -> Signal { + self.error.clone() + } +} + +/// A list of [`InputState`]s. +pub type ValidatorList = Vec>; + +/// A struct that keeps track of all components that must be validated. +/// All of our custom-made components must be placed within an area that contains context with +/// this struct. +#[derive(Debug, Clone)] +pub struct FormValidationRegistry { + pub validators: RwSignal +} + +/// # Validated Form Component +/// +/// A simple HTML `
` wrapper that executes validation logic using a [`FormValidationRegistry`]. +/// It will ensure that any invalid inputs are caught before submission. All input components that +/// are used within this form should call `use_context::()` to register +/// themselves with the form. +/// +/// Example usage in the view macro: +/// ``` +/// view! { +/// +/// {/* As many input components as you want */} +/// +/// } +/// ``` +/// +/// Example input component implementation: +/// ``` +/// fn SomeComponent() -> impl IntoView { +/// // Get FormValidationContext +/// let context = use_context::() +/// .expect("Couldn't find FormValidationRegsitry context"); +/// +/// // Register with the form +/// let error = RwSignal::new(ValidationState::Valid); +/// let form_requested_errors = RwSignal::new(false); +/// let state = InputState::new( +/// "the_input_name", +/// error.clone(), +/// form_requested_errors.clone() +/// ); +/// context.validators.update(|list| list.push(RwSignal::new(state))); +/// +/// /* Other input view logic */ +/// } +/// ``` +#[component] +pub fn ValidatedForm( + children: Children, + /// A callback that runs when the form successfully validates. + #[prop(into)] on_submit: Callback<()>, +) -> impl IntoView { + let validators = RwSignal::new(vec![]); + + // Provide context to all children nested within this component. They will be able to access + // this no matter how nested they are. + provide_context(FormValidationRegistry {validators}); + + let can_submit = Memo::new(move |_| { + log!("Checking {} inputs...", validators.get().len()); + validators + .with(|list| { + list.iter() + // TODO all() short circuits on the first false + .all(|v| { + // Not sure how this will work reactively. + let result = matches!(v.get().error().get(), ValidationState::Valid); + v.update(|validator| validator.form_requested_validation.set(true)); + result + }) + }) + }); + + let submit_success_event = move |e: SubmitEvent| { + e.prevent_default(); + + if !can_submit.get() { + log!("Form failed to submit, there are errors in form inputs."); + return; + } + + log!("Form validation successful."); + on_submit.run(()); + }; + + view! { + + {children()} + + "Submit form (but actually)" + + + } +} diff --git a/src/pages/home_page.rs b/src/pages/home_page.rs index f4c6ed0..ac81f31 100644 --- a/src/pages/home_page.rs +++ b/src/pages/home_page.rs @@ -9,12 +9,9 @@ use std::process::{Command, Stdio}; #[cfg(feature = "ssr")] use std::io::Write; -use crate::common::{ExpandableInfo, UserClaims}; +use crate::common::ExpandableInfo; use crate::pages::UnauthenticatedPage; -use crate::components::{ - ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row, - Select, -}; +use crate::components::{ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row, Select, TextFieldType, ValidatedForm}; use crate::input; use leptos::leptos_dom::logging::console_log; use leptos::prelude::*; @@ -167,6 +164,7 @@ pub fn HomePage() -> impl IntoView { // Replace this fallback with a real loading screen. + {move || { server_resource .get() @@ -227,6 +225,7 @@ pub fn HomePage() -> impl IntoView { disabled=elements_disabled data_member="contact_email" data_map=expandable_react.data + input_type=TextFieldType::Email /> @@ -254,7 +253,7 @@ pub fn HomePage() -> impl IntoView { disabled=elements_disabled data_member="math_sat" data_map=expandable_react.data - input_type="number" + input_type=TextFieldType::Number /> @@ -357,6 +356,7 @@ pub fn HomePage() -> impl IntoView { } }) }} + diff --git a/src/pages/scholarship_info.rs b/src/pages/scholarship_info.rs index b40bff1..6fc24a2 100644 --- a/src/pages/scholarship_info.rs +++ b/src/pages/scholarship_info.rs @@ -5,7 +5,7 @@ use leptos::prelude::*; use leptos_oidc::{AuthLoaded, Authenticated}; use leptos_router::hooks::{use_navigate, use_params}; use crate::common::{ExpandableInfo, ScholarshipFormParams, SubmitStatus, ValueType}; -use crate::components::{ActionButton, Banner, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row}; +use crate::components::{ActionButton, Banner, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row, TextFieldType}; use super::UnauthenticatedPage; use traits::{AsReactive, ReactiveCapture}; use crate::common::ComparisonData; @@ -461,7 +461,7 @@ fn ScholarshipForm( disabled=elements_disabled data_member="amount_per_award" data_map=reactive_info.data - input_type="number" + input_type=TextFieldType::Number /> @@ -471,7 +471,7 @@ fn ScholarshipForm( disabled=elements_disabled data_member="num_awards" data_map=reactive_info.data - input_type="number" + input_type=TextFieldType::Number /> @@ -481,7 +481,7 @@ fn ScholarshipForm( disabled=elements_disabled data_member="total_awards" data_map=reactive_info.data - input_type="number" + input_type=TextFieldType::Number />