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.
This commit is contained in:
alphafetus committed 2025-12-29 18:26:26 -05:00
1 parent 74e7111820
commit 85ec857e38
4 files changed
+259 -322

No files matched your search

+76 -126
View File
@@ -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<RwSignal<ValidationState>>`)
```
{
"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> 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<QuestionComponent> 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<RwSignal<InputState>>
struct InputState {
/// Determines if there is an error and provides an error message.
error: Signal<ValidationState>,
/// Determines if the form has requested to show error messages.
show_message: RwSignal<bool>
}
struct QuestionComponent {
label: String,
placeholder: String,
possible_values: HashMap<str, str>,
// there are definitely values that i'll find while creating this structure.
// Define a new type for ease of use.
type ValidationVec = Vec<RwSignal<InputState>>;
// 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::<ValidationVec>().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.
+2 -2
View File
@@ -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<bool>,
#[prop(into)] disabled: Signal<bool>,
) -> 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<HashMap<String, ValueType>>,
#[prop()] items: Vec<String>,
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
#[prop(optional, into)] disabled: Signal<bool>,
#[prop(optional, into)] label: String,
) -> impl IntoView {
view! {
+1 -1
View File
@@ -15,7 +15,7 @@ pub fn Select(
#[prop(into)] data_member: String,
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop(optional, into)] label: String,
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
#[prop(optional, into)] disabled: Signal<bool>,
) -> impl IntoView {
let on_change = {
let data_member = data_member.clone();
+180 -193
View File
@@ -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<Vec<u8>, 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<ExpandableInfo> = 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::<CreateSampleSubmission>::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::<HtmlAnchorElement>()
.unwrap();
link.set_href(&*data_url);
link.set_target("_blank");
link.click();
}
});
};
//#endregion
view! {
<AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage>
// Replace this fallback with a real loading screen.
<Suspense fallback=Loading>
<ValidatedForm on_submit=Callback::new(move |_| {})>
{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! {
<Row>
<div class="flex flex-col flex-1" />
<Panel>
<Row>
<h1 class="text-3xl font-bold">
"Region 15 Scholarship Application (DEMO)"
</h1>
</Row>
<Row>
<p class="text-sm">
"This is a very simple demonstration of the scholarship application written in Rust."
</p>
</Row>
<Row>
// Text { data_member, label, placeholder }
<OutlinedTextField
label="First Name:"
placeholder="John"
disabled=elements_disabled
data_member="first_name"
data_map=expandable_react.data
/>
<OutlinedTextField
label="Last Name:"
placeholder="Smith"
disabled=elements_disabled
data_member="last_name"
data_map=expandable_react.data
/>
</Row>
<Row>
<OutlinedTextField
label="Contact Email:"
placeholder="student@region15.org"
disabled=elements_disabled
data_member="contact_email"
data_map=expandable_react.data
input_type=TextFieldType::Email
/>
</Row>
<Row>
<OutlinedTextField
label="Phone Number:"
placeholder="123-456-7890"
disabled=elements_disabled
data_member="phone_number"
data_map=expandable_react.data
/>
</Row>
<Row>
<OutlinedTextField
label="Street Address:"
placeholder="123 Fake Street"
disabled=elements_disabled
data_member="address"
data_map=expandable_react.data
/>
</Row>
<Row>
<OutlinedTextField
label="Highest Math SAT Score:"
placeholder="600"
disabled=elements_disabled
data_member="math_sat"
data_map=expandable_react.data
input_type=TextFieldType::Number
/>
</Row>
<Row>
// Radio { data_member, label, items }
<RadioList
label="Town:"
items=vec!["Southbury", "Middlebury"]
.into_iter()
.map(|s| s.into())
.collect()
disabled=elements_disabled
data_member="town"
data_map = expandable_react.data
/>
</Row>
<Row>
// Checkbox { data_member, label, items }
<CheckboxList
label="Favorite Candies:"
items=vec!["Twizzlers", "Reese's", "Starburst"]
.into_iter()
.map(|s| s.into())
.collect()
disabled=elements_disabled
data_member="favorite_candies"
data_map=expandable_react.data
/>
</Row>
<Row>
// Select { data_member, label, items }
<Select
label="Gender:"
value_list=vec!["Male", "Female", "Prefer not to answer"]
.into_iter()
.map(|s| s.to_owned())
.collect()
disabled=elements_disabled
data_member="gender"
data_map=expandable_react.data
/>
</Row>
<Row>
<MultiEntry
data_map=expandable_react.data
data_member="community_involvement"
schema=vec![
input!(Text, "service_name", "Activity Name:", "Some service activity..."),
input!(Number, "service_hours", "Total Service Hours", "20")
]
/>
</Row>
<Row>
<ActionButton
on:click=move |_| {
let captured_map = expandable_react.capture();
console_log(
format!("Map values: {:?}", captured_map).as_str(),
);
submit_action
.dispatch(CreateSampleSubmission {
student_info: captured_map,
});
}
disabled=elements_disabled
>
"Submit"
</ActionButton>
</Row>
<Row>
<ActionButton on: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::<HtmlAnchorElement>()
.unwrap();
link.set_href(&*data_url);
link.set_target("_blank");
link.click();
}
});
}>"Get PDF"</ActionButton>
</Row>
<Row>
<p>{result_msg}</p>
</Row>
<ValidatedForm on_submit=Callback::new(on_submit)>
<Row>
<h1 class="text-3xl font-bold">
"Region 15 Scholarship Application (DEMO)"
</h1>
</Row>
<Row>
<p class="text-sm">
"This is a very simple demonstration of the scholarship application written in Rust."
</p>
</Row>
<Row>
<OutlinedTextField
label="First Name:"
placeholder="John"
disabled=elements_disabled
data_member="first_name"
data_map=form_data
/>
<OutlinedTextField
label="Last Name:"
placeholder="Smith"
disabled=elements_disabled
data_member="last_name"
data_map=form_data
/>
</Row>
<Row>
<OutlinedTextField
label="Contact Email:"
placeholder="student@region15.org"
disabled=elements_disabled
data_member="contact_email"
data_map=form_data
input_type=TextFieldType::Email
/>
</Row>
<Row>
<OutlinedTextField
label="Phone Number:"
placeholder="123-456-7890"
disabled=elements_disabled
data_member="phone_number"
data_map=form_data
/>
</Row>
<Row>
<OutlinedTextField
label="Street Address:"
placeholder="123 Fake Street"
disabled=elements_disabled
data_member="address"
data_map=form_data
/>
</Row>
<Row>
<OutlinedTextField
label="Highest Math SAT Score:"
placeholder="600"
disabled=elements_disabled
data_member="math_sat"
data_map=form_data
input_type=TextFieldType::Number
/>
</Row>
<Row>
<RadioList
label="Town:"
items=vec!["Southbury", "Middlebury"]
.into_iter()
.map(|s| s.into())
.collect()
disabled=elements_disabled
data_member="town"
data_map = form_data
/>
</Row>
<Row>
<CheckboxList
label="Favorite Candies:"
items=vec!["Twizzlers", "Reese's", "Starburst"]
.into_iter()
.map(|s| s.into())
.collect()
disabled=elements_disabled
data_member="favorite_candies"
data_map=form_data
/>
</Row>
<Row>
<Select
label="Gender:"
value_list=vec!["Male", "Female", "Prefer not to answer"]
.into_iter()
.map(|s| s.to_owned())
.collect()
disabled=elements_disabled
data_member="gender"
data_map=form_data
/>
</Row>
<Row>
<MultiEntry
data_map=form_data
data_member="community_involvement"
schema=vec![
input!(Text, "service_name", "Activity Name:", "Some service activity..."),
input!(Number, "service_hours", "Total Service Hours", "20")
]
/>
</Row>
</ValidatedForm>
</Panel>
<div class="flex flex-col flex-1" />
</Row>
}
})
}}
</ValidatedForm>
<Row>
<ActionButton on:click=pdf_button_click>"Get PDF"</ActionButton>
</Row>
</Suspense>
</Authenticated>
</AuthLoaded>