From 74e71118205e9f082da14647b3d4ec0b8e6c6210 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Mon, 29 Dec 2025 18:07:13 -0500 Subject: [PATCH 01/15] 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. --- Cargo.lock | 1 + Cargo.toml | 1 + src/common/form_schema.rs | 4 +- src/components/action_button.rs | 2 +- src/components/mod.rs | 3 +- src/components/text_field.rs | 174 +++++++++++++++++++++---------- src/components/validated_form.rs | 147 ++++++++++++++++++++++++++ src/pages/home_page.rs | 12 +-- src/pages/scholarship_info.rs | 8 +- 9 files changed, 285 insertions(+), 67 deletions(-) create mode 100644 src/components/validated_form.rs diff --git a/Cargo.lock b/Cargo.lock index 67bcabe..3f10111 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2947,6 +2947,7 @@ dependencies = [ "leptos_oidc", "leptos_router", "macros", + "regex", "serde", "serde_dynamo", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 690e781..3bd7f60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/src/common/form_schema.rs b/src/common/form_schema.rs index e5ec97e..0d59eaf 100644 --- a/src/common/form_schema.rs +++ b/src/common/form_schema.rs @@ -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) => diff --git a/src/components/action_button.rs b/src/components/action_button.rs index 8d9728e..9207d84 100644 --- a/src/components/action_button.rs +++ b/src/components/action_button.rs @@ -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, children: Children, ) -> impl IntoView { diff --git a/src/components/mod.rs b/src/components/mod.rs index a66f141..650f63e 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -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::* }; diff --git a/src/components/text_field.rs b/src/components/text_field.rs index 4dae16c..5c22009 100644 --- a/src/components/text_field.rs +++ b/src/components/text_field.rs @@ -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::().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, #[prop(into)] data_member: String, // should this be an RwSignal?? #[prop()] data_map: RwSignal>, - #[prop(optional)] value: RwSignal, - #[prop(default = "text".to_owned(), into)] input_type: String, + #[prop(optional)] input_type: TextFieldType, #[prop(into, optional)] disabled: Signal, - #[prop(optional)] error: RwSignal, #[prop(optional, into)] name: String, #[prop(optional, into)] label: String, - #[prop(optional, into)] error_text: RwSignal, + #[prop(optional, into)] required: Signal ) -> impl IntoView { + let valid = RwSignal::new(true); + let input_ref = NodeRef::::new(); + + // Register this input's validation signal. + let validator_context = use_context::() + .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::() - .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( } diff --git a/src/components/validated_form.rs b/src/components/validated_form.rs new file mode 100644 index 0000000..5ae17bb --- /dev/null +++ b/src/components/validated_form.rs @@ -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, + /// Indicates whether the form has requested the error state. This means that the input + /// should show their error appearances immediately. + form_requested_validation: RwSignal, +} + +impl InputState { + /// Creates a new [`InputState`] with the given error and form requested validation signals. + pub fn new( + name: impl Into, + error: impl Into>, + form_requested_errors: RwSignal + ) -> Self { + Self { + input_name: name.into(), + error: error.into(), + form_requested_validation: form_requested_errors + } + } + + pub fn error(&self) -> Signal { + self.error.clone() + } +} + +/// A list of [`InputState`]s. +pub type ValidatorList = Vec>; + +/// 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 +} + +/// # Validated Form Component +/// +/// A simple HTML `
` 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::()` to register +/// themselves with the form. +/// +/// Example usage in the view macro: +/// ``` +/// view! { +/// +/// {/* As many input components as you want */} +/// +/// } +/// ``` +/// +/// Example input component implementation: +/// ``` +/// fn SomeComponent() -> impl IntoView { +/// // Get FormValidationContext +/// let context = use_context::() +/// .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! { + + {children()} + + "Submit form (but actually)" + + + } +} diff --git a/src/pages/home_page.rs b/src/pages/home_page.rs index f4c6ed0..ac81f31 100644 --- a/src/pages/home_page.rs +++ b/src/pages/home_page.rs @@ -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 { // Replace this fallback with a real loading screen. + {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 /> @@ -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 /> @@ -357,6 +356,7 @@ pub fn HomePage() -> impl IntoView { } }) }} + diff --git a/src/pages/scholarship_info.rs b/src/pages/scholarship_info.rs index b40bff1..6fc24a2 100644 --- a/src/pages/scholarship_info.rs +++ b/src/pages/scholarship_info.rs @@ -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 /> @@ -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 /> @@ -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 /> -- 2.54.0 From 85ec857e38db14bbcc8736ca0d9e400e6023b88d Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Mon, 29 Dec 2025 18:26:26 -0500 Subject: [PATCH 02/15] feat: sweeping improvements to home_page.rs Completely refactors the ownership of all data such that server resources are loaded into a signal asynchronously, then displayed in the form properly. All button handlers are also now separately defined functions, instead of being inline. --- scratch.md | 202 ++++++++------------ src/components/checkbox.rs | 4 +- src/components/select.rs | 2 +- src/pages/home_page.rs | 373 ++++++++++++++++++------------------- 4 files changed, 259 insertions(+), 322 deletions(-) diff --git a/scratch.md b/scratch.md index e8065a7..0ffd88b 100644 --- a/scratch.md +++ b/scratch.md @@ -1,136 +1,86 @@ -Forms should be built for this application with a series of JSON-type files. -Each form will have a series of sections, and each section will have a series of -questions. Each question has a type and a label - question styles may come later. -It may be possible for us to call out specific question types without needing -to parse the types separately, but that feels not-really-worth-it. +To track errors and force form input validation, we're going to create a custom `ValidatedForm` component. -Example: +This component will do two things: + - Provide a submit button (that is disabled if the form is invalid, still thinking about that) + - Define a validation method that will be called on submit that's updated on a per-input basis + (this likely looks like a `Vec>`) -``` -{ - "SectionDefinitions": [ - "SectionTitle": [ - "text": { - "label": "First name question!", - "placeholder": "John", - }, - "text": { - "label": "Last name question!", - "placeholder": "Smith", - } - ], - "Section Two": [ - "text": { - "label": "Section two testing question", - "placeholder": "Some example here", - }, - "select": { - "label": "This is a dropdown element!", - "placeholder": "option1", - "possibleValues": [ - "option1": "something", - "option2": "another something", - ] - } - ] - ] -} -``` +In order for this to work, the form will `provide_context` to the `Vec`, and then it will check this +vector on submit. It doesn't worry about the states inbetween, only during the submission logic. -...and so on. Here's a likely set of structures to use: +The inputs start off with a default state, which will be defined by that input's validation method. +This means that every input will run the validation method on mount and on each change. The first time the +function is called should be during mount, which will set the default state. This does mean that any inputs +that are marked as required will always be invalid on mount, and so we need to determine when these +error messages should be shown and updated. -``` -struct FormDefinitionDoc { - Vec
section_defs; - // and any extra values that should be applied - // across the entire form, like settings. +I think they should be shown in these situations: + - Text inputs: on blur - we don't want to scream at users as they type. + - Selectable inputs: on change and form submit - only show the message if the value is invalid, and not at mount. + +To check values for the selectable inputs at submit time, we'll probably need to provide a refresh method +that can be called on submit. This method must somehow be callable from the outside of the component and +will show the error messages. We'd likely store all this in a struct of some kind, which could contain an +`error` and a `show_message` signal. + +We already know that we can send any `Signal` to another component, so we can just send the `show_message` +signal to the parent component without any issues. We'll just use that signal to show the error messages +in the component as well. Usually we don't want to edit too many signals outside their local context, +but this is a special case – documentation is therefore incredibly important! Remember that we may also +edit the `show_message` signal within its local context as well, so we need to make sure there are no cyclical +dependencies. Alternatively, we could not edit the `show_message` signal at all locally and just use it +to trigger side effects that show messages. Have to think about this more before implementation. + +Here's a rough sketch of it all put together: +```rust +enum ValidationState { + /// A valid input! + Valid, + /// An invalid input with an error message. + Invalid(String) } -struct Section { - Vec questions; - // each section may have a series of settings including how to submit, - // when to submit, and where to submit to at the very least. +// We'll store this in a Vec> +struct InputState { + /// Determines if there is an error and provides an error message. + error: Signal, + /// Determines if the form has requested to show error messages. + show_message: RwSignal } -struct QuestionComponent { - label: String, - placeholder: String, - possible_values: HashMap, - // there are definitely values that i'll find while creating this structure. +// Define a new type for ease of use. +type ValidationVec = Vec>; + +// Parent form component +#[component] +fn Form() -> impl IntoView { + let validation_states: ValidationVec = Vec::new(); + + provide_context(validation_states); + + /* All other form logic */ +} + +fn validation_function(input: String) -> ValidationState { + // Validation logic is specific to each input type. +} + +// Child component +#[component] +fn AnyInput() -> impl IntoView { + let validation_states = use_context::().expect("Couldn't find form validation context"); + let current_input = RwSignal::new(String::new()); // Obviously, this would have a default value. + let error_signal = RwSignal::new(validation_function(current_input.get())); + let show_errors = RwSignal::new(false); // Don't show them on mount! + + // See below for InputState definition + let error_state = InputState { + error: error_signal.clone().into(), + show_message: show_errors.clone() + }; + + validation_states.push(RwSignal::new()) + + /* All other input logic */ } ``` - -Types of questions that are required, starting with the providers. - -Providers must be able to: - - - Log into the website using a username and password (handled by AWS) - - Create and manage multiple scholarships. - - Input scholarship information for each scholarship. - - View students that have applied. - - View student application information (should just be labels??) - - Buttons are a given. Duh. - -Students must be able to: - - - Log in using their Region 15 email (handled by AWS) - - Create and manage their general application. - - Create and manage applications specific to a single scholarship. - - View their acceptance status (need more confirmation) - -Administrators must be able to: - - - Log in using their Region 15 email (handled by AWS) - - Manage student scholarship applications. - - Manage scholarship provider account information. - - Create/manage scholarship information. - - Download student applications if needed. - - Statistics would be nice, but are likely a future thing. - -Right off the bad, I think I can see a few types of buttons and text inputs, -along with dropdowns, checkboxes, our custom `MultiEntry` element from last -time. That means no new options, but it does mean that the `FormDefinition` -needs to be able to keep track of all this information. - -These are some example `QuestionComponent` objects: - -Text fields: - -``` -{ - question_type: "TEXT" | "EMAIL" | "NUMBER", - label: "The question label", - name: "input_name[jsdoc]", - placeholder: "An example input" -} -``` - -Dropdowns/checkboxes/radios: - -``` -{ - question_type: "DROPDOWN" | "CHECKBOX" | "RADIO", - label: "The question label", - name: "input_name[jsdoc]", - placeholder: "The pre-selected option value", - options: [ - "optionValue": "Option Text" - ] -} -``` - -`MultiEntry`: - -``` -{ - question_type: "MULTI", - label: "The question label", - name: "input_name[jsdoc]" -} -``` - -The real question here: how can we store this information in a database? And -this means specifically within DynamoDB, because this is not an easy structure -to store in there. -That's going to be the hardest apart and I'll likely involve the AI in that -planning. diff --git a/src/components/checkbox.rs b/src/components/checkbox.rs index ba711dc..287b1a7 100644 --- a/src/components/checkbox.rs +++ b/src/components/checkbox.rs @@ -9,7 +9,7 @@ pub fn Checkbox( #[prop(into)] on_change: Callback<(), ()>, #[prop(into)] value: String, #[prop(into)] name: String, - #[prop(default = RwSignal::new(false))] disabled: RwSignal, + #[prop(into)] disabled: Signal, ) -> impl IntoView { let id = create_unique_id(&name, &value); @@ -53,7 +53,7 @@ pub fn CheckboxList( #[prop(into)] data_member: String, #[prop()] data_map: RwSignal>, #[prop()] items: Vec, - #[prop(default = RwSignal::new(false))] disabled: RwSignal, + #[prop(optional, into)] disabled: Signal, #[prop(optional, into)] label: String, ) -> impl IntoView { view! { diff --git a/src/components/select.rs b/src/components/select.rs index 9f5fe3c..aaa91c7 100644 --- a/src/components/select.rs +++ b/src/components/select.rs @@ -15,7 +15,7 @@ pub fn Select( #[prop(into)] data_member: String, #[prop()] data_map: RwSignal>, #[prop(optional, into)] label: String, - #[prop(default = RwSignal::new(false))] disabled: RwSignal, + #[prop(optional, into)] disabled: Signal, ) -> impl IntoView { let on_change = { let data_member = data_member.clone(); diff --git a/src/pages/home_page.rs b/src/pages/home_page.rs index ac81f31..a7f2ce2 100644 --- a/src/pages/home_page.rs +++ b/src/pages/home_page.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; // Server dependencies #[cfg(feature = "ssr")] use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue}; @@ -17,10 +18,10 @@ use leptos::leptos_dom::logging::console_log; use leptos::prelude::*; use leptos_oidc::{AuthLoaded, Authenticated}; use base64::Engine; +use leptos::logging::log; use leptos::task::spawn_local; use leptos::web_sys::HtmlAnchorElement; use leptos::wasm_bindgen::JsCast; -use traits::{AsReactive, ReactiveCapture}; use crate::pages::utils::get_user_claims; /// # Get Student Info @@ -141,222 +142,208 @@ pub async fn create_example_pdf() -> Result, ServerFnError> { /// The main home page component. Contains a simple contact form. #[component] pub fn HomePage() -> impl IntoView { - // Creates a reactive value to update the button + //#region Server Resources let user_claims = get_user_claims(); + let user_subject = Memo::new(move |_| { + user_claims.get() + .map(|claim| claim.claims.subject.clone()) + }); - // Note that the value passed in MUST be equatable. - // We get/unwrap the value repeatedly until we get a simple string value, then clone it so that - // we don't lose access to it in the future, should we need it again. - let server_resource: Resource = Resource::new( - move || user_claims.get().map(|claim| claim.claims.subject.clone()), - async |opt_username| match opt_username { - Some(subject) => get_submission(subject.clone()).await.unwrap_or_else(|e| { - console_log(e.to_string().as_str()); - ExpandableInfo::new(subject) - }), - None => ExpandableInfo::new("".to_owned()), - }, + let form_data = RwSignal::new(HashMap::new()); + + let server_resource = Resource::new( + move || user_subject.get(), + async move |subject| get_submission(subject.unwrap_or_default()).await ); let submit_action = ServerAction::::new(); + + Effect::new(move || { + match server_resource.get() { + Some(Ok(submission)) => { + form_data.set(submission.data); + } + Some(Err(err)) => { + log!("Error while getting information: {:?}", err); + } + _ => { + log!("An unknown error occurred."); + } + } + }); + //#endregion + //#region Component State + let elements_disabled = Signal::derive(move || { + submit_action.pending().get() + }); + //#endregion + //#region Event logic + let on_submit = move |_| { + let mut exp_info = ExpandableInfo::new(user_subject.get().unwrap_or_default()); + exp_info.data = form_data.get(); + submit_action.dispatch(CreateSampleSubmission { + student_info: exp_info + }); + }; + + let pdf_button_click = move |_| { + console_log("Attempting to get PDF from server endpoint"); + spawn_local(async move { + let result = create_example_pdf().await; + if let Ok(bytes) = result { + let base64 = base64::engine::general_purpose::STANDARD + .encode(bytes); + let data_url = format!( + "data:application/pdf;base64,{}", + base64, + ); + console_log("Found document, opening in new tab..."); + let link = document() + .create_element("a") + .unwrap() + .dyn_into::() + .unwrap(); + link.set_href(&*data_url); + link.set_target("_blank"); + link.click(); + } + }); + }; + //#endregion view! { // Replace this fallback with a real loading screen. - {move || { server_resource .get() .map(|submission| { - let expandable_react = submission.as_reactive(); - let elements_disabled = RwSignal::new(false); - let result_msg = Signal::derive(move || { - if submit_action.pending().get() { - elements_disabled.set(true); - "Sending request...".to_owned() - } else { - elements_disabled.set(false); - if let Some(result) = submit_action.value().get() { - match result { - Ok(_) => "Request sent successfully.".to_owned(), - Err(e) => format!("Failed to send request: {:?}", e), - } - } else { - "".to_owned() - } - } - }); view! {
- -

- "Region 15 Scholarship Application (DEMO)" -

-
- -

- "This is a very simple demonstration of the scholarship application written in Rust." -

-
- - // Text { data_member, label, placeholder } - - - - - - - - - - - - - - - - - // Radio { data_member, label, items } - - - - // Checkbox { data_member, label, items } - - - - // Select { data_member, label, items } - + + + + +
} }) }} - + + "Get PDF" + -- 2.54.0 From 073819c6c47f0857ea8e50dbeca943f767b36b86 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Mon, 29 Dec 2025 18:31:35 -0500 Subject: [PATCH 03/15] style: reorder some imports --- src/pages/home_page.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/home_page.rs b/src/pages/home_page.rs index a7f2ce2..11be7bf 100644 --- a/src/pages/home_page.rs +++ b/src/pages/home_page.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; // Server dependencies #[cfg(feature = "ssr")] use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue}; @@ -12,6 +11,7 @@ use std::io::Write; use crate::common::ExpandableInfo; use crate::pages::UnauthenticatedPage; +use crate::pages::utils::get_user_claims; use crate::components::{ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row, Select, TextFieldType, ValidatedForm}; use crate::input; use leptos::leptos_dom::logging::console_log; @@ -22,7 +22,7 @@ use leptos::logging::log; use leptos::task::spawn_local; use leptos::web_sys::HtmlAnchorElement; use leptos::wasm_bindgen::JsCast; -use crate::pages::utils::get_user_claims; +use std::collections::HashMap; /// # Get Student Info /// Gets a student's information given their `subject`. -- 2.54.0 From 94c2930d2a1ff34710fd51e87f6d0e3e9a607ee1 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Mon, 29 Dec 2025 19:08:28 -0500 Subject: [PATCH 04/15] feat: signal refactor for loaner page --- src/components/text_field.rs | 3 - src/pages/loaner_page.rs | 148 +++++++++++++++++------------------ 2 files changed, 72 insertions(+), 79 deletions(-) diff --git a/src/components/text_field.rs b/src/components/text_field.rs index 5c22009..7ef089b 100644 --- a/src/components/text_field.rs +++ b/src/components/text_field.rs @@ -21,7 +21,6 @@ pub enum TextFieldType { /// 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 { @@ -36,7 +35,6 @@ fn validate(required: bool, value: &str, input_type: &TextFieldType) -> Validati /// 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) { @@ -48,7 +46,6 @@ fn validate_email(input: &str) -> ValidationState { /// Validates a number. fn validate_number(input: &str) -> ValidationState { - log!("Validating number: {}", input); if input.parse::().is_ok() { ValidationState::Valid } else { diff --git a/src/pages/loaner_page.rs b/src/pages/loaner_page.rs index b03cd9b..a916cbe 100644 --- a/src/pages/loaner_page.rs +++ b/src/pages/loaner_page.rs @@ -5,9 +5,7 @@ use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValu use serde_dynamo::{from_item, to_item}; use crate::common::{ExpandableInfo, ValueType}; -use crate::components::{ - ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select, -}; +use crate::components::{ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select, TextFieldType, ValidatedForm}; use chrono::{FixedOffset, TimeZone}; use leptos::Params; use leptos::either::{Either, EitherOf3}; @@ -247,81 +245,79 @@ pub fn LoanerBorrowForm() -> impl IntoView { let create_entry_action = ServerAction::::new(); let navigate = use_navigate(); - let temp = ExpandableInfo::new(uuid::Uuid::new_v4().to_string()); - let temp_reactive = temp.as_reactive(); - let elements_disabled = RwSignal::new(false); + let data = RwSignal::new(HashMap::new()); + let elements_disabled = Signal::derive(move || create_entry_action.pending().get()); + + let on_submit = move |_| { + create_entry_action.dispatch(CreateBorrowEntry { + input: ExpandableInfo { + subject: uuid::Uuid::new_v4().to_string(), + data: data.get(), + } + }); + }; view! { -
-

"Borrower Information"

-

"Please fill out the form below."

-
- - - - - - - - - - - - "Submit" - + +
+

"Borrower Information"

+

"Please fill out the form below."

+
+ + + + + + + + + + +
} } -- 2.54.0 From f863b4db18e336cb808d569f406403765d8e060a Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Thu, 1 Jan 2026 04:02:36 -0500 Subject: [PATCH 05/15] feat: add validation in checkbox list --- src/components/checkbox.rs | 185 ++++++++++++++-------- src/pages/home_page.rs | 2 +- src/pages/scholarship_info.rs | 285 +++++++++++++++++----------------- 3 files changed, 267 insertions(+), 205 deletions(-) diff --git a/src/components/checkbox.rs b/src/components/checkbox.rs index 287b1a7..80254d3 100644 --- a/src/components/checkbox.rs +++ b/src/components/checkbox.rs @@ -2,16 +2,17 @@ use crate::common::ValueType; use crate::components::utils::create_unique_id; use leptos::prelude::*; use std::collections::HashMap; +use crate::components::{FormValidationRegistry, InputState, ValidationState}; #[component] pub fn Checkbox( #[prop()] checked: Signal, #[prop(into)] on_change: Callback<(), ()>, - #[prop(into)] value: String, + #[prop(into)] value: Signal, #[prop(into)] name: String, #[prop(into)] disabled: Signal, ) -> impl IntoView { - let id = create_unique_id(&name, &value); + let id = create_unique_id(&name, &value.get_untracked()); view! {
- {value.clone()} + {value} } } +fn value_type_to_vec(value_type: &ValueType) -> Vec { + let ValueType::List(Some(list)) = value_type else { + return Vec::new(); + }; + + list.iter().map(|val| { + val.as_string() + .unwrap_or_default() + .unwrap_or_default() + }).collect::>() +} + #[component] pub fn CheckboxList( #[prop(optional)] selected: RwSignal>, @@ -55,71 +68,117 @@ pub fn CheckboxList( #[prop()] items: Vec, #[prop(optional, into)] disabled: Signal, #[prop(optional, into)] label: String, + #[prop(optional, into)] required: Signal ) -> impl IntoView { + //#region Central List Logic + + // Get default values from the data map. This will only run once, when the input is created + // and mounted to the DOM. + let temp_map = data_map.get_untracked(); + let origin_value_type = temp_map.get(&data_member) + .unwrap_or(&ValueType::List(None)); + let origin_list = value_type_to_vec(origin_value_type); + + // "Raw" selected values - just strings, no ValueTyping + let selected_list = RwSignal::new(origin_list); + + Effect::new({ + let data_member = data_member.clone(); + move || { + // Convert selected strings into ValueTypes + let typed_list = selected_list.with(|list| + list.iter().cloned().map(|val| + ValueType::String(Some(val)) + ).collect::>() + ); + + // Update the map + data_map.update(|map| { + map.insert(data_member.clone(), ValueType::List(Some(typed_list))); + }); + } + }); + + //#endregion + //#region Form Validation + + let validation_context = use_context::() + .expect("FormValidationRegistry was not found"); + + let error = Signal::derive(move || { + if required.get() && selected_list.get().len() <= 0 { + ValidationState::Invalid("This field is required.".to_string()) + } else { + ValidationState::Valid + } + }); + let dirty = RwSignal::new(false); + 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() + )); + + validation_context.validators.update(|list| list.push(validator.clone())); + + //#endregion + //#region Render Logic view! { -
- {label} - {items - .into_iter() - .map(|item| { - let checked_signal = Signal::derive({ - let item_name = item.clone(); - let data_member = data_member.clone(); - move || { - data_map.get() // HashMap - .get(&data_member) // Option - .unwrap_or(&ValueType::List(None)) // ValueType - .as_list() // Result>, ValueType> - .unwrap_or(Some(vec!())) // Option> - .unwrap_or(vec!()) // Vec - .contains(&ValueType::String(Some(item_name.clone()))) // bool - } - }); +
+
+ {label} + {items + .into_iter() + .map(|item| { + let item = Signal::derive({ + let cloned = item.clone(); + move || cloned.clone() + }); - let on_change = { - let item_name = item.clone(); - let data_member = data_member.clone(); - move || { - let result = ValueType::String(Some(item_name.clone())); - data_map.update(|map| { - // Attempt to get the value type from the hash map. - match map.get_mut(&data_member) { - Some(value_type) => { - // Attempt to get the value as a list. - match value_type.as_list().unwrap_or(None) { - Some(mut list) => { - // Attempt to find the selected value in the selected list. - // If it's present, remove it, or vice versa. - match list.iter().position(|val| *val == result) { - Some(index) => { list.remove(index); }, - None => { list.push(result); } - }; - // Update the existing value_type entry in the hash map. - *value_type = ValueType::List(Some(list)); - }, - // Insert a new value_type entry. - None => { *value_type = ValueType::List(Some(vec![result])); } - }; - }, - // Insert a new value_type entry. - None => { map.insert(data_member.clone(), ValueType::List(Some(vec![result]))); } - }; + let checked = Signal::derive(move || { + selected_list.get().contains(&item.get()) + }); + + let on_change = move || { + // Update the data list by checking if the value is present in it. If so, + // remove it. If not, add it. + selected_list.update(|list| { + let item = item.get(); + if list.contains(&item) { + list.retain(|val| *val != item); + } else { + list.push(item); + } }); - } - }; + dirty.set(true); + }; - view! { - - } - }) - .collect::>()} + view! { + + } + }) + .collect_view()} +
+ +
+ {move || { + match error.get() { + ValidationState::Invalid(msg) => msg, + _ => "There is no error - should not see this message.".to_string() + } + }} +
+
} + //#endregion } diff --git a/src/pages/home_page.rs b/src/pages/home_page.rs index 11be7bf..0fe5410 100644 --- a/src/pages/home_page.rs +++ b/src/pages/home_page.rs @@ -1,7 +1,6 @@ // Server dependencies #[cfg(feature = "ssr")] use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue}; - #[cfg(feature = "ssr")] use serde_dynamo::{from_item, to_item}; #[cfg(feature = "ssr")] @@ -310,6 +309,7 @@ pub fn HomePage() -> impl IntoView { disabled=elements_disabled data_member="favorite_candies" data_map=form_data + required=true /> diff --git a/src/pages/scholarship_info.rs b/src/pages/scholarship_info.rs index 6fc24a2..27f1bc6 100644 --- a/src/pages/scholarship_info.rs +++ b/src/pages/scholarship_info.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use leptos::either::Either; use leptos::html::Dialog; use leptos::logging::log; @@ -5,9 +6,8 @@ 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, TextFieldType}; +use crate::components::{ActionButton, Banner, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row, TextFieldType, ValidatedForm}; use super::UnauthenticatedPage; -use traits::{AsReactive, ReactiveCapture}; use crate::common::ComparisonData; use crate::pages::utils::get_user_claims; use super::api::{get_comparison_info, get_provider_scholarships, get_scholarship_info, CreateScholarshipInfo, CreateTestComparisons, RegisterScholarship, DeleteProviderScholarship}; @@ -357,14 +357,14 @@ fn ScholarshipForm( _ => false } }); - let result_msg = Signal::derive(move || { - match submit_status.get() { - SubmitStatus::Idle => "".into(), - SubmitStatus::Sending => "Sending submission...".into(), - SubmitStatus::Success => "Success".into(), - SubmitStatus::Error(msg) => format!("Error: {}", msg), - } - }); + // let result_msg = Signal::derive(move || { + // match submit_status.get() { + // SubmitStatus::Idle => "".into(), + // SubmitStatus::Sending => "Sending submission...".into(), + // SubmitStatus::Success => "Success".into(), + // SubmitStatus::Error(msg) => format!("Error: {}", msg), + // } + // }); let submit_action = ServerAction::::new(); @@ -406,6 +406,14 @@ fn ScholarshipForm( } } ); + + let form_data = RwSignal::new(HashMap::new()); + + Effect::new(move || { + if let Some(Ok(scholarship)) = scholarship_info.get() { + form_data.set(scholarship.data); + } + }); let comparison_ids = Memo::new(move |_| { comparison_list.get().map(|list| list.iter() @@ -424,7 +432,17 @@ fn ScholarshipForm( // We'll collect the scholarship's name, num_awards, amount_per_award, total_awards, // fafsa_required, award_to, transcript_required, recipient_selection, essay_requirement (and prompt) // award_night_remarks - + + let on_submit = move |_| { + let mut info = ExpandableInfo::new(scholarship_id.get().unwrap_or_default()); + info.data = form_data.get(); + + log!("Map values: {:?}", info.data); + submit_action.dispatch(CreateScholarshipInfo { + info + }); + }; + view! { "Choose a scholarship from the right, or create a new one."

} > - {move || { - scholarship_info.get() - .map(|res_scholarship| { - match res_scholarship { - Ok(scholarship) => { - let reactive_info = scholarship.as_reactive(); - - Either::Left(view! { - -

- "Scholarship Info Form (test)" -

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - "Submit" - - -

{result_msg}

-
- }) + + {move || { + scholarship_info.get() + .map(|res_scholarship| { + match res_scholarship { + Ok(scholarship) => { + Either::Left(view! { + +

+ "Scholarship Info Form (test)" +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + }) + } + Err(err) => { + Either::Right(view! { +

"Error while getting scholarship: "{err.to_string()}

+ }) + } } - Err(err) => { - Either::Right(view! { -

"Error while getting scholarship: "{err.to_string()}

- }) - } - } - }).collect_view() - }} + }).collect_view() + }} +
-- 2.54.0 From 10d001fd6bc59842977ac21aa9698f53a0b8d1ab Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Fri, 2 Jan 2026 16:45:07 -0500 Subject: [PATCH 06/15] docs: fully document checkbox list --- src/components/checkbox.rs | 60 +++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/src/components/checkbox.rs b/src/components/checkbox.rs index 80254d3..abb980e 100644 --- a/src/components/checkbox.rs +++ b/src/components/checkbox.rs @@ -4,12 +4,44 @@ use leptos::prelude::*; use std::collections::HashMap; use crate::components::{FormValidationRegistry, InputState, ValidationState}; +/// # Checkbox Component +/// This component should only be used from within a [`CheckboxList`] component. +/// +/// It creates a single checkbox with a specific value. Indicating if it is checked is done +/// _externally_ from the component, and requires some sort of signal. The component itself +/// does not track whether it is checked, it only runs the `on_change` callback. +/// +/// Example usage (from within a [`CheckboxList`]: +/// ``` +/// let disabled_signal = RwSignal::new(false); +/// let checked_signal = Signal::derive(move || { +/// // It's recommended to use a derived signal of some sort, but if you want to create +/// // a manually controlled RwSignal, that's fine too. +/// true +/// }); +/// +/// view! { +/// +/// } +/// ``` #[component] pub fn Checkbox( + /// A [`Signal`] indicating whether the checkbox is checked. #[prop()] checked: Signal, + /// A callback that is run when the checkbox is checked and unchecked. #[prop(into)] on_change: Callback<(), ()>, + /// The value of the checkbox. #[prop(into)] value: Signal, + /// The name of the checkbox. This is used to group checkboxes together, so every checkbox + /// in a group should have the same name. #[prop(into)] name: String, + /// A [`Signal`] indicating whether the checkbox is disabled. #[prop(into)] disabled: Signal, ) -> impl IntoView { let id = create_unique_id(&name, &value.get_untracked()); @@ -60,14 +92,40 @@ fn value_type_to_vec(value_type: &ValueType) -> Vec { }).collect::>() } + +/// # Checkbox List Component +/// This component creates a list of checkboxes, each with a specific value. The values are passed +/// through a prop rather than manually creating children. +/// +/// Example usage: +/// ``` +/// let items = vec! [ +/// "Item 1", +/// "Item 2", +/// "Item 3" +/// ]; +/// +/// view! { +/// +/// } +/// ``` #[component] pub fn CheckboxList( - #[prop(optional)] selected: RwSignal>, + /// The data member that this list should edit/display from the `data_map`. #[prop(into)] data_member: String, + /// A [`Signal`] allowing access to a data store. #[prop()] data_map: RwSignal>, + /// The available choices for this list. #[prop()] items: Vec, + /// A [`Signal`] indicating whether the checkbox list is disabled. #[prop(optional, into)] disabled: Signal, + /// A label for the list. Shown like a field name in a form. #[prop(optional, into)] label: String, + /// A [`Signal`] indicating whether the list is required. Used for validation. #[prop(optional, into)] required: Signal ) -> impl IntoView { //#region Central List Logic -- 2.54.0 From b0b6f97b112f5ce78c7c960594de05370c50b033 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Fri, 2 Jan 2026 17:53:28 -0500 Subject: [PATCH 07/15] feat: implement validation for select component --- src/components/checkbox.rs | 4 +- src/components/select.rs | 134 ++++++++++++++++++++++++------- src/components/text_field.rs | 4 +- src/components/validated_form.rs | 5 ++ src/pages/home_page.rs | 2 + 5 files changed, 114 insertions(+), 35 deletions(-) diff --git a/src/components/checkbox.rs b/src/components/checkbox.rs index abb980e..c099950 100644 --- a/src/components/checkbox.rs +++ b/src/components/checkbox.rs @@ -2,7 +2,7 @@ use crate::common::ValueType; use crate::components::utils::create_unique_id; use leptos::prelude::*; use std::collections::HashMap; -use crate::components::{FormValidationRegistry, InputState, ValidationState}; +use crate::components::{use_validation_context, FormValidationRegistry, InputState, ValidationState}; /// # Checkbox Component /// This component should only be used from within a [`CheckboxList`] component. @@ -160,7 +160,7 @@ pub fn CheckboxList( //#endregion //#region Form Validation - let validation_context = use_context::() + let validation_context = use_validation_context() .expect("FormValidationRegistry was not found"); let error = Signal::derive(move || { diff --git a/src/components/select.rs b/src/components/select.rs index aaa91c7..d9e5284 100644 --- a/src/components/select.rs +++ b/src/components/select.rs @@ -3,6 +3,16 @@ use leptos::ev::{Event, Targeted}; use leptos::prelude::*; use leptos::web_sys::HtmlSelectElement; use std::collections::HashMap; +use leptos::logging::{log, warn}; +use crate::components::{use_validation_context, InputState, ValidationState}; + +fn validate(required: bool, value: String, default_value: String) -> ValidationState { + if required && (value.is_empty() || value == default_value) { + ValidationState::Invalid("This input is required.".to_string()) + } else { + ValidationState::Valid + } +} /// A custom-styled select input. /// @@ -11,48 +21,110 @@ use std::collections::HashMap; #[component] pub fn Select( #[prop()] value_list: Vec, + #[prop(optional, into)] default_value: Signal, #[prop(optional)] value: RwSignal, - #[prop(into)] data_member: String, + #[prop(into)] data_member: Signal, #[prop()] data_map: RwSignal>, #[prop(optional, into)] label: String, #[prop(optional, into)] disabled: Signal, + #[prop(optional, into)] required: Signal ) -> impl IntoView { - let on_change = { - let data_member = data_member.clone(); - move |e: Targeted| { - let target_value = e.target().value(); - data_map.update(|map| { - if let Some(val) = map.get_mut(&data_member) { - *val = ValueType::String(Some(target_value)); - } else { - map.insert(data_member.clone(), ValueType::String(Some(target_value))); - } - }); + //#region Value Logic + + let default_raw_value = if default_value.get_untracked().is_empty() { + data_map.get_untracked() + .get(&data_member.get_untracked()) + .cloned() + .unwrap_or_default() + .as_string().unwrap_or_default().unwrap_or_default() + } else { + let default = default_value.get_untracked(); + if !value_list.contains(&default) { + warn!("The default value {} is not in the list of options. This may cause validation issues.", default); } + + default }; + let raw_value = RwSignal::new(default_raw_value); + + Effect::new(move || { + data_map.update(|map| { + map.insert(data_member.get(), ValueType::String(Some(raw_value.get()))); + }); + }); + + //#endregion + //#region Validation Logic + + let error = RwSignal::new( + validate(required.get_untracked(), raw_value.get_untracked(), default_value.get_untracked()) + ); + warn!("Current error state: {:?}", error.get_untracked()); + let dirty = RwSignal::new(false); + let show_errors = Signal::derive(move || dirty.get() && matches!(error.get(), ValidationState::Invalid(_))); + + let input_state = InputState::new(data_member.get(), error.clone(), dirty.clone()); + + let validation_context = use_validation_context() + .expect("Could not find FormValidationRegistry context"); + + validation_context.validators.update(|list| list.push(RwSignal::new(input_state))); + + //#endregion + //#region Event Logic + + let on_change = move |e: Targeted| { + let value = e.target().value(); + log!("Found value {}", value); + raw_value.set(value.clone()); + error.set(validate(required.get(), raw_value.get(), default_value.get())); + dirty.set(true); + data_map.update(|map| { + if let Some(val) = map.get_mut(&data_member.get()) { + *val = ValueType::String(Some(value.clone())); + } else { + map.insert(data_member.get(), ValueType::String(Some(value))); + } + }); + }; + + //#endregion + view! { -
- +
+ +
{move || { - value_list.iter().map( - move |value| { - let display = value.to_owned(); - view! { } - } - ).collect::>() + match error.get() { + ValidationState::Invalid(msg) => msg, + _ => "There is no error - should not see this message.".to_string() + } }} - - +
+
} } diff --git a/src/components/text_field.rs b/src/components/text_field.rs index 7ef089b..efb7e01 100644 --- a/src/components/text_field.rs +++ b/src/components/text_field.rs @@ -3,7 +3,7 @@ use leptos::prelude::*; use std::collections::HashMap; use leptos::html::Input; use leptos::logging::log; -use crate::components::InputState; +use crate::components::{use_validation_context, InputState}; use crate::components::validated_form::{FormValidationRegistry, ValidationState}; /// Determines the available types of text fields. @@ -68,7 +68,7 @@ pub fn OutlinedTextField( let input_ref = NodeRef::::new(); // Register this input's validation signal. - let validator_context = use_context::() + let validator_context = use_validation_context() .expect("FormValidSignal was not found"); let raw_value = RwSignal::new(data_map.get_untracked() diff --git a/src/components/validated_form.rs b/src/components/validated_form.rs index 5ae17bb..b204edc 100644 --- a/src/components/validated_form.rs +++ b/src/components/validated_form.rs @@ -56,6 +56,11 @@ pub struct FormValidationRegistry { pub validators: RwSignal } +/// Gets the [`FormValidationRegistry`] context if it exists. +pub fn use_validation_context() -> Option { + use_context::() +} + /// # Validated Form Component /// /// A simple HTML `
` wrapper that executes validation logic using a [`FormValidationRegistry`]. diff --git a/src/pages/home_page.rs b/src/pages/home_page.rs index 0fe5410..7d81f1d 100644 --- a/src/pages/home_page.rs +++ b/src/pages/home_page.rs @@ -319,9 +319,11 @@ pub fn HomePage() -> impl IntoView { .into_iter() .map(|s| s.to_owned()) .collect() + default_value="Male" disabled=elements_disabled data_member="gender" data_map=form_data + required=true /> -- 2.54.0 From bad5e994b6fb2c79b40bbf8ab04177cae7f090a9 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Sat, 3 Jan 2026 16:00:37 -0500 Subject: [PATCH 08/15] feat: implement validation for radio list component --- src/components/radio.rs | 152 ++++++++++++++++++++++++++-------------- src/pages/home_page.rs | 3 +- 2 files changed, 101 insertions(+), 54 deletions(-) diff --git a/src/components/radio.rs b/src/components/radio.rs index fd8af70..32680ac 100644 --- a/src/components/radio.rs +++ b/src/components/radio.rs @@ -2,16 +2,17 @@ use crate::common::ValueType; use crate::components::utils::create_unique_id; use leptos::prelude::*; use std::collections::HashMap; +use crate::components::{use_validation_context, InputState, ValidationState}; #[component] pub fn Radio( #[prop()] checked: Signal, #[prop(into)] on_change: Callback<(), ()>, - #[prop(into)] value: String, - #[prop(into)] name: String, + #[prop(into)] value: Signal, + #[prop(into)] name: Signal, #[prop(optional, into)] disabled: Signal, ) -> impl IntoView { - let id = create_unique_id(&name, &value); + let id = create_unique_id(&name.get_untracked(), &value.get_untracked()); view! {