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.
This commit is contained in:
alphafetus committed 2025-12-29 18:07:13 -05:00
1 parent 139ffdbe55
commit 74e7111820
9 files changed
+285 -67

No files matched your search

Generated
+1
View File
@@ -2947,6 +2947,7 @@ dependencies = [
"leptos_oidc", "leptos_oidc",
"leptos_router", "leptos_router",
"macros", "macros",
"regex",
"serde", "serde",
"serde_dynamo", "serde_dynamo",
"tokio", "tokio",
+1
View File
@@ -23,6 +23,7 @@ uuid = { version = "1.17.0", features = ["serde", "v4", "js"] }
leptos_oidc = "0.9.0" leptos_oidc = "0.9.0"
base64 = "0.22.1" base64 = "0.22.1"
chrono = "0.4.42" chrono = "0.4.42"
regex = "1.11.2"
macros = { path = "./macros" } macros = { path = "./macros" }
traits = { path = "./traits" } traits = { path = "./traits" }
+2 -2
View File
@@ -1,5 +1,5 @@
use crate::common::ValueType; 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::either::EitherOf5;
use leptos::prelude::RwSignal; use leptos::prelude::RwSignal;
use leptos::{IntoView, view}; use leptos::{IntoView, view};
@@ -49,7 +49,7 @@ impl InputType {
placeholder = placeholder.clone() placeholder = placeholder.clone()
data_member = member.clone() data_member = member.clone()
data_map = data_map data_map = data_map
input_type = "number" input_type = TextFieldType::Number
/> />
}), }),
InputType::Checkbox(member, label, options) => InputType::Checkbox(member, label, options) =>
+1 -1
View File
@@ -2,7 +2,7 @@ use leptos::prelude::*;
#[component] #[component]
pub fn ActionButton( 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<bool>, #[prop(optional, into)] disabled: Signal<bool>,
children: Children, children: Children,
) -> impl IntoView { ) -> impl IntoView {
+2 -1
View File
@@ -11,8 +11,9 @@ mod row;
mod select; mod select;
mod text_field; mod text_field;
mod utils; mod utils;
mod validated_form;
pub use self::{ pub use self::{
action_button::*, banner::*, checkbox::*, chips::*, dashboard_button::*, loading::*, 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::*
}; };
+121 -53
View File
@@ -1,69 +1,130 @@
use crate::common::ValueType; use crate::common::ValueType;
use leptos::leptos_dom::logging::console_log;
use leptos::prelude::*; use leptos::prelude::*;
use std::collections::HashMap; 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::<i32>().is_ok() {
ValidationState::Valid
} else {
ValidationState::Invalid("Value is not a valid number.".to_string())
}
}
#[component] #[component]
pub fn OutlinedTextField( pub fn OutlinedTextField(
#[prop(optional, into)] placeholder: String, #[prop(optional, into)] placeholder: String,
// #[prop()] value: Subfield<TStore, TInner, ValueType>,
#[prop(into)] data_member: String, // should this be an RwSignal?? #[prop(into)] data_member: String, // should this be an RwSignal??
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>, #[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop(optional)] value: RwSignal<ValueType>, #[prop(optional)] input_type: TextFieldType,
#[prop(default = "text".to_owned(), into)] input_type: String,
#[prop(into, optional)] disabled: Signal<bool>, #[prop(into, optional)] disabled: Signal<bool>,
#[prop(optional)] error: RwSignal<bool>,
#[prop(optional, into)] name: String, #[prop(optional, into)] name: String,
#[prop(optional, into)] label: String, #[prop(optional, into)] label: String,
#[prop(optional, into)] error_text: RwSignal<String>, #[prop(optional, into)] required: Signal<bool>
) -> impl IntoView { ) -> impl IntoView {
let valid = RwSignal::new(true);
let input_ref = NodeRef::<Input>::new();
// Register this input's validation signal.
let validator_context = use_context::<FormValidationRegistry>()
.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 // This function parses the input into the correct type. It only accepts numbers or strings as
// valid types, and parses them accordingly. // valid types, and parses them accordingly.
let on_input = { let on_input = {
let input_type = input_type.clone();
let data_member = data_member.clone(); let data_member = data_member.clone();
move |e| { move |e| {
let to_parse = event_target_value(&e); let to_parse = event_target_value(&e);
match input_type.as_str() { raw_value.set(to_parse.clone());
"text" => { error.set(validate(required.get(), &to_parse, &input_type));
// String parse function. It will always parse, but make sure the error let into_map = match input_type {
// state is set properly anyway. TextFieldType::Number => ValueType::Number(Some(to_parse)),
error.set(false); _ => ValueType::String(Some(to_parse))
value.set(ValueType::String(Some(to_parse.clone()))); };
data_map.update(|map| {
map.insert(data_member.clone(), 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.
"number" => { data_map.update(|map| {
// Define number parse function. map.insert(data_member.clone(), into_map);
// 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::<i32>()
.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);
}
}
} }
}; };
@@ -72,11 +133,12 @@ pub fn OutlinedTextField(
<label class="flex flex-col flex-1"> <label class="flex flex-col flex-1">
<span class="block ml-1.5 mb-0 font-bold">{label}</span> <span class="block ml-1.5 mb-0 font-bold">{label}</span>
<input <input
node_ref=input_ref
class="border-2 m-1.5 p-1.5 mt-0 rounded-md bg-transparent relative flex-1 class="border-2 m-1.5 p-1.5 mt-0 rounded-md bg-transparent relative flex-1
transition-all duration-150 transition-all duration-150
border-red-700 bg-transparent border-red-700 bg-transparent
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33" disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
r#type={input_type} r#type={str_type}
disabled={disabled} disabled={disabled}
placeholder={placeholder} placeholder={placeholder}
prop:name=name prop:name=name
@@ -86,12 +148,18 @@ pub fn OutlinedTextField(
.unwrap_or(&ValueType::String(None)) // ValueType .unwrap_or(&ValueType::String(None)) // ValueType
.to_string() .to_string()
on:input=on_input on:input=on_input
on:blur=on_blur
/> />
<div <Show when=move || show_errors.get()>
class="text-red-600 text-sm mr-1.5 ml-1.5" <div class="text-red-600 text-sm mr-1.5 ml-1.5">
class=(["hidden"], move || !error.get())> {move || {
{error_text} match error.get() {
</div> ValidationState::Invalid(msg) => msg,
_ => "There is no error - should not see this message.".to_string()
}
}}
</div>
</Show>
</label> </label>
</div> </div>
} }
+147
View File
@@ -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<ValidationState>,
/// Indicates whether the form has requested the error state. This means that the input
/// should show their error appearances immediately.
form_requested_validation: RwSignal<bool>,
}
impl InputState {
/// Creates a new [`InputState`] with the given error and form requested validation signals.
pub fn new(
name: impl Into<String>,
error: impl Into<Signal<ValidationState>>,
form_requested_errors: RwSignal<bool>
) -> Self {
Self {
input_name: name.into(),
error: error.into(),
form_requested_validation: form_requested_errors
}
}
pub fn error(&self) -> Signal<ValidationState> {
self.error.clone()
}
}
/// A list of [`InputState`]s.
pub type ValidatorList = Vec<RwSignal<InputState>>;
/// 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<ValidatorList>
}
/// # Validated Form Component
///
/// A simple HTML `<form>` 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::<FormValidationRegistry>()` to register
/// themselves with the form.
///
/// Example usage in the view macro:
/// ```
/// view! {
/// <ValidatedForm on_submit=Callback::new(move || {/* something here */})>
/// {/* As many input components as you want */}
/// </ValidatedForm>
/// }
/// ```
///
/// Example input component implementation:
/// ```
/// fn SomeComponent() -> impl IntoView {
/// // Get FormValidationContext
/// let context = use_context::<FormValidationRegistry>()
/// .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! {
<form novalidate on:submit=submit_success_event>
{children()}
<ActionButton
on:click=move |_| {
log!("{:?}", can_submit.get());
}
button_type="submit"
>
"Submit form (but actually)"
</ActionButton>
</form>
}
}
+6 -6
View File
@@ -9,12 +9,9 @@ use std::process::{Command, Stdio};
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use std::io::Write; use std::io::Write;
use crate::common::{ExpandableInfo, UserClaims}; use crate::common::ExpandableInfo;
use crate::pages::UnauthenticatedPage; use crate::pages::UnauthenticatedPage;
use crate::components::{ use crate::components::{ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row, Select, TextFieldType, ValidatedForm};
ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row,
Select,
};
use crate::input; use crate::input;
use leptos::leptos_dom::logging::console_log; use leptos::leptos_dom::logging::console_log;
use leptos::prelude::*; use leptos::prelude::*;
@@ -167,6 +164,7 @@ pub fn HomePage() -> impl IntoView {
<Authenticated unauthenticated=UnauthenticatedPage> <Authenticated unauthenticated=UnauthenticatedPage>
// Replace this fallback with a real loading screen. // Replace this fallback with a real loading screen.
<Suspense fallback=Loading> <Suspense fallback=Loading>
<ValidatedForm on_submit=Callback::new(move |_| {})>
{move || { {move || {
server_resource server_resource
.get() .get()
@@ -227,6 +225,7 @@ pub fn HomePage() -> impl IntoView {
disabled=elements_disabled disabled=elements_disabled
data_member="contact_email" data_member="contact_email"
data_map=expandable_react.data data_map=expandable_react.data
input_type=TextFieldType::Email
/> />
</Row> </Row>
<Row> <Row>
@@ -254,7 +253,7 @@ pub fn HomePage() -> impl IntoView {
disabled=elements_disabled disabled=elements_disabled
data_member="math_sat" data_member="math_sat"
data_map=expandable_react.data data_map=expandable_react.data
input_type="number" input_type=TextFieldType::Number
/> />
</Row> </Row>
<Row> <Row>
@@ -357,6 +356,7 @@ pub fn HomePage() -> impl IntoView {
} }
}) })
}} }}
</ValidatedForm>
</Suspense> </Suspense>
</Authenticated> </Authenticated>
</AuthLoaded> </AuthLoaded>
+4 -4
View File
@@ -5,7 +5,7 @@ use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated}; use leptos_oidc::{AuthLoaded, Authenticated};
use leptos_router::hooks::{use_navigate, use_params}; use leptos_router::hooks::{use_navigate, use_params};
use crate::common::{ExpandableInfo, ScholarshipFormParams, SubmitStatus, ValueType}; 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 super::UnauthenticatedPage;
use traits::{AsReactive, ReactiveCapture}; use traits::{AsReactive, ReactiveCapture};
use crate::common::ComparisonData; use crate::common::ComparisonData;
@@ -461,7 +461,7 @@ fn ScholarshipForm(
disabled=elements_disabled disabled=elements_disabled
data_member="amount_per_award" data_member="amount_per_award"
data_map=reactive_info.data data_map=reactive_info.data
input_type="number" input_type=TextFieldType::Number
/> />
</Row> </Row>
<Row> <Row>
@@ -471,7 +471,7 @@ fn ScholarshipForm(
disabled=elements_disabled disabled=elements_disabled
data_member="num_awards" data_member="num_awards"
data_map=reactive_info.data data_map=reactive_info.data
input_type="number" input_type=TextFieldType::Number
/> />
</Row> </Row>
<Row> <Row>
@@ -481,7 +481,7 @@ fn ScholarshipForm(
disabled=elements_disabled disabled=elements_disabled
data_member="total_awards" data_member="total_awards"
data_map=reactive_info.data data_map=reactive_info.data
input_type="number" input_type=TextFieldType::Number
/> />
</Row> </Row>
<Row> <Row>