feat: implement basic structure of provider applicants page

This commit is contained in:
alphafetus committed 2026-04-08 13:55:07 -04:00
1 parent 3baee8cac7
commit 469f192feb
5 files changed
+500 -18

No files matched your search

-1
View File
@@ -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"] }
+6 -5
View File
@@ -93,11 +93,11 @@ fn ProviderRoutes() -> impl MatchNestedRoutes + Clone {
</ParentRoute>
<Route path=path!("/callback") view=AuthCallbackPage />
<Route path=path!("/profile") view=ProviderContactPage />
<Route path=path!("/scholarships") view=FormClosedPage />
// <ParentRoute path=path!("/scholarships") view=ScholarshipInfoPage>
// <Route path=path!(":id") view=ScholarshipInfoPage />
// <Route path=path!("") view=ScholarshipInfoPage />
// </ParentRoute>
// <Route path=path!("/scholarships") view=FormClosedPage />
<ParentRoute path=path!("/scholarships") view=ScholarshipInfoPage>
<Route path=path!(":id") view=ScholarshipInfoPage />
<Route path=path!("") view=ScholarshipInfoPage />
</ParentRoute>
</ParentRoute>
}
.into_inner()
@@ -158,6 +158,7 @@ fn StudentRoutes() -> impl MatchNestedRoutes + Clone {
path=path!("additional/specifics")
view=student::StudentSpecificRequirementsPage
/>
<Route path=path!("testing") view=student::StudentTestDisplay />
</ParentRoute>
}
.into_inner()
+3 -1
View File
@@ -10,7 +10,9 @@ use std::collections::HashMap;
#[component]
pub fn ValueDisplay(
#[prop(into)] schema: Signal<SchemaNode>,
#[prop(into, optional)] data_member: Signal<String>,
/// The starting member. Starts display from this member in the `data_map`, if it exists.
#[prop(into, optional)]
data_member: Signal<String>,
#[prop(into)] data_map: Signal<HashMap<String, ValueType>>,
) -> impl IntoView {
let data_value = Memo::new(move |_| {
+82 -3
View File
@@ -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<String, crate::common::ValueType>,
data_map: HashMap<String, crate::common::ValueType>,
) -> 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<std::collections::HashMap<String, crate::common::ValueType>, ServerFnError> {
) -> Result<HashMap<String, crate::common::ValueType>, 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<std::collections::HashMap<String, crate::common::ValueType>, ServerFnError> {
) -> Result<HashMap<String, crate::common::ValueType>, 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<HashMap<String, HashMap<String, crate::common::ValueType>>, 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::<String, HashMap<String, ValueType>>::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::<String>();
form_info.remove("HK");
form_info.remove("SK");
let form_info_convert = form_info
.into_iter()
.map(|(k, v)| (k, ValueType::from(&v)))
.collect::<HashMap<String, ValueType>>();
// 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)
}
+409 -8
View File
@@ -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,10 +35,16 @@ pub fn ApplicantsPageShell() -> impl IntoView {
// each of which is still considered a single page.
view! {
<Banner title="R15 Scholarships" logo="/PHS_Stacked_Acronym.png" path="/providers" />
<AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage>
<Banner
title="R15 Scholarships"
logo="/PHS_Stacked_Acronym.png"
path="/providers"
/>
<div class="flex flex-row mt-5">
<div class="flex-1" />
<div class="flex-3 flex flex-row">
<div class="flex-3 flex flex-row items-start">
<ApplicantsScholarshipList />
<div class="flex-3 text-center">
<Outlet />
@@ -39,6 +52,8 @@ pub fn ApplicantsPageShell() -> impl IntoView {
</div>
<div class="flex-1" />
</div>
</Authenticated>
</AuthLoaded>
}
}
@@ -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<String>,
}
// 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::<StudentListParams>();
let auth = expect_context::<AuthSignal>();
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<String, HashMap<String, ValueType>>,
ExpandableInfo,
Vec<ComparisonData>,
),
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! {
<div class="flex flex-col flex-1">
<Suspense fallback=Loading>
{move || {
resource
.get()
.map(|result| {
match result {
Ok((students, scholarship, requirements_list)) => {
view! {
<div>"Showing students here."</div>
<ApplicantsStudentListView
students=students
scholarship=scholarship
requirements=requirements_list
/>
}
.into_any()
}
Err(e) => {
view! {
<div>
"Error while getting eligible students: "{e.to_string()}
</div>
}
.into_any()
}
}
})
}}
</Suspense>
</div>
}
}
#[component]
fn ApplicantsStudentListView(
#[prop(into)] students: Signal<HashMap<String, HashMap<String, ValueType>>>,
#[prop(into)] scholarship: Signal<ExpandableInfo>,
#[prop(into)] requirements: Signal<Vec<ComparisonData>>,
) -> 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::<String>("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<String, HashMap<String, ValueType>>,
scholarship: ExpandableInfo,
requirements: Vec<ComparisonData>,
) -> Result<HashMap<String, HashMap<String, ValueType>>, 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::<Vec<String>>();
Some(string_list)
})
.collect::<Vec<Vec<String>>>();
// 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::<Vec<ComparisonData>>()
})
.collect::<Vec<Vec<ComparisonData>>>();
// 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::<HashMap<String, HashMap<String, ValueType>>>();
// 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! {
<StudentInformationDialog
student_id=current_student_id
visible=dialog_visible
on_close=on_dialog_close
/>
<Show when=move || !error_msg.get().is_empty()>
<div>"Error checking student eligibility: "{error_msg}</div>
</Show>
<div class="flex flex-col">
<For
each=move || eligible_students.get()
key=|(id, _)| id.clone()
children=move |(student_id, student)| {
let student = StoredValue::new(student);
let first_name = Memo::new(move |_| {
student
.get_value()
.get("first_name")
.map(|n| n.as_string().ok().flatten())
.unwrap_or_default()
});
let last_name = Memo::new(move |_| {
student
.get_value()
.get("last_name")
.map(|n| n.as_string().ok().flatten())
.unwrap_or_default()
});
let on_click = move |_| {
navigate_student.run(student_id.clone());
};
// For now, we'll just display each student's first and last name.
// Now I'll add a container with some links. These links will just be appended
// to the end of the current path.
view! {
<div class="text-lg" on:click=on_click>
{first_name}
" "
{last_name}
</div>
}
}
/>
</div>
}
}
#[component]
fn StudentInformationDialog(
#[prop(into)] student_id: Signal<Option<String>>,
// #[prop(into)] scholarship_info: StoredValue<ExpandableInfo>,
#[prop(into)] visible: Signal<bool>,
#[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::<Dialog>::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! {
<dialog
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"
>
<div class="p-5" on:click=move |e: MouseEvent| e.stop_propagation()>
<Suspense fallback=Loading>
{move || Suspend::new(async move {
get_student_data(
student_id.get().unwrap_or_default(),
"demographics".to_string(),
)
.await
.map(|student_info| {
view! {
<ValueDisplay
schema=demographics_schema.get_value()
data_map=student_info
/>
}
})
})}
{move || Suspend::new(async move {
get_student_data(
student_id.get().unwrap_or_default(),
"athletics".to_string(),
)
.await
.map(|student_info| {
view! {
<ValueDisplay
schema=sports_schema.get_value()
data_map=student_info
data_member="sports_participation"
/>
}
})
})}
</Suspense>
</div>
</dialog>
}
}