Merge pull request #60 from R15-Software-Dev/fix/optional-number-inputs

Make number-type inputs properly optional
This commit was merged in pull request #60.
This commit is contained in:
Lucas Vas authored and GitHub committed 2026-02-11 12:18:08 -05:00
commit ebb6fe500c
9 files changed
+125 -98

No files matched your search

+2 -8
View File
@@ -136,14 +136,8 @@ fn StudentRoutes() -> impl MatchNestedRoutes + Clone {
path=path!("additional/work-experience") path=path!("additional/work-experience")
view=student::StudentWorkExperiencePage view=student::StudentWorkExperiencePage
/> />
<Route <Route path=path!("additional/university") view=student::StudentUniversityPage />
path=path!("additional/university") <Route path=path!("additional/family-info") view=student::StudentFamilyPage />
view=student::StudentUniversityPage
/>
<Route
path=path!("additional/family-info")
view=student::StudentFamilyPage
/>
</ParentRoute> </ParentRoute>
} }
.into_inner() .into_inner()
+20 -10
View File
@@ -36,7 +36,7 @@ impl InputType {
) -> impl IntoView { ) -> impl IntoView {
view! { view! {
{match self { {match self {
InputType::Text(member, label, placeholder, required) => InputType::Text(member, label, placeholder, required) => {
view! { view! {
<Row> <Row>
<OutlinedTextField <OutlinedTextField
@@ -47,8 +47,10 @@ impl InputType {
required=required required=required
/> />
</Row> </Row>
}.into_any(), }
InputType::Number(member, label, placeholder, required) => .into_any()
}
InputType::Number(member, label, placeholder, required) => {
view! { view! {
<OutlinedTextField <OutlinedTextField
label=label.clone() label=label.clone()
@@ -58,8 +60,10 @@ impl InputType {
input_type=TextFieldType::Number input_type=TextFieldType::Number
required=required required=required
/> />
}.into_any(), }
InputType::Checkbox(member, label, options, required) => .into_any()
}
InputType::Checkbox(member, label, options, required) => {
view! { view! {
<CheckboxList <CheckboxList
label=label.clone() label=label.clone()
@@ -69,8 +73,10 @@ impl InputType {
data_map=data_map data_map=data_map
required=required required=required
/> />
}.into_any(), }
InputType::Radio(member, label, options, required) => .into_any()
}
InputType::Radio(member, label, options, required) => {
view! { view! {
<RadioList <RadioList
label=label.clone() label=label.clone()
@@ -79,8 +85,10 @@ impl InputType {
items=options.clone() items=options.clone()
required=required required=required
/> />
}.into_any(), }
InputType::Select(member, label, options, required) => .into_any()
}
InputType::Select(member, label, options, required) => {
view! { view! {
<Select <Select
label=label.clone() label=label.clone()
@@ -89,7 +97,9 @@ impl InputType {
data_map=data_map data_map=data_map
required=required required=required
/> />
}.into_any() }
.into_any()
}
}} }}
} }
} }
+1 -1
View File
@@ -13,7 +13,7 @@ use std::fmt::Display;
pub enum ValueType { pub enum ValueType {
/// Indicates a `String` value. /// Indicates a `String` value.
String(Option<String>), String(Option<String>),
/// Indicates an `i32` value. /// Indicates an `i32` value. /// Indicates an `f32` value.
Number(Option<String>), Number(Option<String>),
/// Indicates a `Vec<T>`, where `T` can be any other `ValueType`. /// Indicates a `Vec<T>`, where `T` can be any other `ValueType`.
List(Option<Vec<ValueType>>), List(Option<Vec<ValueType>>),
+37 -29
View File
@@ -2,6 +2,7 @@ use crate::common::ValueType;
use leptos::prelude::*; use leptos::prelude::*;
use std::collections::HashMap; use std::collections::HashMap;
use leptos::html::Input; use leptos::html::Input;
use leptos::logging::debug_log;
use crate::components::{use_validation_context, InputState}; use crate::components::{use_validation_context, InputState};
use crate::components::validated_form::ValidationState; use crate::components::validated_form::ValidationState;
@@ -28,6 +29,8 @@ pub enum TextFieldType {
fn validate(required: bool, value: &str, input_type: &TextFieldType) -> ValidationState { fn validate(required: bool, value: &str, input_type: &TextFieldType) -> ValidationState {
if required && value.is_empty() { if required && value.is_empty() {
ValidationState::Invalid("This field is required.".to_string()) ValidationState::Invalid("This field is required.".to_string())
} else if !required && value.is_empty() {
ValidationState::Valid
} else { } else {
match input_type { match input_type {
TextFieldType::Email(domains) => validate_email(value, domains), TextFieldType::Email(domains) => validate_email(value, domains),
@@ -54,7 +57,7 @@ fn validate_email(input: &str, valid_domains: &Vec<String>) -> ValidationState {
/// Validates a number. /// Validates a number.
fn validate_number(input: &str) -> ValidationState { fn validate_number(input: &str) -> ValidationState {
if input.parse::<i32>().is_ok() { if input.parse::<f32>().is_ok() {
ValidationState::Valid ValidationState::Valid
} else { } else {
ValidationState::Invalid("Value is not a valid number.".to_string()) ValidationState::Invalid("Value is not a valid number.".to_string())
@@ -64,7 +67,7 @@ fn validate_number(input: &str) -> ValidationState {
#[component] #[component]
pub fn OutlinedTextField( pub fn OutlinedTextField(
#[prop(optional, into)] placeholder: String, #[prop(optional, into)] placeholder: String,
#[prop(into)] data_member: String, // should this be an RwSignal?? #[prop(into)] data_member: String,
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>, #[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop(optional, into)] input_type: Signal<TextFieldType>, #[prop(optional, into)] input_type: Signal<TextFieldType>,
#[prop(optional, into)] disabled: Signal<bool>, #[prop(optional, into)] disabled: Signal<bool>,
@@ -73,30 +76,28 @@ pub fn OutlinedTextField(
#[prop(optional, into)] required: Signal<bool> #[prop(optional, into)] required: Signal<bool>
) -> impl IntoView { ) -> impl IntoView {
let input_ref = NodeRef::<Input>::new(); let input_ref = NodeRef::<Input>::new();
let data_member = StoredValue::new(data_member);
// Register this input's validation signal. // Register this input's validation signal.
let validator_context = use_validation_context() let validator_context = use_validation_context()
.expect("FormValidSignal was not found"); .expect("FormValidSignal was not found");
let raw_value = Signal::derive({ let display_value = RwSignal::new(
let data_member = data_member.clone(); data_map.get_untracked()
move || .get(&data_member.get_value())
data_map .map(|v| v.to_string())
.get() .unwrap_or_default()
.get(&data_member) );
.unwrap_or(&ValueType::String(None))
.to_string()
});
let dirty = RwSignal::new(false); let dirty = RwSignal::new(false);
let error = Signal::derive(move || let error = Signal::derive(move ||
validate(required.get(), &raw_value.get(), &input_type.get()) validate(required.get(), &display_value.get(), &input_type.get())
); );
let show_errors = Signal::derive(move || { let show_errors = Signal::derive(move || {
dirty.get() && matches!(error.get(), ValidationState::Invalid(_)) dirty.get() && matches!(error.get(), ValidationState::Invalid(_))
}); });
let validator = RwSignal::new(InputState::new(data_member.clone(), error.clone(), dirty.clone())); let validator = RwSignal::new(InputState::new(data_member.get_value(), error.clone(), dirty.clone()));
validator_context.validators.update(|list| list.push(validator)); validator_context.validators.update(|list| list.push(validator));
@@ -106,35 +107,42 @@ pub fn OutlinedTextField(
}); });
}); });
let str_type = Signal::derive(move || match input_type.get_untracked() {
TextFieldType::Number => "number",
_ => "text" // This is fine even for emails, as we're doing our own validation.
});
let on_blur = move |_| { let on_blur = move |_| {
// Check validity of input's current value. The value will be updated by on_input, // 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). // and it should be a raw input (not parsed into a ValueType).
dirty.set(true); dirty.set(true);
}; };
// Syncs data from the map into the display_value, only when the map is actually changed.
Effect::new(move || {
let new_value = data_map.get()
.get(&data_member.get_value())
.map(|v| v.to_string())
.unwrap_or_default();
if new_value != display_value.get_untracked() {
debug_log!("Found value from map: {}", new_value);
display_value.set(new_value);
}
});
// This function parses the input into the correct type. It only accepts numbers or strings as // This function parses the input into the correct type. It only accepts numbers or strings as
// valid types, and parses them accordingly. // valid types, and parses them accordingly.
let on_input = { let on_input = move |e| {
let data_member = data_member.clone();
move |e| {
let to_parse = event_target_value(&e); let to_parse = event_target_value(&e);
debug_log!("Setting value to display_value: {}", to_parse);
display_value.set(to_parse.clone());
// Push the new value to the data map. This happens regardless of whether the value parses
// or not.
data_map.update(|map| {
let into_map = match input_type.get() { let into_map = match input_type.get() {
TextFieldType::Number => ValueType::Number(Some(to_parse)), TextFieldType::Number => ValueType::Number(Some(to_parse)),
_ => ValueType::String(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 map.insert(data_member.get_value(), into_map);
// 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);
}); });
}
}; };
view! { view! {
@@ -147,11 +155,11 @@ pub fn OutlinedTextField(
transition-all duration-150 transition-all duration-150
border-red-700 bg-transparent border-red-700 bg-transparent
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33" disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
r#type=str_type r#type="text"
disabled=disabled disabled=disabled
placeholder=placeholder placeholder=placeholder
prop:name=name prop:name=name
prop:value=raw_value prop:value=display_value
on:input=on_input on:input=on_input
on:blur=on_blur on:blur=on_blur
/> />
+2 -6
View File
@@ -4,12 +4,8 @@ use leptos::prelude::*;
pub fn AdminScholarshipPage() -> impl IntoView { pub fn AdminScholarshipPage() -> impl IntoView {
view! { view! {
<div class="flex flex-1 flex-col gap-4"> <div class="flex flex-1 flex-col gap-4">
<div class="font-bold text-3xl"> <div class="font-bold text-3xl">"Scholarships View"</div>
"Scholarships View" <div>"This is a placeholder for now. It will show a list of all scholarships."</div>
</div>
<div>
"This is a placeholder for now. It will show a list of all scholarships."
</div>
</div> </div>
} }
} }
+6 -5
View File
@@ -7,10 +7,9 @@ pub fn StudentAcademicsPage() -> impl IntoView {
let controller = use_student_form("academics"); let controller = use_student_form("academics");
view! { view! {
<Show <Suspense fallback=Loading>
when=move || controller.data_resource.get().is_some() {move || controller.data_resource.get().map(|_|
fallback=Loading view! {
>
<div class="flex flex-1" /> <div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6"> <div class="flex flex-col flex-2 mt-6">
<ValidatedForm title="Academic Information" on_submit=controller.submit_action> <ValidatedForm title="Academic Information" on_submit=controller.submit_action>
@@ -57,6 +56,8 @@ pub fn StudentAcademicsPage() -> impl IntoView {
</ValidatedForm> </ValidatedForm>
</div> </div>
<div class="flex flex-1" /> <div class="flex flex-1" />
</Show> }).collect_view()
}
</Suspense>
} }
} }
+1 -4
View File
@@ -8,10 +8,7 @@ pub fn StudentAthleticsPage() -> impl IntoView {
let controller = use_student_form("athletics"); let controller = use_student_form("athletics");
view! { view! {
<Show <Show when=move || controller.data_resource.get().is_some() fallback=Loading>
when=move || controller.data_resource.get().is_some()
fallback=Loading
>
<div class="flex flex-1" /> <div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6"> <div class="flex flex-col flex-2 mt-6">
<ValidatedForm title="Athletics Information" on_submit=controller.submit_action> <ValidatedForm title="Athletics Information" on_submit=controller.submit_action>
+55 -34
View File
@@ -60,53 +60,69 @@ pub fn StudentEligibilityPage() -> impl IntoView {
let Some(Ok(student_info)) = student_resource.get() else { let Some(Ok(student_info)) = student_resource.get() else {
return view! {}.into_any(); return view! {}.into_any();
}; };
let valid_list = scholarships_list
let valid_list = scholarships_list.iter() .iter()
.filter_map(|scholarship| { .filter_map(|scholarship| {
let scholarship_name = scholarship.data.get("name") let scholarship_name = scholarship
.data
.get("name")
.unwrap_or(&ValueType::String(None)) .unwrap_or(&ValueType::String(None))
.as_string().ok().flatten().unwrap_or_default(); .as_string()
.ok()
.flatten()
.unwrap_or_default();
debug_log!("Checking scholarship: {}", scholarship_name); debug_log!("Checking scholarship: {}", scholarship_name);
let requirements_map = scholarship
// Each scholarship will return Some(itself), or None. This is will be automatically .data
// filtered based on the information. Invalid scholarships are None, along with those .get("requirements")
// that fail validation for any reason. Scholarships that are valid or did not choose
// any requirements are Some(scholarship).
let requirements_map = scholarship.data.get("requirements")
.unwrap_or(&ValueType::Map(None)) .unwrap_or(&ValueType::Map(None))
.as_map().ok().flatten() .as_map()
.ok()
.flatten()
.unwrap_or_default() .unwrap_or_default()
.values().cloned() .values()
.cloned()
.collect::<Vec<ValueType>>(); .collect::<Vec<ValueType>>();
let resolved_requirements = requirements_map let resolved_requirements = requirements_map
.iter() .iter()
.map(|list_val| { .map(|list_val| {
// We want to cast to a list, and then map the list to a new let list = list_val
// one that contains the correct ComparisonData. .as_list()
let list = list_val.as_list().ok().flatten().unwrap_or_default(); .ok()
list.iter().filter_map(|v| { .flatten()
let id_string = v.as_string().ok().flatten().unwrap_or_default(); .unwrap_or_default();
relations_list.iter() list.iter()
.filter_map(|v| {
let id_string = v
.as_string()
.ok()
.flatten()
.unwrap_or_default();
relations_list
.iter()
.find(|relation| relation.id == id_string) .find(|relation| relation.id == id_string)
}) })
.cloned() .cloned()
.collect::<Vec<ComparisonData>>() .collect::<Vec<ComparisonData>>()
}) })
.collect::<Vec<Vec<ComparisonData>>>(); .collect::<Vec<Vec<ComparisonData>>>();
debug_log!(
debug_log!("Resolved requirements: {:?}", resolved_requirements.iter().flatten().map(|rel| rel.display_text.clone()).collect::<Vec<String>>()); "Resolved requirements: {:?}", resolved_requirements.iter().flatten().map(|rel| rel.display_text.clone()).collect::<Vec<String>>()
);
let valid = resolved_requirements.iter() let valid = resolved_requirements
.iter()
.all(|list| { .all(|list| {
list.iter().fold(false, |prev, requirement| { list.iter()
let result = requirement.compare(&student_info) .fold(
false,
|prev, requirement| {
let result = requirement
.compare(&student_info)
.unwrap_or(false); .unwrap_or(false);
if prev { prev } else { result } if prev { prev } else { result }
}) },
)
}); });
if valid { if valid {
debug_log!("Scholarship is valid."); debug_log!("Scholarship is valid.");
Some(scholarship.clone()) Some(scholarship.clone())
@@ -116,15 +132,19 @@ pub fn StudentEligibilityPage() -> impl IntoView {
}) })
.collect::<Vec<ExpandableInfo>>(); .collect::<Vec<ExpandableInfo>>();
// Each scholarship will return Some(itself), or None. This is will be automatically
// filtered based on the information. Invalid scholarships are None, along with those
// that fail validation for any reason. Scholarships that are valid or did not choose
// any requirements are Some(scholarship).
// We want to cast to a list, and then map the list to a new
// one that contains the correct ComparisonData.
view! { view! {
<div>{format!("Valid scholarships: {}", valid_list.len())}</div> <div>{format!("Valid scholarships: {}", valid_list.len())}</div>
<For <For
each=move || valid_list.clone() each=move || valid_list.clone()
key=|scholarship| { key=|scholarship| { scholarship.subject.clone() }
scholarship
.subject
.clone()
}
children=move |scholarship| { children=move |scholarship| {
let scholarship = StoredValue::new(scholarship); let scholarship = StoredValue::new(scholarship);
let scholarship_name = Memo::new(move |_| { let scholarship_name = Memo::new(move |_| {
@@ -163,7 +183,8 @@ pub fn StudentEligibilityPage() -> impl IntoView {
} }
} }
/> />
}.into_any() }
.into_any()
}} }}
</Suspense> </Suspense>
</div> </div>
+1 -1
View File
@@ -38,7 +38,7 @@ pub fn StudentShell() -> impl IntoView {
], ],
), ),
), ),
TabInfo::new("Eligible Scholarships", "eligibility", None) TabInfo::new("Eligible Scholarships", "eligibility", None),
] ]
/> />
</Authenticated> </Authenticated>