Merge pull request #62 from R15-Software-Dev/feat/value-display

Implement template student portal
This commit was merged in pull request #62.
This commit is contained in:
Lucas Vas authored and GitHub committed 2026-03-02 16:38:30 -05:00
commit 53bdc96d01
24 files changed
+2725 -621

No files matched your search

Generated
+1272 -347
View File
File diff suppressed because it is too large. Load diff
+19 -10
View File
@@ -7,28 +7,34 @@ edition = "2024"
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
leptos = { version = "0.8" } leptos = { version = "0.8", features = ["multipart"] }
leptos_router = { version = "0.8" } leptos_router = { version = "0.8" }
axum = { version = "0.8", optional = true } axum = { version = "0.8", optional = true }
console_error_panic_hook = { version = "0.1", optional = true} console_error_panic_hook = { version = "0.1", optional = true}
leptos_axum = { version = "0.8", optional = true } leptos_axum = { version = "0.8", optional = true }
leptos_meta = { version = "0.8" } leptos_meta = { version = "0.8" }
tokio = { version = "1", features = ["rt-multi-thread"], optional = true } tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
wasm-bindgen = { version = "=0.2.106", optional = true } wasm-bindgen = { version = "0.2", optional = true }
aws-sdk-dynamodb = { version = "1", optional = true } aws-sdk-dynamodb = { version = "^1", optional = true }
aws-config = { version = "1.6.2", optional = true } aws-sdk-s3 = { version = "1.124.0", optional = true }
serde = "1.0.219" aws-config = { version = "^1.8", optional = true }
serde_dynamo = { version = "4.2.14", features = ["aws-sdk-dynamodb+1"], optional = true } serde = "^1.0"
uuid = { version = "1.17.0", features = ["serde", "v4", "js"] } serde_dynamo = { version = "^4", features = ["aws-sdk-dynamodb+1"], optional = true }
uuid = { version = "^1", features = ["serde", "v4", "js"] }
leptos_oidc = "0.9.0" leptos_oidc = "0.9.0"
base64 = "0.22.1" base64 = "0.22"
chrono = "0.4.42" chrono = "0.4"
leptos_animate = "0.1.9" leptos_animate = "0.1"
macros = { path = "./macros" } macros = { path = "./macros" }
traits = { path = "./traits" } traits = { path = "./traits" }
icondata = { version = "0.7.0", features = ["font-awesome"] } icondata = { version = "0.7.0", features = ["font-awesome"] }
leptos_icons = "0.7.1" leptos_icons = "0.7.1"
indexmap = { version = "2.13.0", features = ["serde"] }
csv = "1.4.0"
leptos-use = { version = "0.18.3", features = ["use_drop_zone"] }
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"], optional = true }
reqwest = { version = "0.13.2", features = ["json"] }
[features] [features]
default = ["ssr"] default = ["ssr"]
@@ -42,11 +48,14 @@ ssr = [
"dep:tokio", "dep:tokio",
"dep:leptos_axum", "dep:leptos_axum",
"dep:aws-sdk-dynamodb", "dep:aws-sdk-dynamodb",
"dep:aws-sdk-s3",
"dep:aws-config", "dep:aws-config",
"dep:serde_dynamo", "dep:serde_dynamo",
"dep:jsonwebtoken",
"leptos/ssr", "leptos/ssr",
"leptos_meta/ssr", "leptos_meta/ssr",
"leptos_router/ssr", "leptos_router/ssr",
"leptos-use/ssr"
] ]
# Defines a size-optimized profile for the WASM bundle in release mode # Defines a size-optimized profile for the WASM bundle in release mode
+1 -1
View File
@@ -1,4 +1,4 @@
FROM rust:1.90 AS builder FROM rust:1.93 AS builder
# Install node for tailwind capability # Install node for tailwind capability
RUN apt-get update -y && apt-get install -y --no-install-recommends clang libc6 mold RUN apt-get update -y && apt-get install -y --no-install-recommends clang libc6 mold
+24 -12
View File
@@ -1,12 +1,20 @@
use crate::pages::{ProviderPortal, ScholarshipInfoPage, ProviderContactPage, LoanerShell, LoanerFallback, LoanerBorrowForm, LoanerReturnForm, AuthCallbackPage, ApplicantsPageFallback, AdminShell, AdminHomePage, AdminProviderPage, AdminScholarshipPage, AdminUtilsPage}; use crate::components::ToastList;
use crate::components::login::ProviderLoginContext;
use crate::pages::student; use crate::pages::student;
use crate::pages::{
AdminHomePage, AdminProviderPage, AdminScholarshipPage, AdminShell, AdminUtilsPage,
ApplicantsPageFallback, AuthCallbackPage, LoanerBorrowForm, LoanerFallback, LoanerReturnForm,
LoanerShell, ProviderContactPage, ProviderPortal, ScholarshipInfoPage,
};
use leptos::prelude::*; use leptos::prelude::*;
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
use leptos_oidc::AuthSignal; use leptos_oidc::AuthSignal;
use leptos_router::{components::{Route, Router, Routes, ParentRoute}, path, MatchNestedRoutes};
use leptos_router::any_nested_route::IntoAnyNestedRoute; use leptos_router::any_nested_route::IntoAnyNestedRoute;
use crate::components::login::ProviderLoginContext; use leptos_router::{
use crate::components::ToastList; MatchNestedRoutes,
components::{ParentRoute, Route, Router, Routes},
path,
};
pub fn shell(options: LeptosOptions) -> impl IntoView { pub fn shell(options: LeptosOptions) -> impl IntoView {
view! { view! {
@@ -66,6 +74,7 @@ pub fn AppWithRoutes() -> impl IntoView {
<ProviderRoutes /> <ProviderRoutes />
<LoanerRoutes /> <LoanerRoutes />
<StudentRoutes /> <StudentRoutes />
<AdminRoutes />
</Routes> </Routes>
</ToastList> </ToastList>
</main> </main>
@@ -86,8 +95,8 @@ fn ProviderRoutes() -> impl MatchNestedRoutes + Clone {
</ParentRoute> </ParentRoute>
</ParentRoute> </ParentRoute>
} }
.into_inner() .into_inner()
.into_any_nested_route() .into_any_nested_route()
} }
#[component(transparent)] #[component(transparent)]
@@ -99,22 +108,23 @@ fn LoanerRoutes() -> impl MatchNestedRoutes + Clone {
<Route path=path!("returning") view=LoanerReturnForm /> <Route path=path!("returning") view=LoanerReturnForm />
</ParentRoute> </ParentRoute>
} }
.into_inner() .into_inner()
.into_any_nested_route() .into_any_nested_route()
} }
#[component(transparent)] #[component(transparent)]
fn AdminRoutes() -> impl MatchNestedRoutes + Clone { fn AdminRoutes() -> impl MatchNestedRoutes + Clone {
view! { view! {
<ParentRoute path=path!("admin") view=AdminShell> <ParentRoute path=path!("admin") view=AdminShell>
<Route path=path!("callback") view=AuthCallbackPage />
<Route path=path!("home") view=AdminHomePage /> <Route path=path!("home") view=AdminHomePage />
<Route path=path!("providers") view=AdminProviderPage /> <Route path=path!("providers") view=AdminProviderPage />
<Route path=path!("scholarships") view=AdminScholarshipPage /> <Route path=path!("scholarships") view=AdminScholarshipPage />
<Route path=path!("utilities") view=AdminUtilsPage /> <Route path=path!("utilities") view=AdminUtilsPage />
</ParentRoute> </ParentRoute>
} }
.into_inner() .into_inner()
.into_any_nested_route() .into_any_nested_route()
} }
#[component(transparent)] #[component(transparent)]
@@ -126,6 +136,7 @@ fn StudentRoutes() -> impl MatchNestedRoutes + Clone {
<Route path=path!("demographics") view=student::StudentDemographicsPage /> <Route path=path!("demographics") view=student::StudentDemographicsPage />
<Route path=path!("eligibility") view=student::StudentEligibilityPage /> <Route path=path!("eligibility") view=student::StudentEligibilityPage />
<Route path=path!("additional") view=student::AdditionalPage /> <Route path=path!("additional") view=student::AdditionalPage />
<Route path=path!("testing") view=student::StudentTestDisplay />
<Route path=path!("additional/academics") view=student::StudentAcademicsPage /> <Route path=path!("additional/academics") view=student::StudentAcademicsPage />
<Route path=path!("additional/athletics") view=student::StudentAthleticsPage /> <Route path=path!("additional/athletics") view=student::StudentAthleticsPage />
<Route <Route
@@ -138,10 +149,11 @@ fn StudentRoutes() -> impl MatchNestedRoutes + Clone {
/> />
<Route path=path!("additional/university") view=student::StudentUniversityPage /> <Route path=path!("additional/university") view=student::StudentUniversityPage />
<Route path=path!("additional/family-info") view=student::StudentFamilyPage /> <Route path=path!("additional/family-info") view=student::StudentFamilyPage />
<Route path=path!("additional/financials") view=student::StudentFinancialPage />
</ParentRoute> </ParentRoute>
} }
.into_inner() .into_inner()
.into_any_nested_route() .into_any_nested_route()
} }
#[component] #[component]
+2 -1
View File
@@ -8,8 +8,9 @@ mod user;
mod value_type; mod value_type;
pub mod animations; pub mod animations;
mod tab_info; mod tab_info;
mod schema_node;
pub use self::{ pub use self::{
comparison::*, date_info::*, expandable_info::*, form_params::*, form_schema::*, comparison::*, date_info::*, expandable_info::*, form_params::*, form_schema::*,
submit_status::*, user::*, value_type::*, tab_info::*, submit_status::*, user::*, value_type::*, tab_info::*, schema_node::*,
}; };
+98
View File
@@ -0,0 +1,98 @@
use std::collections::HashMap;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum SchemaType {
String,
Number,
List,
Map
}
/// # Schema Node
///
/// This is used to create a schema that is used in the [`ValueDisplay`] component. It stores all
/// required information for the display, specifically where to find the data and how to display it.
/// It covers all `ValueType` variants, however for data storage and DX it uses a `SchemaType`
/// instead.
///
/// Some examples are as follows, using the fluent builder notation:
/// - Displaying a single `ValueType::String` from the data map at the key `example_one`:
/// ```
/// SchemaNode::new(SchemaType::String).header("The example string")
/// ```
/// - Displaying a `ValueType::List` which contains only `ValueType::String` entries:
/// ```
/// SchemaNode::new(SchemaType::List)
/// .header("The example list")
/// .item_template(SchemaNode::new(SchemaType::String).header(""))
/// ```
/// - Displaying a `ValueType::Map` which contains a name and age:
/// ```
/// SchemaType::new(SchemaType::Map)
/// .header("The example map")
/// .child("first_name", SchemaNode::new(SchemaType::String).header("First Name:"))
/// .child("age", SchemaNode::new(SchemaType::Number).header("Age:"))
/// ```
/// - Put it all together: a `ValueType::List` which contains `ValueType::Map` entries, each of
/// which also contains the name and age from the previous example:
/// ```
/// SchemaNode::new(SchemaType::List)
/// .header("The example map list")
/// .item_template(
/// SchemaNode::new(SchemaType::Map)
/// .header("The example map from earlier")
/// .child("first_name", SchemaNode::new(SchemaType::String).header("First Name:"))
/// .child("age", SchemaNode::new(SchemaType::Number).header("Age:"))
/// )
/// ```
///
/// This schema allows for definitive typing and styling for every level of the data display.
///
/// ## Important Notes
///
/// This schema does not currently support display of a single nested member, like a single map
/// within a list. This means that you will have to display any listed or mapped information as a
/// list. Consider this when designing the data.
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct SchemaNode {
/// The datatype of the data found using the `data_member` key.
pub data_type: SchemaType,
/// The header of this display.
pub header: String,
/// When used with a SchemaType::List, this determines the datatype and children for every entry
/// in the list. Some examples include a list of maps, where we use the `item_template` to define
/// the SchemaType::Map, and all its children. This is then mapped across all entries in the list,
/// where any entries that do not match the template are skipped.
pub item_template: Option<Box<SchemaNode>>,
/// When used with a SchemaType::Map, this determines what children from the map are displayed
/// and how. It is ignored with all other SchemaType variants.
pub children: IndexMap<String, SchemaNode>,
}
impl SchemaNode {
pub fn new(data_type: SchemaType) -> Self {
SchemaNode {
data_type,
header: "".to_string(),
item_template: None,
children: IndexMap::new(),
}
}
pub fn header(mut self, header: impl Into<String>) -> Self {
self.header = header.into();
self
}
pub fn child(mut self, data_member: impl Into<String>, node: SchemaNode) -> Self {
self.children.insert(data_member.into(), node);
self
}
pub fn item_template(mut self, node: SchemaNode) -> Self {
self.item_template = Some(Box::new(node));
self
}
}
+2
View File
@@ -22,4 +22,6 @@ pub struct UserClaims {
/// The username of the user. This should be their email address. /// The username of the user. This should be their email address.
#[serde(rename = "username")] #[serde(rename = "username")]
pub username: String, pub username: String,
/// The intended use for the token.
pub token_use: String,
} }
+20 -8
View File
@@ -105,8 +105,8 @@ impl Default for ValueType {
impl Display for ValueType { impl Display for ValueType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = match self { let str = match self {
ValueType::String(s) => s.clone().unwrap_or("".to_owned()), ValueType::String(s) => s.clone().unwrap_or_default(),
ValueType::Number(n) => n.clone().unwrap_or("".to_owned()), ValueType::Number(n) => n.clone().unwrap_or_default(),
ValueType::List(l) => format!("{l:?}"), ValueType::List(l) => format!("{l:?}"),
ValueType::Map(m) => format!("{m:?}"), ValueType::Map(m) => format!("{m:?}"),
}; };
@@ -124,20 +124,32 @@ impl From<&AttributeValue> for ValueType {
AttributeValue::S(s) => ValueType::String(Some(s.clone())), AttributeValue::S(s) => ValueType::String(Some(s.clone())),
AttributeValue::N(n) => ValueType::Number(Some(n.clone())), AttributeValue::N(n) => ValueType::Number(Some(n.clone())),
AttributeValue::L(l) => ValueType::List(Some(l.iter().map(|v| v.into()).collect())), AttributeValue::L(l) => ValueType::List(Some(l.iter().map(|v| v.into()).collect())),
AttributeValue::M(m) => ValueType::Map(Some(m.iter().map(|(k, v)| (k.clone(), v.into())).collect())), AttributeValue::M(m) => {
ValueType::Map(Some(m.iter().map(|(k, v)| (k.clone(), v.into())).collect()))
}
_ => ValueType::default(), _ => ValueType::default(),
} }
} }
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
impl Into<AttributeValue> for ValueType { impl From<ValueType> for AttributeValue {
fn into(self) -> AttributeValue { fn from(value: ValueType) -> Self {
match self { match value {
ValueType::String(s) => AttributeValue::S(s.unwrap_or_default()), ValueType::String(s) => AttributeValue::S(s.unwrap_or_default()),
ValueType::Number(n) => AttributeValue::N(n.unwrap_or_default()), ValueType::Number(n) => AttributeValue::N(n.unwrap_or_default()),
ValueType::List(l) => AttributeValue::L(l.unwrap_or_default().into_iter().map(|v| v.into()).collect()), ValueType::List(l) => AttributeValue::L(
ValueType::Map(m) => AttributeValue::M(m.unwrap_or_default().into_iter().map(|(k, v)| (k, v.into())).collect()), l.unwrap_or_default()
.into_iter()
.map(|v| v.into())
.collect(),
),
ValueType::Map(m) => AttributeValue::M(
m.unwrap_or_default()
.into_iter()
.map(|(k, v)| (k, v.into()))
.collect(),
),
} }
} }
} }
+263
View File
@@ -0,0 +1,263 @@
use crate::common::{SchemaNode, SchemaType, ValueType};
use indexmap::IndexMap;
use leptos::logging::debug_log;
use leptos::prelude::*;
use std::collections::HashMap;
/// # Value Display Component
/// This component displays any data from a given `HashMap<String, ValueType>`. It requires a schema
/// to tell it how to display the information, which is covered in the [`SchemaNode`] documentation.
#[component]
pub fn ValueDisplay(
#[prop(into)] schema: Signal<SchemaNode>,
#[prop(into, optional)] data_member: Signal<String>,
#[prop(into)] data_map: Signal<HashMap<String, ValueType>>,
) -> impl IntoView {
let data_value = Memo::new(move |_| {
if data_member.get().is_empty() {
ValueType::Map(Some(data_map.get()))
} else {
data_map
.get()
.get(&data_member.get())
.map(|v| v.clone())
.unwrap_or_default()
}
});
view! {
{move || {
match schema.get().data_type {
SchemaType::String | SchemaType::Number => {
view! {
<PrimitiveDisplay
header=move || schema.get().header
value=data_value
nested=true
/>
}
.into_any()
}
SchemaType::List => {
view! {
<ListDisplay
header=move || schema.get().header
value=data_value
item_template=move || {
schema
.get()
.item_template
.unwrap_or(Box::from(SchemaNode::new(SchemaType::List)))
}
nested=true
/>
}
.into_any()
}
SchemaType::Map => {
view! {
<MapDisplay
value=data_value
header=move || schema.get().header
schema=move || schema.get().children
nested=true
/>
}
.into_any()
}
}
}}
}
}
/// # Display Wrapper Component
/// This component is designed to wrap information for the [`ValueDisplay`] component. It allows
/// for simple uniform styling of the header and capsule, along with the ability for users to
/// collapse the information.
#[component]
fn DisplayWrapper(
#[prop(into, optional)] header: Signal<String>,
#[prop(into, optional)] nested: Signal<bool>,
children: Children,
) -> impl IntoView {
let expanded = RwSignal::new(true);
view! {
<div class="m-3 rounded-lg shadow-lg flex flex-col">
<div
class="p-2 px-3 font-bold rounded-t-lg cursor-pointer"
class=(["bg-gray-300"], move || !nested.get())
class=(["text-white", "bg-red-900"], move || nested.get())
class=(["rounded-lg"], move || !expanded.get())
on:click=move |_| expanded.update(|b| *b = !*b)
>
{header}
</div>
<div
class="grid transition-all ease-in-out"
style:grid-template-rows=move || if expanded.get() { "1fr" } else { "0fr" }
>
<div class="min-h-0 overflow-hidden">
<div class="p-2">{children()}</div>
</div>
</div>
</div>
}
}
/// # Primitive Value Display Component
/// This component allows for the display of a `ValueType::String` or `ValueType::Number`.
#[component]
fn PrimitiveDisplay(
#[prop(into)] header: Signal<String>,
#[prop(into)] value: Signal<ValueType>,
#[prop(into, optional)] nested: Signal<bool>,
) -> impl IntoView {
let display_string = Memo::new(move |_| {
let value = value.get();
match value {
ValueType::String(_) | ValueType::Number(_) => value.to_string(),
_ => "Value is not a string.".to_string(),
}
});
view! {
<DisplayWrapper header=header nested=nested>
<div>{display_string}</div>
</DisplayWrapper>
}
}
/// # List Value Display Component
/// This component allows for the display of a `ValueType::List`. It will show an error if passed
/// anything other than a list. It requires an item template that tells the list how to display
/// each value inside of it, most specifically the type of data inside of it.
#[component]
fn ListDisplay(
#[prop(into)] item_template: Signal<Box<SchemaNode>>,
#[prop(into)] value: Signal<ValueType>,
#[prop(into)] header: Signal<String>,
#[prop(into, optional)] nested: Signal<bool>,
) -> impl IntoView {
// Unwrap the value and check if it's the correct type. To be displayed with this component, it
// MUST be a ValueType::List.
let display_list = Memo::new(move |_| {
let ValueType::List(Some(list)) = value.get() else {
return Vec::new();
};
list.into_iter()
.enumerate()
.collect::<Vec<(usize, ValueType)>>()
});
let is_valid = Memo::new(move |_| matches!(value.get(), ValueType::List(_)));
let item_header = Memo::new(move |_| item_template.get().header);
view! {
<DisplayWrapper header=header nested=nested>
<For
each=move || display_list.get()
key=|(idx, _)| idx.clone()
children=move |(idx, entry)| {
match item_template.get().data_type {
SchemaType::String | SchemaType::Number => {
view! { <PrimitiveDisplay value=entry header=item_header /> }.into_any()
}
SchemaType::List => {
view! {
<div>"Nested lists are not currently supported for display."</div>
}
.into_any()
}
SchemaType::Map => {
let map_header = format!("{} {}", item_template.get().header, idx + 1);
view! {
<MapDisplay
value=entry
header=map_header
schema=move || item_template.get().children
/>
}
.into_any()
}
}
}
/>
</DisplayWrapper>
}
}
/// # Map Data Display Component
/// This component allows for the display of a `ValueType::Map`. The nested information must be defined
/// by a [`SchemaNode`], which tells the display what keys in the value map to search for the
/// display information.
#[component]
fn MapDisplay(
#[prop(into)] value: Signal<ValueType>,
#[prop(into)] header: Signal<String>,
#[prop(into)] schema: Signal<IndexMap<String, SchemaNode>>,
#[prop(into, optional)] nested: Signal<bool>,
) -> impl IntoView {
// Unwrap the value and check if it is the correct type. It must be a ValueType::Map.
let display_map = Memo::new(move |_| {
let ValueType::Map(Some(map)) = value.get() else {
return HashMap::new();
};
map
});
Effect::new(move || {
debug_log!("Current map values: {:?}", display_map.get());
});
let is_valid = Memo::new(move |_| matches!(value.get(), ValueType::Map(_)));
view! {
<DisplayWrapper header=header nested=nested>
<For
each=move || schema.get()
key=|(k, _)| k.clone()
children=move |(member, child_schema)| {
let display_value = Memo::new(move |_| {
display_map.get().get(&member).map(|v| v.clone()).unwrap_or_default()
});
match child_schema.data_type {
SchemaType::String | SchemaType::Number => {
view! {
<PrimitiveDisplay value=display_value header=child_schema.header />
}
.into_any()
}
SchemaType::List => {
let template = child_schema
.item_template
.unwrap_or(Box::from(SchemaNode::new(SchemaType::List)));
view! {
<ListDisplay
value=display_value
item_template=template
header=child_schema.header
/>
}
.into_any()
}
SchemaType::Map => {
view! {
<MapDisplay
value=display_value
schema=child_schema.children
header=child_schema.header
/>
}
.into_any()
}
}
}
/>
</DisplayWrapper>
}
}
+174
View File
@@ -0,0 +1,174 @@
use crate::pages::api::files::{DeleteFile, upload_file};
use leptos::html::{Input, Label};
use leptos::logging::debug_log;
use leptos::prelude::*;
use leptos::web_sys::{File, FormData};
use leptos_icons::Icon;
use leptos_oidc::AuthSignal;
use leptos_use::{UseDropZoneOptions, UseDropZoneReturn, use_drop_zone_with_options};
#[component]
pub fn FileDrop(
#[prop(into)] name: String,
#[prop(into)] form_id: String,
#[prop()] existing_files: Resource<Result<Vec<String>, ServerFnError>>,
) -> impl IntoView {
let zone_ref = NodeRef::<Label>::new();
let input_ref = NodeRef::<Input>::new();
let file_name_list = RwSignal::new(Vec::new());
let hovering = RwSignal::new(false);
let name = StoredValue::new(name);
let form_id = StoredValue::new(form_id);
let auth = expect_context::<AuthSignal>();
let upload_action = Action::new(|form_data: &FormData| {
let form_data = form_data.clone();
async move { upload_file(form_data.into()).await }
});
let delete_action = ServerAction::<DeleteFile>::new();
let uploading = upload_action.pending();
let deleting = delete_action.pending();
let upload_files = Callback::new(move |files: Vec<File>| {
let form_data = FormData::new().unwrap();
files.iter().for_each(|file| {
form_data
.append_with_blob_and_filename(&name.get_value(), file, &file.name())
.unwrap();
});
form_data
.append_with_str("form_id", form_id.get_value().as_str())
.unwrap();
let token = auth
.try_with(|a| {
a.authenticated()
.map(|authenticated| authenticated.access_token())
})
.flatten();
if let Some(access_token) = token {
form_data
.append_with_str("access_token", access_token.as_str())
.unwrap()
}
// Upload file to server.
upload_action.dispatch(form_data);
});
let UseDropZoneReturn {
is_over_drop_zone, ..
} = use_drop_zone_with_options(
zone_ref,
UseDropZoneOptions::default()
.on_over(move |_| hovering.set(true))
.on_drop(move |ev| {
let file = ev.files.first().unwrap();
upload_files.run(vec![file.to_owned()]);
}),
);
let on_input_change = move |_| {
debug_log!("Running on:change");
// This runs when users select a file by clicking on the element. We aren't going
// to link the file list with this input except to update it with new information.
let files = input_ref
.get()
.and_then(|input| input.files())
.map(|list| {
(0..list.length())
.filter_map(|i| list.item(i))
.collect::<Vec<_>>()
})
.unwrap_or_default();
upload_files.run(files);
};
let on_click_file_delete = move |file_name: String| {
let access_token = auth
.try_with(|a| a.authenticated().map(|a| a.access_token()))
.flatten();
delete_action.dispatch(DeleteFile {
form_id: form_id.get_value(),
input_name: name.get_value(),
access_token: access_token.unwrap_or_default(),
file_name,
})
};
// Add file names to the list when upload_action is successful.
Effect::new(move || {
if let Some(Ok(uploaded_file_name)) = upload_action.value().get() {
file_name_list.update(|list| {
list.push(uploaded_file_name);
});
}
});
// Remove file name from the list when delete_action is successful.
Effect::new(move || {
if let Some(Ok(deleted_file_name)) = delete_action.value().get() {
file_name_list.update(|list| {
list.retain(|f| *f != deleted_file_name);
});
}
});
view! {
{move || {
existing_files
.get()
.map(|result| {
if let Ok(files) = result {
file_name_list.set(files);
}
})
}}
<label
node_ref=zone_ref
class="m-1.5 p-2.5 flex flex-col transition-color duration-200 rounded-lg border-2 items-center cursor-pointer"
class=(["border-gray-400"], move || !is_over_drop_zone.get())
class=(["border-blue-500"], move || is_over_drop_zone.get())
>
<div
class="m-2 mb-4 transition-color duration-200 size-10"
class=("text-gray-400", move || !is_over_drop_zone.get())
class=("text-blue-500", move || is_over_drop_zone.get())
>
<Icon icon=icondata::FaFileCirclePlusSolid width="2.5rem" height="2.5rem" />
</div>
<div>"Drop a file here to upload"</div>
<div class="text-sm text-gray-400">"Or click to select a file"</div>
<input node_ref=input_ref type="file" class="hidden" on:change=on_input_change />
</label>
<For
each=move || file_name_list.get()
key=|file| file.clone()
children=move |file| {
let file = StoredValue::new(file);
let on_click = move |_| {
on_click_file_delete(file.get_value());
};
view! {
<div class="m-1.5 flex flex-row relative items-center">
<div class="flex-1">{file.get_value()}</div>
<div class="text-gray-400 hover:text-red-700 right-0" on:click=on_click>
<Icon icon=icondata::FaTrashCanRegular />
</div>
</div>
}
}
/>
}
}
+6 -8
View File
@@ -1,15 +1,13 @@
use leptos::prelude::*; use crate::components::login::setup::provide_auth_context;
use crate::components::login::setup::provide_auth_context; use leptos::prelude::*;
#[component] #[component]
pub fn AdminLoginContext( pub fn AdminLoginContext(children: Children) -> impl IntoView {
children: Children
) -> impl IntoView {
provide_auth_context( provide_auth_context(
crate::utils::use_origin(), crate::utils::use_origin(),
"https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Lfjuy5zaM", "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_rvCU4Xy4j",
"56c2bqvl021rv8d5mq36blt7jv", "1vh1q994rid6cgi6tjf58r9jp4",
"/providers/callback" "/admin/callback",
); );
view! { {children()} } view! { {children()} }
+10 -8
View File
@@ -1,22 +1,24 @@
mod action_button; mod action_button;
mod banner; mod banner;
mod dashboard_button; mod dashboard_button;
mod data_display;
mod date; mod date;
mod file_drop;
mod header;
mod lists;
mod loading; mod loading;
pub mod login;
mod multi_entry; mod multi_entry;
mod panel; mod panel;
mod row; mod row;
mod tabs;
mod text_field; mod text_field;
mod toasts;
mod utils; mod utils;
mod validated_form; mod validated_form;
mod lists;
mod toasts;
mod header;
pub mod login;
mod tabs;
pub use self::{ pub use self::{
action_button::*, banner::*, dashboard_button::*, lists::*, action_button::*, banner::*, dashboard_button::*, data_display::*, date::*, file_drop::*,
loading::*, multi_entry::*, panel::*, row::*, text_field::*, header::*, lists::*, loading::*, multi_entry::*, panel::*, row::*, tabs::*, text_field::*,
validated_form::*, toasts::*, header::*, date::*, tabs::* toasts::*, validated_form::*,
}; };
+19 -13
View File
@@ -1,7 +1,9 @@
use leptos::prelude::*; use crate::common::TabInfo;
use crate::common::TabInfo;
use crate::components::{Banner, TabSidebarList};
use crate::components::login::AdminLoginContext; use crate::components::login::AdminLoginContext;
use crate::components::{Banner, Loading, TabSidebarList};
use crate::pages::UnauthenticatedPage;
use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated};
/// # Admin Shell Component /// # Admin Shell Component
/// ///
@@ -13,16 +15,20 @@ pub fn AdminShell() -> impl IntoView {
view! { view! {
<AdminLoginContext> <AdminLoginContext>
<Banner title="R15 Scholarships Admin" logo="/PHS_Stacked_Acronym.png" /> <Banner title="R15 Scholarships Admin" logo="/PHS_Stacked_Acronym.png" />
// We'll put the tabs here. <AuthLoaded fallback=Loading>
<TabSidebarList <Authenticated unauthenticated=UnauthenticatedPage>
base_path="admin" // We'll put the tabs here.
tabs=vec![ <TabSidebarList
TabInfo::new("Home", "home", None), base_path="admin"
TabInfo::new("Providers", "providers", None), tabs=vec![
TabInfo::new("Scholarships", "scholarships", None), TabInfo::new("Home", "home", None),
TabInfo::new("Utilities", "utilities", None), TabInfo::new("Providers", "providers", None),
] TabInfo::new("Scholarships", "scholarships", None),
/> TabInfo::new("Utilities", "utilities", None),
]
/>
</Authenticated>
</AuthLoaded>
</AdminLoginContext> </AdminLoginContext>
} }
} }
+41 -116
View File
@@ -1,17 +1,21 @@
use chrono::DateTime; use crate::common::{DateInfo, DateRange};
use crate::components::ActionButton; use crate::components::ActionButton;
use crate::pages::api::exports::get_scholarship_csv;
use crate::pages::api::{CreateDates, CreateTestComparisons, exports::GetScholarshipCsv};
use chrono::DateTime;
use leptos::prelude::*; use leptos::prelude::*;
use crate::common::{DateInfo, DateRange}; use leptos::task::spawn_local;
use crate::pages::api::{CreateDates, CreateTestComparisons}; use leptos::wasm_bindgen::JsCast;
use leptos::web_sys::{Blob, HtmlAnchorElement, Url, js_sys};
fn get_important_dates() -> Vec<DateInfo> { fn get_important_dates() -> Vec<DateInfo> {
vec! [ vec![
DateInfo { DateInfo {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
title: "Provider Forms Open".to_string(), title: "Provider Forms Open".to_string(),
date: DateRange::Range( date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-01-20T07:15:00-05:00").unwrap(), DateTime::parse_from_rfc3339("2026-01-20T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-03-03T23:59:00-05:00").unwrap() DateTime::parse_from_rfc3339("2026-03-03T23:59:00-05:00").unwrap(),
), ),
description: "".to_string(), description: "".to_string(),
}, },
@@ -20,7 +24,7 @@ fn get_important_dates() -> Vec<DateInfo> {
title: "Student Forms Open".to_string(), title: "Student Forms Open".to_string(),
date: DateRange::Range( date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-03-03T07:15:00-05:00").unwrap(), DateTime::parse_from_rfc3339("2026-03-03T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-03-27T14:05:00-05:00").unwrap() DateTime::parse_from_rfc3339("2026-03-27T14:05:00-05:00").unwrap(),
), ),
description: "".to_string(), description: "".to_string(),
}, },
@@ -29,7 +33,7 @@ fn get_important_dates() -> Vec<DateInfo> {
title: "Scholarship Decision Window".to_string(), title: "Scholarship Decision Window".to_string(),
date: DateRange::Range( date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-04-10T07:15:00-05:00").unwrap(), DateTime::parse_from_rfc3339("2026-04-10T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-05-01T23:59: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(), description: "Start date may change if available sooner.".to_string(),
}, },
@@ -52,127 +56,48 @@ fn get_important_dates() -> Vec<DateInfo> {
] ]
} }
#[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] #[component]
pub fn AdminUtilsPage() -> impl IntoView { pub fn AdminUtilsPage() -> impl IntoView {
let create_comparisons = ServerAction::<CreateTestComparisons>::new(); let create_comparisons = ServerAction::<CreateTestComparisons>::new();
let create_dates = ServerAction::<CreateDates>::new(); let create_dates = ServerAction::<CreateDates>::new();
let create_export = ServerAction::<CreateScholarshipExport>::new(); let create_export = ServerAction::<GetScholarshipCsv>::new();
let on_click_comparisons = move |_| { let on_click_comparisons = move |_| {
create_comparisons.dispatch(CreateTestComparisons{}); create_comparisons.dispatch(CreateTestComparisons {});
}; };
let on_click_dates = move |_| { let on_click_dates = move |_| {
create_dates.dispatch(CreateDates{dates: get_important_dates()}); create_dates.dispatch(CreateDates {
dates: get_important_dates(),
});
}; };
let on_click_export = move |_| { let on_click_export = move |_| {
create_export.dispatch(CreateScholarshipExport{}); spawn_local(async move {
let file_bytes = get_scholarship_csv().await.unwrap_or_default();
let uint_arr = js_sys::Uint8Array::new_with_length(file_bytes.len() as u32);
uint_arr.copy_from(&file_bytes);
let blob_parts = js_sys::Array::new();
blob_parts.push(&uint_arr);
let blob = Blob::new_with_u8_array_sequence(&blob_parts).unwrap();
let blob_url = Url::create_object_url_with_blob(&blob).unwrap();
let window = window();
let document = window.document().unwrap();
let a: HtmlAnchorElement = document.create_element("a").unwrap().dyn_into().unwrap();
a.set_href(&blob_url);
a.set_download("scholarships.csv");
document.body().unwrap().append_child(&a).unwrap();
a.click();
document.body().unwrap().remove_child(&a).unwrap();
Url::revoke_object_url(&blob_url).unwrap();
});
}; };
view! { view! {
+237
View File
@@ -0,0 +1,237 @@
#[cfg(feature = "ssr")]
mod imports {
pub use super::super::{COMPARISONS_TABLE, PROVIDER_CONTACT_TABLE, SCHOLARSHIPS_TABLE};
pub use crate::common::ValueType;
pub use crate::utils::server::create_dynamo_client;
pub use aws_sdk_dynamodb::Client;
pub use aws_sdk_dynamodb::error::ProvideErrorMetadata;
pub use indexmap::IndexMap;
pub use std::collections::HashMap;
}
use crate::common::ExpandableInfo;
use leptos::prelude::*;
#[server]
pub async fn get_scholarship_csv() -> Result<Vec<u8>, ServerFnError> {
use imports::*;
use leptos::logging::debug_log;
#[derive(serde::Serialize)]
struct ResolvedScholarshipInfo {
contact_email: String,
first_name: String,
last_name: String,
scholarship_name: String,
sports_requirements: String,
gpa_requirements: String,
major_requirements: String,
additional_requirements: String,
community_requirements: String,
residency_requirements: String,
}
// Get the scholarships and comparisons
let client = create_dynamo_client().await;
let scan_table = async |client: &Client, table_name| {
client
.scan()
.table_name(table_name)
.send()
.await
.map(|output| output.items)
.ok()
.flatten()
.unwrap_or_default()
.into_iter()
.map(|item| {
serde_dynamo::from_item(item).unwrap_or(ExpandableInfo::new("".to_string()))
// item.iter()
// .map(|(k, v)| (k.to_owned(), ValueType::from(v)))
// .collect::<HashMap<String, ValueType>>()
})
.collect::<Vec<ExpandableInfo>>()
};
let scan_table_hash_map = async |client: &Client, table_name| {
client
.scan()
.table_name(table_name)
.send()
.await
.map(|output| output.items)
.ok()
.flatten()
.unwrap_or_default()
.into_iter()
.map(|item| {
// serde_dynamo::from_item(item).unwrap_or(ExpandableInfo::new("".to_string()))
item.iter()
.map(|(k, v)| (k.to_owned(), ValueType::from(v)))
.collect::<HashMap<String, ValueType>>()
})
.collect::<Vec<HashMap<String, ValueType>>>()
};
let master_scholarships = scan_table(&client, SCHOLARSHIPS_TABLE).await;
let master_contacts = scan_table_hash_map(&client, PROVIDER_CONTACT_TABLE).await;
let master_relations = scan_table_hash_map(&client, COMPARISONS_TABLE).await;
// We now want to map the relations to the scholarships, as well as the contacts.
// This should take the information from the current scholarship, then relate the IDs of the
// requirements in the scholarship to the IDs of the relations in the master list.
let resolved_scholarships = master_scholarships
.into_iter()
.map(|scholarship| {
// debug_log!("Scholarship: {:?}", scholarship);
// We want the requirements IDs and the provider ID.
let requirements_map = scholarship
.data
.get("requirements")
.unwrap_or(&ValueType::Map(None))
.as_map()
.ok()
.flatten()
.unwrap_or_default();
let provider_id = scholarship
.data
.get("provider_id")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
// Search for the provider ID in the master list.
let contact_info = master_contacts
.iter()
.find(|contact| {
let subject = contact
.get("subject")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
debug_log!("Comparing {:?} and {:?}", subject, provider_id);
subject == provider_id
})
.cloned()
.unwrap_or_default();
// Resolve the requirements - this should resolve as another category-separated map,
// but with requirement display text instead of IDs.
let resolved_requirements = requirements_map
.iter()
.map(|(category, selected_val)| {
let selected_list = selected_val.as_list().ok().flatten().unwrap_or_default();
let resolved_names = selected_list
.into_iter()
.map(|id_val| {
let id = id_val.as_string().ok().flatten().unwrap_or_default();
let resolved_relation = master_relations
.iter()
.find(|item| {
let comp_id = item
.get("id")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
comp_id == id
})
.cloned()
.map(|v| {
v.get("display_text").map(|text_opt| {
text_opt.as_string().ok().flatten().unwrap_or_default()
})
})
.flatten()
.unwrap_or_default();
resolved_relation
})
.collect::<Vec<String>>();
(category.clone(), resolved_names)
})
.collect::<IndexMap<String, Vec<String>>>();
debug_log!("Resolved requirements: {:?}", resolved_requirements);
let contact_email = contact_info
.get("contact_email")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
let first_name = contact_info
.get("first_name")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
let last_name = contact_info
.get("last_name")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
let scholarship_name = scholarship
.data
.get("name")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
debug_log!(
"{:?}, {:?}, {:?}, {:?}",
scholarship_name,
contact_email,
first_name,
last_name
);
// We want to create some structure that's got the correct information. Specifically,
// we only need the provider contact info, the scholarship's name, and the requirements
// that they chose in plain text.
ResolvedScholarshipInfo {
first_name,
last_name,
contact_email,
scholarship_name,
sports_requirements: resolved_requirements
.get("sports_participation")
.map(|list| list.join("; "))
.unwrap_or_default(),
gpa_requirements: resolved_requirements
.get("gpa")
.map(|list| list.join("; "))
.unwrap_or_default(),
major_requirements: resolved_requirements
.get("major")
.map(|list| list.join("; "))
.unwrap_or_default(),
additional_requirements: resolved_requirements
.get("misc")
.map(|list| list.join("; "))
.unwrap_or_default(),
community_requirements: resolved_requirements
.get("community_service")
.map(|list| list.join("; "))
.unwrap_or_default(),
residency_requirements: resolved_requirements
.get("residency")
.map(|list| list.join("; "))
.unwrap_or_default(),
}
})
.collect::<Vec<ResolvedScholarshipInfo>>();
// Now that we have a series of scholarships, we need to serialize them into CSV and send the
// file to the client.
let mut writer = csv::Writer::from_writer(Vec::new());
resolved_scholarships.iter().for_each(|scholarship| {
_ = writer.serialize(scholarship);
});
writer.flush()?;
let result = writer.into_inner()?;
debug_log!("{:?}", String::from_utf8(result.clone()));
Ok(result)
}
+234
View File
@@ -0,0 +1,234 @@
use leptos::logging::debug_log;
use leptos::prelude::*;
use server_fn::codec::{MultipartData, MultipartFormData};
#[cfg(feature = "ssr")]
mod imports {
pub use super::super::{MAIN_TABLE_NAME, S3_BUCKET_NAME};
pub use crate::pages::api::tokens::validate_and_get_token_info;
pub use crate::utils::server::create_aws_config;
pub use aws_sdk_dynamodb::types::{AttributeValue, ReturnValue, Select};
}
#[server(input = MultipartFormData)]
pub async fn upload_file(data: MultipartData) -> Result<String, ServerFnError> {
use imports::*;
let mut multipart = data.into_inner().unwrap();
let mut file_name = String::new();
let mut file_bytes = Vec::<u8>::new();
let mut form_id = String::new();
let mut access_token = String::new();
let mut input_name = String::new();
while let Ok(Some(field)) = multipart.next_field().await {
let field_name = field.name().unwrap_or_default();
match field_name {
"access_token" => access_token = field.text().await?,
"form_id" => form_id = field.text().await?,
_ => {
// NOTE: If there are multiple files put here the function will only upload the last
// one in the data.
// We should likely use chunk streaming in the future, for safety.
input_name = field
.name()
.ok_or(ServerFnError::new("Couldn't find input name"))?
.to_owned();
file_name = field
.file_name()
.ok_or(ServerFnError::new("Couldn't find file name"))?
.to_owned();
file_bytes = field.bytes().await?.to_vec();
}
}
}
if access_token.is_empty() {
return Err(ServerFnError::new("Missing access token"));
}
if form_id.is_empty() {
return Err(ServerFnError::new("Missing form ID"));
}
if file_name.is_empty() {
return Err(ServerFnError::new("Missing file"));
}
if input_name.is_empty() {
return Err(ServerFnError::new("Missing input file"));
}
let user_claims = validate_and_get_token_info(access_token).await?;
let subject = user_claims.subject;
let key = format!("{form_id}/{subject}/{input_name}/{file_name}");
debug_log!("Adding file to S3: {:?}", key);
let s3_client = aws_sdk_s3::Client::new(&create_aws_config().await);
s3_client
.put_object()
.bucket(S3_BUCKET_NAME)
.key(&key)
.body(file_bytes.into())
.send()
.await
.map(|_| ())
.map_err(|e| ServerFnError::new(format!("Couldn't put file to S3: {}", e.to_string())))?;
// Besides uploading the file to S3, we also want to store a FILE entry in Dynamo.
// This will allow the server to quickly return a list of all files that a specific user has
// uploaded, or a list of all files submitted for a specific question with their keys for
// additional access.
let dynamo_client = aws_sdk_dynamodb::Client::new(&create_aws_config().await);
let dynamo_result = dynamo_client
.put_item()
.table_name(MAIN_TABLE_NAME)
.item(
"HK".to_string(),
AttributeValue::S(format!("STUDENT#{subject}")),
)
.item(
"SK".to_string(),
AttributeValue::S(format!("FILE#{form_id}#{input_name}#{file_name}")),
)
.item(
"file_name".to_string(),
AttributeValue::S(file_name.clone()),
)
.item("file_key".to_string(), AttributeValue::S(key.clone()))
.send()
.await
.map_err(|e| {
ServerFnError::new(format!(
"Couldn't put file to Dynamo, file rolled back: {}",
e.to_string()
))
});
// If Dynamo fails, we want to handle the error by rolling back the file. Then, we return a failure.
if let Err(err) = dynamo_result {
s3_client
.delete_object()
.bucket("leptos-scholarships")
.key(&key)
.send()
.await
.map_err(|e| {
ServerFnError::new(format!(
"Couldn't roll back S3 operation: {}",
e.to_string()
))
})?;
return Err(err);
}
Ok(file_name)
}
#[server]
pub async fn delete_file(
access_token: String,
form_id: String,
input_name: String,
file_name: String,
) -> Result<String, ServerFnError> {
use imports::*;
let user_claims = validate_and_get_token_info(access_token).await?;
let subject = user_claims.subject;
let entry_hk = format!("STUDENT#{subject}");
let entry_sk = format!("FILE#{form_id}#{input_name}#{file_name}");
let key = format!("{form_id}/{subject}/{input_name}/{file_name}");
let config = create_aws_config().await;
let s3_client = aws_sdk_s3::Client::new(&config);
let dynamo_client = aws_sdk_dynamodb::Client::new(&config);
let previous_values = dynamo_client
.delete_item()
.table_name(MAIN_TABLE_NAME)
.key("HK".to_string(), AttributeValue::S(entry_hk))
.key("SK".to_string(), AttributeValue::S(entry_sk))
.return_values(ReturnValue::AllOld)
.send()
.await?
.attributes
.unwrap_or_default();
let s3_result = s3_client
.delete_object()
.bucket(S3_BUCKET_NAME)
.key(key)
.send()
.await
.map_err(ServerFnError::from);
if let Err(err) = s3_result {
// Add the key back to Dynamo and return an error.
dynamo_client
.put_item()
.table_name(MAIN_TABLE_NAME)
.set_item(Some(previous_values))
.send()
.await
.map_err(|err| {
ServerFnError::new(format!(
"Couldn't roll back Dynamo operation: {}",
err.to_string()
))
})?;
return Err(err);
}
Ok(file_name)
}
#[server]
pub async fn list_files(
access_token: String,
form_id: String,
input_name: String,
) -> Result<Vec<String>, ServerFnError> {
use imports::*;
let user_claims = validate_and_get_token_info(access_token).await?;
let subject = user_claims.subject;
let entry_hk = format!("STUDENT#{subject}");
let entry_sk = format!("FILE#{form_id}#{input_name}");
debug_log!("Getting file list: HK = {}, SK = {}", entry_hk, entry_sk);
let dynamo_client = aws_sdk_dynamodb::Client::new(&create_aws_config().await);
dynamo_client
.query()
.table_name(MAIN_TABLE_NAME)
.key_condition_expression("HK = :hk AND begins_with(SK, :sk)")
.expression_attribute_values(":hk".to_string(), AttributeValue::S(entry_hk))
.expression_attribute_values(":sk".to_string(), AttributeValue::S(entry_sk))
.send()
.await
.map(|output| {
let Some(items) = output.items else {
return vec![];
};
items
.into_iter()
.filter_map(|item| {
item.get("file_name")
.map(|v| v.as_s().ok().cloned())
.flatten()
})
.collect::<Vec<String>>()
})
.map_err(|e| ServerFnError::new(format!("Couldn't list files: {}", e.to_string())))
}
+12 -6
View File
@@ -1,15 +1,18 @@
mod comparisons;
mod scholarships;
mod dates;
mod admin; mod admin;
mod comparisons;
mod dates;
pub mod exports;
mod providers; mod providers;
mod scholarships;
pub mod students; pub mod students;
pub mod files;
mod tokens;
pub use comparisons::*;
pub use scholarships::*;
pub use dates::*;
pub use admin::*; pub use admin::*;
pub use comparisons::*;
pub use dates::*;
pub use providers::*; pub use providers::*;
pub use scholarships::*;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub static COMPARISONS_TABLE: &str = "leptos-comparisons"; pub static COMPARISONS_TABLE: &str = "leptos-comparisons";
@@ -25,3 +28,6 @@ pub static PROVIDER_CONTACT_TABLE: &str = "leptos-provider-contacts";
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub static MAIN_TABLE_NAME: &str = "scholarships-main"; pub static MAIN_TABLE_NAME: &str = "scholarships-main";
#[cfg(feature = "ssr")]
pub static S3_BUCKET_NAME: &str = "leptos-scholarships";
+50
View File
@@ -0,0 +1,50 @@
#[cfg(feature = "ssr")]
use jsonwebtoken::{jwk::JwkSet, DecodingKey};
use leptos::prelude::*;
use crate::common::UserClaims;
#[cfg(feature = "ssr")]
pub async fn fetch_jwks(user_pool_id: String, region: String) -> Result<JwkSet, ServerFnError> {
let request_url = format!(
"https://cognito-idp.{}.amazonaws.com/{}/.well-known/jwks.json",
region,
user_pool_id
);
let jwks = reqwest::get(&request_url).await?
.json::<JwkSet>()
.await?;
Ok(jwks)
}
/// Validates a JWT, and, if successful, decodes it into a series of user claims.
#[cfg(feature = "ssr")]
pub async fn validate_and_get_token_info(token: String) -> Result<UserClaims, ServerFnError> {
use jsonwebtoken::{decode, Algorithm, Validation};
let jwks = fetch_jwks("us-east-1_Lfjuy5zaM".into(), "us-east-1".into()).await?;
let validation = Validation::new(Algorithm::RS256);
// validation.set_audience(&["scholarships-rs"]);
let header = jsonwebtoken::decode_header(&token)
.map_err(|e| ServerFnError::new(format!("Invalid token header: {e}")))?;
let kid = header.kid.ok_or_else(|| ServerFnError::new("Missing kid header"))?;
let jwk = jwks.find(&kid)
.ok_or_else(|| ServerFnError::new("Couldn't find matching key in JWKS"))?;
let token_data = decode::<UserClaims>(
&token,
&DecodingKey::from_jwk(&jwk)?,
&validation
).map_err(|_| ServerFnError::new("Invalid token found".to_string()))?;
if token_data.claims.token_use != "access" {
return Err(ServerFnError::new("Token type mismatch"));
}
Ok(token_data.claims)
}
+1 -1
View File
@@ -1,5 +1,5 @@
mod about_page; mod about_page;
mod api; pub mod api;
mod comparison_test; mod comparison_test;
mod home_page; mod home_page;
mod loaner_page; mod loaner_page;
+93 -69
View File
@@ -1,9 +1,12 @@
use crate::common::{ComparisonData, ExpandableInfo, ValueType};
use crate::components::{FileDrop, Header};
use crate::pages::api::files::list_files;
use crate::pages::api::students::get_all_student_data;
use crate::pages::api::{get_all_scholarship_info, get_comparison_info};
use crate::utils::get_user_claims;
use leptos::logging::debug_log; use leptos::logging::debug_log;
use leptos::prelude::*; use leptos::prelude::*;
use crate::common::{ComparisonData, ExpandableInfo, ValueType}; use leptos_oidc::AuthSignal;
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] #[component]
pub fn StudentEligibilityPage() -> impl IntoView { pub fn StudentEligibilityPage() -> impl IntoView {
@@ -17,36 +20,30 @@ pub fn StudentEligibilityPage() -> impl IntoView {
// My only reservation with showing the scholarships that aren't valid is that people will want // 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. // to know why. That's hard information to keep track of right now.
let auth = expect_context::<AuthSignal>();
let user_claims = get_user_claims(); let user_claims = get_user_claims();
let user_id = Memo::new(move |_| { let user_id = Memo::new(move |_| user_claims.get().map(|info| info.claims.subject.clone()));
user_claims.get()
.map(|info| info.claims.subject.clone()) let form_id = StoredValue::new("scholarship_essays".to_string());
});
let refresh_trigger = Trigger::new(); let refresh_trigger = Trigger::new();
let scholarships_resource = Resource::new( let scholarships_resource = Resource::new(
move || refresh_trigger.track(), move || refresh_trigger.track(),
async move |_| { async move |_| get_all_scholarship_info().await,
get_all_scholarship_info().await
}
); );
let relations_resource = Resource::new( let relations_resource = Resource::new(
move || refresh_trigger.track(), move || refresh_trigger.track(),
async move |_| { async move |_| get_comparison_info().await,
get_comparison_info().await
}
); );
let student_resource = Resource::new( let student_resource = Resource::new(
move || (user_id.get().unwrap_or_default(), refresh_trigger.track()), move || (user_id.get().unwrap_or_default(), refresh_trigger.track()),
async move |(user_id, _)| { async move |(user_id, _)| get_all_student_data(user_id).await,
get_all_student_data(user_id).await
}
); );
view! { view! {
<div class="flex-1" /> <div class="flex-1" />
<div> <div class="flex-2 mb-4 flex flex-col">
<Suspense fallback=move || { <Suspense fallback=move || {
view! { <div>"Checking eligibility..."</div> } view! { <div>"Checking eligibility..."</div> }
}> }>
@@ -125,64 +122,91 @@ pub fn StudentEligibilityPage() -> impl IntoView {
}); });
if valid { if valid {
debug_log!("Scholarship is valid."); debug_log!("Scholarship is valid.");
Some(scholarship.clone()) let scholarship_id = StoredValue::new(scholarship.subject.clone());
let resource = Resource::new(
move || (
auth
.try_with(|a| a.authenticated().map(|a| a.access_token()))
.flatten(),
form_id.get_value(),
scholarship_id.get_value(),
),
async move |(access_token, form_id, scholarship_id)| {
list_files(
access_token.unwrap_or_default(),
form_id,
scholarship_id,
)
.await
},
);
Some((scholarship.clone(), resource))
} else { } else {
None None
} }
}) })
.collect::<Vec<ExpandableInfo>>(); .collect::<
Vec<(ExpandableInfo, Resource<Result<Vec<String>, ServerFnError>>)>,
// 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).
// We want to cast to a list, and then map the list to a new
// one that contains the correct ComparisonData.
view! { view! {
<div>{format!("Valid scholarships: {}", valid_list.len())}</div> <Header
<For title="Eligible Scholarships"
each=move || valid_list.clone() description="This is the list of scholarships that you are eligible for, as well as those that require essays.
key=|scholarship| { scholarship.subject.clone() } Once you have fulfilled the essay requirement, you will be automatically applied for that scholarship.
children=move |scholarship| { If there is no essay requirement, you have already been applied."
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>
}
}
/> />
<div>
<For
each=move || valid_list.clone()
key=|(scholarship, _)| { scholarship.subject.clone() }
children=move |(scholarship, resource)| {
let scholarship_id = StoredValue::new(
scholarship.subject.clone(),
);
let scholarship_name = StoredValue::new(
scholarship
.data
.get("name")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default(),
);
let scholarship_essay = StoredValue::new(
scholarship
.data
.get("essay_prompt")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default(),
);
view! {
<div class="m-1.5 flex flex-col gap-2 shadow-lg rounded-lg">
<div class="rounded-t-lg bg-red-900 p-2 text-white font-bold">
{scholarship_name.get_value()}
</div>
<div class="p-2 flex flex-col gap-2">
<Show
when=move || !scholarship_essay.get_value().is_empty()
fallback=move || {
view! {
<div>"This scholarship does not require an essay."</div>
}
}
>
<div>{scholarship_essay.get_value()}</div>
<FileDrop
form_id=form_id.get_value()
name=scholarship_id.get_value()
existing_files=resource
/>
</Show>
</div>
</div>
}
}
/>
</div>
} }
.into_any() .into_any()
}} }}
+43
View File
@@ -0,0 +1,43 @@
use crate::components::{FileDrop, Header, Loading};
use crate::pages::api::files::list_files;
use leptos::prelude::*;
use leptos_oidc::AuthSignal;
#[component]
pub fn StudentFinancialPage() -> impl IntoView {
// This is where we'll get information about the files that have been uploaded.
// For now, we'll leave it blank.
let auth = expect_context::<AuthSignal>();
let files_resource = Resource::new(
move || {
auth.try_with(|a| a.authenticated().map(|a| a.access_token()))
.flatten()
},
async move |access_token| {
list_files(
access_token.unwrap_or_default(),
"financial_info".to_string(),
"fafsa".to_string(),
)
.await
},
);
view! {
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
// <ValidatedForm title="Student Demographic Form" on_submit=controller.submit_action>
<Header
title="Financial Information"
description="Please upload your FAFSA SAR (Student Aid Report) here."
/>
<Suspense fallback=Loading>
<FileDrop name="fafsa" form_id="financial_info" existing_files=files_resource />
</Suspense>
// </ValidatedForm>
</div>
<div class="flex flex-1" />
}
}
+21 -17
View File
@@ -1,24 +1,28 @@
mod shell; mod academics_page;
mod home_page;
mod demographics_page;
mod additional_page; mod additional_page;
mod athletics_page; mod athletics_page;
mod academics_page; mod demographics_page;
mod extracurriculars_page;
mod work_experience_page;
mod university_page;
mod family_info_page;
mod form_setup;
mod eligibility_page; mod eligibility_page;
mod extracurriculars_page;
mod family_info_page;
mod financial_page;
mod form_setup;
mod home_page;
mod shell;
mod test_display;
mod university_page;
mod work_experience_page;
pub use shell::*; pub use academics_page::*;
pub use home_page::*;
pub use demographics_page::*;
pub use additional_page::*; pub use additional_page::*;
pub use athletics_page::*; pub use athletics_page::*;
pub use academics_page::*; pub use demographics_page::*;
pub use extracurriculars_page::*;
pub use work_experience_page::*;
pub use university_page::*;
pub use family_info_page::*;
pub use eligibility_page::*; pub use eligibility_page::*;
pub use extracurriculars_page::*;
pub use family_info_page::*;
pub use financial_page::*;
pub use home_page::*;
pub use shell::*;
pub use test_display::*;
pub use university_page::*;
pub use work_experience_page::*;
+6 -4
View File
@@ -1,9 +1,9 @@
use crate::common::{SubTabInfo, TabInfo};
use crate::components::login::StudentLoginContext;
use crate::components::{Banner, Loading, TabSidebarList};
use crate::pages::UnauthenticatedPage;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated}; 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] #[component]
pub fn StudentShell() -> impl IntoView { pub fn StudentShell() -> impl IntoView {
@@ -35,10 +35,12 @@ pub fn StudentShell() -> impl IntoView {
SubTabInfo::new("Extracurriculars", "extracurriculars"), SubTabInfo::new("Extracurriculars", "extracurriculars"),
SubTabInfo::new("University Info", "university"), SubTabInfo::new("University Info", "university"),
SubTabInfo::new("Family Info", "family-info"), SubTabInfo::new("Family Info", "family-info"),
SubTabInfo::new("Financials", "financials"),
], ],
), ),
), ),
TabInfo::new("Eligible Scholarships", "eligibility", None), TabInfo::new("Eligible Scholarships", "eligibility", None),
TabInfo::new("Testing Display", "testing", None)
] ]
/> />
</Authenticated> </Authenticated>
+77
View File
@@ -0,0 +1,77 @@
use crate::common::{SchemaNode, SchemaType};
use crate::components::{Loading, ValueDisplay};
use crate::pages::api::students::get_all_student_data;
use crate::utils::get_user_claims;
use leptos::prelude::*;
#[component]
pub fn StudentTestDisplay() -> impl IntoView {
let create_student_display_schema = move || {
SchemaNode::new(SchemaType::Map)
.header("Student Information")
.child(
"first_name",
SchemaNode::new(SchemaType::String).header("First Name"),
)
.child(
"last_name",
SchemaNode::new(SchemaType::String).header("Last Name"),
)
.child(
"sports_participation",
SchemaNode::new(SchemaType::List)
.header("Sports Activities")
.item_template(
SchemaNode::new(SchemaType::Map)
.header("Sport")
.child(
"sport_name",
SchemaNode::new(SchemaType::String).header("Sport Name"),
)
.child(
"achievements",
SchemaNode::new(SchemaType::String).header("Special Achievements"),
),
),
)
};
let user_claims = get_user_claims();
let user_id = Memo::new(move |_| user_claims.get().map(|info| info.claims.subject.clone()));
let resource = Resource::new(
move || user_id.get().unwrap_or_default(),
async move |id| get_all_student_data(id).await,
);
let schema = StoredValue::new(create_student_display_schema());
view! {
<div class="flex flex-1" />
<Suspense fallback=Loading>
<div class="flex flex-2 flex-col">
{move || {
resource
.get()
.map(|data_map| {
let Ok(map) = data_map else {
return view! {}.into_any();
};
let data_map = StoredValue::new(map);
view! {
<div>
<ValueDisplay
schema=schema.get_value()
data_map=data_map.get_value()
/>
</div>
}
.into_any()
})
}}
</div>
</Suspense>
<div class="flex flex-1" />
}
}