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

+121 -53
View File
@@ -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::<i32>().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<TStore, TInner, ValueType>,
#[prop(into)] data_member: String, // should this be an RwSignal??
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop(optional)] value: RwSignal<ValueType>,
#[prop(default = "text".to_owned(), into)] input_type: String,
#[prop(optional)] input_type: TextFieldType,
#[prop(into, optional)] disabled: Signal<bool>,
#[prop(optional)] error: RwSignal<bool>,
#[prop(optional, into)] name: String,
#[prop(optional, into)] label: String,
#[prop(optional, into)] error_text: RwSignal<String>,
#[prop(optional, into)] required: Signal<bool>
) -> 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
// 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::<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);
}
}
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(
<label class="flex flex-col flex-1">
<span class="block ml-1.5 mb-0 font-bold">{label}</span>
<input
node_ref=input_ref
class="border-2 m-1.5 p-1.5 mt-0 rounded-md bg-transparent relative flex-1
transition-all duration-150
border-red-700 bg-transparent
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
r#type={input_type}
r#type={str_type}
disabled={disabled}
placeholder={placeholder}
prop:name=name
@@ -86,12 +148,18 @@ pub fn OutlinedTextField(
.unwrap_or(&ValueType::String(None)) // ValueType
.to_string()
on:input=on_input
on:blur=on_blur
/>
<div
class="text-red-600 text-sm mr-1.5 ml-1.5"
class=(["hidden"], move || !error.get())>
{error_text}
</div>
<Show when=move || show_errors.get()>
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
{move || {
match error.get() {
ValidationState::Invalid(msg) => msg,
_ => "There is no error - should not see this message.".to_string()
}
}}
</div>
</Show>
</label>
</div>
}