Implement error handling #41

Merged
BobdaFett merged 15 commits from feat/error-handling into main 2026-01-06 19:06:07 +00:00
9 changed files with 285 additions and 67 deletions
Showing only changes of commit 74e7111820 - Show all commits

No files matched your search

Generated
+1
View File
@@ -2947,6 +2947,7 @@ dependencies = [
"leptos_oidc",
"leptos_router",
"macros",
"regex",
"serde",
"serde_dynamo",
"tokio",
+1
View File
@@ -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" }
+2 -2
View File
@@ -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) =>
+1 -1
View File
@@ -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 {
+2 -1
View File
@@ -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
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>
}
+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")]
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>
+4 -4
View File
@@ -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>