Implement error handling #41
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