From db99f39716e7e881ee9fcbcc81ed0c7f1126bf91 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Wed, 18 Mar 2026 11:39:07 -0400 Subject: [PATCH 01/19] style: rustfmt run --- src/pages/student/eligibility_page.rs | 35 ++++++++++++++------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/pages/student/eligibility_page.rs b/src/pages/student/eligibility_page.rs index c49d896..dadc1ce 100644 --- a/src/pages/student/eligibility_page.rs +++ b/src/pages/student/eligibility_page.rs @@ -104,23 +104,24 @@ pub fn StudentEligibilityPage() -> impl IntoView { debug_log!( "Resolved requirements: {:?}", resolved_requirements.iter().flatten().map(|rel| rel.display_text.clone()).collect::>() ); - let valid = !scholarship_name.is_empty() && resolved_requirements - .iter() - .all(|list| { - list.iter() - .fold( - false, - |prev, requirement| { - let result = requirement - .compare(&student_info) - .unwrap_or_else(|err| { - debug_log!("Requirement failed: {err}"); - false - }); - if prev { prev } else { result } - }, - ) - }); + let valid = !scholarship_name.is_empty() + && resolved_requirements + .iter() + .all(|list| { + list.iter() + .fold( + false, + |prev, requirement| { + let result = requirement + .compare(&student_info) + .unwrap_or_else(|err| { + debug_log!("Requirement failed: {err}"); + false + }); + if prev { prev } else { result } + }, + ) + }); if valid { debug_log!("Scholarship is valid."); let scholarship_id = StoredValue::new(scholarship.subject.clone()); From 2e134fb6936b6ccdbf335c00b8616c9bf88e7df6 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Wed, 18 Mar 2026 12:01:06 -0400 Subject: [PATCH 02/19] temp: begin implementation of applicants page --- src/pages/api/mod.rs | 4 +- src/pages/provider_applicants.rs | 117 ++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/src/pages/api/mod.rs b/src/pages/api/mod.rs index e30afcb..5e63eda 100644 --- a/src/pages/api/mod.rs +++ b/src/pages/api/mod.rs @@ -2,11 +2,11 @@ mod admin; mod comparisons; mod dates; pub mod exports; +pub mod files; mod providers; mod scholarships; pub mod students; -pub mod files; -mod tokens; +pub(crate) mod tokens; pub use admin::*; pub use comparisons::*; diff --git a/src/pages/provider_applicants.rs b/src/pages/provider_applicants.rs index 181e907..84fe9a7 100644 --- a/src/pages/provider_applicants.rs +++ b/src/pages/provider_applicants.rs @@ -1,5 +1,8 @@ -use leptos::prelude::*; -use crate::components::Banner; +use crate::common::ValueType; +use crate::components::{Banner, Loading}; +use leptos::either::Either; +use leptos::prelude::*; +use leptos_oidc::AuthSignal; #[component] pub fn ApplicantsPageFallback() -> impl IntoView { @@ -14,4 +17,112 @@ pub fn ApplicantsPageFallback() -> impl IntoView {

"Come back soon!"

} -} \ No newline at end of file +} + +#[component] +pub fn ApplicantsPageShell() -> impl IntoView { + // This shell needs to simply contain everything that will stay consistent across paths. + // The path will change to contain each scholarship's id, which will directly affect the content + // shown in a subroute for this page. + + // As a result, this page likely only needs to contain the banner and the list of scholarships. + // The content will be shown within the right side, but only as a result of some subroutes, + // each of which is still considered a single page. + + view! {
} +} + +#[component] +fn ApplicantsScholarshipList() -> impl IntoView { + use crate::common::ExpandableInfo; + + #[server] + async fn get_provider_scholarships( + access_token: String, + ) -> Result, ServerFnError> { + use crate::pages::api::SCHOLARSHIPS_TABLE; + use crate::pages::api::tokens::validate_and_get_token_info; + + let claims = validate_and_get_token_info(access_token).await?; + + // Get the information from the database. + let client = crate::utils::server::create_dynamo_client().await; + + let output = client + .scan() + .table_name(SCHOLARSHIPS_TABLE) + .expression_attribute_values( + ":id", + serde_dynamo::to_attribute_value(ValueType::String(Some(claims.subject)))?, + ) + .filter_expression("provider_id = :id") + .send() + .await? + .items + .unwrap_or_default() + .into_iter() + .filter_map(|item| serde_dynamo::from_item(item).ok()) + .collect::>(); + + Ok(output) + } + + // Use access token for user identification + let auth = expect_context::(); + let access_token = + Memo::new(move |_| auth.with(|auth| auth.authenticated().map(|a| a.access_token()))); + + // We need to get the scholarships from the API. We don't need the whole scholarship, just the + // name and ID. + let trigger = Trigger::new(); + let scholarships_res = Resource::new( + move || (trigger.track(), access_token.get()), + async move |(_, access_token)| { + get_provider_scholarships(access_token.unwrap_or_default()).await + }, + ); + + view! { +
+

"Scholarships"

+ + {move || { + scholarships_res + .get() + .map(|items_res| { + let items = match items_res { + Ok(items) => items, + Err(e) => { + return Either::Left( + view! { +
+ {format!("Couldn't get scholarships: {}", e.to_string())} +
+ } + .into_any(), + ); + } + }; + Either::Right( + items + .into_iter() + .map(|item| { + + view! { } + .into_any() + }) + .collect_view(), + ) + }) + .collect_view() + }} +
+
+ } +} + +#[component] +fn ApplicantsScholarshipEntry() -> impl IntoView {} + +#[component] +fn ApplicantsStudentList() -> impl IntoView {} From 05b865c608fb2c0fa6297dd28f8345f7aa0fd100 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Wed, 18 Mar 2026 13:48:26 -0400 Subject: [PATCH 03/19] feat: implement applicant page shell --- src/app.rs | 6 +- src/pages/provider_applicants.rs | 108 ++++++++++++++++++++++--------- 2 files changed, 83 insertions(+), 31 deletions(-) diff --git a/src/app.rs b/src/app.rs index 3439ed1..b8d691a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,11 +1,11 @@ use crate::components::ToastList; use crate::components::login::ProviderLoginContext; -use crate::pages::student; use crate::pages::{ AdminHomePage, AdminProviderPage, AdminScholarshipPage, AdminShell, AdminUtilsPage, ApplicantsPageFallback, AuthCallbackPage, LoanerBorrowForm, LoanerFallback, LoanerReturnForm, LoanerShell, ProviderContactPage, ProviderPortal, ScholarshipInfoPage, }; +use crate::pages::{ApplicantsPageShell, student}; use leptos::prelude::*; use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; use leptos_oidc::AuthSignal; @@ -86,7 +86,9 @@ fn ProviderRoutes() -> impl MatchNestedRoutes + Clone { view! { - + + + // diff --git a/src/pages/provider_applicants.rs b/src/pages/provider_applicants.rs index 84fe9a7..c1ae3f3 100644 --- a/src/pages/provider_applicants.rs +++ b/src/pages/provider_applicants.rs @@ -1,20 +1,18 @@ -use crate::common::ValueType; +use crate::common::{ExpandableInfo, ValueType}; use crate::components::{Banner, Loading}; -use leptos::either::Either; use leptos::prelude::*; use leptos_oidc::AuthSignal; +use leptos_router::components::Outlet; +use leptos_router::hooks::use_navigate; #[component] pub fn ApplicantsPageFallback() -> impl IntoView { view! { - -
-

"Applicants Page"

- -

- "This page is under construction! It will show all the students that are eligible for your scholarship(s)." +

+

"Scholarship Applicants"

+

+ "Please select a scholarship from the left to view the eligible applicants."

-

"Come back soon!"

} } @@ -29,7 +27,19 @@ pub fn ApplicantsPageShell() -> impl IntoView { // The content will be shown within the right side, but only as a result of some subroutes, // each of which is still considered a single page. - view! {
} + view! { + +
+
+
+ +
+ +
+
+
+
+ } } #[component] @@ -82,8 +92,17 @@ fn ApplicantsScholarshipList() -> impl IntoView { }, ); + let navigate = use_navigate(); + + // This closure returns another closure that's using the correct scholarship ID. + // It avoids having to define it within the Transition component. It's wrapped in a Callback + // because this makes it Send + Sync. + let on_view_click = Callback::new(move |scholarship_id: String| { + navigate(&*scholarship_id, Default::default()); + }); + view! { -
+

"Scholarships"

{move || { @@ -93,26 +112,28 @@ fn ApplicantsScholarshipList() -> impl IntoView { let items = match items_res { Ok(items) => items, Err(e) => { - return Either::Left( - view! { -
- {format!("Couldn't get scholarships: {}", e.to_string())} -
- } - .into_any(), - ); + return view! { +
+ {format!("Couldn't get scholarships: {}", e.to_string())} +
+ } + .into_any(); } }; - Either::Right( - items - .into_iter() - .map(|item| { - view! { } - .into_any() - }) - .collect_view(), - ) + view! { + + + + } + .into_any() }) .collect_view() }} @@ -122,7 +143,36 @@ fn ApplicantsScholarshipList() -> impl IntoView { } #[component] -fn ApplicantsScholarshipEntry() -> impl IntoView {} +fn ApplicantsScholarshipEntry( + #[prop()] item: ExpandableInfo, + #[prop(into)] on_view_click: Callback, +) -> impl IntoView { + // Store the item's subject. + let subject = StoredValue::new(item.subject); + let scholarship_name = Memo::new(move |_| { + item.data + .get("name") + .map(|name| name.as_string().ok().flatten()) + .flatten() + .unwrap_or("".to_string()) + }); + + let on_click = move |_| { + on_view_click.run(subject.get_value()); + }; + + view! { +
+
{scholarship_name}
+
+ "View Applicants" +
+
+ } +} #[component] fn ApplicantsStudentList() -> impl IntoView {} From 23c002931783c5ed1940d474b2c71feb0c6db702 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Tue, 31 Mar 2026 07:14:44 -0400 Subject: [PATCH 04/19] style: rustfmt run --- src/pages/student/eligibility_page.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/student/eligibility_page.rs b/src/pages/student/eligibility_page.rs index f52e681..ff158b8 100644 --- a/src/pages/student/eligibility_page.rs +++ b/src/pages/student/eligibility_page.rs @@ -142,7 +142,9 @@ pub fn StudentEligibilityPage() -> impl IntoView { ), async move |(access_token, form_id, scholarship_id)| { let Some(access_token) = access_token else { - return Err(ServerFnError::new("Couldn't find access token")); + return Err( + ServerFnError::new("Couldn't find access token"), + ); }; list_files(access_token, form_id, scholarship_id).await }, From 152c01124b01b1ef30326aca1cb7b6a721daf7fa Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Wed, 1 Apr 2026 13:50:31 -0400 Subject: [PATCH 05/19] feat: improve error logging when JSON does not parse --- src/pages/api/tokens.rs | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/pages/api/tokens.rs b/src/pages/api/tokens.rs index efaa12b..e71db7e 100644 --- a/src/pages/api/tokens.rs +++ b/src/pages/api/tokens.rs @@ -1,19 +1,16 @@ -#[cfg(feature = "ssr")] -use jsonwebtoken::{jwk::JwkSet, DecodingKey}; +use crate::common::UserClaims; +#[cfg(feature = "ssr")] +use jsonwebtoken::{DecodingKey, jwk::JwkSet}; use leptos::prelude::*; -use crate::common::UserClaims; #[cfg(feature = "ssr")] pub async fn fetch_jwks(user_pool_id: String, region: String) -> Result { let request_url = format!( "https://cognito-idp.{}.amazonaws.com/{}/.well-known/jwks.json", - region, - user_pool_id + region, user_pool_id ); - let jwks = reqwest::get(&request_url).await? - .json::() - .await?; + let jwks = reqwest::get(&request_url).await?.json::().await?; Ok(jwks) } @@ -21,7 +18,7 @@ pub async fn fetch_jwks(user_pool_id: String, region: String) -> Result Result { - use jsonwebtoken::{decode, Algorithm, Validation}; + use jsonwebtoken::{Algorithm, Validation, decode}; let jwks = fetch_jwks("us-east-1_Lfjuy5zaM".into(), "us-east-1".into()).await?; @@ -31,16 +28,18 @@ pub async fn validate_and_get_token_info(token: String) -> Result( - &token, - &DecodingKey::from_jwk(&jwk)?, - &validation - ).map_err(|_| ServerFnError::new("Invalid token found".to_string()))?; + leptos::logging::debug_log!("Parsing token: {:?}", token); + + let token_data = decode::(&token, &DecodingKey::from_jwk(&jwk)?, &validation) + .map_err(|err| ServerFnError::new(format!("Invalid token found: {err}")))?; if token_data.claims.token_use != "access" { return Err(ServerFnError::new("Token type mismatch")); From 17e339ea00237648c53d9fe59e134576941ce0d1 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Wed, 1 Apr 2026 13:50:49 -0400 Subject: [PATCH 06/19] feat: add cognito:groups field --- src/common/user.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/common/user.rs b/src/common/user.rs index 496037a..525572c 100644 --- a/src/common/user.rs +++ b/src/common/user.rs @@ -24,4 +24,7 @@ pub struct UserClaims { pub username: String, /// The intended use for the token. pub token_use: String, + /// The list of the names of user pool groups that have this user as a member. + #[serde(rename = "cognito:groups")] + pub groups: Vec, } From 3baee8cac7b72275d9fa5d9908d548a36cfc8f7c Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Wed, 1 Apr 2026 13:51:25 -0400 Subject: [PATCH 07/19] feat: add applicants list to the routes --- src/app.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app.rs b/src/app.rs index 0250a79..07bb0e0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,10 +2,11 @@ use crate::components::ToastList; use crate::components::login::ProviderLoginContext; use crate::pages::{ AdminHomePage, AdminProviderPage, AdminScholarshipPage, AdminShell, AdminUtilsPage, - ApplicantsPageFallback, AuthCallbackPage, LoanerBorrowForm, LoanerFallback, LoanerReturnForm, - LoanerShell, ProviderContactPage, ProviderPortal, + ApplicantsPageFallback, ApplicantsStudentList, AuthCallbackPage, LoanerBorrowForm, + LoanerFallback, LoanerReturnForm, LoanerShell, ProviderContactPage, ProviderPortal, + ScholarshipInfoPage, }; -use crate::pages::{FormClosedPage, ApplicantsPageShell, student}; +use crate::pages::{ApplicantsPageShell, student}; use leptos::prelude::*; use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; use leptos_oidc::AuthSignal; @@ -88,6 +89,7 @@ fn ProviderRoutes() -> impl MatchNestedRoutes + Clone { + From 469f192feb321658c95717b6c150a9814f9c5514 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Wed, 8 Apr 2026 13:55:07 -0400 Subject: [PATCH 08/19] feat: implement basic structure of provider applicants page --- Cargo.toml | 1 - src/app.rs | 11 +- src/components/data_display.rs | 4 +- src/pages/api/students.rs | 85 +++++- src/pages/provider_applicants.rs | 433 +++++++++++++++++++++++++++++-- 5 files changed, 508 insertions(+), 26 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2492772..db5a983 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,6 @@ leptos_oidc = "0.9.0" base64 = "0.22" chrono = "0.4" leptos_animate = "0.1" - macros = { path = "./macros" } traits = { path = "./traits" } icondata = { version = "0.7.0", features = ["font-awesome"] } diff --git a/src/app.rs b/src/app.rs index 07bb0e0..bfd5c97 100644 --- a/src/app.rs +++ b/src/app.rs @@ -93,11 +93,11 @@ fn ProviderRoutes() -> impl MatchNestedRoutes + Clone { - - // - // - // - // + // + + + + } .into_inner() @@ -158,6 +158,7 @@ fn StudentRoutes() -> impl MatchNestedRoutes + Clone { path=path!("additional/specifics") view=student::StudentSpecificRequirementsPage /> + } .into_inner() diff --git a/src/components/data_display.rs b/src/components/data_display.rs index 3ab3173..e9b7d23 100644 --- a/src/components/data_display.rs +++ b/src/components/data_display.rs @@ -10,7 +10,9 @@ use std::collections::HashMap; #[component] pub fn ValueDisplay( #[prop(into)] schema: Signal, - #[prop(into, optional)] data_member: Signal, + /// The starting member. Starts display from this member in the `data_map`, if it exists. + #[prop(into, optional)] + data_member: Signal, #[prop(into)] data_map: Signal>, ) -> impl IntoView { let data_value = Memo::new(move |_| { diff --git a/src/pages/api/students.rs b/src/pages/api/students.rs index c18f657..9244d4f 100644 --- a/src/pages/api/students.rs +++ b/src/pages/api/students.rs @@ -1,10 +1,12 @@ use leptos::prelude::*; use leptos::server_fn::codec::Json; +use std::collections::HashMap; #[cfg(feature = "ssr")] mod imports { pub use super::super::MAIN_TABLE_NAME; pub use crate::common::ValueType; + pub use crate::pages::api::tokens::validate_and_get_token_info; pub use crate::utils::server::*; pub use aws_sdk_dynamodb::error::ProvideErrorMetadata; pub use aws_sdk_dynamodb::types::AttributeValue; @@ -16,7 +18,7 @@ mod imports { pub async fn put_student_data( subject: String, data_type: String, - data_map: std::collections::HashMap, + data_map: HashMap, ) -> Result<(), ServerFnError> { use imports::*; @@ -52,7 +54,7 @@ pub async fn put_student_data( pub async fn get_student_data( subject: String, data_type: String, -) -> Result, ServerFnError> { +) -> Result, ServerFnError> { use imports::*; let client = create_dynamo_client().await; @@ -99,7 +101,7 @@ pub async fn get_student_data( #[server] pub async fn get_all_student_data( subject: String, -) -> Result, ServerFnError> { +) -> Result, ServerFnError> { use imports::*; let client = create_dynamo_client().await; @@ -131,3 +133,80 @@ pub async fn get_all_student_data( ServerFnError::new(msg) }) } + +#[server] +pub async fn get_completed_students( + access_token: String, +) -> Result>, ServerFnError> { + use imports::*; + + // We want to get all student information. The requirements are that the students have completed + // the demographics form - everything else may bed from this. + // The easiest way is to get all the information and filter on this side, instead of bookkeeping + // on the database's side. + + // We want to verify the access token first, and make sure that the user has the correct access. + let claims = validate_and_get_token_info(access_token).await?; + if !claims.groups.contains(&"ScholarshipProviders".to_string()) { + return Err(ServerFnError::new( + "User is not in the ScholarshipProviders group", + )); + } + + let client = create_dynamo_client().await; + + let response = client + .scan() + .table_name(MAIN_TABLE_NAME) + .send() + .await + .map_err(|err| { + let msg = err.message().unwrap_or("Unknown error occurred"); + error!("{}", msg); + ServerFnError::new(msg) + })?; + + let Some(items) = response.items else { + return Ok(HashMap::new()); + }; + + let mut output = HashMap::>::new(); + + items.into_iter().for_each(|mut form_info| { + let student_id_full = form_info + .get("HK") + .map(|v| v.as_s().cloned().unwrap_or_default()) + .unwrap_or_default() + .to_owned(); + + let student_id = student_id_full.split("STUDENT#").collect::(); + + form_info.remove("HK"); + form_info.remove("SK"); + + let form_info_convert = form_info + .into_iter() + .map(|(k, v)| (k, ValueType::from(&v))) + .collect::>(); + + // We want to insert all the remaining information into the output map + output + .entry(student_id) + .and_modify(|v| { + v.extend(form_info_convert.clone()); + }) + .or_insert(form_info_convert); + }); + + // Don't love this, but it does verify that the student has completed the demographics form + let output = output + .into_iter() + .filter_map(|(k, student_info)| { + student_info + .get("first_name") + .and_then(|_| Some((k, student_info.clone()))) + }) + .collect(); + + Ok(output) +} diff --git a/src/pages/provider_applicants.rs b/src/pages/provider_applicants.rs index c1ae3f3..2eb78c1 100644 --- a/src/pages/provider_applicants.rs +++ b/src/pages/provider_applicants.rs @@ -1,9 +1,16 @@ -use crate::common::{ExpandableInfo, ValueType}; -use crate::components::{Banner, Loading}; +use crate::common::{ComparisonData, ExpandableInfo, SchemaNode, SchemaType, ValueType}; +use crate::components::{Banner, Loading, ValueDisplay}; +use crate::pages::UnauthenticatedPage; +use crate::pages::api::get_comparison_info; +use crate::pages::api::students::{get_all_student_data, get_student_data}; +use leptos::ev::{Event, MouseEvent}; +use leptos::html::Dialog; +use leptos::logging::debug_log; use leptos::prelude::*; -use leptos_oidc::AuthSignal; +use leptos_oidc::{AuthLoaded, AuthSignal, Authenticated}; use leptos_router::components::Outlet; -use leptos_router::hooks::use_navigate; +use leptos_router::hooks::{query_signal, use_navigate}; +use std::collections::HashMap; #[component] pub fn ApplicantsPageFallback() -> impl IntoView { @@ -28,17 +35,25 @@ pub fn ApplicantsPageShell() -> impl IntoView { // each of which is still considered a single page. view! { - -
-
-
- -
- + + + +
+
+
+ +
+ +
+
+
-
-
-
+ + } } @@ -98,7 +113,8 @@ fn ApplicantsScholarshipList() -> impl IntoView { // It avoids having to define it within the Transition component. It's wrapped in a Callback // because this makes it Send + Sync. let on_view_click = Callback::new(move |scholarship_id: String| { - navigate(&*scholarship_id, Default::default()); + let path = format!("/providers/applicants/{}", scholarship_id); + navigate(&path, Default::default()); }); view! { @@ -175,4 +191,389 @@ fn ApplicantsScholarshipEntry( } #[component] -fn ApplicantsStudentList() -> impl IntoView {} +pub fn ApplicantsStudentList() -> impl IntoView { + use crate::pages::api::get_scholarship_info; + use crate::pages::api::students::get_completed_students; + use leptos_router::hooks::use_params; + use leptos_router::params::Params; + + /// The parameters for the page that displays this component. This may be moved to the page-level + /// rather than the component-level. + #[derive(Params, PartialEq)] + struct StudentListParams { + scholarship_id: Option, + } + + // This is in charge of finding all student information and running the comparisons. + // It will then display all the eligible students. + + let params = use_params::(); + let auth = expect_context::(); + let access_token = + Memo::new(move |_| auth.with(|a| a.authenticated().map(|a| a.access_token()))); + + let scholarship_id = Memo::new(move |_| { + params + .read() + .as_ref() + .ok() + .and_then(|params| params.scholarship_id.clone()) + }); + + #[server] + async fn get_eligibility_info( + token: String, + scholarship_id: String, + ) -> Result< + ( + HashMap>, + ExpandableInfo, + Vec, + ), + ServerFnError, + > { + let (students, scholarships, requirements_list) = tokio::join!( + get_completed_students(token), + get_scholarship_info(scholarship_id), + get_comparison_info() + ); + + Ok((students?, scholarships?, requirements_list?)) + } + + let resource = Resource::new( + move || (access_token.get(), scholarship_id.get()), + move |(access_token, scholarship_id)| async move { + let Some(token) = access_token else { + return Err(ServerFnError::new("Couldn't find access token")); + }; + let Some(scholarship_id) = scholarship_id else { + return Err(ServerFnError::new("Couldn't find scholarship ID")); + }; + + get_eligibility_info(token, scholarship_id).await + }, + ); + + view! { +
+ + {move || { + resource + .get() + .map(|result| { + match result { + Ok((students, scholarship, requirements_list)) => { + view! { +
"Showing students here."
+ + } + .into_any() + } + Err(e) => { + view! { +
+ "Error while getting eligible students: "{e.to_string()} +
+ } + .into_any() + } + } + }) + }} +
+
+ } +} + +#[component] +fn ApplicantsStudentListView( + #[prop(into)] students: Signal>>, + #[prop(into)] scholarship: Signal, + #[prop(into)] requirements: Signal>, +) -> impl IntoView { + let eligible_students = RwSignal::new(HashMap::new()); + let error_msg = RwSignal::new(String::new()); + // let (current_student_id, set_current_student_id) = query_signal::("student_id"); + let current_student_id = RwSignal::new(None); + let navigate_student = Callback::new(move |id: String| { + current_student_id.set(Some(id)); + }); + let dialog_visible = + Signal::derive(move || !current_student_id.get().unwrap_or_default().is_empty()); + let on_dialog_close = Callback::new(move |_| { + current_student_id.set(None); + }); + + fn get_eligible_students( + students: HashMap>, + scholarship: ExpandableInfo, + requirements: Vec, + ) -> Result>, Error> { + let requires_essay = !scholarship + .data + .get("essay_prompt") + .unwrap_or(&ValueType::String(None)) + .as_string() + .map_err(|_| "Couldn't get essay as a string")? + .unwrap_or_default() + .is_empty(); + + // Get the full map of requirements. Each key contains a list of IDs. + let scholarship_requirements = scholarship + .data + .get("requirements") + .map(|val| val.as_map().ok()?) + .flatten() + .unwrap_or_default(); + + // Get the lists of requirements. Maps into string IDs and discards invalid ValueTypes. + let requirement_lists = scholarship_requirements + .into_iter() + .filter_map(|(_, list_val)| { + let list = list_val.as_list().ok().flatten()?; + let string_list = list + .into_iter() + .filter_map(|v| v.as_string().ok().flatten()) + .collect::>(); + + Some(string_list) + }) + .collect::>>(); + + // Resolve the requirement IDs. Discards all IDs that do not resolve. + let resolved_requirements = requirement_lists + .into_iter() + .map(|list| { + list.into_iter() + .filter_map(|id| { + requirements + .iter() + .cloned() + .find(|requirement| requirement.id == id) + }) + .collect::>() + }) + .collect::>>(); + + // Check student eligibility. These are all students that have completed the demographics + // forms. + let eligible_students = students + .into_iter() + .filter_map(|(id, student)| { + // The student needs to pass one requirement from each list. + let result = resolved_requirements.iter().all(|list| { + if list.is_empty() { + return true; + } + + list.iter() + .any(|requirement| requirement.compare(&student).unwrap_or(false)) + }); + + if result { Some((id, student)) } else { None } + }) + .collect::>>(); + + // debug_log!("Total number of eligible students: {}", eligible_students.len()); + + Ok(eligible_students) + } + + Effect::new(move || { + debug_log!("Running eligibility check on students"); + let eligible_res = + get_eligible_students(students.get(), scholarship.get(), requirements.get()); + + match eligible_res { + Ok(list) => { + debug_log!("Total number of eligible students: {}", list.len()); + eligible_students.set(list); + } + Err(e) => error_msg.set(format!("Failed to get eligible students list: {e}")), + } + }); + + // This is actually just a display of all the information from the students list. + // As defined in the ApplicantsStudentList component, this will already be wrapped in a + // flex container. + view! { + + +
"Error checking student eligibility: "{error_msg}
+
+
+ + {first_name} + " " + {last_name} +
+ } + } + /> +
+ } +} + +#[component] +fn StudentInformationDialog( + #[prop(into)] student_id: Signal>, + // #[prop(into)] scholarship_info: StoredValue, + #[prop(into)] visible: Signal, + #[prop(into)] on_close: Callback<()>, +) -> impl IntoView { + // This component needs to just display all student information. + // Specifically, it needs to know what information to display, like whether the scholarship + // requested an essay or financial information. + + let dialog_ref = NodeRef::::new(); + + let demographics_schema = StoredValue::new( + SchemaNode::new(SchemaType::Map) + .header("Demographics Information") + .child( + "first_name", + SchemaNode::new(SchemaType::String).header("First Name"), + ) + .child( + "last_name", + SchemaNode::new(SchemaType::String).header("Last Name"), + ) + .child( + "dob", + SchemaNode::new(SchemaType::String).header("Date of Birth"), + ) + .child( + "gender", + SchemaNode::new(SchemaType::String).header("Gender"), + ) + .child( + "phone_number", + SchemaNode::new(SchemaType::String).header("Preferred Phone Number"), + ) + .child( + "email", + SchemaNode::new(SchemaType::String).header("Preferred Email Address"), + ) + .child( + "street_address", + SchemaNode::new(SchemaType::String).header("Street Address"), + ) + .child("town", SchemaNode::new(SchemaType::String).header("Town")), + ); + + let sports_schema = StoredValue::new( + SchemaNode::new(SchemaType::List) + .header("Sports Activities") + .item_template( + SchemaNode::new(SchemaType::Map) + .header("Sport") + .child( + "sport_name", + SchemaNode::new(SchemaType::String).header("Sport Name"), + ) + .child( + "achievements", + SchemaNode::new(SchemaType::String).header("Special Achievements"), + ), + ), + ); + + let extracurricular_schema = + StoredValue::new(SchemaNode::new(SchemaType::Map).header("Extracurricular Activities")); + + Effect::new(move || { + if let Some(dialog) = dialog_ref.get() { + let open = visible.get(); + if open { + let _ = dialog.show_modal(); + debug_log!("Modal should be visible."); + } else { + dialog.close(); + debug_log!("Closed modal."); + } + } + }); + + view! { + +
+ + {move || Suspend::new(async move { + get_student_data( + student_id.get().unwrap_or_default(), + "demographics".to_string(), + ) + .await + .map(|student_info| { + view! { + + } + }) + })} + {move || Suspend::new(async move { + get_student_data( + student_id.get().unwrap_or_default(), + "athletics".to_string(), + ) + .await + .map(|student_info| { + view! { + + } + }) + })} + +
+
+ } +} From c6386e54fc93f79be5ad9ab8fd1d5cda2e05bc8f Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Wed, 8 Apr 2026 14:37:30 -0400 Subject: [PATCH 09/19] feat: create get_student_file server function --- src/pages/api/students.rs | 51 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/pages/api/students.rs b/src/pages/api/students.rs index 9244d4f..7a4ae1d 100644 --- a/src/pages/api/students.rs +++ b/src/pages/api/students.rs @@ -210,3 +210,54 @@ pub async fn get_completed_students( Ok(output) } + +/// # Get Student File API +/// This function gets a file from the corresponding S3 file key. If the subject found from the +/// `access_token` does not match the owner of the requested file, the requesting user must have +/// provider-level access or higher. +/// +/// ## Possible errors +/// Please check the [`validate_and_get_token_info`] function for all token parsing errors. Otherwise, +/// if the requesting user does not have the correct permissions, it will return a message indicating +/// such. If the file is not found, an error containing "File not found" will be returned. +#[server] +pub async fn get_student_file( + access_token: String, + file_key: String, +) -> Result, ServerFnError> { + use imports::*; + + let user_claims = validate_and_get_token_info(access_token).await?; + + // Check if the user's subject is contained in the file_key (since all files are keyed by the + // user's subject) + if !file_key.contains(&user_claims.subject) { + // Check that the user is a provider. + if !user_claims + .groups + .contains(&"ScholarshipProviders".to_string()) + { + return Err(ServerFnError::new( + "Access denied: user is not the student or a provider", + )); + } + } + + // We now just need to get the actual file. + let client = aws_sdk_s3::Client::new(&create_aws_config().await); + + let result = client + .get_object() + .key(file_key) + .send() + .await + .map_err(|e| { + let msg = e.message().unwrap_or("Unknown error occurred"); + error!("{}", msg); + ServerFnError::new(msg) + })?; + + let bytes = result.body.collect().await?; + + Ok(bytes.to_vec()) +} From 93a4b54d4226f357272d2ac141d986ee382d312d Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Thu, 9 Apr 2026 10:40:14 -0400 Subject: [PATCH 10/19] feat: create file name apis --- src/pages/api/students.rs | 62 ++++++++++++++++++++++++++++++++ src/pages/provider_applicants.rs | 8 ++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/pages/api/students.rs b/src/pages/api/students.rs index 7a4ae1d..88f08cc 100644 --- a/src/pages/api/students.rs +++ b/src/pages/api/students.rs @@ -261,3 +261,65 @@ pub async fn get_student_file( Ok(bytes.to_vec()) } + +/// # Batch Get Student Files API +/// Gets all indicated files and returns a single `.zip` file containing all of them. They will be +/// organized based on their file key. +/// +/// This is more efficient than calling the [`get_student_file`] function as it makes use of S3's +/// `batch_get_object` function. +#[server] +pub async fn batch_get_student_files() -> Result, ServerFnError> { + todo!() +} + +/// # Get Student File Names API +/// Gets a list of file names given a student's ID and the corresponding input ID from the forms. +/// These file names can be used to construct a file key for S3. +/// +/// This API is only accessible by provider-level users. +#[server] +pub async fn get_student_file_names( + access_token: String, + student_id: String, + form_name: String, + question_id: String, +) -> Result, ServerFnError> { + use imports::*; + + let claims = validate_and_get_token_info(access_token).await?; + + // Check if the user has permission to get these files. + if !claims.groups.contains(&"ScholarshipProviders".to_string()) { + return Err(ServerFnError::new("Access denied: user is not a provider")); + } + + let client = create_dynamo_client().await; + + client + .query() + .table_name(MAIN_TABLE_NAME) + .expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}"))) + .expression_attribute_values( + ":sk", + AttributeValue::S(format!("FILE#{form_name}#{question_id}")), + ) + .key_condition_expression("HK = :hk and begins_with(SK, :sk)") + .send() + .await + .map_err(|e| { + let msg = e.message().unwrap_or("Unknown error occurred"); + error!("{}", msg); + ServerFnError::new(msg) + }) + .map(|output| { + let Some(items) = output.items else { + return Vec::new(); + }; + + items + .into_iter() + .filter_map(|item| item.get("SK")?.as_s().ok().cloned()) + .collect::>() + }) +} diff --git a/src/pages/provider_applicants.rs b/src/pages/provider_applicants.rs index 2eb78c1..de9610a 100644 --- a/src/pages/provider_applicants.rs +++ b/src/pages/provider_applicants.rs @@ -1,5 +1,5 @@ use crate::common::{ComparisonData, ExpandableInfo, SchemaNode, SchemaType, ValueType}; -use crate::components::{Banner, Loading, ValueDisplay}; +use crate::components::{ActionButton, Banner, Loading, ValueDisplay}; use crate::pages::UnauthenticatedPage; use crate::pages::api::get_comparison_info; use crate::pages::api::students::{get_all_student_data, get_student_data}; @@ -530,6 +530,11 @@ fn StudentInformationDialog( } }); + let on_click_fafsa = move |_| { + let fafsa_key = format!("financial_info/user_id/fafsa/file_name"); + debug_log!("Getting FAFSA file with key {fafsa_key}"); + }; + view! {
+ "Get FAFSA" {move || Suspend::new(async move { get_student_data( student_id.get().unwrap_or_default(), From c5b11481b39478ae3232429950d662c8ba2f1182 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Thu, 9 Apr 2026 20:53:47 -0400 Subject: [PATCH 11/19] feat: make scholarship filtering work on fafsa and essay requirements --- src/common/expandable_info.rs | 2 +- src/pages/api/students.rs | 63 ++++++++++++++ src/pages/provider_applicants.rs | 143 +++++++++++++++++++++++++++---- 3 files changed, 189 insertions(+), 19 deletions(-) diff --git a/src/common/expandable_info.rs b/src/common/expandable_info.rs index a209606..304c73f 100644 --- a/src/common/expandable_info.rs +++ b/src/common/expandable_info.rs @@ -15,7 +15,7 @@ use std::collections::HashMap; /// now, the table will simply create a single massive `Map` field that contains all the stored /// information in the `HashMap`. We'd prefer to be able to get single values if possible, /// especially for interoperability with other applications, should they be created. -#[derive(Debug, Clone, Serialize, Deserialize, Reactive)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Reactive)] pub struct ExpandableInfo { /// The main key member. Required and used to get/put the information in a database. pub subject: String, diff --git a/src/pages/api/students.rs b/src/pages/api/students.rs index 88f08cc..29291e9 100644 --- a/src/pages/api/students.rs +++ b/src/pages/api/students.rs @@ -323,3 +323,66 @@ pub async fn get_student_file_names( .collect::>() }) } + +/// # Get All Input Files API +/// Gets all files that have been uploaded to the given input. For example, getting all the FAFSA +/// files that were uploaded to the FAFSA file input on the Financial Info page. +/// +/// Returns a `HashMap` keyed by the submitting user's ID, with a value of all files that were +/// uploaded by that user. +/// +/// This is only usable by users with provider-level access. +#[server] +pub async fn get_all_input_files( + access_token: String, + form_name: String, + input_name: String, +) -> Result>, ServerFnError> { + use imports::*; + + let claims = validate_and_get_token_info(access_token).await?; + + if !claims.groups.contains(&"ScholarshipProviders".to_string()) { + return Err(ServerFnError::new("Access denied: user is not a provider")); + } + + let client = create_dynamo_client().await; + + let res = client + .scan() + .table_name(MAIN_TABLE_NAME) + .filter_expression("begins_with(SK, :sk)") + .expression_attribute_values( + ":sk", + AttributeValue::S(format!("FILE#{form_name}#{input_name}")), + ) + .send() + .await + .map_err(|e| { + let msg = e.message().unwrap_or("Unknown error occurred"); + error!("{}", msg); + ServerFnError::new(msg) + })?; + + let mut result_map = HashMap::>::new(); + res.items.unwrap_or_default().iter().for_each(|item| { + let id = item + .get("HK") + .map(|v| v.as_s().cloned().unwrap_or_default()) + .unwrap_or_default() + .split("STUDENT#") + .collect::(); + + let file_key = item + .get("file_key") + .map(|v| v.as_s().cloned().unwrap_or_default()) + .unwrap_or_default(); + + result_map + .entry(id) + .and_modify(|v| v.push(file_key.clone())) + .or_insert(vec![file_key]); + }); + + Ok(result_map) +} diff --git a/src/pages/provider_applicants.rs b/src/pages/provider_applicants.rs index de9610a..c3a5aba 100644 --- a/src/pages/provider_applicants.rs +++ b/src/pages/provider_applicants.rs @@ -2,7 +2,10 @@ use crate::components::{ActionButton, Banner, Loading, ValueDisplay}; use crate::pages::UnauthenticatedPage; use crate::pages::api::get_comparison_info; -use crate::pages::api::students::{get_all_student_data, get_student_data}; +use crate::pages::api::students::{ + BatchGetStudentFiles, GetStudentFile, GetStudentFileNames, get_all_input_files, + get_all_student_data, get_student_data, get_student_file_names, +}; use leptos::ev::{Event, MouseEvent}; use leptos::html::Dialog; use leptos::logging::debug_log; @@ -267,6 +270,7 @@ pub fn ApplicantsStudentList() -> impl IntoView { view! {
"Showing students here."
impl IntoView { #[component] fn ApplicantsStudentListView( + #[prop(into)] access_token: Signal>, #[prop(into)] students: Signal>>, #[prop(into)] scholarship: Signal, #[prop(into)] requirements: Signal>, @@ -309,20 +314,79 @@ fn ApplicantsStudentListView( current_student_id.set(None); }); + let fafsa_required = Memo::new(move |_| { + let required = scholarship + .get() + .data + .get("fafsa_required") + .map(|v| v.as_string().ok().flatten()) + .flatten() + .unwrap_or_default(); + + required == "Yes" + }); + + let essay_required = Memo::new(move |_| { + let prompt = scholarship + .get() + .data + .get("essay_prompt") + .map(|v| v.as_string().ok().flatten()) + .flatten() + .unwrap_or_default(); + + !prompt.is_empty() + }); + + let fafsa_resource = Resource::new( + move || (fafsa_required.get(), access_token.get()), + move |(fafsa_required, access_token)| async move { + // Only get this information if the FAFSA is required. Otherwise, return a blank result. + let Some(access_token) = access_token else { + return Err(ServerFnError::new("Couldn't find access token")); + }; + if !fafsa_required { + return Ok(HashMap::new()); + } + + get_all_input_files( + access_token, + "financial_info".to_string(), + "fafsa".to_string(), + ) + .await + }, + ); + let essay_resource = Resource::new( + move || (essay_required.get(), access_token.get()), + move |(essay_required, access_token)| async move { + // Only get this information if the scholarship requested it. Otherwise, return a blank + // result. + let Some(access_token) = access_token else { + return Err(ServerFnError::new("Couldn't find access token")); + }; + if !essay_required { + return Ok(HashMap::new()); + } + + get_all_input_files( + access_token, + "scholarship_essays".to_string(), + scholarship.get().subject, + ) + .await + }, + ); + fn get_eligible_students( students: HashMap>, scholarship: ExpandableInfo, requirements: Vec, + essay_required: bool, + fafsa_required: bool, + fafsa_list: HashMap>, + essay_list: HashMap>, ) -> Result>, Error> { - let requires_essay = !scholarship - .data - .get("essay_prompt") - .unwrap_or(&ValueType::String(None)) - .as_string() - .map_err(|_| "Couldn't get essay as a string")? - .unwrap_or_default() - .is_empty(); - // Get the full map of requirements. Each key contains a list of IDs. let scholarship_requirements = scholarship .data @@ -366,6 +430,26 @@ fn ApplicantsStudentListView( .into_iter() .filter_map(|(id, student)| { // The student needs to pass one requirement from each list. + // The easiest ones to check first are whether the scholarship requires a student + // essay and/or their fafsa information. If the student does not have these things, + // they are skipped. + + if fafsa_required && !fafsa_list.contains_key(&id) { + // Get the student files related to their fafsa and continue. Return false if not there. + debug_log!( + "Student with id {id} failed - FAFSA is required but was not found." + ); + return None; + } + + if essay_required && !essay_list.contains_key(&id) { + // Get the student's files related to their essays and continue. Return false if not there. + debug_log!( + "Student with id {id} failed - essay is required but was not found." + ); + return None; + } + let result = resolved_requirements.iter().all(|list| { if list.is_empty() { return true; @@ -385,16 +469,30 @@ fn ApplicantsStudentListView( } Effect::new(move || { - debug_log!("Running eligibility check on students"); - let eligible_res = - get_eligible_students(students.get(), scholarship.get(), requirements.get()); + // We have to wait until we've gotten fafsa/essay requirements. + debug_log!("Waiting on essay and FAFSA resources..."); + if let (Some(fafsa_list), Some(essay_list)) = (fafsa_resource.get(), essay_resource.get()) { + debug_log!( + "Running eligibility check on {} students", + students.get().len() + ); + let eligible_res = get_eligible_students( + students.get(), + scholarship.get(), + requirements.get(), + essay_required.get(), + fafsa_required.get(), + fafsa_list.unwrap_or_default(), + essay_list.unwrap_or_default(), + ); - match eligible_res { - Ok(list) => { - debug_log!("Total number of eligible students: {}", list.len()); - eligible_students.set(list); + match eligible_res { + Ok(list) => { + debug_log!("Total number of eligible students: {}", list.len()); + eligible_students.set(list); + } + Err(e) => error_msg.set(format!("Failed to get eligible students list: {e}")), } - Err(e) => error_msg.set(format!("Failed to get eligible students list: {e}")), } }); @@ -463,6 +561,8 @@ fn StudentInformationDialog( let dialog_ref = NodeRef::::new(); + //#region Schema Definitions + let demographics_schema = StoredValue::new( SchemaNode::new(SchemaType::Map) .header("Demographics Information") @@ -517,6 +617,8 @@ fn StudentInformationDialog( let extracurricular_schema = StoredValue::new(SchemaNode::new(SchemaType::Map).header("Extracurricular Activities")); + //#endregion + Effect::new(move || { if let Some(dialog) = dialog_ref.get() { let open = visible.get(); @@ -530,7 +632,12 @@ fn StudentInformationDialog( } }); + // We want to get the student's uploaded files - only the ones that are relevant to the current + // scholarship, meaning their essays and/or FAFSA by request. + let on_click_fafsa = move |_| { + // We need to get the student's file names/keys and then tell the server that we want to + // download them. let fafsa_key = format!("financial_info/user_id/fafsa/file_name"); debug_log!("Getting FAFSA file with key {fafsa_key}"); }; From f8d399f9ae317fd3eae5582b2e7f9ec94ac48dc6 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Thu, 9 Apr 2026 23:49:31 -0400 Subject: [PATCH 12/19] feat: create file download capability --- Cargo.lock | 202 +++++++++++++++++++++++++++++++ Cargo.toml | 4 + src/pages/api/students.rs | 68 ++++++++--- src/pages/provider_applicants.rs | 72 +++++++++-- 4 files changed, 321 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93a5549..50a500c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,23 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -739,6 +756,15 @@ dependencies = [ "either", ] +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "camino" version = "1.2.2" @@ -789,6 +815,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "cmake" version = "0.1.57" @@ -895,6 +931,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.6.0" @@ -1173,6 +1215,12 @@ dependencies = [ "syn", ] +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + [[package]] name = "der" version = "0.6.1" @@ -1359,6 +1407,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2237,6 +2295,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "interpolator" version = "0.5.0" @@ -2683,6 +2750,12 @@ dependencies = [ "tachys", ] +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + [[package]] name = "libc" version = "0.2.182" @@ -2725,6 +2798,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rust2" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae" +dependencies = [ + "sha2", +] + [[package]] name = "macros" version = "0.1.0" @@ -2795,6 +2877,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -2958,6 +3050,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pem" version = "3.0.6" @@ -3016,6 +3118,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "potential_utf" version = "0.1.4" @@ -3031,6 +3139,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppmd-rust" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -3621,6 +3735,7 @@ dependencies = [ "chrono", "console_error_panic_hook", "csv", + "futures", "icondata", "indexmap", "jsonwebtoken 10.3.0", @@ -3640,6 +3755,7 @@ dependencies = [ "traits", "uuid", "wasm-bindgen", + "zip", ] [[package]] @@ -3931,6 +4047,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -4177,6 +4299,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "js-sys", "num-conv", "powerfmt", "serde_core", @@ -4464,6 +4587,12 @@ dependencies = [ "syn", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.19.0" @@ -5311,8 +5440,81 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "8.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcab981e19633ebcf0b001ddd37dd802996098bc1864f90b7c5d970ce76c1d59" +dependencies = [ + "aes", + "bzip2", + "constant_time_eq", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.4.1", + "hmac", + "indexmap", + "lzma-rust2", + "memchr", + "pbkdf2", + "ppmd-rust", + "sha1", + "time", + "typed-path", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index db5a983..9102aac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,8 @@ csv = "1.4.0" leptos-use = { version = "0.18.3", features = ["use_drop_zone"] } jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"], optional = true } reqwest = { version = "0.13.2", features = ["json"] } +zip = { version = "8.5.1", optional = true } +futures = { version = "0.3.32", optional = true } [features] default = ["ssr"] @@ -51,6 +53,8 @@ ssr = [ "dep:aws-config", "dep:serde_dynamo", "dep:jsonwebtoken", + "dep:zip", + "dep:futures", "leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", diff --git a/src/pages/api/students.rs b/src/pages/api/students.rs index 29291e9..60c029b 100644 --- a/src/pages/api/students.rs +++ b/src/pages/api/students.rs @@ -1,6 +1,7 @@ use leptos::prelude::*; use leptos::server_fn::codec::Json; use std::collections::HashMap; +use std::io::Write; #[cfg(feature = "ssr")] mod imports { @@ -12,6 +13,7 @@ mod imports { pub use aws_sdk_dynamodb::types::AttributeValue; pub use leptos::logging::{debug_log, error}; pub use std::collections::HashMap; + pub use zip::write::SimpleFileOptions; } #[server(input = Json)] @@ -211,7 +213,7 @@ pub async fn get_completed_students( Ok(output) } -/// # Get Student File API +/// # Get File by Key API /// This function gets a file from the corresponding S3 file key. If the subject found from the /// `access_token` does not match the owner of the requested file, the requesting user must have /// provider-level access or higher. @@ -221,7 +223,7 @@ pub async fn get_completed_students( /// if the requesting user does not have the correct permissions, it will return a message indicating /// such. If the file is not found, an error containing "File not found" will be returned. #[server] -pub async fn get_student_file( +pub async fn get_file_by_key( access_token: String, file_key: String, ) -> Result, ServerFnError> { @@ -262,29 +264,18 @@ pub async fn get_student_file( Ok(bytes.to_vec()) } -/// # Batch Get Student Files API -/// Gets all indicated files and returns a single `.zip` file containing all of them. They will be -/// organized based on their file key. -/// -/// This is more efficient than calling the [`get_student_file`] function as it makes use of S3's -/// `batch_get_object` function. -#[server] -pub async fn batch_get_student_files() -> Result, ServerFnError> { - todo!() -} - /// # Get Student File Names API /// Gets a list of file names given a student's ID and the corresponding input ID from the forms. /// These file names can be used to construct a file key for S3. /// /// This API is only accessible by provider-level users. #[server] -pub async fn get_student_file_names( +pub async fn get_student_files( access_token: String, student_id: String, form_name: String, question_id: String, -) -> Result, ServerFnError> { +) -> Result, ServerFnError> { use imports::*; let claims = validate_and_get_token_info(access_token).await?; @@ -296,7 +287,7 @@ pub async fn get_student_file_names( let client = create_dynamo_client().await; - client + let file_keys = client .query() .table_name(MAIN_TABLE_NAME) .expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}"))) @@ -319,9 +310,52 @@ pub async fn get_student_file_names( items .into_iter() - .filter_map(|item| item.get("SK")?.as_s().ok().cloned()) + .filter_map(|item| item.get("file_key")?.as_s().ok().cloned()) .collect::>() + })?; + + debug_log!("Await student file futures..."); + let futures: Vec<_> = file_keys + .into_iter() + .map(|file_key| async move { + debug_log!("Requesting file from API with key {file_key}"); + // Get all files from S3. This is a batch operation. + let s3_client = aws_sdk_s3::Client::new(&create_aws_config().await); + let file_output = s3_client + .get_object() + .bucket("leptos-scholarships") + .key(&file_key) + .send() + .await + .ok()?; + + let bytes = file_output.body.collect().await.ok()?.to_vec(); + + Some((file_key, bytes)) }) + .collect(); + + let results = futures::future::join_all(futures) + .await + .into_iter() + .flatten() + .collect::)>>(); + + let cur = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cur); + + debug_log!("Writing {} files to zip file...", results.len()); + for (key, file_bytes) in results { + writer + .start_file(key.clone(), SimpleFileOptions::default()) + .map_err(ServerFnError::new)?; + writer.write_all(&file_bytes).map_err(ServerFnError::new)?; + } + + debug_log!("Getting finished file"); + let finished = writer.finish().map_err(ServerFnError::new)?; + + Ok(finished.into_inner()) } /// # Get All Input Files API diff --git a/src/pages/provider_applicants.rs b/src/pages/provider_applicants.rs index c3a5aba..c1043c5 100644 --- a/src/pages/provider_applicants.rs +++ b/src/pages/provider_applicants.rs @@ -2,14 +2,14 @@ use crate::components::{ActionButton, Banner, Loading, ValueDisplay}; use crate::pages::UnauthenticatedPage; use crate::pages::api::get_comparison_info; -use crate::pages::api::students::{ - BatchGetStudentFiles, GetStudentFile, GetStudentFileNames, get_all_input_files, - get_all_student_data, get_student_data, get_student_file_names, -}; +use crate::pages::api::students::{GetStudentFiles, get_all_input_files, get_student_data}; +use base64::Engine; use leptos::ev::{Event, MouseEvent}; use leptos::html::Dialog; use leptos::logging::debug_log; use leptos::prelude::*; +use leptos::wasm_bindgen::JsCast; +use leptos::web_sys::HtmlAnchorElement; use leptos_oidc::{AuthLoaded, AuthSignal, Authenticated}; use leptos_router::components::Outlet; use leptos_router::hooks::{query_signal, use_navigate}; @@ -501,9 +501,12 @@ fn ApplicantsStudentListView( // flex container. view! {
"Error checking student eligibility: "{error_msg}
@@ -550,9 +553,11 @@ fn ApplicantsStudentListView( #[component] fn StudentInformationDialog( + #[prop(into)] scholarship_id: Signal, #[prop(into)] student_id: Signal>, - // #[prop(into)] scholarship_info: StoredValue, #[prop(into)] visible: Signal, + #[prop(into)] fafsa_required: Signal, + #[prop(into)] essay_required: Signal, #[prop(into)] on_close: Callback<()>, ) -> impl IntoView { // This component needs to just display all student information. @@ -560,6 +565,9 @@ fn StudentInformationDialog( // requested an essay or financial information. let dialog_ref = NodeRef::::new(); + let auth = expect_context::(); + let access_token = + Memo::new(move |_| auth.with(|auth| auth.authenticated().map(|a| a.access_token()))); //#region Schema Definitions @@ -635,13 +643,50 @@ fn StudentInformationDialog( // We want to get the student's uploaded files - only the ones that are relevant to the current // scholarship, meaning their essays and/or FAFSA by request. + let get_student_files = ServerAction::::new(); + let get_buttons_disabled = get_student_files.pending(); let on_click_fafsa = move |_| { // We need to get the student's file names/keys and then tell the server that we want to // download them. - let fafsa_key = format!("financial_info/user_id/fafsa/file_name"); - debug_log!("Getting FAFSA file with key {fafsa_key}"); + get_student_files.dispatch(GetStudentFiles { + access_token: access_token.get().unwrap_or_default(), + student_id: student_id.get().unwrap_or_default(), + form_name: "financial_info".to_string(), + question_id: "fafsa".to_string(), + }); }; + let on_click_essay = move |_| { + debug_log!("Getting essay files"); + get_student_files.dispatch(GetStudentFiles { + access_token: access_token.get().unwrap_or_default(), + student_id: student_id.get().unwrap_or_default(), + form_name: "scholarship_essays".to_string(), + question_id: scholarship_id.get(), + }); + }; + + Effect::new(move || { + if let Some(Ok(file)) = get_student_files.value().get() { + let base64 = base64::engine::general_purpose::STANDARD.encode(file); + let data_url = format!("data:application/zip;base64,{}", base64); + // Create a base64 link and open it. + let a: HtmlAnchorElement = document() + .create_element("a") + .expect("Failed to create anchor element") + .dyn_into() + .unwrap(); + + a.set_download("testing.zip"); + a.set_href(&data_url); + document().body().unwrap().append_child(&a).unwrap(); + a.click(); + a.remove(); + + get_student_files.clear(); + } + }); + view! {
- "Get FAFSA" +
+ + + "Get FAFSA File(s)" + + + + + "Get Essay File(s)" + + +
{move || Suspend::new(async move { get_student_data( student_id.get().unwrap_or_default(), From 5cda2f8070abcc58dfa09887a8a5f17aa9cc99cf Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Fri, 10 Apr 2026 00:36:07 -0400 Subject: [PATCH 13/19] feat: add extra styling to student dialog --- src/pages/provider_applicants.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/pages/provider_applicants.rs b/src/pages/provider_applicants.rs index c1043c5..43c5c78 100644 --- a/src/pages/provider_applicants.rs +++ b/src/pages/provider_applicants.rs @@ -10,6 +10,7 @@ use leptos::logging::debug_log; use leptos::prelude::*; use leptos::wasm_bindgen::JsCast; use leptos::web_sys::HtmlAnchorElement; +use leptos_icons::Icon; use leptos_oidc::{AuthLoaded, AuthSignal, Authenticated}; use leptos_router::components::Outlet; use leptos_router::hooks::{query_signal, use_navigate}; @@ -692,11 +693,17 @@ fn StudentInformationDialog( node_ref=dialog_ref on:cancel=move |_: Event| on_close.run(()) on:click=move |_| on_close.run(()) - class="w-1/2 m-auto rounded-lg shadow-xl/50 - backdrop:backdrop-blur-xs backdrop:transition-backdrop-filter - [open]:flex flex-col items-center transition-all duration-200" + class="py-5 w-1/2 m-auto rounded-lg shadow-xl/50 + backdrop:backdrop-blur-xs backdrop:transition-all + relative open:grid grid-rows-[1fr] flex-col items-center transition-all duration-200 overflow-visible max-h-[90vh]" > -
+
+ +
+
From 2b255ce9687c4f9583d7bfdb0beaf7edab37ceb1 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Fri, 10 Apr 2026 13:44:54 -0400 Subject: [PATCH 14/19] feat: changed zipped file names and disabled download file names --- src/pages/api/students.rs | 3 ++- src/pages/provider_applicants.rs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/api/students.rs b/src/pages/api/students.rs index 60c029b..1a5e57c 100644 --- a/src/pages/api/students.rs +++ b/src/pages/api/students.rs @@ -346,8 +346,9 @@ pub async fn get_student_files( debug_log!("Writing {} files to zip file...", results.len()); for (key, file_bytes) in results { + let file_name = key.split('/').last().unwrap_or_default(); writer - .start_file(key.clone(), SimpleFileOptions::default()) + .start_file(file_name, SimpleFileOptions::default()) .map_err(ServerFnError::new)?; writer.write_all(&file_bytes).map_err(ServerFnError::new)?; } diff --git a/src/pages/provider_applicants.rs b/src/pages/provider_applicants.rs index 43c5c78..cf3f158 100644 --- a/src/pages/provider_applicants.rs +++ b/src/pages/provider_applicants.rs @@ -678,7 +678,7 @@ fn StudentInformationDialog( .dyn_into() .unwrap(); - a.set_download("testing.zip"); + a.set_download(""); a.set_href(&data_url); document().body().unwrap().append_child(&a).unwrap(); a.click(); From 07c1679e1a8746ad2905047a3f808f95e337f90a Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Fri, 10 Apr 2026 17:25:56 -0400 Subject: [PATCH 15/19] feat: style list of eligible students --- src/components/action_button.rs | 2 +- src/pages/provider_applicants.rs | 99 +++++++++++++++++++++++++++----- 2 files changed, 86 insertions(+), 15 deletions(-) diff --git a/src/components/action_button.rs b/src/components/action_button.rs index 7a91b2b..0cd2aeb 100644 --- a/src/components/action_button.rs +++ b/src/components/action_button.rs @@ -8,7 +8,7 @@ pub fn ActionButton( ) -> impl IntoView { view! {
From eae8d2a26a78c44dfaf089cdcfbe1703a1e6305f Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Mon, 13 Apr 2026 00:20:49 -0400 Subject: [PATCH 16/19] feat: implement new data display component --- src/app.rs | 1 - src/common/schema_node.rs | 185 +++++++++++-- src/components/data_display.rs | 422 +++++++++++++++--------------- src/pages/provider_applicants.rs | 170 +++++++++--- src/pages/student/mod.rs | 6 +- src/pages/student/test_display.rs | 77 ------ 6 files changed, 506 insertions(+), 355 deletions(-) delete mode 100644 src/pages/student/test_display.rs diff --git a/src/app.rs b/src/app.rs index bfd5c97..7a27e15 100644 --- a/src/app.rs +++ b/src/app.rs @@ -158,7 +158,6 @@ fn StudentRoutes() -> impl MatchNestedRoutes + Clone { path=path!("additional/specifics") view=student::StudentSpecificRequirementsPage /> - } .into_inner() diff --git a/src/common/schema_node.rs b/src/common/schema_node.rs index 2e1f943..922b3de 100644 --- a/src/common/schema_node.rs +++ b/src/common/schema_node.rs @@ -1,13 +1,35 @@ -use std::collections::HashMap; -use indexmap::IndexMap; +use indexmap::IndexMap; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Default, Debug, Clone)] pub enum SchemaType { + /// Indicates a `ValueType::String` value. + #[default] String, + /// Indicates a `ValueType::Number` value. Number, - List, - Map + /// Indicates a `ValueType::List` containing only strings or numbers. + PrimitiveList, + /// Indicates a `ValueType::List` containing only maps. + MapList, + /// Indicates a `ValueType::Map`. + Map, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone)] +pub enum SchemaContainerStyle { + #[default] + Header, + Capsule, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone)] +pub enum SchemaHeaderStyle { + #[default] + None, + MainHeader, + SubsectionHeader, + Bold, } /// # Schema Node @@ -55,12 +77,16 @@ pub enum SchemaType { /// This schema does not currently support display of a single nested member, like a single map /// within a list. This means that you will have to display any listed or mapped information as a /// list. Consider this when designing the data. -#[derive(Deserialize, Serialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Default, Debug, Clone)] pub struct SchemaNode { + /// The display style of the data. Refer to [`SchemaContainerStyle`] for more information. + pub container_style: SchemaContainerStyle, /// The datatype of the data found using the `data_member` key. pub data_type: SchemaType, /// The header of this display. pub header: String, + /// The style of the header's display. + pub header_style: SchemaHeaderStyle, /// When used with a SchemaType::List, this determines the datatype and children for every entry /// in the list. Some examples include a list of maps, where we use the `item_template` to define /// the SchemaType::Map, and all its children. This is then mapped across all entries in the list, @@ -69,16 +95,28 @@ pub struct SchemaNode { /// When used with a SchemaType::Map, this determines what children from the map are displayed /// and how. It is ignored with all other SchemaType variants. pub children: IndexMap, + /// The primary key for iteration over maps. Used when there is a list of maps. + pub primary_key: Option, } impl SchemaNode { - pub fn new(data_type: SchemaType) -> Self { - SchemaNode { - data_type, - header: "".to_string(), - item_template: None, - children: IndexMap::new(), - } + /// Creates a new [`SchemaBuilder`]. + pub fn builder() -> SchemaBuilder { + SchemaBuilder::default() + } +} + +#[derive(Default, Debug, Clone)] +pub struct SchemaBuilder { + container_style: SchemaContainerStyle, + header: String, + header_style: SchemaHeaderStyle, +} + +impl SchemaBuilder { + pub fn container(mut self, style: SchemaContainerStyle) -> Self { + self.container_style = style; + self } pub fn header(mut self, header: impl Into) -> Self { @@ -86,13 +124,130 @@ impl SchemaNode { self } - pub fn child(mut self, data_member: impl Into, node: SchemaNode) -> Self { - self.children.insert(data_member.into(), node); + pub fn header_style(mut self, header_style: SchemaHeaderStyle) -> Self { + self.header_style = header_style; self } + pub fn primitive_list(self) -> SchemaPrimitiveListBuilder { + SchemaPrimitiveListBuilder::new(self) + } + + pub fn map_list(self) -> SchemaMapListBuilder { + SchemaMapListBuilder::new(self) + } + + pub fn map(self) -> SchemaMapBuilder { + SchemaMapBuilder::new(self) + } + + pub fn string(self) -> SchemaPrimitiveBuilder { + SchemaPrimitiveBuilder::new(self, SchemaType::String) + } + + pub fn number(self) -> SchemaPrimitiveBuilder { + SchemaPrimitiveBuilder::new(self, SchemaType::Number) + } + + fn into_node(self) -> SchemaNode { + SchemaNode { + container_style: self.container_style, + header: self.header, + header_style: self.header_style, + ..Default::default() + } + } +} + +#[derive(Default, Debug, Clone)] +pub struct SchemaPrimitiveListBuilder { + builder: SchemaBuilder, +} + +impl SchemaPrimitiveListBuilder { + fn new(builder: SchemaBuilder) -> Self { + Self { builder } + } +} + +#[derive(Default, Debug, Clone)] +pub struct SchemaMapListBuilder { + builder: SchemaBuilder, + item_template: Option>, + primary_key: String, +} + +impl SchemaMapListBuilder { + fn new(builder: SchemaBuilder) -> Self { + Self { + builder, + ..Default::default() + } + } + pub fn item_template(mut self, node: SchemaNode) -> Self { self.item_template = Some(Box::new(node)); self } + + pub fn primary_key(mut self, primary_key: impl Into) -> Self { + self.primary_key = primary_key.into(); + self + } + + pub fn build(self) -> SchemaNode { + SchemaNode { + data_type: SchemaType::MapList, + item_template: self.item_template, + primary_key: Some(self.primary_key), + ..self.builder.into_node() + } + } +} + +#[derive(Default, Debug, Clone)] +pub struct SchemaMapBuilder { + builder: SchemaBuilder, + children: IndexMap, +} + +impl SchemaMapBuilder { + fn new(builder: SchemaBuilder) -> Self { + Self { + builder, + ..Default::default() + } + } + + pub fn child(mut self, data_member: impl Into, node: SchemaNode) -> Self { + self.children.insert(data_member.into(), node); + self + } + + pub fn build(self) -> SchemaNode { + SchemaNode { + data_type: SchemaType::Map, + children: self.children, + ..self.builder.into_node() + } + } +} + +#[derive(Default, Debug, Clone)] +pub struct SchemaPrimitiveBuilder { + builder: SchemaBuilder, + data_type: SchemaType, +} + +impl SchemaPrimitiveBuilder { + fn new(builder: SchemaBuilder, data_type: SchemaType) -> Self { + Self { data_type, builder } + } + + pub fn build(self) -> SchemaNode { + SchemaNode { + data_type: self.data_type, + ..self.builder.into_node() + } + } } diff --git a/src/components/data_display.rs b/src/components/data_display.rs index e9b7d23..26ef721 100644 --- a/src/components/data_display.rs +++ b/src/components/data_display.rs @@ -1,265 +1,253 @@ -use crate::common::{SchemaNode, SchemaType, ValueType}; -use indexmap::IndexMap; -use leptos::logging::debug_log; +use crate::common::{SchemaContainerStyle, SchemaHeaderStyle, SchemaNode, SchemaType, ValueType}; use leptos::prelude::*; use std::collections::HashMap; -/// # Value Display Component -/// This component displays any data from a given `HashMap`. It requires a schema -/// to tell it how to display the information, which is covered in the [`SchemaNode`] documentation. #[component] -pub fn ValueDisplay( - #[prop(into)] schema: Signal, - /// The starting member. Starts display from this member in the `data_map`, if it exists. +pub fn DataDisplay( + /// The schema that this component should use to display the given data. + #[prop(into)] + schema: Signal, + /// The starting key in the data map. #[prop(into, optional)] data_member: Signal, - #[prop(into)] data_map: Signal>, + /// A `HashMap` containing all the data that should be displayed. + #[prop(into)] + data_map: Signal>, ) -> impl IntoView { - let data_value = Memo::new(move |_| { - if data_member.get().is_empty() { - ValueType::Map(Some(data_map.get())) + // The schema should contain information about the header type and styling, along with the + // information display. This is rendered as part of the DataDisplayWrapper, which is simply a + // wrapper around the information to be displayed. + + // The children are all SchemaNodes as well, meaning that this component needs to call itself + // for each of these children. They will of course contain all the same information that we + // expect here. + + // The actual displayed information is controlled through a DataDisplayView, which will accept + // settings for how the information is parsed. For example, rendering a list may be done by + // a string join operation, or it may be a list of maps, each of which needs to be displayed with + // a specific set of children. The SchemaNode must be able to handle this, and these options + // should be given to the DataDisplayView somehow. + + // As the display_value here is actually an Option, we want to be able to handle cases + // where the display is given no values. In this case, the SchemaNode needs to specify whether + // a missing value is expected or an error. + + let display_value = Memo::new(move |_| { + let member = data_member.get(); + if member.is_empty() { + Some(ValueType::Map(Some(data_map.get()))) } else { - data_map - .get() - .get(&data_member.get()) - .map(|v| v.clone()) - .unwrap_or_default() + data_map.get().get(&member).cloned() } }); view! { - {move || { - match schema.get().data_type { - SchemaType::String | SchemaType::Number => { - view! { - - } - .into_any() - } - SchemaType::List => { - view! { - - } - .into_any() - } - SchemaType::Map => { - view! { - - } - .into_any() - } - } - }} + + + } } -/// # Display Wrapper Component -/// This component is designed to wrap information for the [`ValueDisplay`] component. It allows -/// for simple uniform styling of the header and capsule, along with the ability for users to -/// collapse the information. #[component] -fn DisplayWrapper( - #[prop(into, optional)] header: Signal, - #[prop(into, optional)] nested: Signal, +fn DataDisplayWrapper( + #[prop(into)] container_style: SchemaContainerStyle, + #[prop(into)] header_style: SchemaHeaderStyle, + #[prop(into)] header_text: Signal, children: Children, ) -> impl IntoView { + // This component takes a wrapper style and displays the "chrome" of the interface. It has two + // options: Text Headers or Capsules. This will then render a DataDisplayHeader, which handles + // the styling for the text inside the header specifically. + + // The capsule style is a colored header and a rounded container. + // The text header style is an invisible container. + let expanded = RwSignal::new(true); - view! { -
-
- {header} -
-
-
-
{children()}
+ match container_style { + SchemaContainerStyle::Header => { + // Creates a simple container that only has a header and a series of children. + // The spacing between the header and children is controlled by the header style. + // This type of container is not collapsable at the moment. + view! { +
+
+ +
{children()}
+
-
-
- } -} - -/// # Primitive Value Display Component -/// This component allows for the display of a `ValueType::String` or `ValueType::Number`. -#[component] -fn PrimitiveDisplay( - #[prop(into)] header: Signal, - #[prop(into)] value: Signal, - #[prop(into, optional)] nested: Signal, -) -> impl IntoView { - let display_string = Memo::new(move |_| { - let value = value.get(); - match value { - ValueType::String(_) | ValueType::Number(_) => value.to_string(), - _ => "Value is not a string.".to_string(), + } + .into_any() + } + SchemaContainerStyle::Capsule => { + // Creates a capsule container - it is collapsable, with rounded corners and a colored + // header. + view! { +
+
+ +
+
+
+
{children()}
+
+
+
+ } + .into_any() } - }); - - view! { - -
{display_string}
-
} } -/// # List Value Display Component -/// This component allows for the display of a `ValueType::List`. It will show an error if passed -/// anything other than a list. It requires an item template that tells the list how to display -/// each value inside of it, most specifically the type of data inside of it. #[component] -fn ListDisplay( - #[prop(into)] item_template: Signal>, - #[prop(into)] value: Signal, - #[prop(into)] header: Signal, - #[prop(into, optional)] nested: Signal, +fn DataDisplayHeader( + #[prop(into)] header_style: SchemaHeaderStyle, + #[prop(into)] header_text: Signal, ) -> impl IntoView { - // Unwrap the value and check if it's the correct type. To be displayed with this component, it - // MUST be a ValueType::List. - let display_list = Memo::new(move |_| { - let ValueType::List(Some(list)) = value.get() else { - return Vec::new(); - }; + // This component will take a header style and displays the corresponding text. It has a few + // options, all of which are defined in the HeaderStyle enum. - list.into_iter() - .enumerate() - .collect::>() - }); - - let is_valid = Memo::new(move |_| matches!(value.get(), ValueType::List(_))); - - let item_header = Memo::new(move |_| item_template.get().header); - - view! { - - { - view! { }.into_any() - } - SchemaType::List => { - view! { -
"Nested lists are not currently supported for display."
- } - .into_any() - } - SchemaType::Map => { - let map_header = format!("{} {}", item_template.get().header, idx + 1); - view! { - - } - .into_any() - } - } - } - /> -
+ match header_style { + SchemaHeaderStyle::MainHeader => { + view! {
{header_text}
}.into_any() + } + SchemaHeaderStyle::SubsectionHeader => { + view! {
{header_text}
}.into_any() + } + SchemaHeaderStyle::Bold => view! {
{header_text}
}.into_any(), + SchemaHeaderStyle::None => view! {}.into_any(), } } -/// # Map Data Display Component -/// This component allows for the display of a `ValueType::Map`. The nested information must be defined -/// by a [`SchemaNode`], which tells the display what keys in the value map to search for the -/// display information. #[component] -fn MapDisplay( - #[prop(into)] value: Signal, - #[prop(into)] header: Signal, - #[prop(into)] schema: Signal>, - #[prop(into, optional)] nested: Signal, +fn DataDisplayView( + #[prop(into)] display_data: Signal>, + #[prop(into)] schema: Signal, ) -> impl IntoView { - // Unwrap the value and check if it is the correct type. It must be a ValueType::Map. - let display_map = Memo::new(move |_| { - let ValueType::Map(Some(map)) = value.get() else { - return HashMap::new(); - }; + // This is the part of the data display that does most of the work. It will take the values + // found in the DataDisplay component and perform the logic that actually displays the + // information. This component is only displayed if there is Some(value) in the DataDisplay. - map - }); + // In the case that this is displaying a Map or List, it will recursively call the DataDisplay + // component. - Effect::new(move || { - debug_log!("Current map values: {:?}", display_map.get()); - }); + // If this is displaying a PrimitiveList, it will flatten and join all the values into a single + // string, then display that string. - let is_valid = Memo::new(move |_| matches!(value.get(), ValueType::Map(_))); + let data_type = schema.get().data_type; - view! { - - { - view! { - - } - .into_any() + // Matches the specific data type. Displays errors if the given display data does not match. + match data_type { + SchemaType::String => { + let value = Signal::derive(move || { + display_data + .get() + .map(|v| v.as_string().ok().flatten()) + .flatten() + .unwrap_or("N/A".to_string()) + }); + view! {
{value}
}.into_any() + } + SchemaType::Number => { + let value = Signal::derive(move || { + display_data + .get() + .map(|v| v.as_number().ok().flatten()) + .flatten() + .unwrap_or("N/A".to_string()) + }); + view! {
{value}
}.into_any() + } + SchemaType::PrimitiveList => { + let value = Signal::derive(move || { + display_data + .get() + .map(|v| v.as_list().ok().flatten()) + .flatten() + .unwrap_or_default() + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(", ") + }); + view! {
{value}
}.into_any() + } + SchemaType::MapList => { + let list = Signal::derive(move || { + display_data + .get() + .map(|v| v.as_list().ok().flatten()) + .flatten() + .unwrap_or_default() + .iter() + .filter_map(|v| v.as_map().ok().flatten().clone()) + .collect::>>() + }); + let primary_key = StoredValue::new(schema.get().primary_key.unwrap_or_default()); + view! { + "List is empty."
} + > + { - let template = child_schema - .item_template - .unwrap_or(Box::from(SchemaNode::new(SchemaType::List))); - + children=move |map| { view! { - } - .into_any() } - SchemaType::Map => { + /> +
+ } + .into_any() + } + SchemaType::Map => { + let map = Signal::derive(move || { + display_data + .get() + .map(|v| v.as_map().ok().flatten()) + .flatten() + }); + view! { + "Map was not found."
} + > + } - .into_any() } - } - } - /> - + /> + + } + .into_any() + } } } diff --git a/src/pages/provider_applicants.rs b/src/pages/provider_applicants.rs index 2be2532..4f54eda 100644 --- a/src/pages/provider_applicants.rs +++ b/src/pages/provider_applicants.rs @@ -1,5 +1,8 @@ -use crate::common::{ComparisonData, ExpandableInfo, SchemaNode, SchemaType, ValueType}; -use crate::components::{ActionButton, Banner, Loading, ValueDisplay}; +use crate::common::{ + ComparisonData, ExpandableInfo, SchemaContainerStyle, SchemaHeaderStyle, SchemaNode, + SchemaType, ValueType, +}; +use crate::components::{ActionButton, Banner, DataDisplay, Loading}; use crate::pages::UnauthenticatedPage; use crate::pages::api::get_comparison_info; use crate::pages::api::students::{GetStudentFiles, get_all_input_files, get_student_data}; @@ -9,7 +12,8 @@ use leptos::html::Dialog; use leptos::logging::debug_log; use leptos::prelude::*; use leptos::wasm_bindgen::JsCast; -use leptos::web_sys::HtmlAnchorElement; +use leptos::web_sys; +use leptos::web_sys::{HtmlAnchorElement, ScrollBehavior}; use leptos_icons::Icon; use leptos_oidc::{AuthLoaded, AuthSignal, Authenticated}; use leptos_router::components::Outlet; @@ -319,6 +323,14 @@ fn ApplicantsStudentListView( // let (current_student_id, set_current_student_id) = query_signal::("student_id"); let current_student_id = RwSignal::new(None); let navigate_student = Callback::new(move |id: String| { + let options = web_sys::ScrollToOptions::new(); + options.set_top(0.0); + options.set_behavior(ScrollBehavior::Smooth); + + if let Some(window) = web_sys::window() { + window.scroll_to_with_scroll_to_options(&options); + } + current_student_id.set(Some(id)); }); let dialog_visible = @@ -591,93 +603,172 @@ fn StudentInformationDialog( //#region Schema Definitions let demographics_schema = StoredValue::new( - SchemaNode::new(SchemaType::Map) + SchemaNode::builder() + .container(SchemaContainerStyle::Header) + .header_style(SchemaHeaderStyle::MainHeader) .header("Demographics Information") + .map() .child( "first_name", - SchemaNode::new(SchemaType::String).header("First Name"), + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("First Name") + .string() + .build(), ) .child( "last_name", - SchemaNode::new(SchemaType::String).header("Last Name"), + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Last Name") + .string() + .build(), ) .child( "dob", - SchemaNode::new(SchemaType::String).header("Date of Birth"), + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Date of Birth") + .string() + .build(), ) .child( "gender", - SchemaNode::new(SchemaType::String).header("Gender"), + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Gender") + .string() + .build(), ) .child( "phone_number", - SchemaNode::new(SchemaType::String).header("Preferred Phone Number"), + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Preferred Phone Number") + .string() + .build(), ) .child( "email", - SchemaNode::new(SchemaType::String).header("Preferred Email Address"), + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Preferred Email Address") + .string() + .build(), ) .child( "street_address", - SchemaNode::new(SchemaType::String).header("Street Address"), + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Street Address") + .string() + .build(), ) - .child("town", SchemaNode::new(SchemaType::String).header("Town")), + .child( + "town", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Town") + .string() + .build(), + ) + .build(), ); let sports_schema = StoredValue::new( - SchemaNode::new(SchemaType::List) + SchemaNode::builder() + .header_style(SchemaHeaderStyle::MainHeader) .header("Sports Activities") + .map_list() + .primary_key("uuid") .item_template( - SchemaNode::new(SchemaType::Map) + SchemaNode::builder() + .container(SchemaContainerStyle::Capsule) + .header_style(SchemaHeaderStyle::Bold) .header("Sport") + .map() .child( "sport_name", - SchemaNode::new(SchemaType::String).header("Sport Name"), + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Sport Name") + .string() + .build(), ) .child( "achievements", - SchemaNode::new(SchemaType::String).header("Special Achievements"), - ), - ), + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Special Achievements") + .string() + .build(), + ) + .build(), + ) + .build(), ); let extracurricular_schema = StoredValue::new( - SchemaNode::new(SchemaType::Map) + SchemaNode::builder() + .header_style(SchemaHeaderStyle::MainHeader) .header("Extracurricular Activities") + .map() .child( "service_hours", - SchemaNode::new(SchemaType::String).header("Total Number of Service Hours"), + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Total Number of Service Hours") + .number() + .build(), ) .child( "extracurricular", - SchemaNode::new(SchemaType::List) - .header("List of Activities") + SchemaNode::builder() + .map_list() + .primary_key("uuid") .item_template( - SchemaNode::new(SchemaType::Map) + SchemaNode::builder() + .container(SchemaContainerStyle::Capsule) + .header_style(SchemaHeaderStyle::Bold) .header("Activity") + .map() .child( "activity_name", - SchemaNode::new(SchemaType::String).header("Activity Name"), + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Activity Name") + .string() + .build(), ) .child( "num_hours", - SchemaNode::new(SchemaType::String) - .header("Number of Hours Completed"), + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Approximate Number of Hours Completed") + .number() + .build(), ) .child( "num_weeks", - SchemaNode::new(SchemaType::String) - .header("Number of Weeks Participated"), + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Approximate Number of Weeks Participated") + .number() + .build(), ) .child( "special_involvement", - SchemaNode::new(SchemaType::String).header("Special Involvement"), - ), // .child( - // "grades", - // SchemaNode::new(SchemaType::String).header("Grades Participated") - // ) - ), - ), + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Special Involvement") + .string() + .build(), + ) + .build(), + ) + .build(), + ) + .build(), ); //#endregion @@ -758,9 +849,6 @@ fn StudentInformationDialog( >

"Student Information"

-
- "Click on any header to minimize that block of information." -
@@ -781,7 +869,7 @@ fn StudentInformationDialog( .await .map(|student_info| { view! { - @@ -796,7 +884,7 @@ fn StudentInformationDialog( .await .map(|student_info| { view! { - diff --git a/src/pages/student/mod.rs b/src/pages/student/mod.rs index e625d2d..ed26209 100644 --- a/src/pages/student/mod.rs +++ b/src/pages/student/mod.rs @@ -9,10 +9,9 @@ mod financial_page; mod form_setup; mod home_page; mod shell; -mod test_display; +mod specific_requirements_page; mod university_page; mod work_experience_page; -mod specific_requirements_page; pub use academics_page::*; pub use additional_page::*; @@ -24,7 +23,6 @@ pub use family_info_page::*; pub use financial_page::*; pub use home_page::*; pub use shell::*; -pub use test_display::*; +pub use specific_requirements_page::*; pub use university_page::*; pub use work_experience_page::*; -pub use specific_requirements_page::*; diff --git a/src/pages/student/test_display.rs b/src/pages/student/test_display.rs deleted file mode 100644 index a66d8eb..0000000 --- a/src/pages/student/test_display.rs +++ /dev/null @@ -1,77 +0,0 @@ -use crate::common::{SchemaNode, SchemaType}; -use crate::components::{Loading, ValueDisplay}; -use crate::pages::api::students::get_all_student_data; -use crate::utils::get_user_claims; -use leptos::prelude::*; - -#[component] -pub fn StudentTestDisplay() -> impl IntoView { - let create_student_display_schema = move || { - SchemaNode::new(SchemaType::Map) - .header("Student Information") - .child( - "first_name", - SchemaNode::new(SchemaType::String).header("First Name"), - ) - .child( - "last_name", - SchemaNode::new(SchemaType::String).header("Last Name"), - ) - .child( - "sports_participation", - SchemaNode::new(SchemaType::List) - .header("Sports Activities") - .item_template( - SchemaNode::new(SchemaType::Map) - .header("Sport") - .child( - "sport_name", - SchemaNode::new(SchemaType::String).header("Sport Name"), - ) - .child( - "achievements", - SchemaNode::new(SchemaType::String).header("Special Achievements"), - ), - ), - ) - }; - - let user_claims = get_user_claims(); - let user_id = Memo::new(move |_| user_claims.get().map(|info| info.claims.subject.clone())); - - let resource = Resource::new( - move || user_id.get().unwrap_or_default(), - async move |id| get_all_student_data(id).await, - ); - - let schema = StoredValue::new(create_student_display_schema()); - - view! { -
- -
- {move || { - resource - .get() - .map(|data_map| { - let Ok(map) = data_map else { - return view! {}.into_any(); - }; - let data_map = StoredValue::new(map); - - view! { -
- -
- } - .into_any() - }) - }} -
-
-
- } -} From 21c0df0ded17a0f7dfaf2fe01116144f318a61f7 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Mon, 13 Apr 2026 01:31:03 -0400 Subject: [PATCH 17/19] feat: implement the rest of the required forms --- src/pages/provider_applicants.rs | 395 ++++++++++++++++++++++++++ src/pages/student/family_info_page.rs | 6 +- 2 files changed, 398 insertions(+), 3 deletions(-) diff --git a/src/pages/provider_applicants.rs b/src/pages/provider_applicants.rs index 4f54eda..1e7c832 100644 --- a/src/pages/provider_applicants.rs +++ b/src/pages/provider_applicants.rs @@ -675,6 +675,54 @@ fn StudentInformationDialog( .build(), ); + let academics_schema = StoredValue::new( + SchemaNode::builder() + .header_style(SchemaHeaderStyle::MainHeader) + .header("Academic Information") + .map() + .child( + "unweighted_gpa", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Unweighted GPA") + .number() + .build(), + ) + .child( + "weighted_gpa", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Weighted GPA") + .number() + .build(), + ) + .child( + "sat_score", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Highest SAT Score") + .number() + .build(), + ) + .child( + "act_score", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Highest ACT Score") + .number() + .build(), + ) + .child( + "academic_honors", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Academic Honors") + .string() + .build(), + ) + .build(), + ); + let sports_schema = StoredValue::new( SchemaNode::builder() .header_style(SchemaHeaderStyle::MainHeader) @@ -771,6 +819,280 @@ fn StudentInformationDialog( .build(), ); + let work_exp_schema = StoredValue::new( + SchemaNode::builder() + .header_style(SchemaHeaderStyle::MainHeader) + .header("Work Experience") + .map_list() + .item_template( + SchemaNode::builder() + .container(SchemaContainerStyle::Capsule) + .header_style(SchemaHeaderStyle::Bold) + .header("Experience") + .map() + .child( + "job_title", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Job Title") + .string() + .build(), + ) + .child( + "employer", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Employer") + .string() + .build(), + ) + .child( + "start_date", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Start Date") + .string() + .build(), + ) + .child( + "end_date", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("End Date") + .string() + .build(), + ) + .child( + "num_hours", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Approximate Number of Hours") + .number() + .build(), + ) + .build(), + ) + .build(), + ); + + let university_schema = StoredValue::new( + SchemaNode::builder() + .header_style(SchemaHeaderStyle::MainHeader) + .header("University Information") + .map() + .child( + "college_name", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("University Name") + .string() + .build(), + ) + .child( + "college_city", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("University Street Address") + .string() + .build(), + ) + .child( + "college_state", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("University State") + .string() + .build(), + ) + .child( + "college_zip", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("University ZIP") + .string() + .build(), + ) + .child( + "college_acceptance", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Has the student received an acceptance?") + .string() + .build(), + ) + .child( + "major", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Chosen Major") + .string() + .build(), + ) + .child( + "intended_career", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Intended Career") + .string() + .build(), + ) + .build(), + ); + + let family_schema = StoredValue::new( + SchemaNode::builder() + .header_style(SchemaHeaderStyle::MainHeader) + .header("Family Information") + .map() + .child( + "num_children", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Total number of children in student's family") + .number() + .build(), + ) + .child( + "num_children_college", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Total number of children currently attending college") + .number() + .build(), + ) + .child( + "parent_one_name", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Parent/Guardian 1 Name") + .string() + .build(), + ) + .child( + "parent_one_relationship", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Parent/Guardian 1 Relationship") + .string() + .build(), + ) + .child( + "parent_one_occupation", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Parent/Guardian 1 Occupation") + .string() + .build(), + ) + .child( + "parent_one_employer", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Parent/Guardian 1 Employer") + .string() + .build(), + ) + .child( + "parent_two_name", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Parent/Guardian 2 Name") + .string() + .build(), + ) + .child( + "parent_two_relationship", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Parent/Guardian 2 Relationship") + .string() + .build(), + ) + .child( + "parent_two_occupation", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Parent/Guardian 2 Occupation") + .string() + .build(), + ) + .child( + "parent_two_employer", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Parent/Guardian 2 Employer") + .string() + .build(), + ) + .build(), + ); + + let specifics_schema = StoredValue::new( + SchemaNode::builder() + .header_style(SchemaHeaderStyle::MainHeader) + .header("Extra Eligibility Information") + .map() + .child( + "attend_bas", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Did the student attend BAS?") + .string() + .build(), + ) + .child( + "middsouth_church", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Is the student a member of Midd-South Catholic Church?") + .string() + .build(), + ) + .child( + "family_military_service", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header( + "Does the student have a family member who has served in the US Military?", + ) + .string() + .build(), + ) + .child( + "youth_baseball", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Has the student participated in Pomperaug Youth Baseball?") + .string() + .build(), + ) + .child( + "aquatic_club", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Has the student participated in the Panthers Aquatic Club?") + .string() + .build(), + ) + .child( + "pea_member", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Does the student have a family member in the Region 15 PEA?") + .string() + .build(), + ) + .child( + "music_program", + SchemaNode::builder() + .header_style(SchemaHeaderStyle::Bold) + .header("Has the student participated in the PHS Music Program?") + .string() + .build(), + ) + .build(), + ); + //#endregion Effect::new(move || { @@ -876,6 +1198,21 @@ fn StudentInformationDialog( } }) })} + {move || Suspend::new(async move { + get_student_data( + student_id.get().unwrap_or_default(), + "academics".to_string(), + ) + .await + .map(|student_info| { + view! { + + } + }) + })} {move || Suspend::new(async move { get_student_data( student_id.get().unwrap_or_default(), @@ -907,6 +1244,64 @@ fn StudentInformationDialog( } }) })} + {move || Suspend::new(async move { + get_student_data( + student_id.get().unwrap_or_default(), + "workexp".to_string(), + ) + .await + .map(|student_info| { + view! { + + } + }) + })} + {move || Suspend::new(async move { + get_student_data( + student_id.get().unwrap_or_default(), + "university".to_string(), + ) + .await + .map(|student_info| { + view! { + + } + }) + })} + {move || Suspend::new(async move { + get_student_data(student_id.get().unwrap_or_default(), "family".to_string()) + .await + .map(|student_info| { + view! { + + } + }) + })} + {move || Suspend::new(async move { + get_student_data( + student_id.get().unwrap_or_default(), + "specifics".to_string(), + ) + .await + .map(|student_info| { + view! { + + } + }) + })}
diff --git a/src/pages/student/family_info_page.rs b/src/pages/student/family_info_page.rs index eff0c4b..2fa5bd9 100644 --- a/src/pages/student/family_info_page.rs +++ b/src/pages/student/family_info_page.rs @@ -1,11 +1,11 @@ -use leptos::prelude::*; use crate::components::{Loading, OutlinedTextField, TextFieldType, ValidatedForm}; use crate::pages::student::form_setup::use_student_form; +use leptos::prelude::*; #[component] pub fn StudentFamilyPage() -> impl IntoView { let controller = use_student_form("family", true); - + view! {
@@ -115,4 +115,4 @@ pub fn StudentFamilyPage() -> impl IntoView {
} -} \ No newline at end of file +} From 6294b114df82cf526480efcdf7046791fc7eeb9e Mon Sep 17 00:00:00 2001 From: Lucas Vas <57099895+BobdaFett@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:41:50 -0400 Subject: [PATCH 18/19] Update mod.rs --- src/pages/student/mod.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/pages/student/mod.rs b/src/pages/student/mod.rs index be3a804..b357fea 100644 --- a/src/pages/student/mod.rs +++ b/src/pages/student/mod.rs @@ -11,10 +11,7 @@ mod form_setup; mod home_page; mod shell; mod specific_requirements_page; -<<<<<<< feat/applicants-page -======= mod test_display; ->>>>>>> main mod university_page; mod work_experience_page; @@ -30,9 +27,6 @@ pub use financial_page::*; pub use home_page::*; pub use shell::*; pub use specific_requirements_page::*; -<<<<<<< feat/applicants-page -======= pub use test_display::*; ->>>>>>> main pub use university_page::*; pub use work_experience_page::*; From 4b398c84456d2e3519e62c536bf32b7f011fc403 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Mon, 13 Apr 2026 02:15:07 -0400 Subject: [PATCH 19/19] fix: remove unnecessary module import File was removed in previous commit, git somehow broke. --- src/pages/student/mod.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/student/mod.rs b/src/pages/student/mod.rs index b357fea..eb43345 100644 --- a/src/pages/student/mod.rs +++ b/src/pages/student/mod.rs @@ -11,7 +11,6 @@ mod form_setup; mod home_page; mod shell; mod specific_requirements_page; -mod test_display; mod university_page; mod work_experience_page; @@ -27,6 +26,5 @@ pub use financial_page::*; pub use home_page::*; pub use shell::*; pub use specific_requirements_page::*; -pub use test_display::*; pub use university_page::*; pub use work_experience_page::*;