Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
957fc0c5eb | ||
|
|
8a2d5b0494 | ||
|
|
07fae5878f | ||
|
|
def5c8696b | ||
|
|
7c1e281749 | ||
|
|
49bed2f1e1 | ||
|
|
5929a450ae | ||
|
|
05ef850f82 | ||
|
|
1427c51bd3 | ||
|
|
c6867baad4 | ||
|
|
111a7aea86 | ||
|
|
371db711b2 | ||
|
|
c33a587d96 | ||
|
|
80340d99b9 | ||
|
|
142a60bc7c | ||
|
|
97707a9b80 | ||
|
|
4091bd0162 | ||
|
|
323116663a | ||
|
|
9a6cc934de | ||
|
|
6f600a4818 | ||
|
|
311d5647e8 | ||
|
|
3e4c287aa4 | ||
|
|
9d124c4098 | ||
|
|
840be3f0e5 | ||
|
|
53bdc96d01 | ||
|
|
b422e43288 | ||
|
|
838ae8ba0f | ||
|
|
fb90a9d8ac | ||
|
|
9d156c17e9 | ||
|
|
cc09769f28 | ||
|
|
e8b0ceeb7b | ||
|
|
c035115126 | ||
|
|
6f16bbe311 | ||
|
|
1bccecdc72 | ||
|
|
5cafc7e757 | ||
|
|
3f53e8fda1 | ||
|
|
b932388092 | ||
|
|
40d1ef2a5f | ||
|
|
64554d81bc | ||
|
|
c98621aefa | ||
|
|
be54aa8fd2 | ||
|
|
1afacf6008 | ||
|
|
c850ac208f | ||
|
|
0816f2da8b | ||
|
|
04ba0ff1ea | ||
|
|
f10f01534c | ||
|
|
dddc9e4192 | ||
|
|
ebb6fe500c | ||
|
|
e1de6064d9 | ||
|
|
f0db43817c | ||
|
|
12dfe502f4 |
No files matched your search
Generated
+1272
-347
File diff suppressed because it is too large.
Load diff
+19
-10
@@ -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
@@ -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
@@ -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=FormClosedPage>
|
||||
// <Route path=path!(":id") view=FormClosedPage />
|
||||
// <Route path=path!("") view=FormClosedPage />
|
||||
// </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
@@ -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
@@ -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::*,
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
use crate::pages::api::files::{DeleteFile, upload_file};
|
||||
use leptos::html::{Input, Label};
|
||||
use leptos::logging::debug_log;
|
||||
use leptos::prelude::*;
|
||||
use leptos::web_sys::{File, FormData};
|
||||
use leptos_icons::Icon;
|
||||
use leptos_oidc::AuthSignal;
|
||||
use leptos_use::{UseDropZoneOptions, UseDropZoneReturn, use_drop_zone_with_options};
|
||||
|
||||
#[component]
|
||||
pub fn FileDrop(
|
||||
/// 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 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| {
|
||||
if let Ok(files) = result {
|
||||
file_name_list.set(files);
|
||||
}
|
||||
})
|
||||
}}
|
||||
<label
|
||||
node_ref=zone_ref
|
||||
class="m-1.5 p-2.5 flex flex-col transition-color duration-200 rounded-lg border-2 items-center cursor-pointer"
|
||||
class=(["border-gray-400"], move || !is_over_drop_zone.get())
|
||||
class=(["border-blue-500"], move || is_over_drop_zone.get())
|
||||
>
|
||||
<div
|
||||
class="m-2 mb-4 transition-color duration-200 size-10"
|
||||
class=("text-gray-400", move || !is_over_drop_zone.get())
|
||||
class=("text-blue-500", move || is_over_drop_zone.get())
|
||||
>
|
||||
<Icon icon=icondata::FaFileCirclePlusSolid width="2.5rem" height="2.5rem" />
|
||||
</div>
|
||||
<div>"Drop a file here to upload"</div>
|
||||
<div class="text-sm text-gray-400">"Or click to select a file"</div>
|
||||
<input node_ref=input_ref type="file" class="hidden" on:change=on_input_change />
|
||||
</label>
|
||||
<For
|
||||
each=move || file_name_list.get()
|
||||
key=|file| file.clone()
|
||||
children=move |file| {
|
||||
let file = StoredValue::new(file);
|
||||
let on_click = move |_| {
|
||||
on_click_file_delete(file.get_value());
|
||||
};
|
||||
view! {
|
||||
<div class="m-1.5 flex flex-row relative items-center">
|
||||
<div class="flex-1">{file.get_value()}</div>
|
||||
<div
|
||||
class="text-gray-400 hover:text-red-700 right-0 cursor-pointer"
|
||||
on:click=on_click
|
||||
>
|
||||
<Icon icon=icondata::FaTrashCanRegular />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
/>
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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::*,
|
||||
};
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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! {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
#[cfg(feature = "ssr")]
|
||||
mod imports {
|
||||
pub use super::super::{COMPARISONS_TABLE, PROVIDER_CONTACT_TABLE, SCHOLARSHIPS_TABLE};
|
||||
pub use crate::common::ValueType;
|
||||
pub use crate::utils::server::create_dynamo_client;
|
||||
pub use aws_sdk_dynamodb::Client;
|
||||
pub use aws_sdk_dynamodb::error::ProvideErrorMetadata;
|
||||
pub use indexmap::IndexMap;
|
||||
pub use std::collections::HashMap;
|
||||
}
|
||||
|
||||
use crate::common::ExpandableInfo;
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[server]
|
||||
pub async fn get_scholarship_csv() -> Result<Vec<u8>, ServerFnError> {
|
||||
use imports::*;
|
||||
use leptos::logging::debug_log;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ResolvedScholarshipInfo {
|
||||
contact_email: String,
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
scholarship_name: String,
|
||||
sports_requirements: String,
|
||||
gpa_requirements: String,
|
||||
major_requirements: String,
|
||||
additional_requirements: String,
|
||||
community_requirements: String,
|
||||
residency_requirements: String,
|
||||
}
|
||||
|
||||
// Get the scholarships and comparisons
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
let scan_table = async |client: &Client, table_name| {
|
||||
client
|
||||
.scan()
|
||||
.table_name(table_name)
|
||||
.send()
|
||||
.await
|
||||
.map(|output| output.items)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
serde_dynamo::from_item(item).unwrap_or(ExpandableInfo::new("".to_string()))
|
||||
// item.iter()
|
||||
// .map(|(k, v)| (k.to_owned(), ValueType::from(v)))
|
||||
// .collect::<HashMap<String, ValueType>>()
|
||||
})
|
||||
.collect::<Vec<ExpandableInfo>>()
|
||||
};
|
||||
|
||||
let scan_table_hash_map = async |client: &Client, table_name| {
|
||||
client
|
||||
.scan()
|
||||
.table_name(table_name)
|
||||
.send()
|
||||
.await
|
||||
.map(|output| output.items)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
// serde_dynamo::from_item(item).unwrap_or(ExpandableInfo::new("".to_string()))
|
||||
item.iter()
|
||||
.map(|(k, v)| (k.to_owned(), ValueType::from(v)))
|
||||
.collect::<HashMap<String, ValueType>>()
|
||||
})
|
||||
.collect::<Vec<HashMap<String, ValueType>>>()
|
||||
};
|
||||
|
||||
let master_scholarships = scan_table(&client, SCHOLARSHIPS_TABLE).await;
|
||||
let master_contacts = scan_table_hash_map(&client, PROVIDER_CONTACT_TABLE).await;
|
||||
let master_relations = scan_table_hash_map(&client, COMPARISONS_TABLE).await;
|
||||
|
||||
// We now want to map the relations to the scholarships, as well as the contacts.
|
||||
// This should take the information from the current scholarship, then relate the IDs of the
|
||||
// requirements in the scholarship to the IDs of the relations in the master list.
|
||||
let resolved_scholarships = master_scholarships
|
||||
.into_iter()
|
||||
.map(|scholarship| {
|
||||
// debug_log!("Scholarship: {:?}", scholarship);
|
||||
// We want the requirements IDs and the provider ID.
|
||||
let requirements_map = scholarship
|
||||
.data
|
||||
.get("requirements")
|
||||
.unwrap_or(&ValueType::Map(None))
|
||||
.as_map()
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
let provider_id = scholarship
|
||||
.data
|
||||
.get("provider_id")
|
||||
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Search for the provider ID in the master list.
|
||||
let contact_info = master_contacts
|
||||
.iter()
|
||||
.find(|contact| {
|
||||
let subject = contact
|
||||
.get("subject")
|
||||
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
|
||||
debug_log!("Comparing {:?} and {:?}", subject, provider_id);
|
||||
|
||||
subject == provider_id
|
||||
})
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Resolve the requirements - this should resolve as another category-separated map,
|
||||
// but with requirement display text instead of IDs.
|
||||
let resolved_requirements = requirements_map
|
||||
.iter()
|
||||
.map(|(category, selected_val)| {
|
||||
let selected_list = selected_val.as_list().ok().flatten().unwrap_or_default();
|
||||
|
||||
let resolved_names = selected_list
|
||||
.into_iter()
|
||||
.map(|id_val| {
|
||||
let id = id_val.as_string().ok().flatten().unwrap_or_default();
|
||||
|
||||
let resolved_relation = master_relations
|
||||
.iter()
|
||||
.find(|item| {
|
||||
let comp_id = item
|
||||
.get("id")
|
||||
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
|
||||
comp_id == id
|
||||
})
|
||||
.cloned()
|
||||
.map(|v| {
|
||||
v.get("display_text").map(|text_opt| {
|
||||
text_opt.as_string().ok().flatten().unwrap_or_default()
|
||||
})
|
||||
})
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
resolved_relation
|
||||
})
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
(category.clone(), resolved_names)
|
||||
})
|
||||
.collect::<IndexMap<String, Vec<String>>>();
|
||||
debug_log!("Resolved requirements: {:?}", resolved_requirements);
|
||||
|
||||
let contact_email = contact_info
|
||||
.get("contact_email")
|
||||
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
|
||||
let first_name = contact_info
|
||||
.get("first_name")
|
||||
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
|
||||
let last_name = contact_info
|
||||
.get("last_name")
|
||||
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
|
||||
let scholarship_name = scholarship
|
||||
.data
|
||||
.get("name")
|
||||
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
|
||||
debug_log!(
|
||||
"{:?}, {:?}, {:?}, {:?}",
|
||||
scholarship_name,
|
||||
contact_email,
|
||||
first_name,
|
||||
last_name
|
||||
);
|
||||
|
||||
// We want to create some structure that's got the correct information. Specifically,
|
||||
// we only need the provider contact info, the scholarship's name, and the requirements
|
||||
// that they chose in plain text.
|
||||
ResolvedScholarshipInfo {
|
||||
first_name,
|
||||
last_name,
|
||||
contact_email,
|
||||
scholarship_name,
|
||||
sports_requirements: resolved_requirements
|
||||
.get("sports_participation")
|
||||
.map(|list| list.join("; "))
|
||||
.unwrap_or_default(),
|
||||
gpa_requirements: resolved_requirements
|
||||
.get("gpa")
|
||||
.map(|list| list.join("; "))
|
||||
.unwrap_or_default(),
|
||||
major_requirements: resolved_requirements
|
||||
.get("major")
|
||||
.map(|list| list.join("; "))
|
||||
.unwrap_or_default(),
|
||||
additional_requirements: resolved_requirements
|
||||
.get("misc")
|
||||
.map(|list| list.join("; "))
|
||||
.unwrap_or_default(),
|
||||
community_requirements: resolved_requirements
|
||||
.get("community_service")
|
||||
.map(|list| list.join("; "))
|
||||
.unwrap_or_default(),
|
||||
residency_requirements: resolved_requirements
|
||||
.get("residency")
|
||||
.map(|list| list.join("; "))
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<ResolvedScholarshipInfo>>();
|
||||
|
||||
// Now that we have a series of scholarships, we need to serialize them into CSV and send the
|
||||
// file to the client.
|
||||
let mut writer = csv::Writer::from_writer(Vec::new());
|
||||
resolved_scholarships.iter().for_each(|scholarship| {
|
||||
_ = writer.serialize(scholarship);
|
||||
});
|
||||
writer.flush()?;
|
||||
|
||||
let result = writer.into_inner()?;
|
||||
debug_log!("{:?}", String::from_utf8(result.clone()));
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use leptos::logging::debug_log;
|
||||
use leptos::prelude::*;
|
||||
use server_fn::codec::{MultipartData, MultipartFormData};
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
mod imports {
|
||||
pub use super::super::{MAIN_TABLE_NAME, S3_BUCKET_NAME};
|
||||
pub use crate::pages::api::tokens::validate_and_get_token_info;
|
||||
pub use crate::utils::server::create_aws_config;
|
||||
pub use aws_sdk_dynamodb::types::{AttributeValue, ReturnValue, Select};
|
||||
}
|
||||
|
||||
#[server(input = MultipartFormData)]
|
||||
pub async fn upload_file(data: MultipartData) -> Result<String, ServerFnError> {
|
||||
use imports::*;
|
||||
|
||||
let mut multipart = data.into_inner().unwrap();
|
||||
|
||||
let mut file_name = String::new();
|
||||
let mut file_bytes = Vec::<u8>::new();
|
||||
let mut form_id = String::new();
|
||||
let mut access_token = String::new();
|
||||
let mut input_name = String::new();
|
||||
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
let field_name = field.name().unwrap_or_default();
|
||||
|
||||
match field_name {
|
||||
"access_token" => access_token = field.text().await?,
|
||||
"form_id" => form_id = field.text().await?,
|
||||
_ => {
|
||||
// NOTE: If there are multiple files put here the function will only upload the last
|
||||
// one in the data.
|
||||
// We should likely use chunk streaming in the future, for safety.
|
||||
input_name = field
|
||||
.name()
|
||||
.ok_or(ServerFnError::new("Couldn't find input name"))?
|
||||
.to_owned();
|
||||
file_name = field
|
||||
.file_name()
|
||||
.ok_or(ServerFnError::new("Couldn't find file name"))?
|
||||
.to_owned();
|
||||
file_bytes = field.bytes().await?.to_vec();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if access_token.is_empty() {
|
||||
return Err(ServerFnError::new("Missing access token"));
|
||||
}
|
||||
if form_id.is_empty() {
|
||||
return Err(ServerFnError::new("Missing form ID"));
|
||||
}
|
||||
if file_name.is_empty() {
|
||||
return Err(ServerFnError::new("Missing file"));
|
||||
}
|
||||
if input_name.is_empty() {
|
||||
return Err(ServerFnError::new("Missing input file"));
|
||||
}
|
||||
|
||||
let user_claims = validate_and_get_token_info(access_token).await?;
|
||||
let subject = user_claims.subject;
|
||||
|
||||
let key = format!("{form_id}/{subject}/{input_name}/{file_name}");
|
||||
|
||||
debug_log!("Adding file to S3: {:?}", key);
|
||||
|
||||
let s3_client = aws_sdk_s3::Client::new(&create_aws_config().await);
|
||||
|
||||
s3_client
|
||||
.put_object()
|
||||
.bucket(S3_BUCKET_NAME)
|
||||
.key(&key)
|
||||
.body(file_bytes.into())
|
||||
.send()
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| ServerFnError::new(format!("Couldn't put file to S3: {}", e.to_string())))?;
|
||||
|
||||
// Besides uploading the file to S3, we also want to store a FILE entry in Dynamo.
|
||||
// This will allow the server to quickly return a list of all files that a specific user has
|
||||
// uploaded, or a list of all files submitted for a specific question with their keys for
|
||||
// additional access.
|
||||
let dynamo_client = aws_sdk_dynamodb::Client::new(&create_aws_config().await);
|
||||
|
||||
let dynamo_result = dynamo_client
|
||||
.put_item()
|
||||
.table_name(MAIN_TABLE_NAME)
|
||||
.item(
|
||||
"HK".to_string(),
|
||||
AttributeValue::S(format!("STUDENT#{subject}")),
|
||||
)
|
||||
.item(
|
||||
"SK".to_string(),
|
||||
AttributeValue::S(format!("FILE#{form_id}#{input_name}#{file_name}")),
|
||||
)
|
||||
.item(
|
||||
"file_name".to_string(),
|
||||
AttributeValue::S(file_name.clone()),
|
||||
)
|
||||
.item("file_key".to_string(), AttributeValue::S(key.clone()))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ServerFnError::new(format!(
|
||||
"Couldn't put file to Dynamo, file rolled back: {}",
|
||||
e.to_string()
|
||||
))
|
||||
});
|
||||
|
||||
// If Dynamo fails, we want to handle the error by rolling back the file. Then, we return a failure.
|
||||
if let Err(err) = dynamo_result {
|
||||
s3_client
|
||||
.delete_object()
|
||||
.bucket("leptos-scholarships")
|
||||
.key(&key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ServerFnError::new(format!(
|
||||
"Couldn't roll back S3 operation: {}",
|
||||
e.to_string()
|
||||
))
|
||||
})?;
|
||||
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(file_name)
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn delete_file(
|
||||
access_token: String,
|
||||
form_id: String,
|
||||
input_name: String,
|
||||
file_name: String,
|
||||
) -> Result<String, ServerFnError> {
|
||||
use imports::*;
|
||||
|
||||
let user_claims = validate_and_get_token_info(access_token).await?;
|
||||
let subject = user_claims.subject;
|
||||
|
||||
let entry_hk = format!("STUDENT#{subject}");
|
||||
let entry_sk = format!("FILE#{form_id}#{input_name}#{file_name}");
|
||||
|
||||
let key = format!("{form_id}/{subject}/{input_name}/{file_name}");
|
||||
|
||||
let config = create_aws_config().await;
|
||||
let s3_client = aws_sdk_s3::Client::new(&config);
|
||||
let dynamo_client = aws_sdk_dynamodb::Client::new(&config);
|
||||
|
||||
let previous_values = dynamo_client
|
||||
.delete_item()
|
||||
.table_name(MAIN_TABLE_NAME)
|
||||
.key("HK".to_string(), AttributeValue::S(entry_hk))
|
||||
.key("SK".to_string(), AttributeValue::S(entry_sk))
|
||||
.return_values(ReturnValue::AllOld)
|
||||
.send()
|
||||
.await?
|
||||
.attributes
|
||||
.unwrap_or_default();
|
||||
|
||||
let s3_result = s3_client
|
||||
.delete_object()
|
||||
.bucket(S3_BUCKET_NAME)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ServerFnError::from);
|
||||
|
||||
if let Err(err) = s3_result {
|
||||
// Add the key back to Dynamo and return an error.
|
||||
dynamo_client
|
||||
.put_item()
|
||||
.table_name(MAIN_TABLE_NAME)
|
||||
.set_item(Some(previous_values))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
ServerFnError::new(format!(
|
||||
"Couldn't roll back Dynamo operation: {}",
|
||||
err.to_string()
|
||||
))
|
||||
})?;
|
||||
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(file_name)
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn list_files(
|
||||
access_token: String,
|
||||
form_id: String,
|
||||
input_name: String,
|
||||
) -> Result<Vec<String>, ServerFnError> {
|
||||
use imports::*;
|
||||
|
||||
let user_claims = validate_and_get_token_info(access_token).await?;
|
||||
let subject = user_claims.subject;
|
||||
|
||||
let entry_hk = format!("STUDENT#{subject}");
|
||||
let entry_sk = format!("FILE#{form_id}#{input_name}");
|
||||
|
||||
debug_log!("Getting file list: HK = {}, SK = {}", entry_hk, entry_sk);
|
||||
|
||||
let dynamo_client = aws_sdk_dynamodb::Client::new(&create_aws_config().await);
|
||||
|
||||
dynamo_client
|
||||
.query()
|
||||
.table_name(MAIN_TABLE_NAME)
|
||||
.key_condition_expression("HK = :hk AND begins_with(SK, :sk)")
|
||||
.expression_attribute_values(":hk".to_string(), AttributeValue::S(entry_hk))
|
||||
.expression_attribute_values(":sk".to_string(), AttributeValue::S(entry_sk))
|
||||
.send()
|
||||
.await
|
||||
.map(|output| {
|
||||
let Some(items) = output.items else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
items
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
item.get("file_name")
|
||||
.map(|v| v.as_s().ok().cloned())
|
||||
.flatten()
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
})
|
||||
.map_err(|e| ServerFnError::new(format!("Couldn't list files: {}", e.to_string())))
|
||||
}
|
||||
+12
-6
@@ -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";
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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::*,
|
||||
};
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
@@ -59,4 +61,4 @@ pub fn StudentAthleticsPage() -> impl IntoView {
|
||||
<div class="flex flex-1" />
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use crate::common::{ComparisonData, ExpandableInfo, ValueType};
|
||||
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_log;
|
||||
use leptos::prelude::*;
|
||||
use crate::common::{ComparisonData, ExpandableInfo, ValueType};
|
||||
use crate::pages::api::{get_all_scholarship_info, get_comparison_info};
|
||||
use crate::pages::api::students::get_all_student_data;
|
||||
use crate::utils::get_user_claims;
|
||||
use leptos_oidc::AuthSignal;
|
||||
|
||||
#[component]
|
||||
pub fn StudentEligibilityPage() -> impl IntoView {
|
||||
@@ -13,43 +16,35 @@ 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 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 +55,158 @@ 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()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
debug_log!("Checking scholarship: {}", scholarship_name);
|
||||
|
||||
// Each scholarship will return Some(itself), or None. This is will be automatically
|
||||
// filtered based on the information. Invalid scholarships are None, along with those
|
||||
// that fail validation for any reason. Scholarships that are valid or did not choose
|
||||
// any requirements are Some(scholarship).
|
||||
let requirements_map = scholarship.data.get("requirements")
|
||||
let requirements_map = scholarship
|
||||
.data
|
||||
.get("requirements")
|
||||
.unwrap_or(&ValueType::Map(None))
|
||||
.as_map().ok().flatten()
|
||||
.as_map()
|
||||
.ok()
|
||||
.flatten()
|
||||
.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()
|
||||
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 }
|
||||
})
|
||||
list.iter()
|
||||
.fold(
|
||||
false,
|
||||
|prev, requirement| {
|
||||
let result = requirement
|
||||
.compare(&student_info)
|
||||
.unwrap_or(false);
|
||||
if prev { prev } else { result }
|
||||
},
|
||||
)
|
||||
});
|
||||
|
||||
if valid {
|
||||
debug_log!("Scholarship is valid.");
|
||||
Some(scholarship.clone())
|
||||
let scholarship_id = StoredValue::new(scholarship.subject.clone());
|
||||
let resource = Resource::new(
|
||||
move || (
|
||||
auth
|
||||
.try_with(|a| a.authenticated().map(|a| a.access_token()))
|
||||
.flatten(),
|
||||
form_id.get_value(),
|
||||
scholarship_id.get_value(),
|
||||
),
|
||||
async move |(access_token, form_id, scholarship_id)| {
|
||||
list_files(
|
||||
access_token.unwrap_or_default(),
|
||||
form_id,
|
||||
scholarship_id,
|
||||
)
|
||||
.await
|
||||
},
|
||||
);
|
||||
Some((scholarship.clone(), resource))
|
||||
} else {
|
||||
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>
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::collections::HashMap;
|
||||
use leptos::prelude::*;
|
||||
use uuid::Uuid;
|
||||
use crate::common::ValueType;
|
||||
use crate::components::{Toast, ToastContext};
|
||||
use crate::pages::api::students::{get_student_data, PutStudentData};
|
||||
use crate::utils::get_user_claims;
|
||||
|
||||
@@ -9,11 +11,13 @@ pub struct StudentFormInfo {
|
||||
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();
|
||||
|
||||
@@ -53,11 +57,39 @@ pub fn use_student_form(
|
||||
});
|
||||
};
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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::*;
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user