Compare commits

..
73 Commits
Author SHA1 Message Date
Lucas Vas 44450f407c Merge pull request #75 from R15-Software-Dev/chore/close-providers
Close provider forms
2026-03-25 13:31:14 -04:00
Lucas Vas 70a86f4545 Merge pull request #74 from R15-Software-Dev/feat/file-input-errors
Create error message display for file inputs
2026-03-25 13:28:23 -04:00
alphafetus e6409cc423 chore: close provider forms 2026-03-25 13:20:58 -04:00
alphafetus 4e3161a05f feat: create error message display for file inputs 2026-03-25 13:17:43 -04:00
Lucas Vas 51dfa013df Merge pull request #73 from R15-Software-Dev/fix/blank-id-requests
Fix for blank IDs in submissions
2026-03-25 12:58:55 -04:00
alphafetus 1d41b6022e fix: hopeful fix for blank IDs in submissions 2026-03-25 12:39:50 -04:00
Lucas Vas f9d5b25436 Merge pull request #72 from R15-Software-Dev/fix/filtering-bugs
Check requirement return value on Ok variant
2026-03-23 12:05:22 -04:00
alphafetus 891e324186 fix: check requirement return value on Ok variant 2026-03-23 11:47:06 -04:00
Lucas Vas b502c2f96d Merge pull request #71 from R15-Software-Dev/fix/comparison-filtering
Fix comparison logic for filtering
2026-03-19 13:49:06 -04:00
alphafetus 681da8c326 fix: use correct comparison logic
Was originally short-circuiting on empty lists of requirements.
2026-03-19 13:37:11 -04:00
Lucas Vas 628b607074 Merge pull request #70 from R15-Software-Dev/fix/api-errors
Significantly improve logging for api requests
2026-03-18 12:30:19 -04:00
alphafetus 97be249826 fix: import ProvideErrorMetadata correctly 2026-03-18 12:16:44 -04:00
alphafetus e1bd472a36 feat: significantly improve logging for api requests 2026-03-18 12:08:41 -04:00
Lucas Vas dd19631e33 Merge pull request #69 from R15-Software-Dev/fix/comparison-changes
Small comparison changes
2026-03-18 10:38:26 -04:00
alphafetus a0b206a554 fix: do not display scholarships that are not completed 2026-03-12 10:41:32 -04:00
alphafetus e9b0452d38 feat: improve error messages for client side comparison check 2026-03-11 14:24:25 -04:00
Lucas Vas 448e8bbb38 Merge pull request #68 from R15-Software-Dev/fix/comparison-changes
Comparison and sports changes
2026-03-11 12:49:53 -04:00
alphafetus ae7a17ad92 chore: add volleyball option 2026-03-11 11:54:34 -04:00
alphafetus 750059e9ad fix: attended_bas checks correct data member 2026-03-11 11:53:50 -04:00
alphafetus 5a371ae574 chore: temporarily reopen provider forms (for real this time) 2026-03-11 11:53:05 -04:00
alphafetus d7f44463c6 chore: temporarily reopen provider forms 2026-03-11 11:33:30 -04:00
alphafetus 5872002538 feat: improve error messages in files api 2026-03-11 11:28:52 -04:00
alphafetus 957fc0c5eb chore: change title of form 2026-03-09 12:16:41 -04:00
Lucas Vas 8a2d5b0494 Merge pull request #67 from R15-Software-Dev/hotfix/66-minor-changes
Minor changes to student forms
2026-03-06 13:58:33 -05:00
alphafetus 07fae5878f feat: add toasts and reactive feedback to forms on submission
Users were not able to tell that they had submitted before this.
2026-03-06 13:46:35 -05:00
alphafetus def5c8696b chore: update intended major field 2026-03-06 13:23:51 -05:00
alphafetus 7c1e281749 chore: add due date reminder to home page 2026-03-06 13:22:28 -05:00
alphafetus 49bed2f1e1 fix: correct gender data member 2026-03-06 13:21:18 -05:00
Lucas Vas 5929a450ae Merge pull request #65 from R15-Software-Dev/release/v0.5.0
Release candidate (v0.5.0)
2026-03-06 07:39:50 -05:00
alphafetus 05ef850f82 style: rustfmt run 2026-03-06 07:39:28 -05:00
alphafetus 1427c51bd3 feat: create "specifics" page 2026-03-06 01:55:58 -05:00
alphafetus c6867baad4 feat: add form descriptions 2026-03-06 01:32:34 -05:00
alphafetus 111a7aea86 feat: add specific placeholders 2026-03-06 01:24:30 -05:00
alphafetus 371db711b2 feat: finish portal pages 2026-03-06 01:21:37 -05:00
alphafetus c33a587d96 feat: allow icon to be optional 2026-03-06 01:11:20 -05:00
alphafetus 80340d99b9 feat: add redirect buttons to forms 2026-03-06 01:11:07 -05:00
alphafetus 142a60bc7c feat: update fields 2026-03-06 00:58:25 -05:00
alphafetus 97707a9b80 feat: add new button and additional text 2026-03-06 00:54:45 -05:00
alphafetus 4091bd0162 feat: update fields 2026-03-06 00:44:27 -05:00
alphafetus 323116663a feat: update fields 2026-03-06 00:41:44 -05:00
alphafetus 9a6cc934de feat: use loading icon for eligibility check 2026-03-06 00:34:40 -05:00
alphafetus 6f600a4818 feat: remove testing display 2026-03-06 00:30:44 -05:00
Lucas Vas 311d5647e8 Merge pull request #64 from R15-Software-Dev/feat/close-forms
Create FormClosedPage and close provider form
2026-03-05 09:06:41 -05:00
alphafetus 3e4c287aa4 feat: create FormClosedPage and close provider form 2026-03-04 14:09:41 -05:00
Lucas Vas 9d124c4098 Merge pull request #63 from R15-Software-Dev/feat/file-type-restriction
Implement checking for file types
2026-03-03 18:20:30 -05:00
alphafetus 840be3f0e5 feat: implement checking for file types 2026-03-03 09:44:55 -05:00
Lucas Vas 53bdc96d01 Merge pull request #62 from R15-Software-Dev/feat/value-display
Implement template student portal
2026-03-02 16:38:30 -05:00
alphafetus b422e43288 feat: implement admin login page 2026-03-02 16:16:15 -05:00
alphafetus 838ae8ba0f chore: bump docker to rust:1.93 2026-03-02 15:57:50 -05:00
alphafetus fb90a9d8ac feat: implement procedural display of file drop inputs 2026-03-02 15:43:58 -05:00
alphafetus 9d156c17e9 feat: implement file list retrieval for file drop components 2026-03-02 14:25:07 -05:00
alphafetus cc09769f28 chore: style file list 2026-03-02 13:48:38 -05:00
alphafetus e8b0ceeb7b feat: create financial_page 2026-03-02 13:48:29 -05:00
alphafetus c035115126 feat: allow deletion of files from s3 2026-03-02 13:40:00 -05:00
alphafetus 6f16bbe311 feat: allow reactive updates of UI with inserted file names 2026-03-02 12:29:49 -05:00
alphafetus 1bccecdc72 feat: implement dynamo insert with file upload 2026-03-02 12:00:38 -05:00
alphafetus 5cafc7e757 chore: pc change
Started implementing delete functions.
2026-03-02 08:47:53 -05:00
alphafetus 3f53e8fda1 chore: remove unused import 2026-03-01 12:34:23 -05:00
alphafetus b932388092 feat: style file input 2026-03-01 12:34:02 -05:00
alphafetus 40d1ef2a5f feat: implement file upload functionality 2026-03-01 12:17:20 -05:00
alphafetus 64554d81bc feat: begin implementation of file drop component 2026-02-27 14:52:54 -05:00
alphafetus c98621aefa feat: implement CSV export of scholarships 2026-02-27 07:39:31 -05:00
alphafetus be54aa8fd2 feat: change Into implementation to From 2026-02-20 09:58:16 -05:00
alphafetus 1afacf6008 docs: add docs to data display 2026-02-20 09:16:55 -05:00
alphafetus c850ac208f feat: finish initial data display component 2026-02-20 08:56:13 -05:00
alphafetus 0816f2da8b feat: add indexmap dependency 2026-02-18 09:00:15 -05:00
alphafetus 04ba0ff1ea feat: begin implementation of value display 2026-02-17 13:58:01 -05:00
alphafetus f10f01534c chore: push current changes to different PC 2026-02-16 12:21:35 -05:00
alphafetus dddc9e4192 style: run rustfmt 2026-02-11 12:21:41 -05:00
Lucas Vas ebb6fe500c Merge pull request #60 from R15-Software-Dev/fix/optional-number-inputs
Make number-type inputs properly optional
2026-02-11 12:18:08 -05:00
alphafetus e1de6064d9 fix: allow users to type decimal values 2026-02-11 11:45:47 -05:00
alphafetus f0db43817c fix: prevent validation on empty string 2026-02-10 09:54:58 -05:00
alphafetus 12dfe502f4 chore: rustfmt run 2026-02-10 09:39:30 -05:00
46 changed files with 3716 additions and 1104 deletions

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"]
[dependencies]
leptos = { version = "0.8" }
leptos = { version = "0.8", features = ["multipart"] }
leptos_router = { version = "0.8" }
axum = { version = "0.8", optional = true }
console_error_panic_hook = { version = "0.1", optional = true}
leptos_axum = { version = "0.8", optional = true }
leptos_meta = { version = "0.8" }
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
wasm-bindgen = { version = "=0.2.106", optional = true }
aws-sdk-dynamodb = { version = "1", optional = true }
aws-config = { version = "1.6.2", optional = true }
serde = "1.0.219"
serde_dynamo = { version = "4.2.14", features = ["aws-sdk-dynamodb+1"], optional = true }
uuid = { version = "1.17.0", features = ["serde", "v4", "js"] }
wasm-bindgen = { version = "0.2", optional = true }
aws-sdk-dynamodb = { version = "^1", optional = true }
aws-sdk-s3 = { version = "1.124.0", optional = true }
aws-config = { version = "^1.8", optional = true }
serde = "^1.0"
serde_dynamo = { version = "^4", features = ["aws-sdk-dynamodb+1"], optional = true }
uuid = { version = "^1", features = ["serde", "v4", "js"] }
leptos_oidc = "0.9.0"
base64 = "0.22.1"
chrono = "0.4.42"
leptos_animate = "0.1.9"
base64 = "0.22"
chrono = "0.4"
leptos_animate = "0.1"
macros = { path = "./macros" }
traits = { path = "./traits" }
icondata = { version = "0.7.0", features = ["font-awesome"] }
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]
default = ["ssr"]
@@ -42,11 +48,14 @@ ssr = [
"dep:tokio",
"dep:leptos_axum",
"dep:aws-sdk-dynamodb",
"dep:aws-sdk-s3",
"dep:aws-config",
"dep:serde_dynamo",
"dep:jsonwebtoken",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"leptos-use/ssr"
]
# 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
RUN apt-get update -y && apt-get install -y --no-install-recommends clang libc6 mold
+33 -23
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::pages::student;
use crate::components::ToastList;
use crate::components::login::ProviderLoginContext;
use crate::pages::{
AdminHomePage, AdminProviderPage, AdminScholarshipPage, AdminShell, AdminUtilsPage,
ApplicantsPageFallback, AuthCallbackPage, LoanerBorrowForm, LoanerFallback, LoanerReturnForm,
LoanerShell, ProviderContactPage, ProviderPortal,
};
use crate::pages::{FormClosedPage, student};
use leptos::prelude::*;
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
use leptos_oidc::AuthSignal;
use leptos_router::{components::{Route, Router, Routes, ParentRoute}, path, MatchNestedRoutes};
use leptos_router::any_nested_route::IntoAnyNestedRoute;
use crate::components::login::ProviderLoginContext;
use crate::components::ToastList;
use leptos_router::{
MatchNestedRoutes,
components::{ParentRoute, Route, Router, Routes},
path,
};
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
@@ -66,6 +74,7 @@ pub fn AppWithRoutes() -> impl IntoView {
<ProviderRoutes />
<LoanerRoutes />
<StudentRoutes />
<AdminRoutes />
</Routes>
</ToastList>
</main>
@@ -80,14 +89,15 @@ fn ProviderRoutes() -> impl MatchNestedRoutes + Clone {
<Route path=path!("/applicants") view=ApplicantsPageFallback />
<Route path=path!("/callback") view=AuthCallbackPage />
<Route path=path!("/profile") view=ProviderContactPage />
<ParentRoute path=path!("/scholarships") view=ScholarshipInfoPage>
<Route path=path!(":id") view=ScholarshipInfoPage />
<Route path=path!("") view=ScholarshipInfoPage />
</ParentRoute>
<Route path=path!("/scholarships") view=FormClosedPage />
// <ParentRoute path=path!("/scholarships") view=ScholarshipInfoPage>
// <Route path=path!(":id") view=ScholarshipInfoPage />
// <Route path=path!("") view=ScholarshipInfoPage />
// </ParentRoute>
</ParentRoute>
}
.into_inner()
.into_any_nested_route()
.into_inner()
.into_any_nested_route()
}
#[component(transparent)]
@@ -99,22 +109,23 @@ fn LoanerRoutes() -> impl MatchNestedRoutes + Clone {
<Route path=path!("returning") view=LoanerReturnForm />
</ParentRoute>
}
.into_inner()
.into_any_nested_route()
.into_inner()
.into_any_nested_route()
}
#[component(transparent)]
fn AdminRoutes() -> impl MatchNestedRoutes + Clone {
view! {
<ParentRoute path=path!("admin") view=AdminShell>
<Route path=path!("callback") view=AuthCallbackPage />
<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()
.into_inner()
.into_any_nested_route()
}
#[component(transparent)]
@@ -136,18 +147,17 @@ fn StudentRoutes() -> impl MatchNestedRoutes + Clone {
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 />
<Route path=path!("additional/financials") view=student::StudentFinancialPage />
<Route
path=path!("additional/university")
view=student::StudentUniversityPage
/>
<Route
path=path!("additional/family-info")
view=student::StudentFamilyPage
path=path!("additional/specifics")
view=student::StudentSpecificRequirementsPage
/>
</ParentRoute>
}
.into_inner()
.into_any_nested_route()
.into_inner()
.into_any_nested_route()
}
#[component]
+47 -37
View File
@@ -36,60 +36,70 @@ impl InputType {
) -> impl IntoView {
view! {
{match self {
InputType::Text(member, label, placeholder, required) =>
InputType::Text(member, label, placeholder, required) => {
view! {
<Row>
<OutlinedTextField
label = label.clone()
placeholder = placeholder.clone()
data_member = member.clone()
data_map = data_map
required = required
label=label.clone()
placeholder=placeholder.clone()
data_member=member.clone()
data_map=data_map
required=required
/>
</Row>
}.into_any(),
InputType::Number(member, label, placeholder, required) =>
}
.into_any()
}
InputType::Number(member, label, placeholder, required) => {
view! {
<OutlinedTextField
label = label.clone()
placeholder = placeholder.clone()
data_member = member.clone()
data_map = data_map
input_type = TextFieldType::Number
required = required
label=label.clone()
placeholder=placeholder.clone()
data_member=member.clone()
data_map=data_map
input_type=TextFieldType::Number
required=required
/>
}.into_any(),
InputType::Checkbox(member, label, options, required) =>
}
.into_any()
}
InputType::Checkbox(member, label, options, required) => {
view! {
<CheckboxList
label = label.clone()
name = name.clone()
items = options.clone()
data_member = member.clone()
data_map = data_map
required = required
label=label.clone()
name=name.clone()
items=options.clone()
data_member=member.clone()
data_map=data_map
required=required
/>
}.into_any(),
InputType::Radio(member, label, options, required) =>
}
.into_any()
}
InputType::Radio(member, label, options, required) => {
view! {
<RadioList
label = label.clone()
data_member = member.clone()
data_map = data_map
items = options.clone()
required = required
label=label.clone()
data_member=member.clone()
data_map=data_map
items=options.clone()
required=required
/>
}.into_any(),
InputType::Select(member, label, options, required) =>
}
.into_any()
}
InputType::Select(member, label, options, required) => {
view! {
<Select
label = label.clone()
value_list = options.clone()
data_member = member.clone()
data_map = data_map
required = required
label=label.clone()
value_list=options.clone()
data_member=member.clone()
data_map=data_map
required=required
/>
}.into_any()
}
.into_any()
}
}}
}
}
+2 -1
View File
@@ -8,8 +8,9 @@ mod user;
mod value_type;
pub mod animations;
mod tab_info;
mod schema_node;
pub use self::{
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.
#[serde(rename = "username")]
pub username: String,
/// The intended use for the token.
pub token_use: String,
}
+21 -9
View File
@@ -13,7 +13,7 @@ use std::fmt::Display;
pub enum ValueType {
/// Indicates a `String` value.
String(Option<String>),
/// Indicates an `i32` value.
/// Indicates an `i32` value. /// Indicates an `f32` value.
Number(Option<String>),
/// Indicates a `Vec<T>`, where `T` can be any other `ValueType`.
List(Option<Vec<ValueType>>),
@@ -105,8 +105,8 @@ impl Default for ValueType {
impl Display for ValueType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = match self {
ValueType::String(s) => s.clone().unwrap_or("".to_owned()),
ValueType::Number(n) => n.clone().unwrap_or("".to_owned()),
ValueType::String(s) => s.clone().unwrap_or_default(),
ValueType::Number(n) => n.clone().unwrap_or_default(),
ValueType::List(l) => format!("{l:?}"),
ValueType::Map(m) => format!("{m:?}"),
};
@@ -124,20 +124,32 @@ impl From<&AttributeValue> for ValueType {
AttributeValue::S(s) => ValueType::String(Some(s.clone())),
AttributeValue::N(n) => ValueType::Number(Some(n.clone())),
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(),
}
}
}
#[cfg(feature = "ssr")]
impl Into<AttributeValue> for ValueType {
fn into(self) -> AttributeValue {
match self {
impl From<ValueType> for AttributeValue {
fn from(value: ValueType) -> Self {
match value {
ValueType::String(s) => AttributeValue::S(s.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::Map(m) => AttributeValue::M(m.unwrap_or_default().into_iter().map(|(k, v)| (k, v.into())).collect()),
ValueType::List(l) => AttributeValue::L(
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(),
),
}
}
}
+11 -7
View File
@@ -27,7 +27,7 @@ pub fn DashboardButton(
#[prop(into)]
description: String,
/// The file path or URL of the icon displayed at the top of the button.
#[prop(into)]
#[prop(optional, into)]
icon: String,
/// The route that the user is navigated to when clicking the button.
#[prop(into)]
@@ -37,22 +37,26 @@ pub fn DashboardButton(
let on_click = move |_| navigate(&path, Default::default());
let icon = StoredValue::new(icon);
view! {
<button
type="button"
class="dashboard-button flex items-start gap-3 rounded-lg bg-white border-grey-300 p-6
hover:bg-gray-100 transition cursor-pointer w-full text-left
shadow-[inset_0_0_6px_rgba(0,0,0,0.12)]"
hover:bg-gray-100 transition cursor-pointer w-full text-left
shadow-[inset_0_0_6px_rgba(0,0,0,0.12)]"
on:click=on_click
>
<div class="flex flex-col">
<img src=icon.clone() class="h-8 w-8" alt="icon" />
<h3 class="font-semibold text-base">{title.clone()}</h3>
<Show when=move || !icon.get_value().is_empty()>
<img src=icon.get_value() class="h-8 w-8" alt="icon" />
</Show>
<h3 class="font-semibold text-base">{title.clone()}</h3>
<p class="text-sm text-gray-600 pt-6">{description.clone()}</p>
</div>
</button>
}.into_any()
}
.into_any()
}
+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>
}
}
+231
View File
@@ -0,0 +1,231 @@
use crate::pages::api::files::{DeleteFile, upload_file};
use leptos::html::{Input, Label};
use leptos::logging::{debug_error, 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(
/// The name of this input. Used in file organization.
#[prop(into)]
name: String,
/// The ID of the form containing this input. Used in file organization.
#[prop(into)]
form_id: String,
/// The types that a file is allowed to be. Must be specified as the desired file extension (i.e.
/// `.pdf`, `.txt`, etc.)
#[prop(optional)]
allowed_types: Vec<String>,
/// A [`Resource`] that gives the currently uploaded files for this input.
#[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 error_msg = RwSignal::new(String::new());
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 verify_file_type = move |file_name: String| {
if !allowed_types.is_empty() {
let verified = allowed_types
.iter()
.any(|file_ext| file_name.ends_with(file_ext));
if !verified {
debug_log!("Blocking file with name {file_name}");
} else {
debug_log!("Allowing file with name {file_name}");
}
verified
} else {
true
}
};
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| {
debug_log!("Received dropped file. List has length {}", ev.files.len());
ev.files
.iter()
.for_each(|file| 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();
let verified_files = files
.iter()
.filter_map(|file| verify_file_type(file.name().to_string()).then(|| file.to_owned()))
.collect::<Vec<File>>();
// input_ref.try_update(|input| {
// if let Some(input) = input {
// input.set_files(None);
// }
// });
if !verified_files.is_empty() {
upload_files.run(verified_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.iter()
.find(|v| **v == uploaded_file_name)
.is_none()
.then(|| 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| {
match result {
Ok(files) => {
error_msg.set("".to_string());
file_name_list.set(files);
}
Err(err) => {
let msg = err.to_string();
debug_error!("Error while getting files: {}", msg);
error_msg.set(msg);
}
}
});
}}
<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>
<Show when=move || !error_msg.get().is_empty()>
<div class="flex-1">{error_msg}</div>
</Show>
<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 cursor-pointer"
on:click=on_click
>
<Icon icon=icondata::FaTrashCanRegular />
</div>
</div>
}
}
/>
}
}
+7 -9
View File
@@ -1,16 +1,14 @@
use leptos::prelude::*;
use crate::components::login::setup::provide_auth_context;
use crate::components::login::setup::provide_auth_context;
use leptos::prelude::*;
#[component]
pub fn AdminLoginContext(
children: Children
) -> impl IntoView {
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"
"https://cognito-idp.us-east-1.amazonaws.com/us-east-1_rvCU4Xy4j",
"1vh1q994rid6cgi6tjf58r9jp4",
"/admin/callback",
);
view! { {children()} }
}
}
+10 -8
View File
@@ -1,22 +1,24 @@
mod action_button;
mod banner;
mod dashboard_button;
mod data_display;
mod date;
mod file_drop;
mod header;
mod lists;
mod loading;
pub mod login;
mod multi_entry;
mod panel;
mod row;
mod tabs;
mod text_field;
mod toasts;
mod utils;
mod validated_form;
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::*, tabs::*
action_button::*, banner::*, dashboard_button::*, data_display::*, date::*, file_drop::*,
header::*, lists::*, loading::*, multi_entry::*, panel::*, row::*, tabs::*, text_field::*,
toasts::*, validated_form::*,
};
+40 -32
View File
@@ -2,6 +2,7 @@ use crate::common::ValueType;
use leptos::prelude::*;
use std::collections::HashMap;
use leptos::html::Input;
use leptos::logging::debug_log;
use crate::components::{use_validation_context, InputState};
use crate::components::validated_form::ValidationState;
@@ -28,6 +29,8 @@ pub enum TextFieldType {
fn validate(required: bool, value: &str, input_type: &TextFieldType) -> ValidationState {
if required && value.is_empty() {
ValidationState::Invalid("This field is required.".to_string())
} else if !required && value.is_empty() {
ValidationState::Valid
} else {
match input_type {
TextFieldType::Email(domains) => validate_email(value, domains),
@@ -54,7 +57,7 @@ fn validate_email(input: &str, valid_domains: &Vec<String>) -> ValidationState {
/// Validates a number.
fn validate_number(input: &str) -> ValidationState {
if input.parse::<i32>().is_ok() {
if input.parse::<f32>().is_ok() {
ValidationState::Valid
} else {
ValidationState::Invalid("Value is not a valid number.".to_string())
@@ -64,7 +67,7 @@ fn validate_number(input: &str) -> ValidationState {
#[component]
pub fn OutlinedTextField(
#[prop(optional, into)] placeholder: String,
#[prop(into)] data_member: String, // should this be an RwSignal??
#[prop(into)] data_member: String,
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop(optional, into)] input_type: Signal<TextFieldType>,
#[prop(optional, into)] disabled: Signal<bool>,
@@ -73,30 +76,28 @@ pub fn OutlinedTextField(
#[prop(optional, into)] required: Signal<bool>
) -> impl IntoView {
let input_ref = NodeRef::<Input>::new();
let data_member = StoredValue::new(data_member);
// Register this input's validation signal.
let validator_context = use_validation_context()
.expect("FormValidSignal was not found");
let raw_value = Signal::derive({
let data_member = data_member.clone();
move ||
data_map
.get()
.get(&data_member)
.unwrap_or(&ValueType::String(None))
.to_string()
});
let display_value = RwSignal::new(
data_map.get_untracked()
.get(&data_member.get_value())
.map(|v| v.to_string())
.unwrap_or_default()
);
let dirty = RwSignal::new(false);
let error = Signal::derive(move ||
validate(required.get(), &raw_value.get(), &input_type.get())
validate(required.get(), &display_value.get(), &input_type.get())
);
let show_errors = Signal::derive(move || {
dirty.get() && matches!(error.get(), ValidationState::Invalid(_))
});
let validator = RwSignal::new(InputState::new(data_member.clone(), error.clone(), dirty.clone()));
let validator = RwSignal::new(InputState::new(data_member.get_value(), error.clone(), dirty.clone()));
validator_context.validators.update(|list| list.push(validator));
@@ -106,35 +107,42 @@ pub fn OutlinedTextField(
});
});
let str_type = Signal::derive(move || match input_type.get_untracked() {
TextFieldType::Number => "number",
_ => "text" // This is fine even for emails, as we're doing our own validation.
});
let on_blur = move |_| {
// Check validity of input's current value. The value will be updated by on_input,
// and it should be a raw input (not parsed into a ValueType).
dirty.set(true);
};
// Syncs data from the map into the display_value, only when the map is actually changed.
Effect::new(move || {
let new_value = data_map.get()
.get(&data_member.get_value())
.map(|v| v.to_string())
.unwrap_or_default();
if new_value != display_value.get_untracked() {
debug_log!("Found value from map: {}", new_value);
display_value.set(new_value);
}
});
// This function parses the input into the correct type. It only accepts numbers or strings as
// valid types, and parses them accordingly.
let on_input = {
let data_member = data_member.clone();
move |e| {
let to_parse = event_target_value(&e);
let on_input = move |e| {
let to_parse = event_target_value(&e);
debug_log!("Setting value to display_value: {}", to_parse);
display_value.set(to_parse.clone());
// Push the new value to the data map. This happens regardless of whether the value parses
// or not.
data_map.update(|map| {
let into_map = match input_type.get() {
TextFieldType::Number => ValueType::Number(Some(to_parse)),
_ => ValueType::String(Some(to_parse))
};
// Edit the data map. Note that this should be uncoupled in the future - we don't want
// to update the map inside the component definition, but inside the page/form that
// created this input.
data_map.update(|map| {
map.insert(data_member.clone(), into_map);
});
}
map.insert(data_member.get_value(), into_map);
});
};
view! {
@@ -147,11 +155,11 @@ pub fn OutlinedTextField(
transition-all duration-150
border-red-700 bg-transparent
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
r#type=str_type
r#type="text"
disabled=disabled
placeholder=placeholder
prop:name=name
prop:value=raw_value
prop:value=display_value
on:input=on_input
on:blur=on_blur
/>
+3
View File
@@ -111,6 +111,8 @@ pub fn ValidatedForm(
#[prop(into)] title: Signal<String>,
/// The description for the included header.
#[prop(optional, into)] description: Signal<String>,
/// A `Signal` that determines when the submit button should be disabled.
#[prop(optional, into)] disabled: Signal<bool>,
) -> impl IntoView {
let validators = RwSignal::new(vec![]);
@@ -154,6 +156,7 @@ pub fn ValidatedForm(
<Header title=title description=description />
{children()}
<ActionButton
disabled=disabled
on:click=move |_| {
log!("{:?}", can_submit.get());
}
+2 -6
View File
@@ -4,12 +4,8 @@ use leptos::prelude::*;
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 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>
}
}
+19 -13
View File
@@ -1,7 +1,9 @@
use leptos::prelude::*;
use crate::common::TabInfo;
use crate::components::{Banner, TabSidebarList};
use crate::common::TabInfo;
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
///
@@ -13,16 +15,20 @@ pub fn AdminShell() -> impl IntoView {
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),
]
/>
<AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage>
// 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),
]
/>
</Authenticated>
</AuthLoaded>
</AdminLoginContext>
}
}
+41 -116
View File
@@ -1,17 +1,21 @@
use chrono::DateTime;
use crate::common::{DateInfo, DateRange};
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 crate::common::{DateInfo, DateRange};
use crate::pages::api::{CreateDates, CreateTestComparisons};
use leptos::task::spawn_local;
use leptos::wasm_bindgen::JsCast;
use leptos::web_sys::{Blob, HtmlAnchorElement, Url, js_sys};
fn get_important_dates() -> Vec<DateInfo> {
vec! [
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()
DateTime::parse_from_rfc3339("2026-03-03T23:59:00-05:00").unwrap(),
),
description: "".to_string(),
},
@@ -20,7 +24,7 @@ fn get_important_dates() -> Vec<DateInfo> {
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()
DateTime::parse_from_rfc3339("2026-03-27T14:05:00-05:00").unwrap(),
),
description: "".to_string(),
},
@@ -29,7 +33,7 @@ fn get_important_dates() -> Vec<DateInfo> {
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()
DateTime::parse_from_rfc3339("2026-05-01T23:59:00-05:00").unwrap(),
),
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]
pub fn AdminUtilsPage() -> impl IntoView {
let create_comparisons = ServerAction::<CreateTestComparisons>::new();
let create_dates = ServerAction::<CreateDates>::new();
let create_export = ServerAction::<CreateScholarshipExport>::new();
let create_export = ServerAction::<GetScholarshipCsv>::new();
let on_click_comparisons = move |_| {
create_comparisons.dispatch(CreateTestComparisons{});
create_comparisons.dispatch(CreateTestComparisons {});
};
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 |_| {
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! {
+37 -44
View File
@@ -14,10 +14,9 @@ use crate::common::{
ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison,
TextComparison, TextListComparison, ValueType,
};
use std::collections::HashMap;
use leptos::prelude::ServerFnError;
use leptos::server;
use std::collections::HashMap;
/// 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.
@@ -250,14 +249,18 @@ fn create_major_comparisons() -> Vec<ComparisonData> {
let ids = display_texts
.iter()
.map(|text|
format!("{}_{}", "major", text.to_lowercase().replace(" ", "_").replace("/", "_"))
)
.map(|text| {
format!(
"{}_{}",
"major",
text.to_lowercase().replace(" ", "_").replace("/", "_")
)
})
.collect::<Vec<String>>();
ids.iter().zip(display_texts).map(|(id, display_text)| {
create_major_comparison(id, display_text)
})
ids.iter()
.zip(display_texts)
.map(|(id, display_text)| create_major_comparison(id, display_text))
.collect::<Vec<ComparisonData>>()
}
@@ -280,13 +283,11 @@ fn make_comp_list() -> Vec<ComparisonData> {
"Academic Information",
"Math SAT Score > 650",
);
let service_hours_20 = ComparisonData::new(
"service_hours_20",
"service_hours",
ComparisonType::Number(
NumberComparison::GreaterThanOrEqual
),
ComparisonType::Number(NumberComparison::GreaterThanOrEqual),
ValueType::Number(Some(20.to_string())),
"Community Service",
"20+ service hours",
@@ -295,9 +296,7 @@ fn make_comp_list() -> Vec<ComparisonData> {
let service_hours_25 = ComparisonData::new(
"service_hours_25",
"service_hours",
ComparisonType::Number(
NumberComparison::GreaterThanOrEqual
),
ComparisonType::Number(NumberComparison::GreaterThanOrEqual),
ValueType::Number(Some(25.to_string())),
"Community Service",
"25+ service hours",
@@ -306,9 +305,7 @@ fn make_comp_list() -> Vec<ComparisonData> {
let service_hours_30 = ComparisonData::new(
"service_hours_30",
"service_hours",
ComparisonType::Number(
NumberComparison::GreaterThanOrEqual
),
ComparisonType::Number(NumberComparison::GreaterThanOrEqual),
ValueType::Number(Some(30.to_string())),
"Community Service",
"30+ service hours",
@@ -334,7 +331,7 @@ fn make_comp_list() -> Vec<ComparisonData> {
let attended_bas = ComparisonData::new(
"attended_bas",
"attended_bas",
"attend_bas",
ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())),
"Additional Eligibility Factors",
@@ -365,7 +362,7 @@ fn make_comp_list() -> Vec<ComparisonData> {
ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())),
"Additional Eligibility Factors",
"Participated in Pomperaug Youth Baseball"
"Participated in Pomperaug Youth Baseball",
);
let aquatic_club = ComparisonData::new(
@@ -374,25 +371,25 @@ fn make_comp_list() -> Vec<ComparisonData> {
ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())),
"Additional Eligibility Factors",
"Participated in Panthers Aquatic Club"
"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"
"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"
"PHS Music Program",
);
let mut sports_comps = create_sports_comparisons();
@@ -412,7 +409,7 @@ fn make_comp_list() -> Vec<ComparisonData> {
youth_baseball,
aquatic_club,
pea_member,
music_program
music_program,
];
comp_list.append(&mut sports_comps);
@@ -438,6 +435,7 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
.await
{
let msg = err.message().unwrap_or("An unknown error occurred");
leptos::logging::error!("{}", msg);
return Err(ServerFnError::new(msg));
}
}
@@ -453,15 +451,9 @@ pub async fn get_comparison_info() -> Result<Vec<ComparisonData>, ServerFnError>
// Query the database for all comparisons. The client is only going to use the
// id and display text, but we'll return the whole thing.
match client
.scan()
.table_name(COMPARISONS_TABLE)
.send()
.await
{
match client.scan().table_name(COMPARISONS_TABLE).send().await {
Ok(output) => {
if let Some(items) = output.items {
log!("Found comparisons from API: {:?}", items);
Ok(serde_dynamo::from_items(items)?)
} else {
Ok(vec![])
@@ -469,22 +461,19 @@ pub async fn get_comparison_info() -> Result<Vec<ComparisonData>, ServerFnError>
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
leptos::logging::error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
}
#[server]
pub async fn get_comparisons_categorized() -> Result<HashMap<String, Vec<ComparisonData>>, ServerFnError> {
pub async fn get_comparisons_categorized()
-> Result<HashMap<String, Vec<ComparisonData>>, ServerFnError> {
let client = create_dynamo_client().await;
log!("Getting all comparisons from the database, by category.");
match client
.scan()
.table_name(COMPARISONS_TABLE)
.send()
.await
{
match client.scan().table_name(COMPARISONS_TABLE).send().await {
Ok(output) => {
if let Some(items) = output.items {
log!("Found comparisons, categorizing.");
@@ -492,23 +481,27 @@ pub async fn get_comparisons_categorized() -> Result<HashMap<String, Vec<Compari
let items: Vec<ComparisonData> = serde_dynamo::from_items(items)?;
let categorized = items.iter().fold(
HashMap::<String, Vec<ComparisonData>>::new(), |mut map, comp| {
// Load each comparison into a map, then return that map.
HashMap::<String, Vec<ComparisonData>>::new(),
|mut map, comp| {
// Load each comparison into a map, then return that map.
map.entry(comp.category.clone())
.and_modify(|vec| vec.push(comp.clone()))
.or_insert(vec![comp.clone()]);
map
}
},
);
Ok(categorized)
} else {
Err(ServerFnError::new("Couldn't find any comparisons."))
let msg = "Couldn't find any comparisons.";
leptos::logging::error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
leptos::logging::error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
+32 -32
View File
@@ -1,61 +1,60 @@
use std::collections::HashMap;
use leptos::logging::log;
use leptos::prelude::*;
use crate::common::DateInfo;
use leptos::logging::{error, log};
use leptos::prelude::*;
use std::collections::HashMap;
#[cfg(feature = "ssr")]
use super::DATES_TABLE;
#[cfg(feature = "ssr")]
use aws_sdk_dynamodb::{
types::{WriteRequest, PutRequest},
Client as DynamoClient
};
#[cfg(feature = "ssr")]
use crate::utils::server::create_dynamo_client;
#[cfg(feature = "ssr")]
use std::sync::Arc;
use aws_sdk_dynamodb::{
Client as DynamoClient,
error::ProvideErrorMetadata,
types::{PutRequest, WriteRequest},
};
#[server]
pub async fn create_dates(dates: Vec<DateInfo>) -> Result<(), ServerFnError> {
// Serialize and put dates into the database.
let client = create_dynamo_client().await;
log!("Creating dates: {:?}", dates);
let requests = dates
.into_iter()
.map(|date| {
let item = serde_dynamo::to_item(&date)
.unwrap_or_default();
let put_request = PutRequest::builder()
.set_item(Some(item))
.build()
.expect("Couldn't build request, item is not present");
WriteRequest::builder().put_request(put_request).build()
.filter_map(|date| {
let item = serde_dynamo::to_item(&date).unwrap_or_default();
let put_request = PutRequest::builder().set_item(Some(item)).build().ok()?;
Some(WriteRequest::builder().put_request(put_request).build())
})
.collect::<Vec<WriteRequest>>();
let mut request_map = HashMap::new();
request_map.insert(DATES_TABLE.to_string(), requests);
client
.batch_write_item()
.set_request_items(Some(request_map))
.send()
.await
.map(|_| ())
.map_err(ServerFnError::from)
.map_err(|err| {
let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})
}
#[server]
pub async fn get_important_dates() -> Result<Vec<DateInfo>, ServerFnError> {
log!("Getting important dates list");
let client = create_dynamo_client().await;
client
.scan()
.table_name(DATES_TABLE)
@@ -65,14 +64,15 @@ pub async fn get_important_dates() -> Result<Vec<DateInfo>, ServerFnError> {
if let Some(items) = output.items {
items
.into_iter()
.filter_map(|item| {
serde_dynamo::from_item::<_, DateInfo>(item)
.ok()
})
.filter_map(|item| serde_dynamo::from_item::<_, DateInfo>(item).ok())
.collect::<Vec<DateInfo>>()
} else {
Vec::new()
}
})
.map_err(ServerFnError::from)
.map_err(|err| {
let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})
}
+236
View File
@@ -0,0 +1,236 @@
#[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()?;
Ok(result)
}
+269
View File
@@ -0,0 +1,269 @@
use leptos::leptos_dom::error;
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::{
error::ProvideErrorMetadata as _,
types::{AttributeValue, ReturnValue, Select},
};
pub use aws_sdk_s3::error::ProvideErrorMetadata as _;
}
#[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"));
}
if file_bytes.is_empty() {
return Err(ServerFnError::new("Missing file contents"));
}
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| {
let msg = format!(
"Couldn't put file to S3: {}",
e.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})?;
// 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(|err| {
let msg = format!(
"Couldn't put file to Dynamo, file rolled back: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
});
// 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(|err| {
let msg = format!(
"Failed to rollback Dynamo entry: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})?;
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(|err| {
let msg = format!(
"Failed to put file into S3: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
});
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| {
let msg = format!(
"Failed to rollback Dynamo entry: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})?;
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(|err| {
let msg = format!(
"Failed to get files: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})
}
+12 -6
View File
@@ -1,15 +1,18 @@
mod comparisons;
mod scholarships;
mod dates;
mod admin;
mod comparisons;
mod dates;
pub mod exports;
mod providers;
mod scholarships;
pub mod students;
pub mod files;
mod tokens;
pub use comparisons::*;
pub use scholarships::*;
pub use dates::*;
pub use admin::*;
pub use comparisons::*;
pub use dates::*;
pub use providers::*;
pub use scholarships::*;
#[cfg(feature = "ssr")]
pub static COMPARISONS_TABLE: &str = "leptos-comparisons";
@@ -25,3 +28,6 @@ pub static PROVIDER_CONTACT_TABLE: &str = "leptos-provider-contacts";
#[cfg(feature = "ssr")]
pub static MAIN_TABLE_NAME: &str = "scholarships-main";
#[cfg(feature = "ssr")]
pub static S3_BUCKET_NAME: &str = "leptos-scholarships";
+23 -12
View File
@@ -1,8 +1,8 @@
use leptos::prelude::*;
use leptos::logging::debug_log;
use std::collections::HashMap;
use crate::common::ValueType;
use leptos::logging::{debug_log, error};
use leptos::prelude::*;
use leptos::server_fn::codec::Json;
use crate::common::ValueType;
use std::collections::HashMap;
#[cfg(feature = "ssr")]
use crate::utils::server::{create_dynamo_client, into_attr_map};
@@ -12,8 +12,8 @@ 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;
use aws_sdk_dynamodb::types::AttributeValue;
let client = create_dynamo_client().await;
@@ -31,29 +31,39 @@ pub async fn get_provider_contact(id: String) -> Result<HashMap<String, ValueTyp
return Ok(HashMap::new());
};
let map = item.iter().map(|(key, val)| {
let val_type = ValueType::from(val);
let map = item
.iter()
.map(|(key, val)| {
let val_type = ValueType::from(val);
(key.clone(), val_type)
}).collect::<HashMap<String, ValueType>>();
(key.clone(), val_type)
})
.collect::<HashMap<String, ValueType>>();
Ok(map)
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred.");
error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
}
#[server(input = Json)]
pub async fn put_provider_contact(id: String, contact_info: HashMap<String, ValueType>) -> Result<(), ServerFnError> {
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));
item.insert(
"subject".to_owned(),
aws_sdk_dynamodb::types::AttributeValue::S(id),
);
match client
.put_item()
@@ -65,7 +75,8 @@ pub async fn put_provider_contact(id: String, contact_info: HashMap<String, Valu
Ok(_) => Ok(()),
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred.");
error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
}
}
+90 -56
View File
@@ -1,15 +1,11 @@
#[cfg(feature = "ssr")]
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;
pub use crate::common::ValueType;
pub use crate::utils::server::create_dynamo_client;
pub use aws_sdk_dynamodb::{error::ProvideErrorMetadata, types::AttributeValue};
pub use leptos::logging::{debug_log, error, log};
pub use uuid::Uuid;
}
use crate::common::ExpandableInfo;
@@ -20,7 +16,7 @@ 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,
@@ -39,11 +35,14 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
Ok(serde_dynamo::from_item(item)?)
} else {
log!("Couldn't find scholarship with ID {:?}", id);
Err(ServerFnError::new("Couldn't find scholarship with given ID."))
Err(ServerFnError::new(
"Couldn't find scholarship with given ID.",
))
}
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
@@ -51,12 +50,15 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
#[server(CreateScholarshipInfo, input = Json)]
pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerFnError> {
use imports::*;
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
use imports::*;
let client = create_dynamo_client().await;
log!("Creating or updating scholarship with ID {:?}", info.subject);
log!(
"Creating or updating scholarship with ID {:?}",
info.subject
);
match client
.put_item()
@@ -68,6 +70,7 @@ pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerF
Ok(_) => Ok(()),
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
@@ -76,24 +79,20 @@ 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;
debug_log!("Getting all scholarship info");
match client
.scan()
.table_name(SCHOLARSHIPS_TABLE)
.send()
.await
{
match client.scan().table_name(SCHOLARSHIPS_TABLE).send().await {
Ok(output) => {
if let Some(items) = output.items {
Ok(serde_dynamo::from_items(items)?)
} else {
Ok(Vec::new())
}
},
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
@@ -101,17 +100,25 @@ 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> {
pub async fn get_provider_scholarships(
provider_id: String,
) -> Result<Vec<ExpandableInfo>, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
debug_log!("Getting provider scholarships for provider with ID {:?}", provider_id);
debug_log!(
"Getting provider scholarships for provider with ID {:?}",
provider_id
);
match client
.scan()
.table_name(SCHOLARSHIPS_TABLE)
.expression_attribute_values(":id", serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?)
.expression_attribute_values(
":id",
serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?,
)
.filter_expression("provider_id = :id")
.send()
.await
@@ -124,7 +131,8 @@ pub async fn get_provider_scholarships(provider_id: String) -> Result<Vec<Expand
}
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
@@ -134,14 +142,20 @@ pub async fn get_provider_scholarships(provider_id: String) -> Result<Vec<Expand
#[server(RegisterScholarship)]
pub async fn register_scholarship(provider_id: String) -> Result<String, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
debug_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());
item.data.insert("provider_id".to_string(), ValueType::String(Some(provider_id)));
item.data.insert(
"provider_id".to_string(),
ValueType::String(Some(provider_id)),
);
loop {
let ser_item = serde_dynamo::to_item(&item)?;
match client
@@ -165,10 +179,12 @@ pub async fn register_scholarship(provider_id: String) -> Result<String, ServerF
log!("Current error: {:?}", err);
current_uuid = Uuid::new_v4().to_string();
item.subject = current_uuid.clone();
continue
continue;
}
_ => {
return Err(ServerFnError::new(err.message().unwrap_or("An unknown error occurred")));
return Err(ServerFnError::new(
err.message().unwrap_or("Unknown error occurred"),
));
}
}
}
@@ -178,41 +194,51 @@ 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> {
pub async fn delete_provider_scholarship(
provider_id: String,
scholarship_id: String,
) -> Result<(), ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
debug_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.
match client
.delete_item()
.table_name(SCHOLARSHIPS_TABLE)
.key("subject", AttributeValue::S(scholarship_id))
.expression_attribute_values(":provider_id", serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?)
.expression_attribute_values(
":provider_id",
serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?,
)
.condition_expression("provider_id = :provider_id")
.send()
.await
{
Ok(_) => {
Ok(())
}
Ok(_) => Ok(()),
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
Err(ServerFnError::new(msg))
}
}
}
#[server]
pub async fn get_all_scholarships() -> Result<Vec<std::collections::HashMap<String, crate::common::ValueType>>, ServerFnError> {
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)
@@ -222,12 +248,20 @@ pub async fn get_all_scholarships() -> Result<Vec<std::collections::HashMap<Stri
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<_>>()
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(|err| {
let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})
.map_err(ServerFnError::from)
}
+54 -35
View File
@@ -3,36 +3,34 @@ 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 crate::utils::server::*;
pub use aws_sdk_dynamodb::error::ProvideErrorMetadata;
pub use aws_sdk_dynamodb::types::AttributeValue;
pub use leptos::logging::{debug_log, error};
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>
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))
AttributeValue::S(format!("STUDENT#{}", subject)),
);
data_map_attr.insert(
"SK".into(),
AttributeValue::S(data_type.to_uppercase())
);
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)
@@ -40,7 +38,14 @@ pub async fn put_student_data(
.send()
.await
.map(|_| ())
.map_err(ServerFnError::from)
.map_err(|err| {
let msg = format!(
"Couldn't put item into Dynamo: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})
}
#[server]
@@ -49,9 +54,9 @@ pub async fn get_student_data(
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)
@@ -65,37 +70,42 @@ pub async fn get_student_data(
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));
});
let _ = list.into_iter().flatten().for_each(|(k, v)| {
map.insert(k, ValueType::from(&v));
});
map
})
.map_err(ServerFnError::from)
.map_err(|err| {
let msg = format!(
"Couldn't get item from Dynamo: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})
}
/// 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
subject: String,
) -> Result<std::collections::HashMap<String, crate::common::ValueType>, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
client.query()
client
.query()
.table_name(MAIN_TABLE_NAME)
.key_condition_expression("HK = :hk")
.expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{}", subject)))
@@ -105,10 +115,19 @@ pub async fn get_all_student_data(
let Some(items) = output.items else {
return HashMap::new();
};
items.iter().flatten()
.map(|(|k, v)| (k.clone(), ValueType::from(v)))
items
.iter()
.flatten()
.map(|(k, v)| (k.clone(), ValueType::from(v)))
.collect::<HashMap<String, ValueType>>()
})
.map_err(ServerFnError::from)
.map_err(|err| {
let msg = format!(
"Couldn't get data from Dynamo: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})
}
+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)
}
+19
View File
@@ -0,0 +1,19 @@
use crate::components::Banner;
use leptos::prelude::*;
#[component]
pub fn FormClosedPage() -> impl IntoView {
// This page simply shows a message saying a form is closed. It does not make any distinction
// about which form is closed right now.
view! {
<Banner title="R15 Scholarships" logo="/PHS_Stacked_Acronym.png" path="/providers" />
<div class="flex flex-1 flex-col gap-4 items-center m-4">
<div class="font-bold text-3xl">"This form has been closed!"</div>
<div class="text-lg">
"If you believe this is an error, please contact Sara Smith at "
<a href="mailto:ssmith@region15.org">"ssmith@region15.org"</a>
</div>
</div>
}
}
+3 -2
View File
@@ -1,5 +1,5 @@
mod about_page;
mod api;
pub mod api;
mod comparison_test;
mod home_page;
mod loaner_page;
@@ -11,9 +11,10 @@ mod auth_callback;
mod provider_applicants;
mod admin;
pub(crate) mod student;
mod form_closed_page;
pub use self::{
about_page::*, comparison_test::*, home_page::*, loaner_page::*, provider_portal::*,
unauthenticated_page::*, scholarship_info::*, provider_contact::*,
auth_callback::*, provider_applicants::*, admin::*, student::*,
auth_callback::*, provider_applicants::*, admin::*, student::*, form_closed_page::*,
};
+72 -55
View File
@@ -1,62 +1,79 @@
use leptos::prelude::*;
use crate::components::{Loading, OutlinedTextField, TextFieldType, ValidatedForm};
use crate::components::{Loading, OutlinedTextField, TextFieldType, ValidatedForm};
use crate::pages::student::form_setup::use_student_form;
use leptos::prelude::*;
#[component]
pub fn StudentAcademicsPage() -> impl IntoView {
let controller = use_student_form("academics");
let controller = use_student_form("academics", true);
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>
<Suspense fallback=Loading>
{move || {
controller
.data_resource
.get()
.map(|_| {
view! {
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm
title="Academic Information"
description="This information will allow you to be eligible for any scholarships that have specific academic/grade requirements."
on_submit=controller.submit_action
disabled=controller.submit_pending
>
<OutlinedTextField
label="Unweighted GPA:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="unweighted_gpa"
placeholder="4.0"
required=true
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Weighted GPA:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="weighted_gpa"
placeholder="4.5"
required=true
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Highest SAT Score:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="sat_score"
placeholder="1600"
required=false
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Highest ACT Score:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="act_score"
placeholder="36"
required=false
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Academic Honors:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="academic_honors"
placeholder="High Honors, etc."
required=false
input_type=TextFieldType::Text
/>
</ValidatedForm>
</div>
<div class="flex flex-1" />
}
})
.collect_view()
}}
</Suspense>
}
}
}
+56 -4
View File
@@ -1,13 +1,65 @@
use leptos::prelude::*;
use crate::components::DashboardButton;
#[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="flex-1" />
<div class="flex flex-2 flex-col gap-4 items-center m-4">
<div class="font-bold text-3xl">"Student Additional Information Forms"</div>
<div class="text-lg">
"This is just a placeholder for now. We'll show redirect buttons to each form."
"All forms listed under this tab are for additional eligibility factors that some scholarships require."
</div>
<div class="text-lg">
"None of these forms are required, but they may increase the total number of scholarships that you are eligible to be applied to."
</div>
<DashboardButton
title="Scholarship Book"
description="View scholarship requirements"
icon="/Book.png"
path="https://docs.google.com/document/d/1qVs0Lwvwl1bVGHznocoev3AF56KlhTfs/view"
/>
<DashboardButton
title="Academic Information"
description="Grades, tests, and more"
path="/students/additional/academics"
/>
<DashboardButton
title="Athletics"
description="Sports and outdoors"
path="/students/additional/athletics"
/>
<DashboardButton
title="Work Experience"
description="Add job information"
path="/students/additional/work-experience"
/>
<DashboardButton
title="Extracurriculars"
description="Clubs and activities"
path="/students/additional/extracurriculars"
/>
<DashboardButton
title="University Information"
description="Where are you going to school?"
path="/students/additional/university"
/>
<DashboardButton
title="Family Information"
description="General parent and sibling information"
path="/students/additional/family-info"
/>
<DashboardButton
title="Scholarship Specifics"
description="Scholarship-specific questions"
path="/students/additional/specifics"
/>
<DashboardButton
title="Financials"
description="FAFSA SAR Upload"
path="/students/additional/financials"
/>
</div>
<div class="flex-1" />
}
}
}
+13 -10
View File
@@ -1,20 +1,22 @@
use leptos::prelude::*;
use crate::components::{Loading, MultiEntry, ValidatedForm};
use crate::components::{Loading, MultiEntry, ValidatedForm};
use crate::input;
use crate::pages::student::form_setup::use_student_form;
use leptos::prelude::*;
#[component]
pub fn StudentAthleticsPage() -> impl IntoView {
let controller = use_student_form("athletics");
let controller = use_student_form("athletics", true);
view! {
<Show
when=move || controller.data_resource.get().is_some()
fallback=Loading
>
<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>
<ValidatedForm
title="Athletics Information"
description="This information will allow you to be eligible for any scholarships that have specific sports requirements."
on_submit=controller.submit_action
disabled=controller.submit_pending
>
<MultiEntry
label="Athletics Activities:"
description="Leave blank if not applicable."
@@ -38,7 +40,8 @@ pub fn StudentAthleticsPage() -> impl IntoView {
"Lacrosse",
"Softball",
"Tennis",
"Baseball"
"Baseball",
"Volleyball"
]
),
input!(
@@ -59,4 +62,4 @@ pub fn StudentAthleticsPage() -> impl IntoView {
<div class="flex flex-1" />
</Show>
}
}
}
+38 -7
View File
@@ -1,24 +1,29 @@
use leptos::prelude::*;
use crate::components::{Loading, OutlinedTextField, Select, TextFieldType, ValidatedForm};
use crate::pages::student::form_setup::use_student_form;
use leptos::prelude::*;
#[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
// 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");
let controller = use_student_form("demographics", true);
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.
<ValidatedForm
title="Student Demographic Form"
description="Here you'll set up some basic information about yourself."
on_submit=controller.submit_action
disabled=controller.submit_pending
>
<OutlinedTextField
label="First Name:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="first_name"
placeholder="John"
@@ -27,15 +32,37 @@ pub fn StudentDemographicsPage() -> impl IntoView {
/>
<OutlinedTextField
label="Last Name:"
disabled=controller.submit_pending
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="Date of Birth:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="dob"
placeholder="MM/DD/YYYY"
input_type=TextFieldType::Text
required=true
/>
<Select
label="Gender:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="gender"
required=true
value_list=vec![
"Male".to_string(),
"Female".to_string(),
"Prefer not to answer/Other".to_string(),
]
/>
<OutlinedTextField
label="Student ID Number:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="id_number"
placeholder="Your 7 digit number..."
@@ -44,6 +71,7 @@ pub fn StudentDemographicsPage() -> impl IntoView {
/>
<OutlinedTextField
label="Preferred Email:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="email"
placeholder="me@example.com"
@@ -52,6 +80,7 @@ pub fn StudentDemographicsPage() -> impl IntoView {
/>
<OutlinedTextField
label="Preferred Phone Number:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="phone_number"
placeholder="000-000-0000"
@@ -60,6 +89,7 @@ pub fn StudentDemographicsPage() -> impl IntoView {
/>
<OutlinedTextField
label="Street Address:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="street_address"
placeholder="234 Judd Rd"
@@ -68,6 +98,7 @@ pub fn StudentDemographicsPage() -> impl IntoView {
/>
<Select
label="Town:"
disabled=controller.submit_pending
value_list=vec!["Southbury".to_string(), "Middlebury".to_string()]
data_map=controller.data_map
data_member="town"
+165 -109
View File
@@ -1,9 +1,12 @@
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::components::{FileDrop, Header, Loading};
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_error, debug_log};
use leptos::prelude::*;
use leptos_oidc::AuthSignal;
#[component]
pub fn StudentEligibilityPage() -> impl IntoView {
@@ -13,43 +16,40 @@ pub fn StudentEligibilityPage() -> impl IntoView {
// 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 auth = expect_context::<AuthSignal>();
let user_claims = get_user_claims();
let user_id = Memo::new(move |_| {
user_claims.get()
.map(|info| info.claims.subject.clone())
let user_id = Memo::new(move |_| user_claims.get().map(|info| info.claims.subject.clone()));
let access_token = Memo::new(move |_| auth.get().authenticated().map(|a| a.access_token()));
Effect::new(move || {
debug_log!("User ID was updated: {}", user_id.get().unwrap_or_default());
});
let form_id = StoredValue::new("scholarship_essays".to_string());
let refresh_trigger = Trigger::new();
let scholarships_resource = Resource::new(
move || refresh_trigger.track(),
async move |_| {
get_all_scholarship_info().await
}
async move |_| get_all_scholarship_info().await,
);
let relations_resource = Resource::new(
move || refresh_trigger.track(),
async move |_| {
get_comparison_info().await
}
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
}
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> }
}>
<div class="flex-2 mb-4 flex flex-col">
<Suspense fallback=Loading>
{move || {
let Some(Ok(scholarships_list)) = scholarships_resource.get() else {
return view! {}.into_any();
@@ -60,110 +60,166 @@ pub fn StudentEligibilityPage() -> impl IntoView {
let Some(Ok(student_info)) = student_resource.get() else {
return view! {}.into_any();
};
let valid_list = scholarships_list.iter()
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();
let scholarship_name = scholarship
.data
.get("name")
.unwrap_or(&ValueType::String(None))
.as_string()
.ok()?
.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")
let requirements_map = scholarship
.data
.get("requirements")
.unwrap_or(&ValueType::Map(None))
.as_map().ok().flatten()
.as_map()
.ok()?
.unwrap_or_default()
.values().cloned()
.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>>()
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 }
})
});
debug_log!(
"Resolved requirements: {:?}", resolved_requirements.iter().flatten().map(|rel| rel.display_text.clone()).collect::<Vec<String>>()
);
let valid = !scholarship_name.is_empty()
&& resolved_requirements
.iter()
.all(|list| {
debug_log!("Checking list: {:?}", list);
if list.is_empty() {
true
} else {
list.iter()
.any(|requirement| {
let result = requirement.compare(&student_info);
match result.as_ref() {
Ok(status) => {
debug_log!(
"Requirement with id {} is Ok({})", requirement.id, status
);
status.clone()
}
Err(e) => {
debug_error!(
"Requirement with id {} failed: {}", requirement.id, e
);
false
}
}
})
}
});
if valid {
debug_log!("Scholarship is valid.");
Some(scholarship.clone())
let scholarship_id = StoredValue::new(scholarship.subject.clone());
let resource = Resource::new(
move || (
access_token.get(),
form_id.get_value(),
scholarship_id.get_value(),
),
async move |(access_token, form_id, scholarship_id)| {
let Some(access_token) = access_token else {
return Ok(Vec::new());
};
list_files(access_token, form_id, scholarship_id).await
},
);
Some((scholarship.clone(), resource))
} else {
None
}
})
.collect::<Vec<ExpandableInfo>>();
.collect::<
Vec<(ExpandableInfo, Resource<Result<Vec<String>, ServerFnError>>)>,
>();
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>
}
}
<Header
title="Eligible Scholarships"
description="This is the list of scholarships that you are eligible for, as well as those that require essays.
Once you have fulfilled the essay requirement, you will be automatically applied for that scholarship.
If there is no essay requirement, you have already been applied."
/>
}.into_any()
<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()
}}
</Suspense>
</div>
+6 -3
View File
@@ -5,19 +5,22 @@ use crate::pages::student::form_setup::use_student_form;
#[component]
pub fn StudentExtracurricularsPage() -> impl IntoView {
let controller = use_student_form("demographics");
let controller = use_student_form("demographics", true);
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"
title="Extracurricular and Community Service Information"
description="This information will allow you to be eligible for any scholarships that have specific extracurricular requirements."
on_submit=controller.submit_action
disabled=controller.submit_pending
>
// Inputs here.
<OutlinedTextField
label="Total number of service hours:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="service_hours"
placeholder="Any number..."
@@ -36,7 +39,7 @@ pub fn StudentExtracurricularsPage() -> impl IntoView {
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, ""
Text, "special_involvement", "Any special involvement:", false, "Club president, etc."
),
input!(
Checkbox, "grades", "Grades Participated:", true, ["9th", "10th", "11th", "12th"]
+17 -4
View File
@@ -4,16 +4,22 @@ use crate::pages::student::form_setup::use_student_form;
#[component]
pub fn StudentFamilyPage() -> impl IntoView {
let controller = use_student_form("family");
let controller = use_student_form("family", true);
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>
<ValidatedForm
title="Family Information"
description="General family information. You must fill out information about at least one parent."
on_submit=controller.submit_action
disabled=controller.submit_pending
>
// Inputs here.
<OutlinedTextField
label="Total number of children in family:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="num_children"
placeholder="Any number..."
@@ -22,6 +28,7 @@ pub fn StudentFamilyPage() -> impl IntoView {
/>
<OutlinedTextField
label="Total number of children currently attending college:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="num_children_college"
placeholder="Any number..."
@@ -30,6 +37,7 @@ pub fn StudentFamilyPage() -> impl IntoView {
/>
<OutlinedTextField
label="Parent/Guardian 1 Name:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="parent_one_name"
placeholder="John Smith"
@@ -39,6 +47,7 @@ pub fn StudentFamilyPage() -> impl IntoView {
// TODO Could this be a select dropdown?
<OutlinedTextField
label="Parent/Guardian 1 Relationship:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="parent_one_relationship"
placeholder="Mother/Father"
@@ -48,6 +57,7 @@ pub fn StudentFamilyPage() -> impl IntoView {
// TODO Get a better example
<OutlinedTextField
label="Parent/Guardian 1 Occupation:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="parent_one_occupation"
placeholder="Milkman"
@@ -56,6 +66,7 @@ pub fn StudentFamilyPage() -> impl IntoView {
/>
<OutlinedTextField
label="Parent/Guardian 1 Employer:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="parent_one_employer"
placeholder="Example Employer"
@@ -65,24 +76,25 @@ pub fn StudentFamilyPage() -> impl IntoView {
<OutlinedTextField
label="Parent/Guardian 2 Name:"
disabled=controller.submit_pending
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:"
disabled=controller.submit_pending
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:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="parent_two_occupation"
placeholder="Milkman"
@@ -91,6 +103,7 @@ pub fn StudentFamilyPage() -> impl IntoView {
/>
<OutlinedTextField
label="Parent/Guardian 2 Employer:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="parent_two_employer"
placeholder="Example Employer"
+47
View File
@@ -0,0 +1,47 @@
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">
<Header
title="Financial Information"
description="Please upload your FAFSA SAR (Student Aid Report) here. This information is NOT required,
but some scholarships may recognize this as a requirement."
/>
<Suspense fallback=Loading>
<FileDrop
name="fafsa"
form_id="financial_info"
existing_files=files_resource
allowed_types=vec![".pdf".to_string()]
/>
</Suspense>
</div>
<div class="flex flex-1" />
}
}
+55 -22
View File
@@ -1,37 +1,40 @@
use std::collections::HashMap;
use leptos::prelude::*;
use crate::common::ValueType;
use crate::pages::api::students::{get_student_data, PutStudentData};
use crate::components::{Toast, ToastContext};
use crate::pages::api::students::{PutStudentData, get_student_data};
use crate::utils::get_user_claims;
use leptos::prelude::*;
use std::collections::HashMap;
use uuid::Uuid;
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 data_resource: Resource<Result<HashMap<String, ValueType>, ServerFnError>>,
pub submit_pending: Memo<bool>,
}
pub fn use_student_form(
form_type: impl Into<Signal<String>>
form_type: impl Into<Signal<String>>,
enable_toasts: bool,
) -> 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 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
move || (user_id.get(), form_type.get(), refresh_trigger.track()),
async move |(id, form_type, _)| {
let Some(id) = id else {
return Ok(HashMap::new());
};
get_student_data(id, form_type).await
},
);
Effect::new(move || {
@@ -46,18 +49,48 @@ pub fn use_student_form(
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()
});
if let Some(id) = user_id.get() {
submit_action.dispatch(PutStudentData {
subject: id,
data_type: form_type.get(),
data_map: data_map.get(),
});
}
};
if enable_toasts {
let mut toasts = expect_context::<ToastContext>();
Effect::watch(
move || submit_action.value().get(),
move |value, _, _| {
let Some(result) = value else {
return;
};
let toast = match result {
Ok(_) => Toast::new()
.id(Uuid::new_v4())
.header("Submission Successful")
.msg("You may continue editing or fill out another form."),
Err(e) => Toast::new()
.id(Uuid::new_v4())
.header("Submission Failed")
.msg(e.to_string()),
};
toasts.toast(toast);
untrack(move || submit_action.clear());
},
false,
);
}
StudentFormInfo {
data_map,
refresh_trigger,
data_resource,
user_id,
submit_action: on_submit.into(),
submit_pending: submit_action.pending(),
}
}
}
+29 -45
View File
@@ -1,55 +1,39 @@
use std::collections::HashMap;
use leptos::logging::log;
use crate::components::DashboardButton;
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="flex-1" />
<div class="flex flex-2 flex-col gap-4 items-center m-4 mt-10">
<div class="font-bold text-3xl">"Region 15 Scholarship Application"</div>
<div class="text-lg">
"This is just a placeholder for now. We'll show some redirect buttons."
"Welcome to the R15 general scholarship application! If you have any questions, please direct them to Sara Smith at "
<a class="text-blue-500 underline" href="mailto:ssmith@region15.org">
"ssmith@region15.org"
</a>"."
</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 class="text-lg">"The due date for this form is on April 2nd, 2026 at 2:05pm."</div>
<div class="text-lg">"Here are some links to get you started:"</div>
<DashboardButton
title="Scholarship Book"
description="View scholarship requirements"
icon="/Book.png"
path="https://docs.google.com/document/d/1qVs0Lwvwl1bVGHznocoev3AF56KlhTfs/view"
/>
<DashboardButton
title="Demographics Form"
description="Basic student information"
icon="/Person_Black.png"
path="/students/demographics"
/>
<DashboardButton
title="Additional Forms"
description="Extra eligibility factors"
icon="/Form_Black.png"
path="/students/additional"
/>
</div>
<div class="flex-1" />
}
}
+22 -16
View File
@@ -1,24 +1,30 @@
mod shell;
mod home_page;
mod demographics_page;
mod academics_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 demographics_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;
mod specific_requirements_page;
pub use shell::*;
pub use home_page::*;
pub use demographics_page::*;
pub use academics_page::*;
pub use additional_page::*;
pub use athletics_page::*;
pub use academics_page::*;
pub use demographics_page::*;
pub use eligibility_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 financial_page::*;
pub use home_page::*;
pub use shell::*;
pub use test_display::*;
pub use university_page::*;
pub use work_experience_page::*;
pub use specific_requirements_page::*;
+8 -6
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_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 {
@@ -15,7 +15,7 @@ pub fn StudentShell() -> impl IntoView {
title="R15 Student Scholarship Application"
logo="/PHS_Stacked_Acronym.png"
/>
<div class="flex flex-1 min-h-0">
<AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage>
@@ -35,10 +35,12 @@ pub fn StudentShell() -> impl IntoView {
SubTabInfo::new("Extracurriculars", "extracurriculars"),
SubTabInfo::new("University Info", "university"),
SubTabInfo::new("Family Info", "family-info"),
SubTabInfo::new("Specifics", "specifics"),
SubTabInfo::new("Financials", "financials"),
],
),
),
TabInfo::new("Eligible Scholarships", "eligibility", None)
TabInfo::new("Eligible Scholarships", "eligibility", None),
]
/>
</Authenticated>
@@ -0,0 +1,91 @@
use crate::components::{Loading, RadioList, ValidatedForm};
use crate::pages::student::form_setup::use_student_form;
use leptos::prelude::*;
#[component]
pub fn StudentSpecificRequirementsPage() -> impl IntoView {
let controller = use_student_form("specifics", true);
view! {
<Suspense fallback=Loading>
{move || {
controller
.data_resource
.get()
.map(move |_| {
view! {
<div class="flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm
title="Scholarship-Specific Eligibility Requirements"
description="These questions are specific to some scholarships. Indicate yes or no for each question."
on_submit=controller.submit_action
disabled=controller.submit_pending
>
<RadioList
label="Have you attended BAS?"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="attend_bas"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
<RadioList
label="Are you a member of Midd-South Catholic Church?"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="middsouth_church"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
<RadioList
label="Do you have a family member that is or has served in the US military? (Note: you may be asked for proof of service)"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="family_military_service"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
<RadioList
label="Have you participated in Pomperaug Youth Baseball?"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="youth_baseball"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
<RadioList
label="Have you participated in the Panthers Aquatic Club?"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="aquatic_club"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
<RadioList
label="Are at least one of your parents a member of the Region 15 PEA?"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="pea_member"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
<RadioList
label="Have you participated in the PHS Music Program (Band, Chorus, etc)?"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="music_program"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
</ValidatedForm>
</div>
<div class="flex-1" />
}
.into_any()
})
.collect_view()
}}
</Suspense>
}
}
+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" />
}
}
+54 -9
View File
@@ -4,7 +4,7 @@ use crate::pages::student::form_setup::use_student_form;
#[component]
pub fn StudentUniversityPage() -> impl IntoView {
let controller = use_student_form("university");
let controller = use_student_form("university", true);
// college name, state, city, zip
// major, study field, intended career, college_acceptance
@@ -13,10 +13,16 @@ pub fn StudentUniversityPage() -> impl IntoView {
<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>
<ValidatedForm
title="University Information"
description="Here you may fill out general information about the university you plan to attend."
on_submit=controller.submit_action
disabled=controller.submit_pending
>
// Inputs here.
<OutlinedTextField
label="University Name:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="college_name"
placeholder="University of Example"
@@ -25,23 +31,25 @@ pub fn StudentUniversityPage() -> impl IntoView {
/>
<OutlinedTextField
label="University Street Address:"
disabled=controller.submit_pending
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:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="college_state"
placeholder="The State of Panic"
placeholder="CT, AZ, etc."
input_type=TextFieldType::Text
required=true
/>
<OutlinedTextField
label="University ZIP:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="college_zip"
placeholder="12345"
@@ -50,19 +58,56 @@ pub fn StudentUniversityPage() -> impl IntoView {
/>
<Select
label="Have you been sent an acceptance to the university?"
disabled=controller.submit_pending
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:"
<Select
label="Intended Major/Field of Study:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="major"
placeholder="Computer Science"
input_type=TextFieldType::Text
required=true
value_list=vec![
"Music".to_string(),
"Education".to_string(),
"Special Education".to_string(),
"Speech Pathology".to_string(),
"School Psychology".to_string(),
"School Counseling".to_string(),
"Occupational Therapy".to_string(),
"Physical Therapy".to_string(),
"Nursing".to_string(),
"Allied Health".to_string(),
"Fine/Performing Arts".to_string(),
"Writing/Communication".to_string(),
"History".to_string(),
"Government".to_string(),
"Political Science".to_string(),
"Social Work".to_string(),
"Sports Medicine".to_string(),
"Athletic Training".to_string(),
"Horticulture".to_string(),
"Conservation Studies".to_string(),
"Ecology".to_string(),
"Environmental Studies".to_string(),
"Urban Planning".to_string(),
"Landscaping".to_string(),
"Legal Studies".to_string(),
"Criminal Justice".to_string(),
"None of the above".to_string(),
]
/>
<OutlinedTextField
label="Intended Career:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="intended_career"
placeholder="Engineer, Artist, etc."
input_type=TextFieldType::Text
required=false
/>
</ValidatedForm>
</div>
+19 -8
View File
@@ -1,17 +1,22 @@
use leptos::prelude::*;
use crate::components::{Loading, MultiEntry, ValidatedForm};
use crate::components::{Loading, MultiEntry, ValidatedForm};
use crate::input;
use crate::pages::student::form_setup::use_student_form;
use leptos::prelude::*;
#[component]
pub fn StudentWorkExperiencePage() -> impl IntoView {
let controller = use_student_form("workexp");
let controller = use_student_form("workexp", true);
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>
<ValidatedForm
title="Student Work Experience"
description="Here you may add information about your past/current workplace."
on_submit=controller.submit_action
disabled=controller.submit_pending
>
<MultiEntry
label="Work Experience:"
description="Leave blank if not applicable."
@@ -22,9 +27,15 @@ pub fn StudentWorkExperiencePage() -> impl IntoView {
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"),
input!(
Text, "start_date", "Approximate start date:", true, "01/01/2000"
),
input!(
Text, "end_date", "End date:", true, "01/01/2026, or current if not applicable"
),
input!(
Number, "num_hours", "Approximate number of hours per week:", true, "25"
),
]
/>
</ValidatedForm>
@@ -32,4 +43,4 @@ pub fn StudentWorkExperiencePage() -> impl IntoView {
<div class="flex flex-1" />
</Show>
}
}
}