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:
1 parent
139ffdbe55
commit
74e7111820
9 files changed
+285
-67
No files matched your search
Generated
+1
@@ -2947,6 +2947,7 @@ dependencies = [
|
||||
"leptos_oidc",
|
||||
"leptos_router",
|
||||
"macros",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_dynamo",
|
||||
"tokio",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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<bool>,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
|
||||
@@ -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::*
|
||||
};
|
||||
+121
-53
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
<Authenticated unauthenticated=UnauthenticatedPage>
|
||||
// Replace this fallback with a real loading screen.
|
||||
<Suspense fallback=Loading>
|
||||
<ValidatedForm on_submit=Callback::new(move |_| {})>
|
||||
{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
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
@@ -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
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
@@ -357,6 +356,7 @@ pub fn HomePage() -> impl IntoView {
|
||||
}
|
||||
})
|
||||
}}
|
||||
</ValidatedForm>
|
||||
</Suspense>
|
||||
</Authenticated>
|
||||
</AuthLoaded>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
@@ -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
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
@@ -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
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
|
||||
Reference in new issue
Block a user