Merge pull request #58 from R15-Software-Dev/feat/admin-page

PR with too many features
This commit was merged in pull request #58.
This commit is contained in:
Lucas Vas authored and GitHub committed 2026-02-10 09:31:49 -05:00
commit 1abedae760
56 files changed
+2208 -399

No files matched your search

+51 -1
View File
@@ -1,4 +1,5 @@
use crate::pages::{ProviderPortal, ScholarshipInfoPage, ProviderContactPage, LoanerShell, LoanerFallback, LoanerBorrowForm, LoanerReturnForm, AuthCallbackPage, ApplicantsPageFallback};
use crate::pages::{ProviderPortal, ScholarshipInfoPage, ProviderContactPage, LoanerShell, LoanerFallback, LoanerBorrowForm, LoanerReturnForm, AuthCallbackPage, ApplicantsPageFallback, AdminShell, AdminHomePage, AdminProviderPage, AdminScholarshipPage, AdminUtilsPage};
use crate::pages::student;
use leptos::prelude::*;
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
use leptos_oidc::AuthSignal;
@@ -61,8 +62,10 @@ pub fn AppWithRoutes() -> impl IntoView {
<ToastList>
// TODO Create a 404 page
<Routes fallback=|| "Page not found.".into_view()>
// <Route path=path!("/testing") view=TestPage />
<ProviderRoutes />
<LoanerRoutes />
<StudentRoutes />
</Routes>
</ToastList>
</main>
@@ -100,6 +103,53 @@ fn LoanerRoutes() -> impl MatchNestedRoutes + Clone {
.into_any_nested_route()
}
#[component(transparent)]
fn AdminRoutes() -> impl MatchNestedRoutes + Clone {
view! {
<ParentRoute path=path!("admin") view=AdminShell>
<Route path=path!("home") view=AdminHomePage />
<Route path=path!("providers") view=AdminProviderPage />
<Route path=path!("scholarships") view=AdminScholarshipPage />
<Route path=path!("utilities") view=AdminUtilsPage />
</ParentRoute>
}
.into_inner()
.into_any_nested_route()
}
#[component(transparent)]
fn StudentRoutes() -> impl MatchNestedRoutes + Clone {
view! {
<ParentRoute path=path!("students") view=student::StudentShell>
<Route path=path!("callback") view=AuthCallbackPage />
<Route path=path!("home") view=student::StudentHomePage />
<Route path=path!("demographics") view=student::StudentDemographicsPage />
<Route path=path!("eligibility") view=student::StudentEligibilityPage />
<Route path=path!("additional") view=student::AdditionalPage />
<Route path=path!("additional/academics") view=student::StudentAcademicsPage />
<Route path=path!("additional/athletics") view=student::StudentAthleticsPage />
<Route
path=path!("additional/extracurriculars")
view=student::StudentExtracurricularsPage
/>
<Route
path=path!("additional/work-experience")
view=student::StudentWorkExperiencePage
/>
<Route
path=path!("additional/university")
view=student::StudentUniversityPage
/>
<Route
path=path!("additional/family-info")
view=student::StudentFamilyPage
/>
</ParentRoute>
}
.into_inner()
.into_any_nested_route()
}
#[component]
pub fn AuthErrorView() -> impl IntoView {
let auth = use_context::<AuthSignal>().expect("AuthError: RwSignal<AuthStore> was not found");
+10 -11
View File
@@ -2,7 +2,8 @@ use super::{
Comparison, MapListComparison, NestedListComparison, NumberComparison, NumberListComparison,
TextComparison, TextListComparison,
};
use crate::common::{ExpandableInfo, ValueType};
use crate::common::ValueType;
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -42,15 +43,13 @@ impl ComparisonData {
}
}
pub fn compare(&self, student_data: &ExpandableInfo) -> Result<bool, String> {
let value = student_data.data.get(&self.member).expect(
format!(
"Couldn't find member {:?}, current student data: {:?}",
self.member, student_data
pub fn compare(&self, student_data: &HashMap<String, ValueType>) -> Result<bool, String> {
student_data.get(&self.member)
.ok_or_else(||
format!("Couldn't find member {:?}, current student data: {:?}", self.member, student_data)
)
.as_str(),
);
self.comparison.evaluate(value, &self.target_value)
.and_then(|value| self.comparison.evaluate(value, &self.target_value))
.or_else(|err| Err(err.to_string()))
}
}
@@ -106,11 +105,11 @@ mod tests {
display_text: "First Name is Jane".to_string(),
};
let mut result = text_comp_success.compare(&student_data);
let mut result = text_comp_success.compare(&student_data.data);
assert!(result.is_ok());
assert_eq!(result.unwrap(), true);
result = text_comp_fail.compare(&student_data);
result = text_comp_fail.compare(&student_data.data);
assert!(result.is_err());
}
}
+4 -2
View File
@@ -17,14 +17,16 @@ impl Comparison for NumberComparison {
// The prerequisites for all number comparisons is that the target value is Some(number) and the
// comparison member points to Some(number) in the student_info.
let student_value = match comp_value {
ValueType::Number(Some(num)) => num.parse::<i32>().unwrap(),
ValueType::Number(Some(num)) => num.parse::<f32>().unwrap(),
_ => {
return Err("Student value is not a number".into());
}
};
leptos::logging::log!("Parsing value: {:?}", target_value);
let target = match target_value {
ValueType::Number(Some(num)) => num.parse::<i32>().unwrap(),
ValueType::Number(Some(num)) => num.parse::<f32>().unwrap(),
_ => {
return Err("Target value is not a number".into());
}
+9 -1
View File
@@ -26,7 +26,14 @@ pub enum InputType {
}
impl InputType {
pub fn into_view(self, data_map: RwSignal<HashMap<String, ValueType>>) -> impl IntoView {
/// Converts an `InputType` into an `impl IntoView`. A name must be specified in the case that
/// the component is a checkbox/radio list, which requires a unique name to function properly.
/// Many times, the name will be the same as the data member.
pub fn into_view(
self,
data_map: RwSignal<HashMap<String, ValueType>>,
name: String,
) -> impl IntoView {
view! {
{match self {
InputType::Text(member, label, placeholder, required) =>
@@ -56,6 +63,7 @@ impl InputType {
view! {
<CheckboxList
label = label.clone()
name = name.clone()
items = options.clone()
data_member = member.clone()
data_map = data_map
+2 -1
View File
@@ -7,8 +7,9 @@ mod submit_status;
mod user;
mod value_type;
pub mod animations;
mod tab_info;
pub use self::{
comparison::*, date_info::*, expandable_info::*, form_params::*, form_schema::*,
submit_status::*, user::*, value_type::*,
submit_status::*, user::*, value_type::*, tab_info::*,
};
+35
View File
@@ -0,0 +1,35 @@
#[derive(Debug, Clone)]
pub struct TabInfo {
pub text: String,
pub path: String,
pub sub_paths: Vec<SubTabInfo>,
}
impl TabInfo {
pub fn new(
text: impl Into<String>,
path: impl Into<String>,
sub_paths: Option<Vec<SubTabInfo>>
) -> Self {
Self {
text: text.into(),
path: path.into(),
sub_paths: sub_paths.unwrap_or_default()
}
}
}
#[derive(Debug, Clone)]
pub struct SubTabInfo {
pub text: String,
pub path: String,
}
impl SubTabInfo {
pub fn new(text: impl Into<String>, path: impl Into<String>) -> Self {
Self {
text: text.into(),
path: path.into()
}
}
}
+18 -28
View File
@@ -22,7 +22,7 @@ pub fn Banner(
title: String,
/// The file path or URL of a logo displayed at the top left of the banner.
#[prop(into)]
logo: String,
logo: Signal<String>,
/// The route that the navigates user to a page (home page).
#[prop(into, optional)]
path: Signal<String>,
@@ -39,36 +39,26 @@ pub fn Banner(
};
view! {
<div class="mx-auto">
<div class="bg-red-900 shadow-lg shadow-[inset_0_2px_4px_0_rgba(0,0,0,0.3),0_4px_6px_-1px_rgba(127,29,29,0.3),0_2px_4px_-1px_rgba(127,29,29,0.2)]">
<div class="flex flex-row items-center gap-4 p-6 pb-2">
<div class="flex-shrink-0">
<img
src=logo.clone()
class="h-10 w-10 cursor-pointer"
alt="icon"
on:click=on_click
/>
</div>
<div class="flex-1">
<h2 class="text-white text-2xl font-bold">{title.clone()}</h2>
</div>
<div class="w-screen bg-red-900">
<div class="flex flex-row items-center gap-4 p-6 pb-2">
<div class="flex-shrink-0">
<img src=logo class="h-10 w-10 cursor-pointer" alt="icon" on:click=on_click />
</div>
<div
class="flex flex-row items-center gap-4"
class=("p-2", move || !show_back.get())
>
<Show when=move || show_back.get()>
<a
href=path
class="flex flex-row items-center gap-2 m-2 ml-14 text-gray-200 hover:underline"
>
<Icon icon=icondata::FaArrowLeftSolid />
<span>"Back to Dashboard"</span>
</a>
</Show>
<div class="flex-1">
<h2 class="text-white text-2xl font-bold">{title.clone()}</h2>
</div>
</div>
<div class="flex flex-row items-center gap-4" class=("p-2", move || !show_back.get())>
<Show when=move || show_back.get()>
<a
href=path
class="flex flex-row items-center gap-2 m-2 ml-14 text-gray-200 hover:underline"
>
<Icon icon=icondata::FaArrowLeftSolid />
<span>"Back to Dashboard"</span>
</a>
</Show>
</div>
</div>
}
}
+5 -1
View File
@@ -103,6 +103,10 @@ pub fn Checkbox(
/// ```
#[component]
pub fn CheckboxList(
/// The name that this list should use. This doesn't need to be specified all the time, but is
/// for use in elements that may treat this list as a sub-input (components like the [`MultiEntry`]
/// do this)
#[prop(optional, into)] name: Signal<String>,
/// The data member that this list should edit/display from the `data_map`.
#[prop(into)] data_member: Signal<String>,
/// A [`Signal`] allowing access to a data store.
@@ -160,7 +164,7 @@ pub fn CheckboxList(
on_change=on_change
// The actual selected values are tracked by this element, not by the checkboxes themselves.
value=item
name=data_member
name=move || format!("{}_{}", name.get(), data_member.get())
disabled=disabled
/>
}
+3 -1
View File
@@ -1,6 +1,8 @@
mod utils;
mod checkbox;
mod chips;
mod select_nav_list;
pub use checkbox::*;
pub use chips::*;
pub use chips::*;
pub use select_nav_list::*;
@@ -0,0 +1,179 @@
use leptos::logging::log;
use leptos::prelude::*;
use leptos_router::components::A;
use crate::components::utils;
#[derive(Debug, Clone)]
pub struct NavListInfo {
pub text: Vec<String>,
pub id: String,
pub slug: String,
}
#[component]
fn NavListItem(
#[prop(into)] name: Signal<String>,
#[prop(into)] value: Signal<String>,
#[prop(into)] text: Signal<Vec<String>>,
#[prop(into)] slug: Signal<String>,
#[prop(into)] selected: Signal<bool>,
#[prop(into)] on_change: Callback<()>,
) -> impl IntoView {
let linked_info = Signal::derive(move ||
text.get()
.get(0)
.cloned()
.unwrap_or_default()
);
let other_info = Signal::derive(move ||
text.get()
.iter()
.cloned()
.skip(1)
.collect::<Vec<String>>()
);
view! {
<div
class="flex flex-row gap-2 p-1 rounded-lg"
class=(["bg-yellow-100"], move || selected.get())
>
<NavListCheckbox
name=name
value=value
on_change=move || on_change.run(())
selected=selected
/>
<A
href=move || slug.get()
{..}
class="flex flex-1 items-center underline hover:text-blue-400"
>
{linked_info}
</A>
<For each=move || other_info.get() key=|info| info.clone() let(info)>
<p class="flex flex-1 items-center">{info}</p>
</For>
</div>
}
}
#[component]
fn NavListCheckbox(
#[prop(into)] selected: Signal<bool>,
#[prop(into)] on_change: Callback<()>,
#[prop(into)] name: Signal<String>,
#[prop(into)] value: Signal<String>,
) -> impl IntoView {
let id = Memo::new(move |_| utils::create_unique_id(&name.get(), &value.get()));
view! {
<label for=id class="flex items-center">
<input
class="hidden relative peer shrink-0"
type="checkbox"
name=name
id=id
prop:checked=selected
on:change=move |_| on_change.run(())
/>
<div class="relative inline-block h-5 w-5 m-2 rounded-sm border border-gray-300 bg-white
transition-bg duration-150 peer-checked:bg-red-700 peer-checked:border-red-700
[&>span]:opacity-0 peer-checked:[&>span]:opacity-100
peer-disabled:bg-gray-600/33 peer-disabled:border-gray-600
peer-disabled:peer-checked:bg-gray-600">
// This span is the check icon. It is centered within the div and has a border on the bottom
// and right sides, which is then rotated to appear like a checkmark.
<span class="absolute left-1/2 top-1/2 block h-3 w-1.5 -translate-x-1/2 -translate-y-1/2
rotate-45 transform border-b-3 border-r-3
border-white transition-opacity duration-150"></span>
</div>
</label>
}
}
/// # Selectable Nav-List Component
///
/// This component takes a `Vec<NavListInfo>` to show a list. The list contains links that append
/// the `NavListItem::slug` to the current path, and each item is selectable. The list of selected
/// items is usable in this component's parent.
#[component]
pub fn SelectableNavList(
#[prop(into)] selected: RwSignal<Vec<String>>,
#[prop(into)] items: Signal<Vec<NavListInfo>>,
#[prop(into)] headers: Signal<Vec<String>>,
#[prop(into)] name: Signal<String>,
) -> impl IntoView {
let all_selected = RwSignal::new(false);
let all_selected_change = move || {
if all_selected.get() {
log!("Unchecking all values.");
all_selected.set(false);
selected.set(Vec::new());
} else {
log!("Checking all values.");
all_selected.set(true);
selected.set(
items
.get()
.iter()
.map(|info| info.id.clone())
.collect::<Vec<String>>()
);
}
};
view! {
<div class="flex flex-1 flex-col m-3">
// Header block - contains evenly spaced headers and the "select all" checkbox.
// Include a checkbox here that's got the same style as the items. This maintains spacing.
// All headers *must* be evenly spaced, otherwise the list starts getting confusing.
<div class="flex flex-row bg-red-800 rounded-lg p-1 gap-2">
<NavListCheckbox
selected=all_selected
name=name
value="select-all"
on_change=all_selected_change
/>
<For each=move || headers.get() key=|header| header.clone() let(header_text)>
<div class="flex flex-1 text-white font-bold items-center">{header_text}</div>
</For>
</div>
// Items block - contains constructed items. These are selectable.
<For
each=move || items.get()
key=|item| item.id.clone()
children=move |entry| {
let id = Signal::derive(move || entry.id.clone());
let text = Signal::derive(move || entry.text.clone());
let selected_sig = Signal::derive(move || selected.get().contains(&id.get()));
let on_change = move || {
selected
.update(|list| {
let id = id.get();
if list.contains(&id) {
log!("Found value {}, removing.", id);
list.retain(|s| s != &id);
} else {
log!("Adding value {} to the list.", id);
list.push(id);
}
});
all_selected.set(false);
};
view! {
<NavListItem
name=name
text=text
value=id.clone()
slug=entry.slug
selected=selected_sig
on_change=on_change
/>
}
}
/>
</div>
}
}
+2 -1
View File
@@ -151,6 +151,7 @@ pub fn RadioList(
#[prop(optional, into)] label: String,
#[prop(optional, into)] required: Signal<bool>,
#[prop(optional, into)] other_prompt: Signal<String>,
#[prop(optional, into)] name: Signal<String>
) -> impl IntoView {
//#region Value Logic
@@ -262,7 +263,7 @@ pub fn RadioList(
on_change=on_change
// The actual selected values are tracked by this element, not by the checkboxes themselves.
value=item
name=data_member
name=move || format!("{}_{}", name.get(), data_member.get())
disabled=disabled
/>
}
+20 -43
View File
@@ -3,7 +3,6 @@ use leptos::ev::{Event, Targeted};
use leptos::prelude::*;
use leptos::web_sys::HtmlSelectElement;
use std::collections::HashMap;
use leptos::logging::{log, warn};
use crate::components::{use_validation_context, InputState, ValidationState};
fn validate(required: bool, value: String) -> ValidationState {
@@ -20,7 +19,7 @@ fn validate(required: bool, value: String) -> ValidationState {
/// by the `value` prop of the component.
#[component]
pub fn Select(
#[prop()] value_list: Vec<String>,
#[prop(into)] value_list: Signal<Vec<String>>,
#[prop(into)] data_member: Signal<String>,
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop(optional, into)] label: String,
@@ -28,28 +27,19 @@ pub fn Select(
#[prop(optional, into)] required: Signal<bool>
) -> impl IntoView {
//#region Value Logic
let default_raw_value = data_map.get_untracked()
.get(&data_member.get_untracked())
.cloned()
.unwrap_or_default()
.as_string().unwrap_or_default().unwrap_or_default();
let raw_value = RwSignal::new(default_raw_value);
Effect::new(move || {
data_map.update(|map| {
map.insert(data_member.get(), ValueType::String(Some(raw_value.get())));
});
let value = Memo::new(move |_| {
data_map.with(|map| {
map.get(&data_member.get_untracked())
.and_then(|v| v.as_string().ok().flatten())
.unwrap_or_default()
})
});
//#endregion
//#region Validation Logic
let error = RwSignal::new(
validate(required.get_untracked(), raw_value.get_untracked())
);
warn!("Current error state: {:?}", error.get_untracked());
let error = Memo::new(move |_| validate(required.get(), value.get()));
let dirty = RwSignal::new(false);
let show_errors = Signal::derive(move || dirty.get() && matches!(error.get(), ValidationState::Invalid(_)));
@@ -68,19 +58,12 @@ pub fn Select(
//#endregion
//#region Event Logic
let on_change = move |e: Targeted<Event, HtmlSelectElement>| {
let value = e.target().value();
log!("Found value {}", value);
raw_value.set(value.clone());
error.set(validate(required.get(), raw_value.get()));
dirty.set(true);
data_map.update(|map| {
if let Some(val) = map.get_mut(&data_member.get()) {
*val = ValueType::String(Some(value.clone()));
} else {
map.insert(data_member.get(), ValueType::String(Some(value)));
}
map.insert(data_member.get(), ValueType::String(Some(value)));
});
};
@@ -96,27 +79,21 @@ pub fn Select(
transition-border duration-150
border-red-700 bg-transparent
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
prop:value=move || value.get()
on:change:target=on_change
disabled=disabled
>
// This closure handles the display of the options.
<option
value=""
disabled
hidden
selected=move || raw_value.get().is_empty()
>
<option value="" disabled hidden selected=move || value.get().is_empty()>
"Select one..."
</option>
{move || {
value_list
.iter()
.map(move |value| {
let display = value.to_owned();
view! { <option value=display>{display.clone()}</option> }
})
.collect::<Vec<_>>()
}}
// We can reasonably assume that the options in the list will be distinct.
<For
each=move || value_list.get()
key=|value| value.clone()
children=move |val| view! { <option value=val>{val.clone()}</option> }
/>
</select>
</label>
</div>
+1 -1
View File
@@ -3,7 +3,7 @@ use leptos::prelude::*;
#[component]
pub fn Loading() -> impl IntoView {
view! {
<div class="size-20 mx-auto">
<div class="size-20 mx-auto mt-20">
<img src="/loading.gif" />
</div>
}
+16
View File
@@ -0,0 +1,16 @@
use leptos::prelude::*;
use crate::components::login::setup::provide_auth_context;
#[component]
pub fn AdminLoginContext(
children: Children
) -> impl IntoView {
provide_auth_context(
crate::utils::use_origin(),
"https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Lfjuy5zaM",
"56c2bqvl021rv8d5mq36blt7jv",
"/providers/callback"
);
view! { {children()} }
}
+3 -1
View File
@@ -1,5 +1,7 @@
pub(crate) mod setup;
mod provider;
pub use provider::*;
mod student;
pub use student::*;
pub(crate) mod setup;
mod admin;
pub use admin::*;
+4 -3
View File
@@ -1,5 +1,4 @@
use leptos::prelude::*;
use leptos_router::components::Outlet;
use super::setup::provide_auth_context;
///# Student Login Context Component
@@ -16,7 +15,9 @@ use super::setup::provide_auth_context;
/// </StudentLoginContext>
/// }
#[component]
pub fn StudentLoginContext() -> impl IntoView {
pub fn StudentLoginContext(
children: Children,
) -> impl IntoView {
provide_auth_context(
crate::utils::use_origin(),
"https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Lfjuy5zaM",
@@ -24,5 +25,5 @@ pub fn StudentLoginContext() -> impl IntoView {
"/students/callback"
);
view! { <Outlet /> }
view! { {children()} }
}
+2 -1
View File
@@ -13,9 +13,10 @@ mod lists;
mod toasts;
mod header;
pub mod login;
mod tabs;
pub use self::{
action_button::*, banner::*, dashboard_button::*, lists::*,
loading::*, multi_entry::*, panel::*, row::*, text_field::*,
validated_form::*, toasts::*, header::*, date::*
validated_form::*, toasts::*, header::*, date::*, tabs::*
};
+112 -66
View File
@@ -1,8 +1,9 @@
use crate::common::{InputType, ValueType};
use crate::components::ActionButton;
use leptos::leptos_dom::logging::console_log;
use leptos::prelude::*;
use std::collections::HashMap;
use leptos::logging::log;
use leptos_icons::Icon;
use uuid::Uuid;
// region Helper Functions
@@ -12,6 +13,14 @@ fn get_uuid_from_map(map: &HashMap<String, ValueType>) -> Option<String> {
map.get("uuid")?.as_string().unwrap_or(None)
}
/// Does the same as [`get_uuid_from_map`], but starts from a [`ValueType`] enum.
fn get_uuid(vt: ValueType) -> Option<String> {
vt.as_map().ok().flatten().unwrap_or_default()
.get("uuid")?
.as_string()
.ok().flatten()
}
/// Updates an Entry within the given `Vec`.
fn update_entry_list(list: &mut Vec<ValueType>, data: &HashMap<String, ValueType>) {
if let Some(uuid) = get_uuid_from_map(&data) {
@@ -66,81 +75,103 @@ fn new_entry() -> ValueType {
/// `first_name` and `last_name` keys within that `Entry`.
#[component]
pub fn MultiEntry(
#[prop(into)] label: Signal<String>,
#[prop(optional, into)] description: Signal<String>,
/// The data member that contains the list of entries within the `data_map`.
#[prop(into)]
data_member: String,
data_member: Signal<String>,
/// The data map that contains all the information. Data is found within this map using the
/// component's `data_member` input.
#[prop()]
data_map: RwSignal<HashMap<String, ValueType>>,
/// The type and order of the inputs for each `Entry` component within this `MultiEntry` component.
#[prop(optional)]
schema: Vec<InputType>,
#[prop(optional, into)]
schema: Signal<Vec<InputType>>,
) -> impl IntoView {
// Make the values within the list reactive. Each of these values should be a ValueType::Map,
// so we can pass these directly to the application.
let data_reactive = RwSignal::new(
data_map
.get_untracked()
.get(&data_member)
.unwrap_or(&ValueType::List(None))
.as_list()
.unwrap_or(Some(Vec::<ValueType>::new()))
.unwrap_or(Vec::new()),
);
// This effect updates the data_map with any new values from the data_reactive list, every time
// the data_reactive list is changed. This allows changes from within the Entry component to
// bubble up to the data_map effectively.
Effect::new(move |_| {
let entry_list = data_reactive.get();
data_map.update(|map| {
map.insert(data_member.clone(), ValueType::List(Some(entry_list)));
});
let data_list = Memo::new(move |_| {
data_map.with(|map| {
map.get(&data_member.get())
.and_then(|v| v.as_list().ok().flatten())
.unwrap_or_default()
})
});
// This effect updates the data_map with any new values from the data_list, every time
// the data_list is changed. This allows changes from within the Entry component to
// bubble up to the data_map effectively.
let update_parent_list = move |list| {
log!("Updating parent list using child list {:?}", list);
data_map.update(|map| {
map.insert(data_member.get(), ValueType::List(Some(list)));
});
};
let add_entry = move |_| data_reactive.update(|list| list.push(new_entry()));
let add_entry = move |_| data_map.update(|map| {
log!("Adding new entry.");
let mut list = data_list.get_untracked();
list.push(new_entry());
map.insert(data_member.get(), ValueType::List(Some(list)));
});
view! {
<div class="flex flex-col flex-1">
<div class="flex flex-col gap-2 p-2">
<span class="font-bold">{label}</span>
<Show when=move || !description.get().is_empty()>
<span>{description}</span>
</Show>
</div>
<div class="flex flex-col gap-6 p-2">
// Render the entries
<For
each=move || data_reactive.get()
key=|entry| get_uuid_from_map(&entry.as_map().unwrap().unwrap()) // Entries MUST be given a unique ID. This is absolutely ridiculous.
children=move |mut entry_map| {
// The map_signal is the map used for the internal element. This signal will
// keep track of the individual entry's data, and whenever it is changed,
// the parent data_map will also be updated to reflect these changes.
let map_signal = RwSignal::new(entry_map
.as_map()
.unwrap_or_default()
.unwrap_or_default());
// This effect updates the data_reactive list with the map_signal values, every
// time the map_signal is changed.
Effect::new(move |_| {
let child_map = map_signal.get(); // HashMap
data_reactive.update(|mut list| {
update_entry_list(&mut list, &child_map);
});
});
view! {
<Entry
data_map = map_signal
schema = schema.clone()
/>
}
<Show
when=move || !data_list.get().is_empty()
fallback=|| {
view! { <div class="mx-auto">"You haven't added any entries yet."</div> }
}
/>
>
<For
each=move || data_list.get()
// Entries MUST be given a unique ID.
key=|entry| get_uuid(entry.clone())
children=move |mut entry_map| {
let map_signal = RwSignal::new(
entry_map.as_map().ok().flatten().unwrap_or_default(),
);
let unique_id = Memo::new(move |_| {
get_uuid_from_map(&map_signal.get()).unwrap_or_default()
});
let on_delete = move || {
let mut list = data_list.get_untracked();
let uuid = unique_id.get_untracked();
list.retain(|map| {
get_uuid(map.clone()).unwrap_or_default() != uuid
});
update_parent_list(list);
};
Effect::new(move |_| {
let child_map = map_signal.get();
let mut list = data_list.get_untracked();
update_entry_list(&mut list, &child_map);
update_parent_list(list);
});
view! {
<Entry
data_map=map_signal
schema=schema
id=unique_id
on_delete=on_delete
/>
}
}
/>
</Show>
</div>
<ActionButton on:click = add_entry>"Add entry"</ActionButton>
<ActionButton on:click=move |_| {
console_log(format!("Entry list: {:?}", data_reactive.get()).as_str());
}
>"Print Info"</ActionButton>
<ActionButton on:click=add_entry>"Add entry"</ActionButton>
</div>
}
}
@@ -176,20 +207,35 @@ fn Entry(
/// The data map that contains all information. Data is found within this map using the `member`
/// props. This map should be unique to this entry; in other words, it should be contained and
/// passed down from within the entry list in the parent `MultiEntry` component.
#[prop()]
data_map: RwSignal<HashMap<String, ValueType>>,
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
/// The schema of this entry. Should contain all information about what inputs to use and what
/// data members those inputs modify.
#[prop()]
schema: Vec<InputType>,
#[prop(into)] schema: Signal<Vec<InputType>>,
/// The unique ID for this entry.
#[prop(into)] id: Signal<String>,
/// The function to run when the delete button is clicked.
#[prop(into)] on_delete: Callback<()>,
) -> impl IntoView {
view! {
<div class="flex flex-col flex-1 p-2 rounded-sm transition-shadow shadow-sm hover:shadow-lg/30">
{schema.iter()
.map(|input_type| {
input_type.clone().into_view(data_map.clone())
}).collect_view()
}
<div class="relative rounded-sm transition-shadow shadow-sm hover:shadow-lg/30">
// Delete button
<div
class="absolute rounded-full w-8 h-8 -top-4 -right-4 flex items-center justify-center bg-gray-100 text-gray-700 hover:text-red-600 transition-all cursor-pointer"
on:click=move |_| on_delete.run(())
>
<Icon icon=icondata::FaTrashCanRegular />
</div>
// Input section
<div class="flex flex-col flex-1 p-2">
{move || {
schema
.get()
.iter()
.map(|input_type| input_type.clone().into_view(data_map.clone(), id.get()))
.collect_view()
}}
</div>
</div>
}
}
+2
View File
@@ -0,0 +1,2 @@
mod sidebar;
pub use sidebar::*;
+141
View File
@@ -0,0 +1,141 @@
use leptos::prelude::*;
use leptos_router::components::{Outlet, A};
use leptos_router::hooks::use_location;
use crate::common::{SubTabInfo, TabInfo};
/// # Tab Sidebar Component
///
/// This component creates a list of tabs that correspond to the given `Vec<TabInfo>`.
/// There are a few things to note:
///
/// - This component defines an [`Outlet`], meaning that it is designed to display the route
/// information on its own. If you intend to use this on a page, the page should not also contain
/// an `Outlet` component unless it needs to render its own nested children.
/// - The "selected" appearance for the individual tabs work best when their paths are at the same
/// depth. For example:
/// ```
/// let good_list = vec! [
/// TabInfo {
/// ...,
/// path: "/default/path"
/// },
/// TabInfo {
/// ...,
/// path: "/good/path"
/// }
/// ];
///
/// let bad_list = vec! [
/// TabInfo {
/// ...,
/// path: "/default/path"
/// },
/// TabInfo {
/// ...,
/// path: "/bad/path/here"
/// }
/// ]
/// ```
///
/// Example usage in a view (assuming the good list from above):
/// ```
/// view! {
/// <TabSidebarList
/// tabs=good_list
/// />
/// // Remember, no Outlets!
/// }
/// ```
#[component]
pub fn TabSidebarList(
#[prop(into)] base_path: Signal<String>,
#[prop(into)] tabs: Signal<Vec<TabInfo>>,
) -> impl IntoView {
// Create a series of tabs based on a given list of names and paths.
view! {
<div class="flex flex-1 flex-row w-full">
<nav class="flex flex-col bg-red-900 w-64 overflow-scroll">
<For
each=move || tabs.get()
key=|info| info.text.clone()
let(TabInfo { text, path, sub_paths })
>
<SidebarTab base_path=base_path text=text path=path sub_paths=sub_paths />
</For>
</nav>
<div class="flex flex-1 overflow-scroll">
<Outlet />
</div>
</div>
}
}
#[component]
fn SidebarTab(
#[prop(into)] base_path: Signal<String>,
#[prop(into)] text: Signal<String>,
#[prop(into)] path: Signal<String>,
#[prop(into)] sub_paths: Signal<Vec<SubTabInfo>>,
) -> impl IntoView {
let location = use_location();
let full_path = Memo::new(move |_| {
format!("/{}/{}", base_path.get(), path.get())
});
let selected = Memo::new(move |_| {
let current_path = location.pathname.get();
// There's extra logic here as well, but for now this works.
current_path.starts_with(&full_path.get())
});
view! {
<div
class="transition-all duration-300"
class=(["bg-transparent", "text-white"], move || !selected.get())
class=(["bg-white", "text-black"], move || selected.get())
>
<A href=move || path.get()>
<div class="p-3 transition-bg font-bold">{text}</div>
</A>
<div
class="grid transition-all ease-in-out"
style:grid-template-rows=move || if selected.get() { "1fr" } else { "0fr" }
>
<div class="overflow-hidden min-h-0">
<For
each=move || sub_paths.get()
key=|info| info.text.clone()
let(SubTabInfo { text, path })
>
<SidebarSubTab parent_path=full_path text=text path=path />
</For>
</div>
</div>
</div>
}
}
#[component]
fn SidebarSubTab(
#[prop(into)] parent_path: Signal<String>,
#[prop(into)] text: Signal<String>,
#[prop(into)] path: Signal<String>,
) -> impl IntoView {
let full_path = Memo::new(move |_| {
format!("{}/{}", parent_path.get(), path.get())
});
view! {
<A
href=move || full_path.get()
{..}
class="block p-3 pl-10 transition-all aria-[current]:font-bold"
>
{text}
</A>
}
}
+3 -9
View File
@@ -5,6 +5,7 @@ use crate::components::{ActionButton, Header};
/// Indicates the current state of an input.
#[derive(Default, Debug, Clone)]
#[derive(PartialEq)]
pub enum ValidationState {
/// A valid input.
#[default]
@@ -149,15 +150,8 @@ pub fn ValidatedForm(
};
view! {
<form
class="flex flex-col gap-2"
novalidate
on:submit=submit_success_event
>
<Header
title=title
description=description
/>
<form class="flex flex-col gap-2 py-7" novalidate on:submit=submit_success_event>
<Header title=title description=description />
{children()}
<ActionButton
on:click=move |_| {
+11
View File
@@ -0,0 +1,11 @@
use leptos::prelude::*;
#[component]
pub fn AdminHomePage() -> impl IntoView {
view! {
<div class="flex flex-1 flex-col gap-4 items-center m-4">
<div class="font-bold text-3xl">"Admin Home Page"</div>
<div class="text-lg">"This is just a placeholder for now."</div>
</div>
}
}
+11
View File
@@ -0,0 +1,11 @@
mod shell;
mod home_page;
mod provider_page;
mod scholarship_page;
mod utils_page;
pub use shell::*;
pub use home_page::*;
pub use provider_page::*;
pub use scholarship_page::*;
pub use utils_page::*;
+83
View File
@@ -0,0 +1,83 @@
use leptos::logging::log;
use leptos::prelude::*;
use crate::common::ValueType;
use crate::components::{NavListInfo, SelectableNavList};
use crate::pages::api::get_all_providers;
#[component]
pub fn AdminProviderPage() -> impl IntoView {
let selected_items = RwSignal::new(Vec::new());
let provider_list = RwSignal::new(Vec::new());
let provider_trigger = Trigger::new();
let provider_resource = Resource::new(
move || provider_trigger.track(),
async move |_| {
get_all_providers().await
}
);
Effect::new(move || {
if let Some(Err(err)) = provider_resource.get() {
log!("Found error: {}", err.to_string());
}
let Some(Ok(list)) = provider_resource.get() else {
return;
};
let members_list = vec![
"contact_email".to_string(),
"first_name".to_string(),
"last_name".to_string(),
];
// Convert the list into a series of NavListInfo
let converted = list
.into_iter()
.map(|info| {
let mut text_list = vec![];
members_list.iter().for_each(|member| {
let val = info.get(member)
.unwrap_or(&ValueType::String(None))
.as_string()
.ok().flatten()
.unwrap_or_default();
text_list.push(val);
});
let subject = info.get("subject")
.unwrap_or(&ValueType::String(None))
.to_string();
NavListInfo {
text: text_list,
id: subject.clone(),
slug: subject,
}
})
.collect();
provider_list.set(converted);
});
let headers = vec![
"Email",
"First Name",
"Last Name"
]
.iter()
.map(|s| s.to_string())
.collect::<Vec<String>>();
view! {
<div class="flex flex-1 flex-col gap-4 items-stretch mt-4">
<div class="font-bold text-3xl self-center">"Provider View"</div>
<SelectableNavList
selected=selected_items
name="providers-nav-list"
items=provider_list
headers=headers
/>
</div>
}
}
+15
View File
@@ -0,0 +1,15 @@
use leptos::prelude::*;
#[component]
pub fn AdminScholarshipPage() -> impl IntoView {
view! {
<div class="flex flex-1 flex-col gap-4">
<div class="font-bold text-3xl">
"Scholarships View"
</div>
<div>
"This is a placeholder for now. It will show a list of all scholarships."
</div>
</div>
}
}
+28
View File
@@ -0,0 +1,28 @@
use leptos::prelude::*;
use crate::common::TabInfo;
use crate::components::{Banner, TabSidebarList};
use crate::components::login::AdminLoginContext;
/// # Admin Shell Component
///
/// This shell should keep track of pretty much everything that changes across the admin page. It
/// will provide the login context, the banner, and the tabs.
#[component]
pub fn AdminShell() -> impl IntoView {
// Create login context
view! {
<AdminLoginContext>
<Banner title="R15 Scholarships Admin" logo="/PHS_Stacked_Acronym.png" />
// We'll put the tabs here.
<TabSidebarList
base_path="admin"
tabs=vec![
TabInfo::new("Home", "home", None),
TabInfo::new("Providers", "providers", None),
TabInfo::new("Scholarships", "scholarships", None),
TabInfo::new("Utilities", "utilities", None),
]
/>
</AdminLoginContext>
}
}
+190
View File
@@ -0,0 +1,190 @@
use chrono::DateTime;
use crate::components::ActionButton;
use leptos::prelude::*;
use crate::common::{DateInfo, DateRange};
use crate::pages::api::{CreateDates, CreateTestComparisons};
fn get_important_dates() -> Vec<DateInfo> {
vec! [
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Provider Forms Open".to_string(),
date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-01-20T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-03-03T23:59:00-05:00").unwrap()
),
description: "".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Student Forms Open".to_string(),
date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-03-03T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-03-27T14:05:00-05:00").unwrap()
),
description: "".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Scholarship Decision Window".to_string(),
date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-04-10T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-05-01T23:59:00-05:00").unwrap()
),
description: "Start date may change if available sooner.".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "PHS Scholarship Committee".to_string(),
date: DateRange::Single(
DateTime::parse_from_rfc3339("2026-05-18T00:00:00-05:00").unwrap(),
),
description: "Time TBD".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Scholarship and Awards Night".to_string(),
date: DateRange::Single(
DateTime::parse_from_rfc3339("2026-06-09T00:00:00-05:00").unwrap(),
),
description: "Time TBD".to_string(),
},
]
}
#[server]
async fn create_scholarship_export() -> Result<Vec<u8>, ServerFnError> {
// Exports olarship information - specifically the name of the scholarship, the name of the
// provider, and the scholarship's requirements.
// This means we'll need to get all the relations, all the scholarships, and all the contact info.
use crate::utils::server::create_dynamo_client;
use aws_sdk_dynamodb::client::Client;
use crate::pages::api::{get_comparison_info, SCHOLARSHIPS_TABLE, PROVIDER_CONTACT_TABLE};
use std::collections::HashMap;
use crate::common::ValueType;
let scan_table_items = async |client: &Client, table_name: &str| {
client.scan()
.table_name(table_name)
.send()
.await
.ok().and_then(|res| res.items)
.unwrap_or_default()
};
#[derive(serde::Serialize, serde::Deserialize, Debug)]
struct ScholarshipInfo {
name: String,
provider_name: String,
relations: Vec<String>,
}
let client = create_dynamo_client().await;
let scholarships = dbg!(scan_table_items(&client, SCHOLARSHIPS_TABLE)
.await
.iter().map(|item| {
item.into_iter()
.map(|(k, v)| (k.clone(), ValueType::from(v)))
.collect()
}).collect::<Vec<HashMap<String, ValueType>>>()
);
let contacts = dbg!(
scan_table_items(&client, PROVIDER_CONTACT_TABLE).await
.iter().map(|item| {
item.into_iter()
.map(|(k, v)| (k.clone(), ValueType::from(v)))
.collect()
}).collect::<Vec<HashMap<String, ValueType>>>()
);
let db_relations = dbg!(
get_comparison_info().await.unwrap_or_default()
);
// Build a new structure containing some of the scholarship's information.
let resolved = scholarships.into_iter()
.map(|scholarship| {
// We just want the scholarship's name, the relations, and the provider's name.
// In here, we're going to resolve all the scholarship's information to provide this in
// a readable format.
let name = scholarship.get("name")
.unwrap_or(&ValueType::String(None))
.to_string();
// The list of relations - we're going to search the master list for each comparison's
// name. It's structured as a map of lists, or a ValueType::Map(Option<HashMap<String, ValueType::List(Option<Vec<ValueType::String()>>)>>)
let relations = scholarship.get("requirements")
.unwrap_or(&ValueType::Map(None))
.as_map().ok().flatten()
.unwrap_or_default() // We have our map of categories, now we need all the values inside each category.
.iter()
.map(|(_, list)| {
list.as_list().ok().flatten()
.unwrap_or_default()
})
.flatten()
.collect::<Vec<ValueType>>() // This is a Vec<ValueType::String>
.iter().map(|val| val.clone().as_string().ok().flatten().unwrap_or_default())
.collect::<Vec<String>>();
// The provider ID, which we'll use to search for the provider's name.
let provider_id = scholarship.get("provider_id")
.unwrap_or(&ValueType::String(None))
.to_string();
// This is a mess, we'll clean it up later.
let parsed_relations = relations
.iter()
.filter_map(|relation| {
db_relations.iter().find(|search| {
search.id == *relation
})
.and_then(|search| Some(search.display_text.clone()))
})
.collect::<Vec<String>>();
ScholarshipInfo {
name,
provider_name: provider_id,
relations: parsed_relations,
}
})
.collect::<Vec<ScholarshipInfo>>();
// TODO Convert into CSV and send to client. For now I think I'm just going to serialize to json and print it.
leptos::logging::log!("Scholarships list: {:?}", resolved);
Ok(Vec::new())
}
#[component]
pub fn AdminUtilsPage() -> impl IntoView {
let create_comparisons = ServerAction::<CreateTestComparisons>::new();
let create_dates = ServerAction::<CreateDates>::new();
let create_export = ServerAction::<CreateScholarshipExport>::new();
let on_click_comparisons = move |_| {
create_comparisons.dispatch(CreateTestComparisons{});
};
let on_click_dates = move |_| {
create_dates.dispatch(CreateDates{dates: get_important_dates()});
};
let on_click_export = move |_| {
create_export.dispatch(CreateScholarshipExport{});
};
view! {
<div class="mx-auto px-6">
<div class="flex flex-col gap-4">
<div class="self-centered text-lg mt-6">
"This utility page provides buttons to initialize the corresponding lists of information."
</div>
<ActionButton on:click=on_click_comparisons>"Create Comparisons"</ActionButton>
<ActionButton on:click=on_click_dates>"Create Dates"</ActionButton>
<ActionButton on:click=on_click_export>"Scholarship Export"</ActionButton>
</div>
</div>
}.into_any()
}
+44
View File
@@ -0,0 +1,44 @@
use leptos::prelude::*;
use std::collections::HashMap;
use crate::common::ValueType;
use leptos::logging::log;
#[cfg(feature = "ssr")]
mod server_logic {
pub use crate::utils::server::create_dynamo_client;
pub use super::super::PROVIDER_CONTACT_TABLE;
}
#[server]
pub async fn get_all_providers() -> Result<Vec<HashMap<String, ValueType>>, ServerFnError> {
use server_logic::*;
// We want to get all provider information from the contacts table.
// This will be migrated to the new table structure when it happens.
let client = create_dynamo_client().await;
log!("Scanning all provider information from {}", PROVIDER_CONTACT_TABLE);
client
.scan()
.table_name(PROVIDER_CONTACT_TABLE)
.send()
.await
.map(|output| {
if let Some(items) = output.items {
items
.into_iter()
.map(|item| {
item.into_iter().map(|(key, attr)| {
let val = ValueType::from(&attr);
(key, val)
}).collect()
})
.collect::<Vec<HashMap<String, ValueType>>>()
} else {
Vec::new()
}
})
.map_err(ServerFnError::from)
}
+69 -26
View File
@@ -7,6 +7,9 @@ use aws_sdk_dynamodb::error::ProvideErrorMetadata;
#[cfg(feature = "ssr")]
use leptos::logging::log;
#[cfg(feature = "ssr")]
use super::COMPARISONS_TABLE;
use crate::common::{
ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison,
TextComparison, TextListComparison, ValueType,
@@ -15,8 +18,6 @@ use std::collections::HashMap;
use leptos::prelude::ServerFnError;
use leptos::server;
#[cfg(feature = "ssr")]
static COMPARISONS_TABLE: &str = "leptos-comparisons";
/// Helper function to create all sports comparisons. Only used to create initial
/// comparison data, and should be removed upon completion of a comparison editor page.
@@ -188,6 +189,17 @@ fn create_sports_comparisons() -> Vec<ComparisonData> {
"Sports Participation",
"Tennis",
),
ComparisonData::new(
"sports_baseball",
"sports_participation",
ComparisonType::MapList(MapListComparison::FlattenToTextList(
"sport_name".to_string(),
Box::new(TextListComparison::Contains),
)),
ValueType::String(Some("Baseball".to_string())),
"Sports Participation",
"Baseball",
),
]
}
@@ -252,12 +264,12 @@ fn create_major_comparisons() -> Vec<ComparisonData> {
#[allow(unused)]
fn make_comp_list() -> Vec<ComparisonData> {
let gpa_3 = ComparisonData::new(
"gpa_3",
"gpa_top_third",
"weighted_gpa",
ComparisonType::Number(NumberComparison::GreaterThanOrEqual),
ValueType::Number(Some(3.0.to_string())),
ValueType::Number(Some(6.264.to_string())),
"GPA Limits",
"GPA >= 3.0",
"Top Third GPA",
);
let test_comp = ComparisonData::new(
@@ -271,13 +283,10 @@ fn make_comp_list() -> Vec<ComparisonData> {
let service_hours_20 = ComparisonData::new(
"service_hours_20",
"community_involvement",
ComparisonType::MapList(MapListComparison::FlattenToNumberList(
"service_hours".to_string(),
Box::new(NumberListComparison::Sum(Box::new(
NumberComparison::GreaterThanOrEqual,
))),
)),
"service_hours",
ComparisonType::Number(
NumberComparison::GreaterThanOrEqual
),
ValueType::Number(Some(20.to_string())),
"Community Service",
"20+ service hours",
@@ -285,13 +294,10 @@ fn make_comp_list() -> Vec<ComparisonData> {
let service_hours_25 = ComparisonData::new(
"service_hours_25",
"community_involvement",
ComparisonType::MapList(MapListComparison::FlattenToNumberList(
"service_hours".to_string(),
Box::new(NumberListComparison::Sum(Box::new(
NumberComparison::GreaterThanOrEqual,
))),
)),
"service_hours",
ComparisonType::Number(
NumberComparison::GreaterThanOrEqual
),
ValueType::Number(Some(25.to_string())),
"Community Service",
"25+ service hours",
@@ -299,13 +305,10 @@ fn make_comp_list() -> Vec<ComparisonData> {
let service_hours_30 = ComparisonData::new(
"service_hours_30",
"community_involvement",
ComparisonType::MapList(MapListComparison::FlattenToNumberList(
"service_hours".to_string(),
Box::new(NumberListComparison::Sum(Box::new(
NumberComparison::GreaterThanOrEqual,
))),
)),
"service_hours",
ComparisonType::Number(
NumberComparison::GreaterThanOrEqual
),
ValueType::Number(Some(30.to_string())),
"Community Service",
"30+ service hours",
@@ -356,6 +359,42 @@ fn make_comp_list() -> Vec<ComparisonData> {
"Family with Military Service",
);
let youth_baseball = ComparisonData::new(
"youth_baseball",
"youth_baseball",
ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())),
"Additional Eligibility Factors",
"Participated in Pomperaug Youth Baseball"
);
let aquatic_club = ComparisonData::new(
"aquatic_club",
"aquatic_club",
ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())),
"Additional Eligibility Factors",
"Participated in Panthers Aquatic Club"
);
let pea_member = ComparisonData::new(
"pea_member",
"pea_member",
ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())),
"Additional Eligibility Factors",
"Has a Parent in the PEA"
);
let music_program = ComparisonData::new(
"music_program",
"music_program",
ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())),
"Sports Participation",
"PHS Music Program"
);
let mut sports_comps = create_sports_comparisons();
let mut major_comps = create_major_comparisons();
@@ -370,6 +409,10 @@ fn make_comp_list() -> Vec<ComparisonData> {
attended_bas,
midd_south,
family_military,
youth_baseball,
aquatic_club,
pea_member,
music_program
];
comp_list.append(&mut sports_comps);
+1 -1
View File
@@ -4,7 +4,7 @@ use leptos::prelude::*;
use crate::common::DateInfo;
#[cfg(feature = "ssr")]
static DATES_TABLE: &str = "leptos-dates";
use super::DATES_TABLE;
#[cfg(feature = "ssr")]
use aws_sdk_dynamodb::{
+23 -3
View File
@@ -1,7 +1,27 @@
mod comparisons;
pub use comparisons::*;
mod scholarships;
pub use scholarships::*;
mod dates;
pub use dates::*;
mod admin;
mod providers;
pub mod students;
pub use comparisons::*;
pub use scholarships::*;
pub use dates::*;
pub use admin::*;
pub use providers::*;
#[cfg(feature = "ssr")]
pub static COMPARISONS_TABLE: &str = "leptos-comparisons";
#[cfg(feature = "ssr")]
pub static DATES_TABLE: &str = "leptos-dates";
#[cfg(feature = "ssr")]
pub static SCHOLARSHIPS_TABLE: &str = "leptos-scholarships";
#[cfg(feature = "ssr")]
pub static PROVIDER_CONTACT_TABLE: &str = "leptos-provider-contacts";
#[cfg(feature = "ssr")]
pub static MAIN_TABLE_NAME: &str = "scholarships-main";
+71
View File
@@ -0,0 +1,71 @@
use leptos::prelude::*;
use leptos::logging::debug_log;
use std::collections::HashMap;
use leptos::server_fn::codec::Json;
use crate::common::ValueType;
#[cfg(feature = "ssr")]
use crate::utils::server::{create_dynamo_client, into_attr_map};
#[cfg(feature = "ssr")]
static PROVIDER_CONTACT_TABLE: &str = "leptos-provider-contacts";
#[server]
pub async fn get_provider_contact(id: String) -> Result<HashMap<String, ValueType>, ServerFnError> {
use aws_sdk_dynamodb::types::AttributeValue;
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
let client = create_dynamo_client().await;
debug_log!("Getting contact info for provider {}", id);
match client
.get_item()
.table_name(PROVIDER_CONTACT_TABLE)
.key("subject", AttributeValue::S(id))
.send()
.await
{
Ok(output) => {
let Some(item) = output.item else {
return Ok(HashMap::new());
};
let map = item.iter().map(|(key, val)| {
let val_type = ValueType::from(val);
(key.clone(), val_type)
}).collect::<HashMap<String, ValueType>>();
Ok(map)
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred.");
Err(ServerFnError::new(msg))
}
}
}
#[server(input = Json)]
pub async fn put_provider_contact(id: String, contact_info: HashMap<String, ValueType>) -> Result<(), ServerFnError> {
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
let client = create_dynamo_client().await;
let mut item = into_attr_map(contact_info);
item.insert("subject".to_owned(), aws_sdk_dynamodb::types::AttributeValue::S(id));
match client
.put_item()
.table_name(PROVIDER_CONTACT_TABLE)
.set_item(Some(item))
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred.");
Err(ServerFnError::new(msg))
}
}
}
+55 -21
View File
@@ -1,27 +1,26 @@
#[cfg(feature = "ssr")]
use aws_sdk_dynamodb::{
types::AttributeValue,
error::ProvideErrorMetadata,
};
#[cfg(feature = "ssr")]
use leptos::leptos_dom::log;
#[cfg(feature = "ssr")]
use uuid::Uuid;
#[cfg(feature = "ssr")]
use crate::utils::server::{create_dynamo_client, into_attr_map};
#[cfg(feature = "ssr")]
use crate::common::ValueType;
mod imports {
pub use leptos::logging::debug_log;
pub use aws_sdk_dynamodb::{
types::AttributeValue,
error::ProvideErrorMetadata,
};
pub use leptos::leptos_dom::log;
pub use uuid::Uuid;
pub use crate::utils::server::{create_dynamo_client};
pub use crate::common::ValueType;
pub use super::super::SCHOLARSHIPS_TABLE;
}
use crate::common::ExpandableInfo;
use leptos::prelude::ServerFnError;
use leptos::server;
#[cfg(feature = "ssr")]
static SCHOLARSHIPS_TABLE: &str = "leptos-scholarships";
use leptos::server_fn::codec::Json;
#[server(GetScholarshipInfo)]
pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
// Perform the operation - we just want to return all data that's contained in this entry,
@@ -50,8 +49,9 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
}
}
#[server(CreateScholarshipInfo)]
#[server(CreateScholarshipInfo, input = Json)]
pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerFnError> {
use imports::*;
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
let client = create_dynamo_client().await;
@@ -75,8 +75,10 @@ pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerF
#[server(GetAllScholarshipInfo)]
pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
log!("Getting all scholarship info");
debug_log!("Getting all scholarship info");
match client
.scan()
.table_name(SCHOLARSHIPS_TABLE)
@@ -100,9 +102,11 @@ pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnE
/// Gets all scholarships that are associated with the given scholarship provider's ID.
#[server(GetProviderScholarships)]
pub async fn get_provider_scholarships(provider_id: String) -> Result<Vec<ExpandableInfo>, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
log!("Getting provider scholarships for provider with ID {:?}", provider_id);
debug_log!("Getting provider scholarships for provider with ID {:?}", provider_id);
match client
.scan()
@@ -129,9 +133,11 @@ pub async fn get_provider_scholarships(provider_id: String) -> Result<Vec<Expand
/// Creates a new scholarship with a unique ID, and then returns that ID.
#[server(RegisterScholarship)]
pub async fn register_scholarship(provider_id: String) -> Result<String, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
log!("Creating scholarship for provider with ID {:?}", provider_id);
debug_log!("Creating scholarship for provider with ID {:?}", provider_id);
let mut current_uuid = Uuid::new_v4().to_string();
let mut item = ExpandableInfo::new(current_uuid.clone());
@@ -173,9 +179,11 @@ pub async fn register_scholarship(provider_id: String) -> Result<String, ServerF
/// Deletes a provider's scholarship given their provider ID and scholarship ID.
#[server(DeleteProviderScholarship)]
pub async fn delete_provider_scholarship(provider_id: String, scholarship_id: String) -> Result<(), ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
log!("Deleting scholarship with ID {:?} for provider with ID {:?}", scholarship_id, provider_id);
debug_log!("Deleting scholarship with ID {:?} for provider with ID {:?}", scholarship_id, provider_id);
// When we delete a scholarship, we need to ensure that the provider's ID matches the scholarship,
// otherwise everyone can delete anyone else's scholarships.
@@ -197,3 +205,29 @@ pub async fn delete_provider_scholarship(provider_id: String, scholarship_id: St
}
}
}
#[server]
pub async fn get_all_scholarships() -> Result<Vec<std::collections::HashMap<String, crate::common::ValueType>>, ServerFnError> {
use imports::*;
use std::collections::HashMap;
let client = create_dynamo_client().await;
client
.scan()
.table_name(SCHOLARSHIPS_TABLE)
.send()
.await
.map(|output| {
let Some(items) = output.items else {
return Vec::new();
};
items.iter().map(|item| {
// Each item should be simply converted from an AttributeValue to a ValueType.
item.iter().map(|(k, v)| (k.clone(), ValueType::from(v)))
.collect::<HashMap<String, ValueType>>()
}).collect::<Vec<_>>()
})
.map_err(ServerFnError::from)
}
+114
View File
@@ -0,0 +1,114 @@
use leptos::prelude::*;
use leptos::server_fn::codec::Json;
#[cfg(feature = "ssr")]
mod imports {
pub use crate::utils::server::*;
pub use aws_sdk_dynamodb::types::AttributeValue;
pub use super::super::MAIN_TABLE_NAME;
pub use crate::common::ValueType;
pub use std::collections::HashMap;
pub use leptos::logging::debug_log;
}
#[server(input = Json)]
pub async fn put_student_data(
subject: String,
data_type: String,
data_map: std::collections::HashMap<String, crate::common::ValueType>
) -> Result<(), ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
let mut data_map_attr = into_attr_map(data_map);
data_map_attr.insert(
"HK".into(),
AttributeValue::S(format!("STUDENT#{}", subject))
);
data_map_attr.insert(
"SK".into(),
AttributeValue::S(data_type.to_uppercase())
);
debug_log!("Inserting this item: {:?}", data_map_attr);
client
.put_item()
.table_name(MAIN_TABLE_NAME)
.set_item(Some(data_map_attr))
.send()
.await
.map(|_| ())
.map_err(ServerFnError::from)
}
#[server]
pub async fn get_student_data(
subject: String,
data_type: String,
) -> Result<std::collections::HashMap<String, crate::common::ValueType>, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
client
.query()
.table_name(MAIN_TABLE_NAME)
.key_condition_expression("HK = :hk AND SK = :sk")
.expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{}", subject)))
.expression_attribute_values(":sk", AttributeValue::S(data_type.to_uppercase()))
.send()
.await
.map(|output| {
// Map the output to a HashMap<String, ValueType>
let Some(list) = output.items else {
return HashMap::new();
};
let mut map = HashMap::new();
let _ = list
.into_iter()
.flatten()
.for_each(|(k, v)| {
map.insert(k, ValueType::from(&v));
});
map
})
.map_err(ServerFnError::from)
}
/// Gets and flattens all of a student's information from the database, regardless of their defined
/// sort key.
///
/// Note: if sorted data from the `put_student_data` function contains the same fields, this
/// function will have unexpected behavior. For example, if a `first_name` field appears in multiple
/// locations in the database, all for this single student, they will overwrite each other in no
/// specific order.
#[server]
pub async fn get_all_student_data(
subject: String
) -> Result<std::collections::HashMap<String, crate::common::ValueType>, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
client.query()
.table_name(MAIN_TABLE_NAME)
.key_condition_expression("HK = :hk")
.expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{}", subject)))
.send()
.await
.map(|output| {
let Some(items) = output.items else {
return HashMap::new();
};
items.iter().flatten()
.map(|(|k, v)| (k.clone(), ValueType::from(v)))
.collect::<HashMap<String, ValueType>>()
})
.map_err(ServerFnError::from)
}
+1 -1
View File
@@ -88,7 +88,7 @@ pub fn ComparisonTestPage() -> impl IntoView {
_ => None,
};
let comparison_outcome = match comp_opt {
Some(comp) => comp.compare(&student),
Some(comp) => comp.compare(&student.data),
_ => Err("".to_string()),
};
match comparison_outcome {
+2 -14
View File
@@ -12,7 +12,7 @@ use crate::common::ExpandableInfo;
use crate::pages::UnauthenticatedPage;
use crate::components::{
ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row,
Select, TextFieldType, ValidatedForm
TextFieldType, ValidatedForm
};
use crate::input;
use leptos::leptos_dom::logging::console_log;
@@ -329,21 +329,9 @@ pub fn HomePage() -> impl IntoView {
required=true
/>
</Row>
<Row>
<Select
label="Gender:"
value_list=vec!["Male", "Female", "Prefer not to answer"]
.into_iter()
.map(|s| s.to_owned())
.collect()
disabled=elements_disabled
data_member="gender"
data_map=form_data
required=true
/>
</Row>
<Row>
<MultiEntry
label="Testing community involvement:"
data_map=form_data
data_member="community_involvement"
schema=vec![
+4 -3
View File
@@ -5,14 +5,15 @@ mod home_page;
mod loaner_page;
mod provider_portal;
mod scholarship_info;
mod test_page;
mod unauthenticated_page;
mod provider_contact;
mod auth_callback;
mod provider_applicants;
mod admin;
pub(crate) mod student;
pub use self::{
about_page::*, comparison_test::*, home_page::*, loaner_page::*, provider_portal::*,
test_page::*, unauthenticated_page::*, scholarship_info::*, provider_contact::*,
auth_callback::*, provider_applicants::*
unauthenticated_page::*, scholarship_info::*, provider_contact::*,
auth_callback::*, provider_applicants::*, admin::*, student::*,
};
+3 -1
View File
@@ -8,7 +8,9 @@ pub fn ApplicantsPageFallback() -> impl IntoView {
<div class="flex flex-col gap-4 mt-3 items-center justify-center">
<h1 class="text-3xl font-bold">"Applicants Page"</h1>
<p>"This page is under construction! It will show all the students that are eligible for your scholarship(s)."</p>
<p>
"This page is under construction! It will show all the students that are eligible for your scholarship(s)."
</p>
<p>"Come back soon!"</p>
</div>
}
+4 -70
View File
@@ -1,77 +1,11 @@
#[cfg(feature = "ssr")]
use crate::utils::server::{create_dynamo_client, into_attr_map};
use std::collections::HashMap;
use std::collections::HashMap;
use leptos::logging::log;
use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated};
use crate::common::ValueType;
use crate::components::{Banner, Loading, OutlinedTextField, Panel, Row, TextFieldType, Toast, ToastContext, ToastList, ValidatedForm};
use crate::components::{Banner, Loading, OutlinedTextField, Panel, Row, TextFieldType, Toast, ToastContext, ValidatedForm};
use crate::pages::UnauthenticatedPage;
use crate::utils::get_user_claims;
#[cfg(feature = "ssr")]
static PROVIDER_CONTACT_TABLE: &str = "leptos-provider-contacts";
#[server]
async fn get_provider_contact(id: String) -> Result<HashMap<String, ValueType>, ServerFnError> {
use aws_sdk_dynamodb::types::AttributeValue;
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
let client = create_dynamo_client().await;
log!("Getting contact info for provider {}", id);
match client
.get_item()
.table_name(PROVIDER_CONTACT_TABLE)
.key("subject", AttributeValue::S(id))
.send()
.await
{
Ok(output) => {
let Some(item) = output.item else {
return Ok(HashMap::new());
};
let map = item.iter().map(|(key, val)| {
let val_type = ValueType::from(val);
(key.clone(), val_type)
}).collect::<HashMap<String, ValueType>>();
Ok(map)
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred.");
Err(ServerFnError::new(msg))
}
}
}
#[server]
async fn put_provider_contact(id: String, contact_info: HashMap<String, ValueType>) -> Result<(), ServerFnError> {
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
let client = create_dynamo_client().await;
let mut item = into_attr_map(contact_info);
item.insert("subject".to_owned(), aws_sdk_dynamodb::types::AttributeValue::S(id));
match client
.put_item()
.table_name(PROVIDER_CONTACT_TABLE)
.set_item(Some(item))
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred.");
Err(ServerFnError::new(msg))
}
}
}
use super::api::{get_provider_contact, PutProviderContact};
#[component]
pub fn ProviderContactPage() -> impl IntoView {
@@ -186,7 +120,7 @@ pub fn ProviderContactPage() -> impl IntoView {
<Row>
<OutlinedTextField
label="Contact Email:"
placeholder="student@region15.org"
placeholder="example@mymailservice.com"
disabled=elements_disabled
data_member="contact_email"
data_map=contact_info
+9 -9
View File
@@ -1,7 +1,7 @@
use std::collections::HashMap;
use leptos::either::Either;
use leptos::html::Dialog;
use leptos::logging::log;
use leptos::logging::debug_log;
use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated};
use leptos_router::hooks::{use_navigate, use_params};
@@ -111,7 +111,7 @@ fn ScholarshipList(
(refresh_token.get(), provider_id.get())
},
|(_, provider_id)| async move {
log!("Fetching from scholarship API.");
debug_log!("Fetching from scholarship API.");
let Some(provider_id) = provider_id else {
return Ok(Vec::new());
};
@@ -179,7 +179,7 @@ fn ScholarshipList(
.get()
.map(|s| s.subject.clone())
.unwrap_or_default();
log!("Deleting scholarship with subject {}", subject);
debug_log!("Deleting scholarship with subject {}", subject);
delete_action
.dispatch(DeleteProviderScholarship {
scholarship_id: subject,
@@ -385,10 +385,10 @@ fn ScholarshipForm(
if let Some(Ok(scholarship)) = scholarship_info.get() {
// Get the default chips data.
let chips_default = if let Some(ValueType::Map(Some(map))) = scholarship.data.get("requirements") {
log!("Found requirements map: {:?}", map);
debug_log!("Found requirements map: {:?}", map);
map.clone()
} else {
log!("Couldn't find requirements map in scholarship info. Using empty map.");
debug_log!("Couldn't find requirements map in scholarship info. Using empty map.");
HashMap::new()
};
@@ -496,7 +496,7 @@ fn ScholarshipForm(
info.data = form_data.get();
info.data.insert("requirements".to_string(), ValueType::Map(Some(chips_data.get())));
log!("Map values: {:?}", info.data);
debug_log!("Map values: {:?}", info.data);
submit_action.dispatch(CreateScholarshipInfo {
info
});
@@ -505,7 +505,7 @@ fn ScholarshipForm(
Effect::watch(
move || submit_action.value().get(),
move |value, _, _| {
log!("Running submit_action effect");
debug_log!("Running submit_action effect");
let Some(result) = value else {
return;
};
@@ -639,7 +639,7 @@ fn ScholarshipForm(
<Header
title="Eligibility Requirements"
description="Choose any requirements that this scholarship may have. If there are no extra requirements,
leave all options blank, however please note that this will apply all students to your scholarship."
leave all options blank, however please note that this will apply all students to your scholarship."
/>
<Row>
<ChipsList
@@ -653,7 +653,7 @@ fn ScholarshipForm(
</Row>
<Row>
<ChipsList
label="Sports Participation"
label="Sports and Activities"
data_member="sports_participation"
data_map=chips_data
values=sports_ids
+62
View File
@@ -0,0 +1,62 @@
use leptos::prelude::*;
use crate::components::{Loading, OutlinedTextField, TextFieldType, ValidatedForm};
use crate::pages::student::form_setup::use_student_form;
#[component]
pub fn StudentAcademicsPage() -> impl IntoView {
let controller = use_student_form("academics");
view! {
<Show
when=move || controller.data_resource.get().is_some()
fallback=Loading
>
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm title="Academic Information" on_submit=controller.submit_action>
<OutlinedTextField
label="Unweighted GPA:"
data_map=controller.data_map
data_member="unweighted_gpa"
placeholder="4.0"
required=true
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Weighted GPA:"
data_map=controller.data_map
data_member="weighted_gpa"
placeholder="4.5"
required=true
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Highest SAT Score:"
data_map=controller.data_map
data_member="sat_score"
placeholder="1600"
required=false
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Highest ACT Score:"
data_map=controller.data_map
data_member="act_score"
placeholder="36"
required=false
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Academic Honors:"
data_map=controller.data_map
data_member="academic_honors"
placeholder="TBD"
required=false
input_type=TextFieldType::Text
/>
</ValidatedForm>
</div>
<div class="flex flex-1" />
</Show>
}
}
+13
View File
@@ -0,0 +1,13 @@
use leptos::prelude::*;
#[component]
pub fn AdditionalPage() -> impl IntoView {
view! {
<div class="flex flex-1 flex-col gap-4 items-center m-4">
<div class="font-bold text-3xl">"Student Additional Forms"</div>
<div class="text-lg">
"This is just a placeholder for now. We'll show redirect buttons to each form."
</div>
</div>
}
}
+62
View File
@@ -0,0 +1,62 @@
use leptos::prelude::*;
use crate::components::{Loading, MultiEntry, ValidatedForm};
use crate::input;
use crate::pages::student::form_setup::use_student_form;
#[component]
pub fn StudentAthleticsPage() -> impl IntoView {
let controller = use_student_form("athletics");
view! {
<Show
when=move || controller.data_resource.get().is_some()
fallback=Loading
>
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm title="Athletics Information" on_submit=controller.submit_action>
<MultiEntry
label="Athletics Activities:"
description="Leave blank if not applicable."
data_map=controller.data_map
data_member="sports_participation"
schema=vec![
input!(
Select, "sport_name", "Sport Name:", true, [
"Football",
"Soccer",
"Cheerleading",
"Field Hockey",
"Swimming",
"Golf",
"Basketball",
"Track",
"Gymnastics",
"Ice Hockey",
"Ski",
"Wrestling",
"Lacrosse",
"Softball",
"Tennis",
"Baseball"
]
),
input!(
Checkbox, "grades", "Grades Participated:", true, [
"9th",
"10th",
"11th",
"12th"
]
),
input!(
Text, "achievements", "Special Achievements:", false, "Example 1, Example 2, etc..."
),
]
/>
</ValidatedForm>
</div>
<div class="flex flex-1" />
</Show>
}
}
+81
View File
@@ -0,0 +1,81 @@
use leptos::prelude::*;
use crate::components::{Loading, OutlinedTextField, Select, TextFieldType, ValidatedForm};
use crate::pages::student::form_setup::use_student_form;
#[component]
pub fn StudentDemographicsPage() -> impl IntoView {
// This page should show a form that the students need to fill out.
// It's a simple form, really nothing more than a contact form.
// I'm going to test out creating this form without using a panel - we'll just use the page as
// it is with a specially spaced invisible div instead of a shaded container.
let controller = use_student_form("demographics");
view! {
<Show when=move || controller.data_resource.get().is_some() fallback=Loading>
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm title="Student Demographic Form" on_submit=controller.submit_action>
// Inputs here.
<OutlinedTextField
label="First Name:"
data_map=controller.data_map
data_member="first_name"
placeholder="John"
input_type=TextFieldType::Text
required=true
/>
<OutlinedTextField
label="Last Name:"
data_map=controller.data_map
data_member="last_name"
placeholder="Smith"
input_type=TextFieldType::Text
required=true
/>
// There must be a date of birth selection field here.
<OutlinedTextField
label="Student ID Number:"
data_map=controller.data_map
data_member="id_number"
placeholder="Your 7 digit number..."
input_type=TextFieldType::Number
required=true
/>
<OutlinedTextField
label="Preferred Email:"
data_map=controller.data_map
data_member="email"
placeholder="me@example.com"
input_type=TextFieldType::Email(vec!["*".to_string()])
required=true
/>
<OutlinedTextField
label="Preferred Phone Number:"
data_map=controller.data_map
data_member="phone_number"
placeholder="000-000-0000"
input_type=TextFieldType::Text
required=true
/>
<OutlinedTextField
label="Street Address:"
data_map=controller.data_map
data_member="street_address"
placeholder="234 Judd Rd"
input_type=TextFieldType::Text
required=true
/>
<Select
label="Town:"
value_list=vec!["Southbury".to_string(), "Middlebury".to_string()]
data_map=controller.data_map
data_member="town"
required=true
/>
</ValidatedForm>
</div>
<div class="flex flex-1" />
</Show>
}
}
+172
View File
@@ -0,0 +1,172 @@
use leptos::logging::debug_log;
use leptos::prelude::*;
use crate::common::{ComparisonData, ExpandableInfo, ValueType};
use crate::pages::api::{get_all_scholarship_info, get_comparison_info};
use crate::pages::api::students::get_all_student_data;
use crate::utils::get_user_claims;
#[component]
pub fn StudentEligibilityPage() -> impl IntoView {
// This page will show all scholarships that the student is eligible for.
// The scholarships are obviously pulled using a readonly API, and we'll also need to resolve
// all of the relation IDs that are stored inside of it.
// Then, for each scholarship found, we'll check every relation and filter out scholarships that
// do not pass the check. We'll show some sort of simple list to indicate what scholarships passed
// the check, and another to indicate which ones didn't.
// My only reservation with showing the scholarships that aren't valid is that people will want
// to know why. That's hard information to keep track of right now.
let user_claims = get_user_claims();
let user_id = Memo::new(move |_| {
user_claims.get()
.map(|info| info.claims.subject.clone())
});
let refresh_trigger = Trigger::new();
let scholarships_resource = Resource::new(
move || refresh_trigger.track(),
async move |_| {
get_all_scholarship_info().await
}
);
let relations_resource = Resource::new(
move || refresh_trigger.track(),
async move |_| {
get_comparison_info().await
}
);
let student_resource = Resource::new(
move || (user_id.get().unwrap_or_default(), refresh_trigger.track()),
async move |(user_id, _)| {
get_all_student_data(user_id).await
}
);
view! {
<div class="flex-1" />
<div>
<Suspense fallback=move || {
view! { <div>"Checking eligibility..."</div> }
}>
{move || {
let Some(Ok(scholarships_list)) = scholarships_resource.get() else {
return view! {}.into_any();
};
let Some(Ok(relations_list)) = relations_resource.get() else {
return view! {}.into_any();
};
let Some(Ok(student_info)) = student_resource.get() else {
return view! {}.into_any();
};
let valid_list = scholarships_list.iter()
.filter_map(|scholarship| {
let scholarship_name = scholarship.data.get("name")
.unwrap_or(&ValueType::String(None))
.as_string().ok().flatten().unwrap_or_default();
debug_log!("Checking scholarship: {}", scholarship_name);
// Each scholarship will return Some(itself), or None. This is will be automatically
// filtered based on the information. Invalid scholarships are None, along with those
// that fail validation for any reason. Scholarships that are valid or did not choose
// any requirements are Some(scholarship).
let requirements_map = scholarship.data.get("requirements")
.unwrap_or(&ValueType::Map(None))
.as_map().ok().flatten()
.unwrap_or_default()
.values().cloned()
.collect::<Vec<ValueType>>();
let resolved_requirements = requirements_map
.iter()
.map(|list_val| {
// We want to cast to a list, and then map the list to a new
// one that contains the correct ComparisonData.
let list = list_val.as_list().ok().flatten().unwrap_or_default();
list.iter().filter_map(|v| {
let id_string = v.as_string().ok().flatten().unwrap_or_default();
relations_list.iter()
.find(|relation| relation.id == id_string)
})
.cloned()
.collect::<Vec<ComparisonData>>()
})
.collect::<Vec<Vec<ComparisonData>>>();
debug_log!("Resolved requirements: {:?}", resolved_requirements.iter().flatten().map(|rel| rel.display_text.clone()).collect::<Vec<String>>());
let valid = resolved_requirements.iter()
.all(|list| {
list.iter().fold(false, |prev, requirement| {
let result = requirement.compare(&student_info)
.unwrap_or(false);
if prev { prev } else { result }
})
});
if valid {
debug_log!("Scholarship is valid.");
Some(scholarship.clone())
} else {
None
}
})
.collect::<Vec<ExpandableInfo>>();
view! {
<div>{format!("Valid scholarships: {}", valid_list.len())}</div>
<For
each=move || valid_list.clone()
key=|scholarship| {
scholarship
.subject
.clone()
}
children=move |scholarship| {
let scholarship = StoredValue::new(scholarship);
let scholarship_name = Memo::new(move |_| {
scholarship
.read_value()
.data
.get("name")
.unwrap_or(&ValueType::String(None))
.as_string()
.ok()
.flatten()
.unwrap_or_default()
});
let scholarship_essay_prompt = Memo::new(move |_| {
scholarship
.read_value()
.data
.get("essay_prompt")
.unwrap_or(&ValueType::String(None))
.as_string()
.ok()
.flatten()
.unwrap_or_default()
});
let essay_required = Memo::new(move |_| {
!scholarship_essay_prompt.get().is_empty()
});
view! {
<div class="flex flex-1 flex-row gap-2 p-3">
<div>{scholarship_name}</div>
<Show when=move || essay_required.get()>
<div>{scholarship_essay_prompt}</div>
</Show>
</div>
}
}
/>
}.into_any()
}}
</Suspense>
</div>
<div class="flex-1" />
}
}
@@ -0,0 +1,51 @@
use leptos::prelude::*;
use crate::components::{Loading, MultiEntry, OutlinedTextField, TextFieldType, ValidatedForm};
use crate::input;
use crate::pages::student::form_setup::use_student_form;
#[component]
pub fn StudentExtracurricularsPage() -> impl IntoView {
let controller = use_student_form("demographics");
view! {
<Show when=move || controller.data_resource.get().is_some() fallback=Loading>
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm
title="Extracurricular Information"
on_submit=controller.submit_action
>
// Inputs here.
<OutlinedTextField
label="Total number of service hours:"
data_map=controller.data_map
data_member="service_hours"
placeholder="Any number..."
input_type=TextFieldType::Number
required=true
/>
<MultiEntry
label="Extracurricular Activities:"
description="Leave blank if not applicable."
data_map=controller.data_map
data_member="extracurricular"
schema=vec![
input!(
Text, "activity_name", "Activity Name:", true, "Some activity..."
),
input!(Number, "num_hours", "Number of hours completed:", true, "40"),
input!(Number, "num_weeks", "Number of weeks participated:", true, "3"),
input!(
Text, "special_involvement", "Any special involvement:", false, ""
),
input!(
Checkbox, "grades", "Grades Participated:", true, ["9th", "10th", "11th", "12th"]
),
]
/>
</ValidatedForm>
</div>
<div class="flex flex-1" />
</Show>
}
}
+105
View File
@@ -0,0 +1,105 @@
use leptos::prelude::*;
use crate::components::{Loading, OutlinedTextField, TextFieldType, ValidatedForm};
use crate::pages::student::form_setup::use_student_form;
#[component]
pub fn StudentFamilyPage() -> impl IntoView {
let controller = use_student_form("family");
view! {
<Show when=move || controller.data_resource.get().is_some() fallback=Loading>
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm title="Family Information" on_submit=controller.submit_action>
// Inputs here.
<OutlinedTextField
label="Total number of children in family:"
data_map=controller.data_map
data_member="num_children"
placeholder="Any number..."
input_type=TextFieldType::Number
required=true
/>
<OutlinedTextField
label="Total number of children currently attending college:"
data_map=controller.data_map
data_member="num_children_college"
placeholder="Any number..."
input_type=TextFieldType::Number
required=true
/>
<OutlinedTextField
label="Parent/Guardian 1 Name:"
data_map=controller.data_map
data_member="parent_one_name"
placeholder="John Smith"
input_type=TextFieldType::Text
required=true
/>
// TODO Could this be a select dropdown?
<OutlinedTextField
label="Parent/Guardian 1 Relationship:"
data_map=controller.data_map
data_member="parent_one_relationship"
placeholder="Mother/Father"
input_type=TextFieldType::Text
required=true
/>
// TODO Get a better example
<OutlinedTextField
label="Parent/Guardian 1 Occupation:"
data_map=controller.data_map
data_member="parent_one_occupation"
placeholder="Milkman"
input_type=TextFieldType::Text
required=true
/>
<OutlinedTextField
label="Parent/Guardian 1 Employer:"
data_map=controller.data_map
data_member="parent_one_employer"
placeholder="Example Employer"
input_type=TextFieldType::Text
required=true
/>
<OutlinedTextField
label="Parent/Guardian 2 Name:"
data_map=controller.data_map
data_member="parent_two_name"
placeholder="John Smith"
input_type=TextFieldType::Text
required=false
/>
// TODO Could this be a select dropdown?
<OutlinedTextField
label="Parent/Guardian 2 Relationship:"
data_map=controller.data_map
data_member="parent_two_relationship"
placeholder="Mother/Father"
input_type=TextFieldType::Text
required=false
/>
// TODO Get a better example
<OutlinedTextField
label="Parent/Guardian 2 Occupation:"
data_map=controller.data_map
data_member="parent_two_occupation"
placeholder="Milkman"
input_type=TextFieldType::Text
required=false
/>
<OutlinedTextField
label="Parent/Guardian 2 Employer:"
data_map=controller.data_map
data_member="parent_two_employer"
placeholder="Example Employer"
input_type=TextFieldType::Text
required=false
/>
</ValidatedForm>
</div>
<div class="flex flex-1" />
</Show>
}
}
+63
View File
@@ -0,0 +1,63 @@
use std::collections::HashMap;
use leptos::prelude::*;
use crate::common::ValueType;
use crate::pages::api::students::{get_student_data, PutStudentData};
use crate::utils::get_user_claims;
pub struct StudentFormInfo {
pub data_map: RwSignal<HashMap<String, ValueType>>,
pub user_id: Memo<Option<String>>,
pub submit_action: Callback<()>,
pub refresh_trigger: Trigger,
pub data_resource: Resource<Result<HashMap<String, ValueType>, ServerFnError>>
}
pub fn use_student_form(
form_type: impl Into<Signal<String>>
) -> StudentFormInfo {
let form_type = form_type.into();
let user_claims = get_user_claims();
let user_id = Memo::new(move |_| {
user_claims.get()
.map(|info| info.claims.subject.clone())
});
let data_map = RwSignal::new(HashMap::new());
let refresh_trigger = Trigger::new();
let data_resource = Resource::new(
move || (user_id.get().unwrap_or_default(), form_type.get(), refresh_trigger.track()),
async move |(id, form_type, _)| get_student_data(
id,
form_type,
).await
);
Effect::new(move || {
data_resource.with(|map_opt| {
let Some(Ok(map)) = map_opt else {
return;
};
data_map.set(map.to_owned());
});
});
let submit_action = ServerAction::<PutStudentData>::new();
let on_submit = move || {
submit_action.dispatch(PutStudentData {
subject: user_id.get().unwrap_or_default(),
data_type: form_type.get(),
data_map: data_map.get()
});
};
StudentFormInfo {
data_map,
refresh_trigger,
data_resource,
user_id,
submit_action: on_submit.into(),
}
}
+55
View File
@@ -0,0 +1,55 @@
use std::collections::HashMap;
use leptos::logging::log;
use leptos::prelude::*;
use crate::common::ValueType;
use crate::components::ActionButton;
use crate::pages::api::students::{GetStudentData, PutStudentData};
use crate::utils::get_user_claims;
#[component]
pub fn StudentHomePage() -> impl IntoView {
let user_claims = get_user_claims();
let user_id = Memo::new(move |_| {
user_claims.get()
.map(|info| info.claims.subject.clone())
});
let create_student_data = ServerAction::<PutStudentData>::new();
let get_student_data = ServerAction::<GetStudentData>::new();
let on_click_student_test = move |_| {
create_student_data.dispatch(PutStudentData {
subject: user_id.get().unwrap_or_default(),
data_type: "TESTING".into(),
data_map: HashMap::from([
("testing_info".into(), ValueType::String(Some("hello!".to_string())))
]),
});
};
let on_click_get_student = move |_| {
get_student_data.dispatch(GetStudentData {
subject: user_id.get().unwrap_or_default(),
data_type: "TESTING".into()
});
};
Effect::new(move || {
let Some(value) = get_student_data.value().get() else {
return;
};
log!("{:?}", value);
});
view! {
<div class="flex flex-1 flex-col gap-4 items-center m-4">
<div class="font-bold text-3xl">"Student Home Page"</div>
<div class="text-lg">
"This is just a placeholder for now. We'll show some redirect buttons."
</div>
<ActionButton on:click=on_click_student_test>"Test Put Student Function"</ActionButton>
<ActionButton on:click=on_click_get_student>"Test Get Student Function"</ActionButton>
</div>
}
}
+24
View File
@@ -0,0 +1,24 @@
mod shell;
mod home_page;
mod demographics_page;
mod additional_page;
mod athletics_page;
mod academics_page;
mod extracurriculars_page;
mod work_experience_page;
mod university_page;
mod family_info_page;
mod form_setup;
mod eligibility_page;
pub use shell::*;
pub use home_page::*;
pub use demographics_page::*;
pub use additional_page::*;
pub use athletics_page::*;
pub use academics_page::*;
pub use extracurriculars_page::*;
pub use work_experience_page::*;
pub use university_page::*;
pub use family_info_page::*;
pub use eligibility_page::*;
+50
View File
@@ -0,0 +1,50 @@
use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated};
use crate::common::{SubTabInfo, TabInfo};
use crate::components::{Banner, Loading, TabSidebarList};
use crate::components::login::StudentLoginContext;
use crate::pages::UnauthenticatedPage;
#[component]
pub fn StudentShell() -> impl IntoView {
// Create login context
view! {
<StudentLoginContext>
<div class="h-screen flex flex-col overflow-hidden">
<Banner
title="R15 Student Scholarship Application"
logo="/PHS_Stacked_Acronym.png"
/>
<div class="flex flex-1 min-h-0">
<AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage>
<TabSidebarList
base_path="students"
tabs=vec![
TabInfo::new("Home", "home", None),
TabInfo::new("Demographics", "demographics", None),
TabInfo::new(
"Additional Info",
"additional",
Some(
vec![
SubTabInfo::new("Academics", "academics"),
SubTabInfo::new("Athletics", "athletics"),
SubTabInfo::new("Work Experience", "work-experience"),
SubTabInfo::new("Extracurriculars", "extracurriculars"),
SubTabInfo::new("University Info", "university"),
SubTabInfo::new("Family Info", "family-info"),
],
),
),
TabInfo::new("Eligible Scholarships", "eligibility", None)
]
/>
</Authenticated>
</AuthLoaded>
</div>
</div>
</StudentLoginContext>
}
}
+72
View File
@@ -0,0 +1,72 @@
use leptos::prelude::*;
use crate::components::{Loading, OutlinedTextField, Select, TextFieldType, ValidatedForm};
use crate::pages::student::form_setup::use_student_form;
#[component]
pub fn StudentUniversityPage() -> impl IntoView {
let controller = use_student_form("university");
// college name, state, city, zip
// major, study field, intended career, college_acceptance
view! {
<Show when=move || controller.data_resource.get().is_some() fallback=Loading>
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm title="University Information" on_submit=controller.submit_action>
// Inputs here.
<OutlinedTextField
label="University Name:"
data_map=controller.data_map
data_member="college_name"
placeholder="University of Example"
input_type=TextFieldType::Text
required=true
/>
<OutlinedTextField
label="University Street Address:"
data_map=controller.data_map
data_member="college_city"
placeholder="123 Example Rd"
input_type=TextFieldType::Text
required=true
/>
// TODO Make this a select dropdown.
<OutlinedTextField
label="University State:"
data_map=controller.data_map
data_member="college_state"
placeholder="The State of Panic"
input_type=TextFieldType::Text
required=true
/>
<OutlinedTextField
label="University ZIP:"
data_map=controller.data_map
data_member="college_zip"
placeholder="12345"
input_type=TextFieldType::Text
required=true
/>
<Select
label="Have you been sent an acceptance to the university?"
data_map=controller.data_map
data_member="college_acceptance"
required=true
value_list=vec!["Yes".to_string(), "No".to_string()]
/>
// TODO Make this a select dropdown.
<OutlinedTextField
label="Chosen Major:"
data_map=controller.data_map
data_member="major"
placeholder="Computer Science"
input_type=TextFieldType::Text
required=true
/>
</ValidatedForm>
</div>
<div class="flex flex-1" />
</Show>
}
}
+35
View File
@@ -0,0 +1,35 @@
use leptos::prelude::*;
use crate::components::{Loading, MultiEntry, ValidatedForm};
use crate::input;
use crate::pages::student::form_setup::use_student_form;
#[component]
pub fn StudentWorkExperiencePage() -> impl IntoView {
let controller = use_student_form("workexp");
view! {
<Show when=move || controller.data_resource.get().is_some() fallback=Loading>
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm title="Student Work Experience" on_submit=controller.submit_action>
<MultiEntry
label="Work Experience:"
description="Leave blank if not applicable."
data_map=controller.data_map
data_member="extracurricular"
schema=vec![
input!(
Text, "job_title", "Job Title:", true, "Waiter, Cashier, etc..."
),
input!(Text, "employer", "Employer:", true, "Your employer's name..."),
input!(Number, "start_date", "Start date:", true, "40"),
input!(Number, "end_date", "End date:", true, "3"),
input!(Number, "num_hours", "Number of hours completed:", true, "120"),
]
/>
</ValidatedForm>
</div>
<div class="flex flex-1" />
</Show>
}
}
-76
View File
@@ -1,76 +0,0 @@
use chrono::DateTime;
use crate::components::ActionButton;
use leptos::prelude::*;
use crate::common::{DateInfo, DateRange};
use crate::pages::api::{CreateDates, CreateTestComparisons};
fn get_important_dates() -> Vec<DateInfo> {
vec! [
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Provider Forms Open".to_string(),
date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-01-20T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-03-03T23:59:00-05:00").unwrap()
),
description: "".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Student Forms Open".to_string(),
date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-03-03T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-03-27T14:05:00-05:00").unwrap()
),
description: "".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Scholarship Decision Window".to_string(),
date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-04-10T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-05-01T23:59:00-05:00").unwrap()
),
description: "Start date may change if available sooner.".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "PHS Scholarship Committee".to_string(),
date: DateRange::Single(
DateTime::parse_from_rfc3339("2026-05-18T00:00:00-05:00").unwrap(),
),
description: "Time TBD".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Scholarship and Awards Night".to_string(),
date: DateRange::Single(
DateTime::parse_from_rfc3339("2026-06-09T00:00:00-05:00").unwrap(),
),
description: "Time TBD".to_string(),
},
]
}
#[component]
pub fn TestPage() -> impl IntoView {
let create_comparisons = ServerAction::<CreateTestComparisons>::new();
let create_dates = ServerAction::<CreateDates>::new();
let on_click_comparisons = move |_| {
create_comparisons.dispatch(CreateTestComparisons{});
};
let on_click_dates = move |_| {
create_dates.dispatch(CreateDates{dates: get_important_dates()});
};
view! {
<div class="mx-auto px-6">
<div class="flex flex-col gap-4">
<ActionButton on:click=on_click_comparisons>"Create Comparisons"</ActionButton>
<ActionButton on:click=on_click_dates>"Create Dates"</ActionButton>
</div>
</div>
}.into_any()
}
+2 -2
View File
@@ -32,9 +32,9 @@ pub fn UnauthenticatedPage() -> impl IntoView {
};
view! {
<div class="flex h-screen justify-center items-center pb-50">
<div class="flex mx-auto h-screen justify-center items-center pb-50">
<div class="bg-white p-8 border rounded-lg shadow-lg">
<h3>"You're not signed in. Please sign in with your Region 15 email."</h3>
<h3>"You're not signed in. Please sign in first."</h3>
<div class="flex pt-4 justify-center items-center">
<ActionButton on:click=on_click>"Sign in"</ActionButton>
</div>
+1 -1
View File
@@ -22,7 +22,7 @@ pub fn use_origin() -> String {
/// Gets the current user claims. This function should only be used in an area that has
/// access to an AuthSignal, or it will result in a total failure.
pub fn get_user_claims() -> Signal<Option<TokenData<UserClaims>>> {
let auth = use_context::<AuthSignal>().expect("Couldn't find AuthSignal.");
let auth = expect_context::<AuthSignal>();
Signal::derive(move || {
auth.with(|auth| {
auth.authenticated().and_then(|data| {