Add date list component #50

Merged
BobdaFett merged 37 commits from feat/date-component into main 2026-01-16 14:02:18 +00:00
6 changed files with 243 additions and 113 deletions
Showing only changes of commit 0b4aae4db3 - Show all commits

No files matched your search

+148 -18
View File
@@ -2,6 +2,7 @@ use crate::common::ValueType;
use crate::components::utils::create_unique_id;
use leptos::prelude::*;
use std::collections::HashMap;
use leptos::html::Input;
use crate::components::{use_validation_context, InputState, ValidationState};
#[component]
@@ -20,9 +21,7 @@ pub fn Radio(
// Using the "peer" class links this input with the div "peer" at the same level.
// By using the "peer-checked" selector this div can toggle states based on this
// input's state (checked/unchecked)
class="
relative peer shrink-0
hidden"
class="relative peer shrink-0 hidden"
type="radio"
id=id.clone()
name=name
@@ -43,7 +42,95 @@ pub fn Radio(
rotate-45 transform border-3 border-3 rounded-full bg-white
border-white transition-opacity duration-150"></span>
</div>
<span>{value.clone()}</span>
<span class="px-0.5 py-0.5 flex-1">{value.clone()}</span>
</label>
}
}
/// # Ratio Other Component
/// This is a simple "other" field that contains a text input component. It can be selected using the
/// same logic as the [`Radio`] component, and also has a secondary `RwSignal` that contains the
/// current value of the embedded text input.
///
/// Example usage: <still working on this>
#[component]
fn RadioOther(
#[prop(into)] name: Signal<String>,
#[prop(into)] checked: Signal<bool>,
#[prop(into)] on_change: Callback<()>,
#[prop(into)] disabled: Signal<bool>,
#[prop(optional, into)] value: RwSignal<String>,
#[prop(into)] dirty: RwSignal<bool>,
#[prop(into)] placeholder: Signal<String>,
) -> impl IntoView {
let id = create_unique_id(&name.get_untracked(), &"other".to_string());
let input_ref = NodeRef::<Input>::new();
let input_disabled = Signal::derive(move || {
!checked.get() || disabled.get()
});
Effect::new(move || {
checked.get()
.then(|| {
if let Some(node) = input_ref.get() {
node.focus().expect("Couldn't focus input element");
}
});
});
let on_change_text = move |ev| {
let val = event_target_value(&ev);
value.set(val);
on_change.run(());
};
let on_blur_text = move |_| {
dirty.set(true);
};
view! {
<label for=id class="flex items_center">
<input
// Using the "peer" class links this input with the div "peer" at the same level.
// By using the "peer-checked" selector this div can toggle states based on this
// input's state (checked/unchecked)
class="relative peer shrink-0 hidden"
type="radio"
id=id.clone()
name=name
on:change=move |_| on_change.run(())
prop:checked=checked
disabled=disabled
/>
// This div displays the box for the checkbox, and applies some styling to the child
// span element.
<div class="relative inline-block min-h-5 min-w-5 h-5 w-5 m-1 mr-2 rounded-full border border-gray-300 bg-white
transition-bg duration-200 peer-checked:bg-red-700 peer-checked:border-red-700
[&>span]:opacity-0 peer-checked:[&>span]:opacity-100
peer-disabled:bg-gray-600/33 peer-disabled:border-gray-600
peer-disabled:peer-checked:bg-gray-600">
// This span is the check icon. It is centered within the div and has a border on the bottom
// and right sides, which is then rotated to appear like a checkmark.
<span
class="absolute left-1/2 top-1/2 block h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2
rotate-45 transform border-3 border-3 rounded-full bg-white
border-white transition-opacity duration-150"></span>
</div>
<input
node_ref=input_ref
class="border-b-2 mb-1.5 px-1.5 py-0.5 rounded-t-xs bg-transparent relative flex-1 transition-all duration-150
border-red-700 disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
r#type="text"
disabled=input_disabled
placeholder=placeholder
prop:value=value
on:input=on_change_text
on:blur=on_blur_text
/>
</label>
}
}
@@ -64,24 +151,47 @@ pub fn RadioList(
#[prop(optional, into)] disabled: Signal<bool>,
#[prop(optional, into)] label: String,
#[prop(optional, into)] required: Signal<bool>,
#[prop(optional, into)] other_prompt: Signal<String>,
) -> impl IntoView {
//#region Value Logic
// Store a single signal that is the currently selected value. We can only have one selected
// value, so there's no list needed. The default selected value is whatever the map has selected
// already, or nothing.
let default_value = match data_map.get_untracked().get(&data_member.get_untracked()) {
Some(ValueType::String(Some(value))) => value.clone(),
_ => String::new()
};
let selected_value = RwSignal::new(default_value);
let hydrated = RwSignal::new(false);
let selected_value = RwSignal::new(String::new());
let other_checked = RwSignal::new(false);
let other_value = RwSignal::new(String::new());
// When the selected value changes, update the data map at the correct location
// Hydration effect that resolves data. Sets up all data properly.
Effect::new({
let items = items.clone();
move || {
if hydrated.get() {
return;
}
let default_value = match data_map.get_untracked().get(&data_member.get_untracked()) {
Some(ValueType::String(Some(value))) => value.clone(),
_ => String::new()
};
if !items.contains(&default_value) && !default_value.is_empty() {
other_checked.set(true);
other_value.set(default_value.clone());
}
selected_value.set(default_value);
hydrated.set(true);
}
});
// When the other option is selected and the other value changes, update the selected value.
Effect::new(move || {
data_map.update(|map| {
map.insert(data_member.get(), ValueType::String(Some(selected_value.get())));
});
other_checked.get()
.then(|| selected_value.set(other_value.get()));
});
//#endregion
@@ -109,7 +219,15 @@ pub fn RadioList(
list.retain(|v| *v.get_untracked().input_name != *input_state.get_untracked().input_name)
});
});
// When the selected value changes, update the data map at the correct location and validate.
Effect::new(move || {
error.set(validate(selected_value.get(), required.get()));
data_map.update(|map| {
map.insert(data_member.get(), ValueType::String(Some(selected_value.get())));
});
});
//#endregion
//#region Render Logic
@@ -122,9 +240,12 @@ pub fn RadioList(
.map(|item| {
let item = RwSignal::new(item);
let checked_signal = Signal::derive(move || {
selected_value.get() == item.get()
selected_value.get() == item.get() && !other_checked.get()
});
let on_change = move || {
// on_change only runs when the radio button is checked, not when unchecked.
// Change the selected value to the new one.
other_checked.set(false);
selected_value.set(item.get());
error
.set(
@@ -136,9 +257,6 @@ pub fn RadioList(
dirty.set(true);
};
// on_change only runs when the radio button is checked, not when unchecked.
// Change the selected value to the new one.
view! {
<Radio
checked=checked_signal
@@ -151,6 +269,18 @@ pub fn RadioList(
}
})
.collect::<Vec<_>>()}
<Show when=move || !other_prompt.get().is_empty()>
<RadioOther
checked=other_checked
name=data_member
disabled=disabled
value=other_value
on_change=move |_| other_checked.set(true)
placeholder=other_prompt
dirty=dirty
/>
</Show>
</div>
<Show when=move || show_errors.get()>
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
+1
View File
@@ -15,6 +15,7 @@ use std::collections::HashMap;
use leptos::prelude::ServerFnError;
use leptos::server;
#[cfg(feature = "ssr")]
static COMPARISONS_TABLE: &str = "leptos-comparison-test";
/// Helper function to create all sports comparisons. Only used to create initial
+1
View File
@@ -17,6 +17,7 @@ use crate::common::ExpandableInfo;
use leptos::prelude::ServerFnError;
use leptos::server;
#[cfg(feature = "ssr")]
static SCHOLARSHIPS_TABLE: &str = "leptos-scholarship-test";
#[server(GetScholarshipInfo, endpoint = "/scholarship/info/get")]
+4 -24
View File
@@ -8,7 +8,7 @@ use std::process::{Command, Stdio};
#[cfg(feature = "ssr")]
use std::io::Write;
use crate::common::{ComparisonData, ExpandableInfo};
use crate::common::ExpandableInfo;
use crate::pages::UnauthenticatedPage;
use crate::components::{
ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row,
@@ -213,28 +213,6 @@ pub fn HomePage() -> impl IntoView {
};
//#endregion
let comparison_resource = Resource::new(
move || user_subject.get(),
async move |_| get_comparisons_categorized().await
);
let comparison_info = RwSignal::new(HashMap::<String, Vec<ComparisonData>>::new());
Effect::new(move || {
match comparison_resource.get() {
Some(Ok(comparisons)) => {
log!("Found comparison info: {:?}", comparisons);
comparison_info.set(comparisons);
}
Some(Err(err)) => {
log!("Error while getting comparisons: {:?}", err);
}
_ => {
log!("An unknown error occurred.");
}
}
});
view! {
<AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage>
@@ -336,6 +314,7 @@ pub fn HomePage() -> impl IntoView {
data_member="town"
data_map=form_data
required=true
other_prompt="Other..."
/>
</Row>
<Row>
@@ -384,7 +363,8 @@ pub fn HomePage() -> impl IntoView {
</Row>
}
})
}} <Row>
}}
<Row>
<ActionButton on:click=pdf_button_click>"Get PDF"</ActionButton>
</Row>
</Suspense>
+17 -11
View File
@@ -120,17 +120,23 @@ pub fn ProviderContactPage() -> impl IntoView {
let mut toasts_context = expect_context::<ToastContext>();
Effect::new(move || {
submit_action.value().get().is_some()
.then(|| {
toasts_context.toast(
Toast::new()
.id(uuid::Uuid::new_v4())
.header("Submission Successful")
.msg("You can go back or continue editing your responses.")
);
submit_action.clear();
});
match submit_action.value().get() {
Some(Ok(_)) => toasts_context.toast(
Toast::new()
.id(uuid::Uuid::new_v4())
.header("Submission Successful")
.msg("You can go back or continue editing your responses.")
),
Some(Err(err)) => toasts_context.toast(
Toast::new()
.id(uuid::Uuid::new_v4())
.header("Submission Failed")
.msg(err.to_string())
),
_ => {}
}
submit_action.clear();
});
// Display contact form.
+72 -60
View File
@@ -361,42 +361,7 @@ fn ScholarshipForm(
/// submission fails, this function is not run.
#[prop(into)] on_submit: Callback<()>
) -> impl IntoView {
let submit_action = ServerAction::<CreateScholarshipInfo>::new();
let submit_status = RwSignal::new(SubmitStatus::Idle);
let elements_disabled = Signal::derive(move || {
matches!(submit_action.pending().get(), true)
});
Effect::new(move || {
if submit_action.pending().get() {
submit_status.set(SubmitStatus::Sending);
return;
}
if let Some(result) = submit_action.value().get() {
match result {
Ok(()) => {
submit_status.set(SubmitStatus::Success);
on_submit.run(());
},
Err(_err) => submit_status.set(SubmitStatus::Error(()))
}
}
});
let comparison_lists = Resource::new(
move || scholarship_id.get().is_some(),
async move |_| {
get_comparisons_categorized().await
}
);
Effect::new(move || {
if let Some(Ok(map)) = comparison_lists.get() {
log!("Available categories: {:?}", map.keys());
}
});
//#region Form Data Setup
let scholarship_info = Resource::new(
move || scholarship_id.get(),
@@ -428,6 +393,22 @@ fn ScholarshipForm(
}
});
//#endregion
//#region Comparison Logic
let comparison_lists = Resource::new(
move || scholarship_id.get().is_some(),
async move |_| {
get_comparisons_categorized().await
}
);
Effect::new(move || {
if let Some(Ok(map)) = comparison_lists.get() {
log!("Available categories: {:?}", map.keys());
}
});
let get_comp_category = move |category: &str| {
if let Some(Ok(map)) = comparison_lists.get() {
map.get(category).unwrap_or(&Vec::new()).clone()
@@ -469,6 +450,33 @@ fn ScholarshipForm(
let major_ids = Signal::derive(move || get_comp_ids("Majors"));
let major_text = Signal::derive(move || get_comp_text("Majors"));
//#endregion
//#region Submission Logic
let submit_action = ServerAction::<CreateScholarshipInfo>::new();
let submit_status = RwSignal::new(SubmitStatus::Idle);
let elements_disabled = Signal::derive(move || {
matches!(submit_action.pending().get(), true)
});
Effect::new(move || {
if submit_action.pending().get() {
submit_status.set(SubmitStatus::Sending);
return;
}
if let Some(result) = submit_action.value().get() {
match result {
Ok(()) => {
submit_status.set(SubmitStatus::Success);
on_submit.run(());
},
Err(_err) => submit_status.set(SubmitStatus::Error(()))
}
}
});
let mut toasts = expect_context::<ToastContext>();
let on_submit = move |_| {
@@ -481,21 +489,29 @@ fn ScholarshipForm(
info
});
};
Effect::new(move || {
submit_action.value().get().is_some()
.then(|| {
log!("Displaying toasts!");
toasts.toast(
Toast::new()
.id(uuid::Uuid::new_v4())
.msg("Succesfully submitted.")
);
submit_action.clear();
});
match submit_action.value().get() {
Some(Ok(_)) => toasts.toast(
Toast::new()
.id(uuid::Uuid::new_v4())
.header("Submission Successful")
.msg("You can go back or continue editing your responses.")
),
Some(Err(err)) => toasts.toast(
Toast::new()
.id(uuid::Uuid::new_v4())
.header("Submission Failed")
.msg(err.to_string())
),
_ => {}
}
submit_action.clear();
});
//#endregion
let create_comps = ServerAction::<CreateTestComparisons>::new();
view! {
@@ -586,25 +602,21 @@ fn ScholarshipForm(
label="Will the award be made to the school or the student?"
/>
</Row>
<Row>
<RadioList
data_member="essay_required"
data_map=form_data
items=vec!["Yes".to_string(), "No".to_string()]
disabled=elements_disabled
label="Do you require a student essay?"
/>
</Row>
<Row>
<RadioList
data_member="essay_prompt"
data_map=form_data
items=vec!["Test 1", "Test 2", "Test 3"]
items=vec![
"Write an essay describing your major/career direction. Include any abilities, interests, experiences, employment or clubs you have had that have affected your direction. If you are undecided, indicate why.",
"Why is community service important? How has it personally affected you and your goals and what role do you hope to play in it in the future?",
"How has participation in sports been beneficial to your personal, academic, and emotional growth?"
]
.iter()
.map(|s| s.to_string())
.map(|prompt| prompt.to_string())
.collect()
disabled=elements_disabled
label="If so, select a prompt from the list below."
label="Select an essay prompt, or leave blank if none is required:"
other_prompt="Custom prompt..."
/>
</Row>
<Row>