Make number-type inputs properly optional #60

Merged
BobdaFett merged 3 commits from fix/optional-number-inputs into main 2026-02-11 17:18:09 +00:00
9 changed files with 209 additions and 182 deletions

No files matched your search

+2 -8
View File
@@ -136,14 +136,8 @@ fn StudentRoutes() -> impl MatchNestedRoutes + Clone {
path=path!("additional/work-experience")
view=student::StudentWorkExperiencePage
/>
<Route
path=path!("additional/university")
view=student::StudentUniversityPage
/>
<Route
path=path!("additional/family-info")
view=student::StudentFamilyPage
/>
<Route path=path!("additional/university") view=student::StudentUniversityPage />
<Route path=path!("additional/family-info") view=student::StudentFamilyPage />
</ParentRoute>
}
.into_inner()
+47 -37
View File
@@ -36,60 +36,70 @@ impl InputType {
) -> impl IntoView {
view! {
{match self {
InputType::Text(member, label, placeholder, required) =>
InputType::Text(member, label, placeholder, required) => {
view! {
<Row>
<OutlinedTextField
label = label.clone()
placeholder = placeholder.clone()
data_member = member.clone()
data_map = data_map
required = required
label=label.clone()
placeholder=placeholder.clone()
data_member=member.clone()
data_map=data_map
required=required
/>
</Row>
}.into_any(),
InputType::Number(member, label, placeholder, required) =>
}
.into_any()
}
InputType::Number(member, label, placeholder, required) => {
view! {
<OutlinedTextField
label = label.clone()
placeholder = placeholder.clone()
data_member = member.clone()
data_map = data_map
input_type = TextFieldType::Number
required = required
label=label.clone()
placeholder=placeholder.clone()
data_member=member.clone()
data_map=data_map
input_type=TextFieldType::Number
required=required
/>
}.into_any(),
InputType::Checkbox(member, label, options, required) =>
}
.into_any()
}
InputType::Checkbox(member, label, options, required) => {
view! {
<CheckboxList
label = label.clone()
name = name.clone()
items = options.clone()
data_member = member.clone()
data_map = data_map
required = required
label=label.clone()
name=name.clone()
items=options.clone()
data_member=member.clone()
data_map=data_map
required=required
/>
}.into_any(),
InputType::Radio(member, label, options, required) =>
}
.into_any()
}
InputType::Radio(member, label, options, required) => {
view! {
<RadioList
label = label.clone()
data_member = member.clone()
data_map = data_map
items = options.clone()
required = required
label=label.clone()
data_member=member.clone()
data_map=data_map
items=options.clone()
required=required
/>
}.into_any(),
InputType::Select(member, label, options, required) =>
}
.into_any()
}
InputType::Select(member, label, options, required) => {
view! {
<Select
label = label.clone()
value_list = options.clone()
data_member = member.clone()
data_map = data_map
required = required
label=label.clone()
value_list=options.clone()
data_member=member.clone()
data_map=data_map
required=required
/>
}.into_any()
}
.into_any()
}
}}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ use std::fmt::Display;
pub enum ValueType {
/// Indicates a `String` value.
String(Option<String>),
/// Indicates an `i32` value.
/// Indicates an `i32` value. /// Indicates an `f32` value.
Number(Option<String>),
/// Indicates a `Vec<T>`, where `T` can be any other `ValueType`.
List(Option<Vec<ValueType>>),
+40 -32
View File
@@ -2,6 +2,7 @@ use crate::common::ValueType;
use leptos::prelude::*;
use std::collections::HashMap;
use leptos::html::Input;
use leptos::logging::debug_log;
use crate::components::{use_validation_context, InputState};
use crate::components::validated_form::ValidationState;
@@ -28,6 +29,8 @@ pub enum TextFieldType {
fn validate(required: bool, value: &str, input_type: &TextFieldType) -> ValidationState {
if required && value.is_empty() {
ValidationState::Invalid("This field is required.".to_string())
} else if !required && value.is_empty() {
ValidationState::Valid
} else {
match input_type {
TextFieldType::Email(domains) => validate_email(value, domains),
@@ -54,7 +57,7 @@ fn validate_email(input: &str, valid_domains: &Vec<String>) -> ValidationState {
/// Validates a number.
fn validate_number(input: &str) -> ValidationState {
if input.parse::<i32>().is_ok() {
if input.parse::<f32>().is_ok() {
ValidationState::Valid
} else {
ValidationState::Invalid("Value is not a valid number.".to_string())
@@ -64,7 +67,7 @@ fn validate_number(input: &str) -> ValidationState {
#[component]
pub fn OutlinedTextField(
#[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(optional, into)] input_type: Signal<TextFieldType>,
#[prop(optional, into)] disabled: Signal<bool>,
@@ -73,30 +76,28 @@ pub fn OutlinedTextField(
#[prop(optional, into)] required: Signal<bool>
) -> impl IntoView {
let input_ref = NodeRef::<Input>::new();
let data_member = StoredValue::new(data_member);
// Register this input's validation signal.
let validator_context = use_validation_context()
.expect("FormValidSignal was not found");
let raw_value = Signal::derive({
let data_member = data_member.clone();
move ||
data_map
.get()
.get(&data_member)
.unwrap_or(&ValueType::String(None))
.to_string()
});
let display_value = RwSignal::new(
data_map.get_untracked()
.get(&data_member.get_value())
.map(|v| v.to_string())
.unwrap_or_default()
);
let dirty = RwSignal::new(false);
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 || {
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));
@@ -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 |_| {
// 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);
};
// 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
// valid types, and parses them accordingly.
let on_input = {
let data_member = data_member.clone();
move |e| {
let to_parse = event_target_value(&e);
let on_input = move |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() {
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);
});
}
map.insert(data_member.get_value(), into_map);
});
};
view! {
@@ -147,11 +155,11 @@ pub fn OutlinedTextField(
transition-all duration-150
border-red-700 bg-transparent
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
r#type=str_type
r#type="text"
disabled=disabled
placeholder=placeholder
prop:name=name
prop:value=raw_value
prop:value=display_value
on:input=on_input
on:blur=on_blur
/>
+2 -6
View File
@@ -4,12 +4,8 @@ use leptos::prelude::*;
pub fn AdminScholarshipPage() -> impl IntoView {
view! {
<div class="flex flex-1 flex-col gap-4">
<div class="font-bold text-3xl">
"Scholarships View"
</div>
<div>
"This is a placeholder for now. It will show a list of all scholarships."
</div>
<div class="font-bold text-3xl">"Scholarships View"</div>
<div>"This is a placeholder for now. It will show a list of all scholarships."</div>
</div>
}
}
+52 -51
View File
@@ -7,56 +7,57 @@ pub fn StudentAcademicsPage() -> impl IntoView {
let controller = use_student_form("academics");
view! {
<Show
when=move || controller.data_resource.get().is_some()
fallback=Loading
>
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm title="Academic Information" on_submit=controller.submit_action>
<OutlinedTextField
label="Unweighted GPA:"
data_map=controller.data_map
data_member="unweighted_gpa"
placeholder="4.0"
required=true
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Weighted GPA:"
data_map=controller.data_map
data_member="weighted_gpa"
placeholder="4.5"
required=true
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Highest SAT Score:"
data_map=controller.data_map
data_member="sat_score"
placeholder="1600"
required=false
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Highest ACT Score:"
data_map=controller.data_map
data_member="act_score"
placeholder="36"
required=false
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Academic Honors:"
data_map=controller.data_map
data_member="academic_honors"
placeholder="TBD"
required=false
input_type=TextFieldType::Text
/>
</ValidatedForm>
</div>
<div class="flex flex-1" />
</Show>
<Suspense fallback=Loading>
{move || controller.data_resource.get().map(|_|
view! {
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm title="Academic Information" on_submit=controller.submit_action>
<OutlinedTextField
label="Unweighted GPA:"
data_map=controller.data_map
data_member="unweighted_gpa"
placeholder="4.0"
required=true
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Weighted GPA:"
data_map=controller.data_map
data_member="weighted_gpa"
placeholder="4.5"
required=true
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Highest SAT Score:"
data_map=controller.data_map
data_member="sat_score"
placeholder="1600"
required=false
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Highest ACT Score:"
data_map=controller.data_map
data_member="act_score"
placeholder="36"
required=false
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Academic Honors:"
data_map=controller.data_map
data_member="academic_honors"
placeholder="TBD"
required=false
input_type=TextFieldType::Text
/>
</ValidatedForm>
</div>
<div class="flex flex-1" />
}).collect_view()
}
</Suspense>
}
}
+1 -4
View File
@@ -8,10 +8,7 @@ pub fn StudentAthleticsPage() -> impl IntoView {
let controller = use_student_form("athletics");
view! {
<Show
when=move || controller.data_resource.get().is_some()
fallback=Loading
>
<Show when=move || controller.data_resource.get().is_some() fallback=Loading>
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm title="Athletics Information" on_submit=controller.submit_action>
+62 -41
View File
@@ -60,53 +60,69 @@ pub fn StudentEligibilityPage() -> impl IntoView {
let Some(Ok(student_info)) = student_resource.get() else {
return view! {}.into_any();
};
let valid_list = scholarships_list.iter()
let valid_list = scholarships_list
.iter()
.filter_map(|scholarship| {
let scholarship_name = scholarship.data.get("name")
.unwrap_or(&ValueType::String(None))
.as_string().ok().flatten().unwrap_or_default();
let scholarship_name = scholarship
.data
.get("name")
.unwrap_or(&ValueType::String(None))
.as_string()
.ok()
.flatten()
.unwrap_or_default();
debug_log!("Checking scholarship: {}", scholarship_name);
// 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).
let requirements_map = scholarship.data.get("requirements")
let requirements_map = scholarship
.data
.get("requirements")
.unwrap_or(&ValueType::Map(None))
.as_map().ok().flatten()
.as_map()
.ok()
.flatten()
.unwrap_or_default()
.values().cloned()
.values()
.cloned()
.collect::<Vec<ValueType>>();
let resolved_requirements = requirements_map
.iter()
.map(|list_val| {
// We want to cast to a list, and then map the list to a new
// one that contains the correct ComparisonData.
let list = list_val.as_list().ok().flatten().unwrap_or_default();
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)
})
.cloned()
.collect::<Vec<ComparisonData>>()
let list = list_val
.as_list()
.ok()
.flatten()
.unwrap_or_default();
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)
})
.cloned()
.collect::<Vec<ComparisonData>>()
})
.collect::<Vec<Vec<ComparisonData>>>();
debug_log!("Resolved requirements: {:?}", resolved_requirements.iter().flatten().map(|rel| rel.display_text.clone()).collect::<Vec<String>>());
let valid = resolved_requirements.iter()
debug_log!(
"Resolved requirements: {:?}", resolved_requirements.iter().flatten().map(|rel| rel.display_text.clone()).collect::<Vec<String>>()
);
let valid = resolved_requirements
.iter()
.all(|list| {
list.iter().fold(false, |prev, requirement| {
let result = requirement.compare(&student_info)
.unwrap_or(false);
if prev { prev } else { result }
})
list.iter()
.fold(
false,
|prev, requirement| {
let result = requirement
.compare(&student_info)
.unwrap_or(false);
if prev { prev } else { result }
},
)
});
if valid {
debug_log!("Scholarship is valid.");
Some(scholarship.clone())
@@ -116,15 +132,19 @@ pub fn StudentEligibilityPage() -> impl IntoView {
})
.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! {
<div>{format!("Valid scholarships: {}", valid_list.len())}</div>
<For
each=move || valid_list.clone()
key=|scholarship| {
scholarship
.subject
.clone()
}
key=|scholarship| { scholarship.subject.clone() }
children=move |scholarship| {
let scholarship = StoredValue::new(scholarship);
let scholarship_name = Memo::new(move |_| {
@@ -163,7 +183,8 @@ pub fn StudentEligibilityPage() -> impl IntoView {
}
}
/>
}.into_any()
}
.into_any()
}}
</Suspense>
</div>
+2 -2
View File
@@ -15,7 +15,7 @@ pub fn StudentShell() -> impl IntoView {
title="R15 Student Scholarship Application"
logo="/PHS_Stacked_Acronym.png"
/>
<div class="flex flex-1 min-h-0">
<AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage>
@@ -38,7 +38,7 @@ pub fn StudentShell() -> impl IntoView {
],
),
),
TabInfo::new("Eligible Scholarships", "eligibility", None)
TabInfo::new("Eligible Scholarships", "eligibility", None),
]
/>
</Authenticated>