Compare commits

...
70 Commits
Author SHA1 Message Date
Lucas Vas 032e978a34 Merge pull request #56 from R15-Software-Dev/feat/table-changes
Final provider changes
2026-01-19 14:44:27 -05:00
alphafetus 11012bb567 feat: add applicants page fallback 2026-01-19 13:14:09 -05:00
alphafetus 354875186f chore: remove unused imports 2026-01-19 13:04:50 -05:00
alphafetus c0d3288e28 chore: simplify test page 2026-01-19 13:04:28 -05:00
alphafetus e508e0a916 chore: change to production tables
This should be handled by environment variables in the future.
2026-01-19 12:44:51 -05:00
Lucas Vas 444e5ffb72 Merge pull request #55 from R15-Software-Dev/ci/remove-debug-info
Release preparation for forms
2026-01-16 23:18:48 -05:00
Lucas Vas 67586d0424 Merge pull request #53 from R15-Software-Dev/feat/back-button
Create back button
2026-01-16 21:40:51 -05:00
alphafetus cc83d3f015 chore: additional styling changes 2026-01-16 21:35:45 -05:00
alphafetus bab6cdae28 chore: small reorganization to help error appearances 2026-01-16 21:27:49 -05:00
alphafetus 116d02029c chore: sort comparisons by alpha order 2026-01-16 21:24:34 -05:00
alphafetus b6bc412f56 chore: small wording change 2026-01-16 21:14:51 -05:00
alphafetus 84f27e3c9e chore: restyle loading component 2026-01-16 21:13:45 -05:00
alphafetus 0b2dc1b0c1 chore: various style changes throughout the app 2026-01-16 21:06:44 -05:00
alphafetus 081d4ca5ca chore: remove unused routes 2026-01-16 20:40:37 -05:00
alphafetus b17c462abb chore: remove debug information from form pages 2026-01-16 20:38:10 -05:00
alphafetus 0c00c59162 feat: implement back button for banner
This button is intended to bring the user all the way back to the specified dashboard. Another could be created to exit the form, but I don't think that's needed for now.
2026-01-16 20:27:01 -05:00
alphafetus ce05be8c02 Merge branch 'main' into feat/back-button 2026-01-16 10:07:44 -05:00
alphafetus 95b69a36a8 fix: remove infinite loop in submit_action effects 2026-01-16 10:07:19 -05:00
alphafetus e24dcd0a90 style: rustfmt run 2026-01-16 09:19:07 -05:00
Lucas Vas 9ebab0689a Merge pull request #50 from R15-Software-Dev/feat/date-component
Add date list component
2026-01-16 09:02:18 -05:00
alphafetus 84212d2360 fix: apply into_any on pages that will not compile
This is a production-only build failure due to type explosion.
2026-01-16 08:54:41 -05:00
alphafetus 0b4aae4db3 Merge branch 'main' into feat/date-component
# Conflicts:
#	src/components/lists/single/radio.rs
#	src/pages/home_page.rs
2026-01-16 08:27:27 -05:00
alphafetus c3dbe3e2ac style: remove annoying indent 2026-01-16 08:20:31 -05:00
alphafetus 87aa104055 feat: make banner text larger 2026-01-16 08:20:05 -05:00
alphafetus 6105fa3bd5 feat: add dates list to provider portal 2026-01-16 08:19:52 -05:00
alphafetus bde15de634 chore: remove testing dates list 2026-01-16 08:19:18 -05:00
alphafetus 7d7c96e642 feat: sort dates list before displaying the list 2026-01-15 13:57:59 -05:00
alphafetus f15d151694 feat: create date api and dynamo context
The DynamoClient context should be used across the entire application - there are some significant performance improvements when under real load.
2026-01-15 13:57:12 -05:00
alphafetus 5ea7461a4d chore: add new appearance for closed ranges 2026-01-15 12:13:24 -05:00
alphafetus 67d0f766f8 feat: implement automatic date status determination 2026-01-15 12:09:48 -05:00
alphafetus 09de9fcf91 feat: implement provider and student specific login contexts 2026-01-15 09:48:21 -05:00
alphafetus d42573bf65 feat: implement auth callback page
This page will allow users to redirect to their previous path when they sign in.
2026-01-15 08:37:43 -05:00
alphafetus 4e8f4a3c5e style: automatic rustfmt run 2026-01-15 07:30:32 -05:00
alphafetus 9eacc9a618 temp: pc change (add server function and date comparison) 2026-01-15 00:54:55 -05:00
alphafetus 6fdf7f0113 feat: add improvements to the date component 2026-01-14 12:56:32 -05:00
alphafetus e143149dac merge: add missing import 2026-01-14 11:38:53 -05:00
alphafetus 26ee1cf7b4 Merge branch 'main' into feat/date-component
# Conflicts:
#	src/app.rs
#	src/common/mod.rs
#	src/components/mod.rs
#	src/pages/api/scholarships.rs
#	src/pages/home_page.rs
#	src/pages/mod.rs
#	src/pages/scholarship_info.rs
2026-01-14 11:24:27 -05:00
Lucas Vas 73392d0f42 Merge pull request #49 from R15-Software-Dev/fix/toast-display-error
Show error toasts on failed submissions
2026-01-14 11:05:08 -05:00
Lucas Vas 9f208fb297 Merge pull request #47 from R15-Software-Dev/feat/radio-other
Add "Other" option to radio buttons
2026-01-14 09:43:29 -05:00
alphafetus ed0a9a0fff fix: show error toasts on failed submissions 2026-01-14 09:42:02 -05:00
Lucas Vas c4d5c061a7 Merge pull request #46 from R15-Software-Dev/feat/email-options
Create new email validation system
2026-01-14 09:32:07 -05:00
alphafetus f3e2a0cb83 feat: implement new email validation system 2026-01-14 08:42:07 -05:00
alphafetus 7cf2d132ae feat: add essay prompts to scholarship info form 2026-01-14 08:12:32 -05:00
alphafetus 6a9ce30834 fix: small hydration error, don't select other by default 2026-01-14 08:12:12 -05:00
alphafetus b19b696490 feat: implement other field for radio buttons 2026-01-14 07:47:08 -05:00
Lucas Vas 1d58e99878 Merge pull request #45 from R15-Software-Dev/feature/categorize-chips
Add major comparisons
2026-01-13 22:23:57 -05:00
alphafetus 8af64cbf35 feat: create static global constants for table names 2026-01-13 16:13:46 -05:00
alphafetus 5f6e0edab2 fix: create cleanup handlers for lists 2026-01-13 16:01:17 -05:00
alphafetus 22d2c96520 feat: add major comparisons to scholarship info form 2026-01-13 15:52:38 -05:00
alphafetus c1f0c0cb81 fix: add hydration effect to selectable lists 2026-01-13 15:52:15 -05:00
meraghon f401e994a4 Merge branch 'main' into feat/date-component
# Conflicts:
#	src/app.rs
#	src/common/mod.rs
2026-01-02 15:35:09 -05:00
meraghon bb9eb465f4 Display date status 2026-01-02 15:30:40 -05:00
meraghon 53fd3a56c4 add example usages for Single and Range 2025-12-22 11:40:52 -05:00
meraghon 24c20f4199 deleted unused file 2025-12-22 10:37:05 -05:00
meraghon 7cf2facb18 function renders status of each state 2025-12-22 10:36:56 -05:00
meraghon 03f7adb74d Created DateStatus enum 2025-12-22 10:36:07 -05:00
meraghon 9e7fa3afcc Merge branch 'main' into feat/date-component
# Conflicts:
#	src/app.rs
#	src/common/mod.rs
2025-12-22 08:05:00 -05:00
meraghon 5e9e9bcf58 Test date range enum 2025-12-19 10:28:19 -05:00
meraghon fab172c4d1 Implement function for date range enum in date.rs 2025-12-19 10:28:07 -05:00
meraghon 38a0482b8c Date range enum 2025-12-19 10:27:43 -05:00
meraghon ae320f9763 Merge branch 'main' into feat/date-component
# Conflicts:
#	src/common/mod.rs
#	src/components/mod.rs
2025-12-12 13:16:49 -05:00
meraghon d43a6ed153 Test date element with hard-coded struct info 2025-12-12 12:13:55 -05:00
meraghon e3ea13d07d Implement date element with struct 2025-12-12 12:13:41 -05:00
meraghon 7abb1c369f Create DateInfo struct 2025-12-12 12:13:25 -05:00
meraghon 5ad00e69af Create struct file 2025-12-09 11:51:28 -05:00
meraghon bbeb713fd6 Pictures need to start with "/" 2025-12-05 12:40:12 -05:00
meraghon 4115d24bd8 Added status to date 2025-12-05 12:39:41 -05:00
meraghon 3b1bf88567 Test in frontend 2025-12-05 11:59:39 -05:00
meraghon d5659f6da7 Added to mod.rs 2025-12-05 11:59:30 -05:00
meraghon 7645ecc240 Framework for date component design 2025-12-05 11:59:22 -05:00
45 changed files with 1742 additions and 716 deletions

No files matched your search

Generated
+217 -1
View File
@@ -1548,6 +1548,211 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "icondata"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "412c95f764f89aad1a8505aa4273d931b66d5a26d1e6609a37243784a9128ad9"
dependencies = [
"icondata_ai",
"icondata_bi",
"icondata_bs",
"icondata_cg",
"icondata_ch",
"icondata_core",
"icondata_fa",
"icondata_fi",
"icondata_hi",
"icondata_im",
"icondata_io",
"icondata_lu",
"icondata_mdi",
"icondata_oc",
"icondata_ri",
"icondata_si",
"icondata_tb",
"icondata_ti",
"icondata_vs",
"icondata_wi",
]
[[package]]
name = "icondata_ai"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "153b31389f611b00f7392c2f79b5bb8332e30a5eee5bffd66fc4fe767c1de59c"
dependencies = [
"icondata_core",
]
[[package]]
name = "icondata_bi"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f178e5643b61ddf06b4b0c68e0fa5674cbd750be9414e5e02e2e3dd740d9924"
dependencies = [
"icondata_core",
]
[[package]]
name = "icondata_bs"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a8a72809ffc53e8a61dd19079693dbc0478b7b27e09ca1c1e81c04e283f485"
dependencies = [
"icondata_core",
]
[[package]]
name = "icondata_cg"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db064a49229a38dd5bae614b194adc5e3ee5351f5c5d9e539cd159bf84a92539"
dependencies = [
"icondata_core",
]
[[package]]
name = "icondata_ch"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "998079d1d0673fbb1bd3753ef714e5668fb5f595ffa9bc89eb5b01b6b4f75ca5"
dependencies = [
"icondata_core",
]
[[package]]
name = "icondata_core"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c97be924215abd5e630d84e95a47c710138a6559b4c55039f4f33aa897fa859"
[[package]]
name = "icondata_fa"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0108c409b3c06be1124a377b827922ad6091cb9eb467d521ea1cb3b1c3d7a76"
dependencies = [
"icondata_core",
]
[[package]]
name = "icondata_fi"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0656636c0e5c070e8501667f287e1751ea4bec9945eed3c66ac472511632b9b0"
dependencies = [
"icondata_core",
]
[[package]]
name = "icondata_hi"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f8ba6ed6e155898d296fc5d4127eb651b667643368698dd02b1e9383eab3eef"
dependencies = [
"icondata_core",
]
[[package]]
name = "icondata_im"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e723800b6a6067a40cf311a9135d739412323f03f88e826a20f1249933187aa5"
dependencies = [
"icondata_core",
]
[[package]]
name = "icondata_io"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7886629becd205d6f035dc084b68d2b9cc5ba8b03714f2c533c921ed98c6525a"
dependencies = [
"icondata_core",
]
[[package]]
name = "icondata_lu"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f25663a6cc5371e9e33c98bab870f4aa2fd68c88853f1467c8fccfaf112782b1"
dependencies = [
"icondata_core",
]
[[package]]
name = "icondata_mdi"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9517acba09219ff7caebd797b2db68f09b3621c6bc848fe9894f2d30e4f6cdb"
dependencies = [
"icondata_core",
]
[[package]]
name = "icondata_oc"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "088863ff0b1dba4a6e8b3fec797be6ed8503c2d44fd47107f7f6da3abcb6cc87"
dependencies = [
"icondata_core",
]
[[package]]
name = "icondata_ri"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b1851b5662f59b888ba24595886a9e6b6f5c11151b3640ee1875810f7544865"
dependencies = [
"icondata_core",
]
[[package]]
name = "icondata_si"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68943eec8a766d8e5213385610aa47bf0bec5ddb76fdcf819721a05fb5c8f6c5"
dependencies = [
"icondata_core",
]
[[package]]
name = "icondata_tb"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16f08d4ae577034728dc8856d3bdbced6a406aadfd7386e0e24e5efb7be98685"
dependencies = [
"icondata_core",
]
[[package]]
name = "icondata_ti"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a26660305b27c2831c8e701434e55f6fb589c12bdd43e5ec1e2ae270dbeb185e"
dependencies = [
"icondata_core",
]
[[package]]
name = "icondata_vs"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe2547385392ec350a5a7f80c463767b1d7f7a37cf1431cac2754918a4c1b6a9"
dependencies = [
"icondata_core",
]
[[package]]
name = "icondata_wi"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "352f5a5df000eabb35894cec75048e97a46d738ebdaf1e060670b0cf473a2419"
dependencies = [
"icondata_core",
]
[[package]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.1.1" version = "2.1.1"
@@ -1902,6 +2107,16 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "leptos_icons"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "274959df1a852fc431b03f27d9cd3129fb0f8cca9c8d383e4d668f62543cbec9"
dependencies = [
"icondata_core",
"leptos",
]
[[package]] [[package]]
name = "leptos_integration_utils" name = "leptos_integration_utils"
version = "0.8.7" version = "0.8.7"
@@ -2810,14 +3025,15 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"console_error_panic_hook", "console_error_panic_hook",
"icondata",
"leptos", "leptos",
"leptos_animate", "leptos_animate",
"leptos_axum", "leptos_axum",
"leptos_icons",
"leptos_meta", "leptos_meta",
"leptos_oidc", "leptos_oidc",
"leptos_router", "leptos_router",
"macros", "macros",
"regex",
"serde", "serde",
"serde_dynamo", "serde_dynamo",
"tokio", "tokio",
+2 -1
View File
@@ -23,11 +23,12 @@ uuid = { version = "1.17.0", features = ["serde", "v4", "js"] }
leptos_oidc = "0.9.0" leptos_oidc = "0.9.0"
base64 = "0.22.1" base64 = "0.22.1"
chrono = "0.4.42" chrono = "0.4.42"
regex = "1.11.2"
leptos_animate = "0.1.9" leptos_animate = "0.1.9"
macros = { path = "./macros" } macros = { path = "./macros" }
traits = { path = "./traits" } traits = { path = "./traits" }
icondata = { version = "0.7.0", features = ["font-awesome"] }
leptos_icons = "0.7.1"
[features] [features]
default = ["ssr"] default = ["ssr"]
+40 -59
View File
@@ -1,8 +1,10 @@
use crate::pages::{AboutPage, HomePage, ProviderPortal, ComparisonTestPage, ScholarshipInfoPage, TestPage, ProviderContactPage, LoanerShell, LoanerFallback, LoanerBorrowForm, LoanerReturnForm}; use crate::pages::{ProviderPortal, ScholarshipInfoPage, ProviderContactPage, LoanerShell, LoanerFallback, LoanerBorrowForm, LoanerReturnForm, AuthCallbackPage, ApplicantsPageFallback};
use leptos::prelude::*; use leptos::prelude::*;
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge}; use leptos_oidc::AuthSignal;
use leptos_router::{components::{Route, Router, Routes, ParentRoute}, path}; 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 crate::components::ToastList;
pub fn shell(options: LeptosOptions) -> impl IntoView { pub fn shell(options: LeptosOptions) -> impl IntoView {
@@ -31,10 +33,10 @@ pub fn App() -> impl IntoView {
view! { view! {
// injects a stylesheet into the document <head> // injects a stylesheet into the document <head>
// id=leptos means cargo-leptos will hot-reload this stylesheet // id=leptos means cargo-leptos will hot-reload this stylesheet
<Stylesheet id="leptos" href="/pkg/scholarships-rs-wasm.css"/> <Stylesheet id="leptos" href="/pkg/scholarships-rs-wasm.css" />
// sets the document title // sets the document title
<Title text="R15 Scholarship App DEV"/> <Title text="R15 Scholarship App DEV" />
// content for this welcome page // content for this welcome page
<Router> <Router>
@@ -43,54 +45,15 @@ pub fn App() -> impl IntoView {
} }
} }
fn use_origin() -> String {
#[cfg(target_arch = "wasm32")] {
use leptos::web_sys;
// Just get the current URL origin.
web_sys::window()
.unwrap()
.location()
.origin()
.unwrap_or("http://localhost:3000/".to_string())
}
#[cfg(not(target_arch = "wasm32"))] {
// Read the expected origin out of an environment variable.
std::env::var("LP_SITE_ORIGIN").unwrap_or("http://localhost:3000/".to_string())
}
}
#[component] #[component]
pub fn AppWithRoutes() -> impl IntoView { pub fn AppWithRoutes() -> impl IntoView {
provide_meta_context(); provide_meta_context();
let current_origin = use_origin();
// Authentication setup
let parameters = AuthParameters {
issuer: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Lfjuy5zaM".into(),
client_id: "10jr2h3vtpu9n7gj46pvg5qo2q".into(),
redirect_uri: format!("{}/", use_origin()),
post_logout_redirect_uri: current_origin,
scope: Some("openid%20profile%20email".into()),
audience: None,
challenge: Challenge::None,
};
let auth = Auth::signal();
provide_context(auth); // allows use of this signal in lower areas of the tree without
// explicitly passing it through the html tree
let _ = Auth::init(parameters);
view! { view! {
// injects a stylesheet into the document <head> // injects a stylesheet into the document <head>
// id=leptos means cargo-leptos will hot-reload this stylesheet // id=leptos means cargo-leptos will hot-reload this stylesheet
<Stylesheet id="leptos" href="/pkg/scholarships-rs-wasm.css" /> <Stylesheet id="leptos" href="/pkg/scholarships-rs-wasm.css" />
// <AuthLoading><p>"Authentication is loading"</p></AuthLoading>
// <AuthErrorContext><AuthErrorView/></AuthErrorContext>
// sets the document title // sets the document title
<Title text="R15 Scholarship App DEV" /> <Title text="R15 Scholarship App DEV" />
@@ -98,27 +61,45 @@ pub fn AppWithRoutes() -> impl IntoView {
<ToastList> <ToastList>
// TODO Create a 404 page // TODO Create a 404 page
<Routes fallback=|| "Page not found.".into_view()> <Routes fallback=|| "Page not found.".into_view()>
<Route path=path!("") view=HomePage/> <ProviderRoutes />
<Route path=path!("/about") view=AboutPage/> <LoanerRoutes />
<Route path=path!("/test_page") view=TestPage/>
<Route path=path!("/comparison") view=ComparisonTestPage />
<Route path=path!("/providers") view=ProviderPortal/>
<Route path=path!("/providers/profile") view=ProviderContactPage />
<ParentRoute path=path!("/providers/scholarships") view=ScholarshipInfoPage>
<Route path=path!(":id") view=ScholarshipInfoPage/>
<Route path=path!("") view=ScholarshipInfoPage/>
</ParentRoute>
<ParentRoute path=path!("loaners") view=LoanerShell>
<Route path=path!("") view=LoanerFallback />
<Route path=path!("borrowing") view=LoanerBorrowForm />
<Route path=path!("returning") view=LoanerReturnForm />
</ParentRoute>
</Routes> </Routes>
</ToastList> </ToastList>
</main> </main>
} }
} }
#[component(transparent)]
fn ProviderRoutes() -> impl MatchNestedRoutes + Clone {
view! {
<ParentRoute path=path!("/providers") view=ProviderLoginContext>
<Route path=path!("") view=ProviderPortal />
<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>
</ParentRoute>
}
.into_inner()
.into_any_nested_route()
}
#[component(transparent)]
fn LoanerRoutes() -> impl MatchNestedRoutes + Clone {
view! {
<ParentRoute path=path!("loaners") view=LoanerShell>
<Route path=path!("") view=LoanerFallback />
<Route path=path!("borrowing") view=LoanerBorrowForm />
<Route path=path!("returning") view=LoanerReturnForm />
</ParentRoute>
}
.into_inner()
.into_any_nested_route()
}
#[component] #[component]
pub fn AuthErrorView() -> impl IntoView { pub fn AuthErrorView() -> impl IntoView {
let auth = use_context::<AuthSignal>().expect("AuthError: RwSignal<AuthStore> was not found"); let auth = use_context::<AuthSignal>().expect("AuthError: RwSignal<AuthStore> was not found");
+63
View File
@@ -0,0 +1,63 @@
use chrono::{DateTime, FixedOffset, Local};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DateInfo {
pub id: String,
pub title: String,
pub date: DateRange,
pub description: String,
}
impl DateInfo {
pub fn get_start_date(&self) -> DateTime<FixedOffset> {
match self.date {
DateRange::Single(date) | DateRange::Range(date, _) => date,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DateRange {
Single(DateTime<FixedOffset>),
Range(DateTime<FixedOffset>, DateTime<FixedOffset>),
}
impl DateRange {
pub fn get_status(&self) -> DateStatus {
match self {
DateRange::Single(_) => DateStatus::Blank,
DateRange::Range(start_date, end_date) => {
let local = Local::now();
// Check the amount of time from now to the start date. Values that are negative
// indicate that the start date has already passed.
if dbg!(start_date.signed_duration_since(local).num_days()) > 0 {
DateStatus::Upcoming
} else {
// Check the amount of time from now to the end date. The value will be positive if
// the end date is in the future. We will be specific to the minute.
let duration_until = dbg!(end_date).signed_duration_since(local);
let num_days = dbg!(duration_until.num_days());
let num_mins = dbg!(duration_until.num_minutes());
if num_mins <= 0 {
DateStatus::Closed
} else if num_days <= 5 {
DateStatus::Deadline
} else {
DateStatus::Open
}
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd, Eq, Ord)]
pub enum DateStatus {
Closed,
Deadline,
Open,
Upcoming,
Blank,
}
+1 -1
View File
@@ -15,7 +15,7 @@ use std::collections::HashMap;
/// now, the table will simply create a single massive `Map` field that contains all the stored /// now, the table will simply create a single massive `Map` field that contains all the stored
/// information in the `HashMap`. We'd prefer to be able to get single values if possible, /// information in the `HashMap`. We'd prefer to be able to get single values if possible,
/// especially for interoperability with other applications, should they be created. /// especially for interoperability with other applications, should they be created.
#[derive(Debug, Clone, Serialize, Deserialize, Reactive, )] #[derive(Debug, Clone, Serialize, Deserialize, Reactive)]
pub struct ExpandableInfo { pub struct ExpandableInfo {
/// The main key member. Required and used to get/put the information in a database. /// The main key member. Required and used to get/put the information in a database.
pub subject: String, pub subject: String,
+6 -2
View File
@@ -1,10 +1,14 @@
mod comparison; mod comparison;
mod date_info;
mod expandable_info; mod expandable_info;
mod form_params; mod form_params;
mod form_schema; mod form_schema;
mod submit_status;
mod user; mod user;
mod value_type; mod value_type;
mod submit_status;
pub mod animations; pub mod animations;
pub use self::{comparison::*, expandable_info::*, form_schema::*, user::*, value_type::*, form_params::*, submit_status::*}; pub use self::{
comparison::*, date_info::*, expandable_info::*, form_params::*, form_schema::*,
submit_status::*, user::*, value_type::*,
};
+1 -1
View File
@@ -8,7 +8,7 @@ pub fn ActionButton(
) -> impl IntoView { ) -> impl IntoView {
view! { view! {
<button <button
class="m-1.5 p-2.5 rounded-lg font-semibold text-white transition-all duration-400 cursor-pointer" class="m-1.5 p-2.5 min-w-20 rounded-lg font-semibold text-white transition-all duration-400 cursor-pointer"
class=(["bg-red-800", "hover:bg-red-900"], move || !disabled.get()) class=(["bg-red-800", "hover:bg-red-900"], move || !disabled.get())
class=(["bg-gray-600", "pointer-events-none"], move || disabled.get()) class=(["bg-gray-600", "pointer-events-none"], move || disabled.get())
r#type=button_type r#type=button_type
+39 -13
View File
@@ -1,5 +1,6 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos_router::hooks::use_navigate; use leptos_router::hooks::use_navigate;
use leptos_icons::Icon;
/// # Banner Component /// # Banner Component
/// ///
@@ -23,26 +24,51 @@ pub fn Banner(
#[prop(into)] #[prop(into)]
logo: String, logo: String,
/// The route that the navigates user to a page (home page). /// The route that the navigates user to a page (home page).
#[prop(into, default = String::from("/"))] #[prop(into, optional)]
path: String, path: Signal<String>,
) -> impl IntoView { ) -> impl IntoView {
let navigate = use_navigate(); let navigate = use_navigate();
let on_click = move |_| navigate(&path, Default::default()); let show_back = Signal::derive(move || !path.get().is_empty());
let on_click = move |_| {
let path = path.get();
if !path.is_empty() {
navigate(&path, Default::default());
}
};
view! { view! {
<div class="mx-auto"> <div class="mx-auto">
<div class="bg-red-900 shadow-lg shadow-[inset_0_2px_4px_0_rgba(0,0,0,0.3),0_4px_6px_-1px_rgba(127,29,29,0.3),0_2px_4px_-1px_rgba(127,29,29,0.2)]"> <div class="bg-red-900 shadow-lg shadow-[inset_0_2px_4px_0_rgba(0,0,0,0.3),0_4px_6px_-1px_rgba(127,29,29,0.3),0_2px_4px_-1px_rgba(127,29,29,0.2)]">
<div class="flex items-center gap-4 p-6"> <div class="flex flex-row items-center gap-4 p-6 pb-2">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<img src={logo.clone()} class="h-10 w-10 cursor-pointer" alt="icon" on:click=on_click/> <img
</div> src=logo.clone()
<div class="flex-1"> class="h-10 w-10 cursor-pointer"
<h2 class="text-white text-lg font-bold">{title.clone()}</h2> alt="icon"
</div> on:click=on_click
/>
</div>
<div class="flex-1">
<h2 class="text-white text-2xl font-bold">{title.clone()}</h2>
</div>
</div>
<div
class="flex flex-row items-center gap-4"
class=("p-2", move || !show_back.get())
>
<Show when=move || show_back.get()>
<a
href=path
class="flex flex-row items-center gap-2 m-2 ml-14 text-gray-200 hover:underline"
>
<Icon icon=icondata::FaArrowLeftSolid />
<span>"Back to Dashboard"</span>
</a>
</Show>
</div>
</div> </div>
</div>
</div> </div>
} }
} }
+8 -11
View File
@@ -41,21 +41,18 @@ pub fn DashboardButton(
<button <button
type="button" type="button"
class="dashboard-button flex items-start gap-3 rounded-lg bg-white border-grey-300 p-6 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 hover:bg-gray-100 transition cursor-pointer w-full text-left
shadow-[inset_0_0_6px_rgba(0,0,0,0.12)]" shadow-[inset_0_0_6px_rgba(0,0,0,0.12)]"
on:click=on_click> on:click=on_click
>
<div class="flex flex-col"> <div class="flex flex-col">
<img src={icon.clone()} class="h-8 w-8" alt="icon"/> <img src=icon.clone() class="h-8 w-8" alt="icon" />
<h3 class="font-semibold text-base"> <h3 class="font-semibold text-base">{title.clone()}</h3>
{title.clone()}
</h3>
<p class="text-sm text-gray-600 pt-6"> <p class="text-sm text-gray-600 pt-6">{description.clone()}</p>
{description.clone()}
</p>
</div> </div>
</button> </button>
} }.into_any()
} }
+124
View File
@@ -0,0 +1,124 @@
use crate::common::{DateInfo, DateRange, DateStatus};
use leptos::prelude::*;
fn render_dates(dates: DateRange) -> impl IntoView {
let date_text = match dates {
DateRange::Single(date) => format!("{}", date.format("%A %B %d, %I:%M%p")),
DateRange::Range(start_date, end_date) =>
format!("{} - {}",
start_date.format("%D %I:%M%p"),
end_date.format("%D %I:%M%p")
),
};
view! { <span>{move || date_text.clone()}</span> }
}
fn render_status(date: DateRange) -> impl IntoView {
let status = date.get_status();
let status_text = match dbg!(status.clone()) {
DateStatus::Blank => "",
DateStatus::Upcoming => "Upcoming",
DateStatus::Open => "Open",
DateStatus::Deadline => "Deadline",
DateStatus::Closed => "Closed",
};
let background_class = match status {
DateStatus::Blank => "hidden border-none",
DateStatus::Upcoming => "bg-yellow-100 text-yellow-800 border-yellow-100",
DateStatus::Open => "bg-green-100 text-green-800 border-green-100",
DateStatus::Deadline => "bg-red-100 text-red-800 border-red-100",
DateStatus::Closed => "bg-red-700 text-white border-red-700"
};
let classes =
format!("inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs
font-medium w-fit whitespace-nowrap shrink-0 {}",
background_class
);
view! { <span class=classes>{move || status_text}</span> }
}
/// # Date Component
/// A single date, for use in a [`DateList`] component.
///
/// Example usage:
/// ```
/// let date_info = DateInfo {
/// title: "Example Title".to_string(),
/// date: DateRange::Single("January 1st, 1990".to_string()),
/// description: "A description of the date.".to_string(),
/// status: DateStatus::Upcoming
/// }
///
/// view! {
/// <Date
/// info=date_info
/// />
/// }
/// ```
#[component]
fn Date(
/// Struct of title, date, and description
#[prop(into)] info: Signal<DateInfo>,
) -> impl IntoView {
view! {
<div class="flex items-start gap-3 rounded-lg border-grey-300 p-3
w-full text-left shadow-lg">
// <div class="flex-shrink-0 mt-0.5">
// <img src={icon} class="h-4 w-4" alt="icon"/>
// </div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="font-bold text-md text-base">{info.get().title}</h3>
<h3 class="text-md text-base mt-1">
{move || render_dates(info.get().date)}
</h3>
<p class="text-sm text-gray-600">{info.get().description}</p>
</div>
<div class="flex-shrink-0 ml-2">{move || render_status(info.get().date)}</div>
</div>
</div>
</div>
}
}
///# Dates List Component
/// Displays a list of important dates in a column, with badges that draw attention to dates that
/// are more important.
///
/// Example usage:
/// ```
/// let dates = vec![
/// DateInfo {
/// title: "Example Title".to_string(),
/// date: DateRange::Single("January 1st, 1990".to_string()),
/// description: "A description of the date.".to_string(),
/// status: DateStatus::Upcoming
/// }
/// ]
///
/// view! {
/// <DateList dates=dates />
/// }
/// ```
#[component]
pub fn DateList(
#[prop(into)] dates: Signal<Vec<DateInfo>>,
) -> impl IntoView {
view! {
{move || {
dates
.get()
.iter()
.map(|info| {
view! { <Date info=info.clone() /> }
})
.collect_view()
}}
}.into_any()
}
+1 -1
View File
@@ -26,7 +26,7 @@ pub fn Header(
}); });
view! { view! {
<div class="m-1.5 mb-5"> <div class="flex flex-col gap-2 m-1.5 mt-3 mb-5">
<div class="text-3xl font-bold"> <div class="text-3xl font-bold">
{title} {title}
</div> </div>
+18 -17
View File
@@ -41,11 +41,11 @@ pub fn Checkbox(
#[prop(into)] value: Signal<String>, #[prop(into)] value: Signal<String>,
/// The name of the checkbox. This is used to group checkboxes together, so every checkbox /// The name of the checkbox. This is used to group checkboxes together, so every checkbox
/// in a group should have the same name. /// in a group should have the same name.
#[prop(into)] name: String, #[prop(into)] name: Signal<String>,
/// A [`Signal`] indicating whether the checkbox is disabled. /// A [`Signal`] indicating whether the checkbox is disabled.
#[prop(into)] disabled: Signal<bool>, #[prop(into)] disabled: Signal<bool>,
) -> impl IntoView { ) -> impl IntoView {
let id = create_unique_id(&name, &value.get_untracked()); let id = create_unique_id(&name.get(), &value.get_untracked());
view! { view! {
<label for=id class="flex items-center"> <label for=id class="flex items-center">
@@ -104,7 +104,7 @@ pub fn Checkbox(
#[component] #[component]
pub fn CheckboxList( pub fn CheckboxList(
/// The data member that this list should edit/display from the `data_map`. /// The data member that this list should edit/display from the `data_map`.
#[prop(into)] data_member: String, #[prop(into)] data_member: Signal<String>,
/// A [`Signal`] allowing access to a data store. /// A [`Signal`] allowing access to a data store.
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>, #[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
/// The available choices for this list. /// The available choices for this list.
@@ -134,32 +134,33 @@ pub fn CheckboxList(
let cloned = item.clone(); let cloned = item.clone();
move || cloned.clone() move || cloned.clone()
}); });
let checked = Signal::derive(move || { let checked = Signal::derive(move || {
controller.selected_list.get().contains(&item.get()) controller.selected_list.get().contains(&item.get())
}); });
let on_change = move || { let on_change = move || {
// Update the data list by checking if the value is present in it. If so, controller
// remove it. If not, add it. .selected_list
controller.selected_list.update(|list| { .update(|list| {
let item = item.get(); let item = item.get();
if list.contains(&item) { if list.contains(&item) {
list.retain(|val| *val != item); list.retain(|val| *val != item);
} else { } else {
list.push(item); list.push(item);
} }
}); });
controller.dirty.set(true); controller.dirty.set(true);
}; };
// Update the data list by checking if the value is present in it. If so,
// remove it. If not, add it.
view! { view! {
<Checkbox <Checkbox
checked=checked checked=checked
on_change=on_change on_change=on_change
// The actual selected values are tracked by this element, not by the checkboxes themselves. // The actual selected values are tracked by this element, not by the checkboxes themselves.
value=item value=item
name=data_member.clone() name=data_member
disabled=disabled disabled=disabled
/> />
} }
@@ -171,7 +172,7 @@ pub fn CheckboxList(
{move || { {move || {
match controller.error.get() { match controller.error.get() {
ValidationState::Invalid(msg) => msg, ValidationState::Invalid(msg) => msg,
_ => "There is no error - should not see this message.".to_string() _ => "There is no error - should not see this message.".to_string(),
} }
}} }}
</div> </div>
+64 -50
View File
@@ -27,14 +27,14 @@ use super::utils::use_selectable_list;
pub fn Chip( pub fn Chip(
#[prop(into)] display_text: Signal<String>, #[prop(into)] display_text: Signal<String>,
#[prop(into)] value: Signal<String>, #[prop(into)] value: Signal<String>,
#[prop(into)] name: String, #[prop(into)] name: Signal<String>,
#[prop(into)] on_change: Callback<(), ()>, #[prop(into)] on_change: Callback<(), ()>,
#[prop()] checked: Signal<bool>, #[prop()] checked: Signal<bool>,
#[prop(optional, into)] disabled: Signal<bool>, #[prop(optional, into)] disabled: Signal<bool>,
) -> impl IntoView { ) -> impl IntoView {
// Generate a unique id - ensure that the value doesn't contain spaces. // Generate a unique id - ensure that the value doesn't contain spaces.
// Without this id, checkbox/radio inputs can interfere with each other. // Without this id, checkbox/radio inputs can interfere with each other.
let id = create_unique_id(&name, &value.get_untracked()); let id = create_unique_id(&name.get(), &value.get_untracked());
view! { view! {
<label for=id> <label for=id>
@@ -45,16 +45,17 @@ pub fn Chip(
id=id.clone() id=id.clone()
prop:checked=checked prop:checked=checked
on:change=move |_| on_change.run(()) on:change=move |_| on_change.run(())
disabled=disabled /> disabled=disabled
/>
<div class="rounded-full p-2.5 <div class="rounded-full p-2.5
cursor-pointer text-base cursor-pointer text-base
bg-transparent border-1 border-gray-300 bg-transparent border-1 border-gray-300
shadow-md/50 hover:shadow-md/70 shadow-md/50 hover:shadow-md/70
peer-checked:bg-red-700 peer-checked:border-red-700 peer-checked:text-white peer-checked:bg-red-700 peer-checked:border-red-700 peer-checked:text-white
peer-disabled:peer-checked:bg-gray-700 peer-disabled:peer-checked:bg-gray-700
peer-disabled:peer-checked:border-gray-700 peer-disabled:peer-checked:border-gray-700
peer-disabled:cursor-default peer-disabled:cursor-default
transition-all duration-150"> transition-all duration-150">
<span>{display_text.clone()}</span> <span>{display_text.clone()}</span>
</div> </div>
</label> </label>
@@ -71,7 +72,7 @@ pub fn Chip(
#[component] #[component]
pub fn ChipsList( pub fn ChipsList(
/// The member in the data map that this input affects. /// The member in the data map that this input affects.
#[prop(into)] data_member: String, #[prop(into)] data_member: Signal<String>,
/// The data map that contains the list of values. This will be edited as the input changes. /// The data map that contains the list of values. This will be edited as the input changes.
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>, #[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
/// The list of strings that should be displayed in the list. /// The list of strings that should be displayed in the list.
@@ -95,50 +96,63 @@ pub fn ChipsList(
view! { view! {
<div class="flex flex-col"> <div class="flex flex-col">
<div class="m-1.5 mt-0 mb-0"> <div class="flex flex-col gap-2 m-1.5 mt-0 mb-0">
<span class="font-bold">{label}</span> <span class="font-bold">{label}</span>
<div class="flex flex-row flex-wrap gap-2 mb-1"> <div class="flex flex-row flex-wrap gap-2 mb-1">
{move || displayed_text.get().into_iter().zip(values.get()) {move || {
.into_iter() displayed_text
.map(|(text, value)| { .get()
let value = RwSignal::new(value); .into_iter()
.zip(values.get())
.into_iter()
.map(|(text, value)| {
let value = RwSignal::new(value);
let checked = Signal::derive(move || {
controller.selected_list.get().contains(&value.get())
});
let on_change = move || {
let value = value.get();
let list = controller.selected_list.get();
if list.contains(&value) {
allows_multiple
.get()
.then(|| {
controller
.selected_list
.update(|list| list.retain(|v| v != &value))
})
.or_else(|| Some(controller.selected_list.set(Vec::new())));
} else {
allows_multiple
.get()
.then(|| {
controller
.selected_list
.update(|list| list.push(value.clone()))
})
.or_else(|| Some(
controller.selected_list.set(vec![value.clone()]),
));
}
controller.dirty.set(true);
};
let checked = Signal::derive(move || {
controller.selected_list.get().contains(&value.get())
});
let on_change = move || {
// Update the data list by checking if the value is present in it. If so, // Update the data list by checking if the value is present in it. If so,
// remove it. If not, add it. // remove it. If not, add it.
let value = value.get();
let list = controller.selected_list.get();
if list.contains(&value) { view! {
allows_multiple.get() <Chip
.then(|| controller.selected_list.update(|list| list.retain(|v| v != &value))) checked=checked
.or_else(|| Some(controller.selected_list.set(Vec::new()))); on_change=on_change
} else { value=value
allows_multiple.get() display_text=text
.then(|| controller.selected_list.update(|list| list.push(value.clone()))) name=data_member
.or_else(|| Some(controller.selected_list.set(vec![value.clone()]))); disabled=disabled
/>
} }
})
controller.dirty.set(true); .collect_view()
}; }}
view! {
<Chip
checked=checked
on_change=on_change
value=value
display_text=text
name=data_member.clone()
disabled=disabled
/>
}
})
.collect_view()
}
</div> </div>
</div> </div>
<Show when=move || controller.show_errors.get()> <Show when=move || controller.show_errors.get()>
@@ -146,7 +160,7 @@ pub fn ChipsList(
{move || { {move || {
match controller.error.get() { match controller.error.get() {
ValidationState::Invalid(msg) => msg, ValidationState::Invalid(msg) => msg,
_ => "There is no error - should not see this message.".to_string() _ => "There is no error - should not see this message.".to_string(),
} }
}} }}
</div> </div>
+32 -13
View File
@@ -23,24 +23,37 @@ fn value_type_to_vec(value_type: &ValueType) -> Vec<String> {
} }
pub fn use_selectable_list( pub fn use_selectable_list(
data_member: String, data_member: Signal<String>,
data_map: RwSignal<HashMap<String, ValueType>>, data_map: RwSignal<HashMap<String, ValueType>>,
required: Signal<bool> required: Signal<bool>
) -> ListController { ) -> ListController {
//#region Central List Logic //#region Central List Logic
// Get default values from the data map. This will only run once, when the input is created
// and mounted to the DOM.
let temp_map = data_map.get_untracked();
let origin_value_type = temp_map.get(&data_member)
.unwrap_or(&ValueType::List(None));
let origin_list = value_type_to_vec(origin_value_type);
// "Raw" selected values - just strings, no ValueTyping // "Raw" selected values - just strings, no ValueTyping
let selected_list = RwSignal::new(origin_list); let selected_list = RwSignal::new(Vec::new());
let hydrated = RwSignal::new(false);
let dirty = RwSignal::new(false);
// Hydration effect - runs only once, to hydrate data.
Effect::new(move || {
if hydrated.get() {
return;
}
// React to data_map changes
let map = data_map.get();
let key = data_member.get();
let list = map
.get(&key)
.unwrap_or(&ValueType::List(None));
selected_list.set(value_type_to_vec(list));
hydrated.set(true);
});
Effect::new({ Effect::new({
let data_member = data_member.clone();
move || { move || {
// Convert selected strings into ValueTypes // Convert selected strings into ValueTypes
let typed_list = selected_list.with(|list| let typed_list = selected_list.with(|list|
@@ -51,7 +64,7 @@ pub fn use_selectable_list(
// Update the map // Update the map
data_map.update(|map| { data_map.update(|map| {
map.insert(data_member.clone(), ValueType::List(Some(typed_list))); map.insert(data_member.get(), ValueType::List(Some(typed_list)));
}); });
} }
}); });
@@ -69,17 +82,23 @@ pub fn use_selectable_list(
ValidationState::Valid ValidationState::Valid
} }
}); });
let dirty = RwSignal::new(false);
let show_errors = Signal::derive(move || dirty.get() && matches!(error.get(), ValidationState::Invalid(_))); let show_errors = Signal::derive(move || dirty.get() && matches!(error.get(), ValidationState::Invalid(_)));
let validator = RwSignal::new(InputState::new( let validator = RwSignal::new(InputState::new(
data_member.clone(), data_member.get(),
error.clone(), error.clone(),
dirty.clone() dirty.clone()
)); ));
validation_context.validators.update(|list| list.push(validator.clone())); validation_context.validators.update(|list| list.push(validator.clone()));
on_cleanup(move || {
validation_context.validators.update(|list| {
list.retain(|v| *v.get_untracked().input_name != *validator.get_untracked().input_name)
});
});
//#endregion //#endregion
ListController { ListController {
+164 -24
View File
@@ -2,6 +2,7 @@ use crate::common::ValueType;
use crate::components::utils::create_unique_id; use crate::components::utils::create_unique_id;
use leptos::prelude::*; use leptos::prelude::*;
use std::collections::HashMap; use std::collections::HashMap;
use leptos::html::Input;
use crate::components::{use_validation_context, InputState, ValidationState}; use crate::components::{use_validation_context, InputState, ValidationState};
#[component] #[component]
@@ -20,9 +21,7 @@ pub fn Radio(
// Using the "peer" class links this input with the div "peer" at the same level. // Using the "peer" class links this input with the div "peer" at the same level.
// By using the "peer-checked" selector this div can toggle states based on this // By using the "peer-checked" selector this div can toggle states based on this
// input's state (checked/unchecked) // input's state (checked/unchecked)
class=" class="relative peer shrink-0 hidden"
relative peer shrink-0
hidden"
type="radio" type="radio"
id=id.clone() id=id.clone()
name=name name=name
@@ -32,7 +31,7 @@ pub fn Radio(
/> />
// This div displays the box for the checkbox, and applies some styling to the child // This div displays the box for the checkbox, and applies some styling to the child
// span element. // span element.
<div class="relative inline-block h-5 w-5 m-1 mr-2 rounded-full border border-gray-300 bg-white <div class="relative inline-block h-5 w-5 m-1 mr-2 rounded-full border border-gray-400 bg-white
transition-bg duration-200 peer-checked:bg-red-700 peer-checked:border-red-700 transition-bg duration-200 peer-checked:bg-red-700 peer-checked:border-red-700
[&>span]:opacity-0 peer-checked:[&>span]:opacity-100 [&>span]:opacity-0 peer-checked:[&>span]:opacity-100
peer-disabled:bg-gray-600/33 peer-disabled:border-gray-600 peer-disabled:bg-gray-600/33 peer-disabled:border-gray-600
@@ -43,7 +42,94 @@ pub fn Radio(
rotate-45 transform border-3 border-3 rounded-full bg-white rotate-45 transform border-3 border-3 rounded-full bg-white
border-white transition-opacity duration-150"></span> border-white transition-opacity duration-150"></span>
</div> </div>
<span>{value.clone()}</span> <span class="px-0.5 py-0.5 flex-1">{value.clone()}</span>
</label>
}
}
/// # Ratio Other Component
/// This is a simple "other" field that contains a text input component. It can be selected using the
/// same logic as the [`Radio`] component, and also has a secondary `RwSignal` that contains the
/// current value of the embedded text input.
///
/// Example usage: <still working on this>
#[component]
fn RadioOther(
#[prop(into)] name: Signal<String>,
#[prop(into)] checked: Signal<bool>,
#[prop(into)] on_change: Callback<()>,
#[prop(into)] disabled: Signal<bool>,
#[prop(optional, into)] value: RwSignal<String>,
#[prop(into)] dirty: RwSignal<bool>,
#[prop(into)] placeholder: Signal<String>,
) -> impl IntoView {
let id = create_unique_id(&name.get_untracked(), &"other".to_string());
let input_ref = NodeRef::<Input>::new();
let input_disabled = Signal::derive(move || {
!checked.get() || disabled.get()
});
Effect::new(move || {
checked.get()
.then(|| {
if let Some(node) = input_ref.get() {
node.focus().expect("Couldn't focus input element");
}
});
});
let on_change_text = move |ev| {
let val = event_target_value(&ev);
value.set(val);
on_change.run(());
};
let on_blur_text = move |_| {
dirty.set(true);
};
view! {
<label for=id class="flex items_center">
<input
// Using the "peer" class links this input with the div "peer" at the same level.
// By using the "peer-checked" selector this div can toggle states based on this
// input's state (checked/unchecked)
class="relative peer shrink-0 hidden"
type="radio"
id=id.clone()
name=name
on:change=move |_| on_change.run(())
prop:checked=checked
disabled=disabled
/>
// This div displays the box for the checkbox, and applies some styling to the child
// span element.
<div class="relative inline-block min-h-5 min-w-5 h-5 w-5 m-1 mr-2 rounded-full border border-gray-400 bg-white
transition-bg duration-200 peer-checked:bg-red-700 peer-checked:border-red-700
[&>span]:opacity-0 peer-checked:[&>span]:opacity-100
peer-disabled:bg-gray-600/33 peer-disabled:border-gray-600
peer-disabled:peer-checked:bg-gray-600">
// This span is the check icon. It is centered within the div and has a border on the bottom
// and right sides, which is then rotated to appear like a checkmark.
<span class="absolute left-1/2 top-1/2 block h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2
rotate-45 transform border-3 border-3 rounded-full bg-white
border-white transition-opacity duration-150"></span>
</div>
<input
node_ref=input_ref
class="border-b-2 mb-1.5 px-1.5 py-0.5 rounded-t-xs bg-transparent relative flex-1 transition-all duration-150
border-red-700 disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
r#type="text"
disabled=input_disabled
placeholder=placeholder
prop:value=value
on:input=on_change_text
on:blur=on_blur_text
/>
</label> </label>
} }
} }
@@ -64,24 +150,47 @@ pub fn RadioList(
#[prop(optional, into)] disabled: Signal<bool>, #[prop(optional, into)] disabled: Signal<bool>,
#[prop(optional, into)] label: String, #[prop(optional, into)] label: String,
#[prop(optional, into)] required: Signal<bool>, #[prop(optional, into)] required: Signal<bool>,
#[prop(optional, into)] other_prompt: Signal<String>,
) -> impl IntoView { ) -> impl IntoView {
//#region Value Logic //#region Value Logic
// Store a single signal that is the currently selected value. We can only have one selected // Store a single signal that is the currently selected value. We can only have one selected
// value, so there's no list needed. The default selected value is whatever the map has selected // value, so there's no list needed. The default selected value is whatever the map has selected
// already, or nothing. // already, or nothing.
let default_value = match data_map.get_untracked().get(&data_member.get_untracked()) {
Some(ValueType::String(Some(value))) => value.clone(),
_ => String::new()
};
let selected_value = RwSignal::new(default_value); let hydrated = RwSignal::new(false);
let selected_value = RwSignal::new(String::new());
let other_checked = RwSignal::new(false);
let other_value = RwSignal::new(String::new());
// When the selected value changes, update the data map at the correct location // Hydration effect that resolves data. Sets up all data properly.
Effect::new({
let items = items.clone();
move || {
if hydrated.get() {
return;
}
let default_value = match data_map.get_untracked().get(&data_member.get_untracked()) {
Some(ValueType::String(Some(value))) => value.clone(),
_ => String::new()
};
if !items.contains(&default_value) && !default_value.is_empty() {
other_checked.set(true);
other_value.set(default_value.clone());
}
selected_value.set(default_value);
hydrated.set(true);
}
});
// When the other option is selected and the other value changes, update the selected value.
Effect::new(move || { Effect::new(move || {
data_map.update(|map| { other_checked.get()
map.insert(data_member.get(), ValueType::String(Some(selected_value.get()))); .then(|| selected_value.set(other_value.get()));
});
}); });
//#endregion //#endregion
@@ -96,13 +205,27 @@ pub fn RadioList(
dirty.get() && matches!(error.get(), ValidationState::Invalid(_)) dirty.get() && matches!(error.get(), ValidationState::Invalid(_))
}); });
let input_state = InputState::new( let input_state = RwSignal::new(InputState::new(
data_member.get_untracked(), data_member.get_untracked(),
error.clone(), error.clone(),
dirty.clone() dirty.clone()
); ));
validation_context.validators.update(|list| list.push(RwSignal::new(input_state))); validation_context.validators.update(|list| list.push(input_state));
on_cleanup(move || {
validation_context.validators.update(|list| {
list.retain(|v| *v.get_untracked().input_name != *input_state.get_untracked().input_name)
});
});
// When the selected value changes, update the data map at the correct location and validate.
Effect::new(move || {
error.set(validate(selected_value.get(), required.get()));
data_map.update(|map| {
map.insert(data_member.get(), ValueType::String(Some(selected_value.get())));
});
});
//#endregion //#endregion
//#region Render Logic //#region Render Logic
@@ -115,18 +238,23 @@ pub fn RadioList(
.into_iter() .into_iter()
.map(|item| { .map(|item| {
let item = RwSignal::new(item); let item = RwSignal::new(item);
let checked_signal = Signal::derive(move || { let checked_signal = Signal::derive(move || {
selected_value.get() == item.get() selected_value.get() == item.get() && !other_checked.get()
}); });
let on_change = move || { let on_change = move || {
// on_change only runs when the radio button is checked, not when unchecked. other_checked.set(false);
// Change the selected value to the new one.
selected_value.set(item.get()); selected_value.set(item.get());
error.set(validate(selected_value.get_untracked(), required.get_untracked())); error
.set(
validate(
selected_value.get_untracked(),
required.get_untracked(),
),
);
dirty.set(true); dirty.set(true);
}; };
// on_change only runs when the radio button is checked, not when unchecked.
// Change the selected value to the new one.
view! { view! {
<Radio <Radio
@@ -140,13 +268,25 @@ pub fn RadioList(
} }
}) })
.collect::<Vec<_>>()} .collect::<Vec<_>>()}
<Show when=move || !other_prompt.get().is_empty()>
<RadioOther
checked=other_checked
name=data_member
disabled=disabled
value=other_value
on_change=move |_| other_checked.set(true)
placeholder=other_prompt
dirty=dirty
/>
</Show>
</div> </div>
<Show when=move || show_errors.get()> <Show when=move || show_errors.get()>
<div class="text-red-600 text-sm mr-1.5 ml-1.5"> <div class="text-red-600 text-sm mr-1.5 ml-1.5">
{move || { {move || {
match error.get() { match error.get() {
ValidationState::Invalid(msg) => msg, ValidationState::Invalid(msg) => msg,
_ => "There is no error - should not see this message.".to_string() _ => "There is no error - should not see this message.".to_string(),
} }
}} }}
</div> </div>
+22 -13
View File
@@ -53,12 +53,18 @@ pub fn Select(
let dirty = RwSignal::new(false); let dirty = RwSignal::new(false);
let show_errors = Signal::derive(move || dirty.get() && matches!(error.get(), ValidationState::Invalid(_))); let show_errors = Signal::derive(move || dirty.get() && matches!(error.get(), ValidationState::Invalid(_)));
let input_state = InputState::new(data_member.get(), error.clone(), dirty.clone()); let input_state = RwSignal::new(InputState::new(data_member.get(), error.clone(), dirty.clone()));
let validation_context = use_validation_context() let validation_context = use_validation_context()
.expect("Could not find FormValidationRegistry context"); .expect("Could not find FormValidationRegistry context");
validation_context.validators.update(|list| list.push(RwSignal::new(input_state))); validation_context.validators.update(|list| list.push(input_state));
on_cleanup(move || {
validation_context.validators.update(|list| {
list.retain(|v| *v.get_untracked().input_name != *input_state.get_untracked().input_name)
});
});
//#endregion //#endregion
//#region Event Logic //#region Event Logic
@@ -87,9 +93,9 @@ pub fn Select(
<span class="ml-1.5 mb-0 font-bold">{label}</span> <span class="ml-1.5 mb-0 font-bold">{label}</span>
<select <select
class="relative flex-1 border-2 m-1.5 mt-0 p-1.5 rounded-md class="relative flex-1 border-2 m-1.5 mt-0 p-1.5 rounded-md
transition-border duration-150 transition-border duration-150
border-red-700 bg-transparent border-red-700 bg-transparent
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33" disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
on:change:target=on_change on:change:target=on_change
disabled=disabled disabled=disabled
> >
@@ -98,15 +104,18 @@ pub fn Select(
value="" value=""
disabled disabled
hidden hidden
selected={move || raw_value.get().is_empty()} selected=move || raw_value.get().is_empty()
>"Select one..."</option> >
"Select one..."
</option>
{move || { {move || {
value_list.iter().map( value_list
move |value| { .iter()
.map(move |value| {
let display = value.to_owned(); let display = value.to_owned();
view! { <option value={display}>{display.clone()}</option> } view! { <option value=display>{display.clone()}</option> }
} })
).collect::<Vec<_>>() .collect::<Vec<_>>()
}} }}
</select> </select>
</label> </label>
@@ -116,7 +125,7 @@ pub fn Select(
{move || { {move || {
match error.get() { match error.get() {
ValidationState::Invalid(msg) => msg, ValidationState::Invalid(msg) => msg,
_ => "There is no error - should not see this message.".to_string() _ => "There is no error - should not see this message.".to_string(),
} }
}} }}
</div> </div>
+1 -1
View File
@@ -3,7 +3,7 @@ use leptos::prelude::*;
#[component] #[component]
pub fn Loading() -> impl IntoView { pub fn Loading() -> impl IntoView {
view! { view! {
<div> <div class="size-20 mx-auto">
<img src="/loading.gif" /> <img src="/loading.gif" />
</div> </div>
} }
+5
View File
@@ -0,0 +1,5 @@
mod provider;
pub use provider::*;
mod student;
pub use student::*;
pub(crate) mod setup;
+28
View File
@@ -0,0 +1,28 @@
use leptos::prelude::*;
use leptos_router::components::Outlet;
use super::setup::provide_auth_context;
///# Provider Login Context Component
///
/// This component handles providing context to the provider login information.
/// It creates the corresponding [`AuthSignal`], specific to the student login page and redirects.
///
/// Example usage:
/// ```
/// view! {
/// <ProviderLoginContext>
/// // The rest of the student application. Don't forget AuthLoaded and
/// // Authenticated components!
/// </ProviderLoginContext>
/// }
#[component]
pub fn ProviderLoginContext() -> impl IntoView {
provide_auth_context(
crate::utils::use_origin(),
"https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Lfjuy5zaM",
"56c2bqvl021rv8d5mq36blt7jv",
"/providers/callback"
);
view! { <Outlet /> }
}
+24
View File
@@ -0,0 +1,24 @@
use leptos::prelude::*;
use leptos_oidc::{Auth, AuthParameters, Challenge};
pub fn provide_auth_context(
origin: String,
issuer: impl Into<String>,
client_id: impl Into<String>,
redirect_path: impl Into<String>,
) {
let params = AuthParameters {
issuer: issuer.into(),
client_id: client_id.into(),
redirect_uri: format!("{}{}", origin, redirect_path.into()),
post_logout_redirect_uri: origin,
scope: Some("openid%20profile%20email".into()),
audience: None,
challenge: Challenge::None,
};
let auth = Auth::signal();
provide_context(auth);
let _ = Auth::init(params);
}
+28
View File
@@ -0,0 +1,28 @@
use leptos::prelude::*;
use leptos_router::components::Outlet;
use super::setup::provide_auth_context;
///# Student Login Context Component
///
/// This component handles providing context to the student login information.
/// It creates the corresponding [`AuthSignal`], specific to the student login page and redirects.
///
/// Example usage:
/// ```
/// view! {
/// <StudentLoginContext>
/// // The rest of the student application. Don't forget AuthLoaded and
/// // Authenticated components!
/// </StudentLoginContext>
/// }
#[component]
pub fn StudentLoginContext() -> impl IntoView {
provide_auth_context(
crate::utils::use_origin(),
"https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Lfjuy5zaM",
"10jr2h3vtpu9n7gj46pvg5qo2q",
"/students/callback"
);
view! { <Outlet /> }
}
+3 -1
View File
@@ -1,6 +1,7 @@
mod action_button; mod action_button;
mod banner; mod banner;
mod dashboard_button; mod dashboard_button;
mod date;
mod loading; mod loading;
mod multi_entry; mod multi_entry;
mod panel; mod panel;
@@ -11,9 +12,10 @@ mod validated_form;
mod lists; mod lists;
mod toasts; mod toasts;
mod header; mod header;
pub mod login;
pub use self::{ pub use self::{
action_button::*, banner::*, dashboard_button::*, lists::*, action_button::*, banner::*, dashboard_button::*, lists::*,
loading::*, multi_entry::*, panel::*, row::*, text_field::*, loading::*, multi_entry::*, panel::*, row::*, text_field::*,
validated_form::*, toasts::*, header::* validated_form::*, toasts::*, header::*, date::*
}; };
+28 -19
View File
@@ -13,9 +13,15 @@ pub enum TextFieldType {
Text, Text,
/// A text field that accepts only numbers. /// A text field that accepts only numbers.
Number, Number,
/// A text field that accepts only Region 15 email addresses. /// A text field that accepts the specified email domains.
/// TODO Accept more than just Region 15 addresses. /// Values should be given as the string following the '@' in an address, or '*' for any.
Email ///
/// Example usage:
/// ```
/// TextFieldType::Email(vec!["gmail.com".to_string(), "customdomain.org".to_string()]);
/// TextFieldType::Email(vec!["*".to_string()]);
/// ```
Email(Vec<String>)
} }
/// Entry point for validating a text field. /// Entry point for validating a text field.
@@ -24,7 +30,7 @@ fn validate(required: bool, value: &str, input_type: &TextFieldType) -> Validati
ValidationState::Invalid("This field is required.".to_string()) ValidationState::Invalid("This field is required.".to_string())
} else { } else {
match input_type { match input_type {
TextFieldType::Email => validate_email(value), TextFieldType::Email(domains) => validate_email(value, domains),
TextFieldType::Number => validate_number(value), TextFieldType::Number => validate_number(value),
TextFieldType::Text => ValidationState::Valid TextFieldType::Text => ValidationState::Valid
} }
@@ -32,15 +38,18 @@ fn validate(required: bool, value: &str, input_type: &TextFieldType) -> Validati
} }
/// Validates an email address. /// Validates an email address.
fn validate_email(input: &str) -> ValidationState { fn validate_email(input: &str, valid_domains: &Vec<String>) -> ValidationState {
// TODO Accept more than just Region 15 addresses. // Check for wildcard domain. We won't check domains if it's present.
let pattern = regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@region15\.org$").expect("Invalid regex pattern"); let wild = valid_domains.contains(&"*".to_string());
if pattern.is_match(input) { input.split_once('@')
ValidationState::Valid .and_then(|(_, domain)| {
} else { (wild || valid_domains.contains(&domain.to_string()))
ValidationState::Invalid("Please provide a valid Region 15 email address.".to_string()) .then(|| ValidationState::Valid)
} .or_else(|| Some(ValidationState::Invalid(
format!("Email address must match one of the following: {}", valid_domains.join(", "))
)))
}).unwrap_or(ValidationState::Invalid("Invalid email address.".to_string()))
} }
/// Validates a number. /// Validates a number.
@@ -135,12 +144,12 @@ pub fn OutlinedTextField(
<input <input
node_ref=input_ref node_ref=input_ref
class="border-2 m-1.5 p-1.5 mt-0 rounded-md bg-transparent relative flex-1 class="border-2 m-1.5 p-1.5 mt-0 rounded-md bg-transparent relative flex-1
transition-all duration-150 transition-all duration-150
border-red-700 bg-transparent border-red-700 bg-transparent
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33" disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
r#type={str_type} r#type=str_type
disabled={disabled} disabled=disabled
placeholder={placeholder} placeholder=placeholder
prop:name=name prop:name=name
prop:value=raw_value prop:value=raw_value
on:input=on_input on:input=on_input
@@ -151,7 +160,7 @@ pub fn OutlinedTextField(
{move || { {move || {
match error.get() { match error.get() {
ValidationState::Invalid(msg) => msg, ValidationState::Invalid(msg) => msg,
_ => "There is no error - should not see this message.".to_string() _ => "There is no error - should not see this message.".to_string(),
} }
}} }}
</div> </div>
+5 -1
View File
@@ -149,7 +149,11 @@ pub fn ValidatedForm(
}; };
view! { view! {
<form novalidate on:submit=submit_success_event> <form
class="flex flex-col gap-2"
novalidate
on:submit=submit_success_event
>
<Header <Header
title=title title=title
description=description description=description
+1
View File
@@ -2,6 +2,7 @@ pub mod app;
mod common; mod common;
mod components; mod components;
mod pages; mod pages;
pub mod utils;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen] #[wasm_bindgen::prelude::wasm_bindgen]
+19 -4
View File
@@ -6,6 +6,11 @@ async fn main() {
use leptos::prelude::*; use leptos::prelude::*;
use leptos_axum::{LeptosRoutes, generate_route_list}; use leptos_axum::{LeptosRoutes, generate_route_list};
use scholarships_rs::app::*; use scholarships_rs::app::*;
use scholarships_rs::utils::server::create_dynamo_client;
use std::sync::Arc;
let dynamo_client = create_dynamo_client().await;
let shared_client = Arc::new(dynamo_client);
let conf = get_configuration(None).unwrap(); let conf = get_configuration(None).unwrap();
let addr = conf.leptos_options.site_addr; let addr = conf.leptos_options.site_addr;
@@ -14,10 +19,20 @@ async fn main() {
let routes = generate_route_list(App); let routes = generate_route_list(App);
let app = Router::new() let app = Router::new()
.leptos_routes(&leptos_options, routes, { .leptos_routes_with_context(
let leptos_options = leptos_options.clone(); &leptos_options,
move || shell(leptos_options.clone()) routes,
}) {
let shared_client = Arc::clone(&shared_client);
move || {
provide_context(Arc::clone(&shared_client));
}
},
{
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
}
)
.fallback(leptos_axum::file_and_error_handler(shell)) .fallback(leptos_axum::file_and_error_handler(shell))
.with_state(leptos_options); .with_state(leptos_options);
+67 -6
View File
@@ -1,6 +1,5 @@
use std::collections::HashMap;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use crate::pages::utils::server_utils::create_dynamo_client; use crate::utils::server::create_dynamo_client;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use aws_sdk_dynamodb::error::ProvideErrorMetadata; use aws_sdk_dynamodb::error::ProvideErrorMetadata;
@@ -12,9 +11,13 @@ use crate::common::{
ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison, ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison,
TextComparison, TextListComparison, ValueType, TextComparison, TextListComparison, ValueType,
}; };
use std::collections::HashMap;
use leptos::prelude::ServerFnError; use leptos::prelude::ServerFnError;
use leptos::server; use leptos::server;
#[cfg(feature = "ssr")]
static COMPARISONS_TABLE: &str = "leptos-comparisons";
/// Helper function to create all sports comparisons. Only used to create initial /// Helper function to create all sports comparisons. Only used to create initial
/// comparison data, and should be removed upon completion of a comparison editor page. /// comparison data, and should be removed upon completion of a comparison editor page.
#[allow(unused)] #[allow(unused)]
@@ -188,6 +191,64 @@ fn create_sports_comparisons() -> Vec<ComparisonData> {
] ]
} }
fn create_major_comparison(
id: impl Into<String>,
display_text: impl Into<String>,
) -> ComparisonData {
let display = display_text.into();
ComparisonData::new(
id,
"major",
ComparisonType::Text(TextComparison::Contains),
ValueType::String(Some(display.clone())),
"Majors",
display,
)
}
fn create_major_comparisons() -> Vec<ComparisonData> {
let display_texts = vec![
"Music",
"Education",
"Special Education",
"Speech Pathology",
"School Psychology",
"School Counseling",
"Occupational Therapy",
"Physical Therapy",
"Nursing",
"Allied Health",
"Fine/Performing Arts",
"Writing/Communication",
"History",
"Government",
"Political Science",
"Social Work",
"Sports Medicine",
"Athletic Training",
"Horticulture",
"Conservation Studies",
"Ecology",
"Environmental Studies",
"Urban Planning",
"Landscaping",
"Legal Studies",
"Criminal Justice",
];
let ids = display_texts
.iter()
.map(|text|
format!("{}_{}", "major", text.to_lowercase().replace(" ", "_").replace("/", "_"))
)
.collect::<Vec<String>>();
ids.iter().zip(display_texts).map(|(id, display_text)| {
create_major_comparison(id, display_text)
})
.collect::<Vec<ComparisonData>>()
}
#[allow(unused)] #[allow(unused)]
fn make_comp_list() -> Vec<ComparisonData> { fn make_comp_list() -> Vec<ComparisonData> {
let gpa_3 = ComparisonData::new( let gpa_3 = ComparisonData::new(
@@ -296,7 +357,7 @@ fn make_comp_list() -> Vec<ComparisonData> {
); );
let mut sports_comps = create_sports_comparisons(); let mut sports_comps = create_sports_comparisons();
let mut major_comps = Vec::<ComparisonData>::new(); let mut major_comps = create_major_comparisons();
let mut comp_list = vec![ let mut comp_list = vec![
gpa_3, gpa_3,
@@ -328,7 +389,7 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
for comparison in comp_list { for comparison in comp_list {
if let Err(err) = client if let Err(err) = client
.put_item() .put_item()
.table_name("leptos-comparison-test") .table_name(COMPARISONS_TABLE)
.set_item(Some(serde_dynamo::to_item(&comparison)?)) .set_item(Some(serde_dynamo::to_item(&comparison)?))
.send() .send()
.await .await
@@ -351,7 +412,7 @@ pub async fn get_comparison_info() -> Result<Vec<ComparisonData>, ServerFnError>
// id and display text, but we'll return the whole thing. // id and display text, but we'll return the whole thing.
match client match client
.scan() .scan()
.table_name("leptos-comparison-test") .table_name(COMPARISONS_TABLE)
.send() .send()
.await .await
{ {
@@ -377,7 +438,7 @@ pub async fn get_comparisons_categorized() -> Result<HashMap<String, Vec<Compari
match client match client
.scan() .scan()
.table_name("leptos-comparison-test") .table_name(COMPARISONS_TABLE)
.send() .send()
.await .await
{ {
+78
View File
@@ -0,0 +1,78 @@
use std::collections::HashMap;
use leptos::logging::log;
use leptos::prelude::*;
use crate::common::DateInfo;
#[cfg(feature = "ssr")]
static DATES_TABLE: &str = "leptos-dates";
#[cfg(feature = "ssr")]
use aws_sdk_dynamodb::{
types::{WriteRequest, PutRequest},
Client as DynamoClient
};
#[cfg(feature = "ssr")]
use crate::utils::server::create_dynamo_client;
#[cfg(feature = "ssr")]
use std::sync::Arc;
#[server]
pub async fn create_dates(dates: Vec<DateInfo>) -> Result<(), ServerFnError> {
// Serialize and put dates into the database.
let client = create_dynamo_client().await;
log!("Creating dates: {:?}", dates);
let requests = dates
.into_iter()
.map(|date| {
let item = serde_dynamo::to_item(&date)
.unwrap_or_default();
let put_request = PutRequest::builder()
.set_item(Some(item))
.build()
.expect("Couldn't build request, item is not present");
WriteRequest::builder().put_request(put_request).build()
})
.collect::<Vec<WriteRequest>>();
let mut request_map = HashMap::new();
request_map.insert(DATES_TABLE.to_string(), requests);
client
.batch_write_item()
.set_request_items(Some(request_map))
.send()
.await
.map(|_| ())
.map_err(ServerFnError::from)
}
#[server]
pub async fn get_important_dates() -> Result<Vec<DateInfo>, ServerFnError> {
let client = create_dynamo_client().await;
client
.scan()
.table_name(DATES_TABLE)
.send()
.await
.map(|output| {
if let Some(items) = output.items {
items
.into_iter()
.filter_map(|item| {
serde_dynamo::from_item::<_, DateInfo>(item)
.ok()
})
.collect::<Vec<DateInfo>>()
} else {
Vec::new()
}
})
.map_err(ServerFnError::from)
}
+4 -1
View File
@@ -1,4 +1,7 @@
mod comparisons; mod comparisons;
pub use comparisons::*;
mod scholarships; mod scholarships;
pub use scholarships::*;
mod dates;
pub use dates::*;
pub use self::{comparisons::*, scholarships::*};
+18 -17
View File
@@ -8,7 +8,7 @@ use leptos::leptos_dom::log;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use uuid::Uuid; use uuid::Uuid;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use crate::pages::utils::server_utils::create_dynamo_client; use crate::utils::server::{create_dynamo_client, into_attr_map};
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use crate::common::ValueType; use crate::common::ValueType;
@@ -17,7 +17,10 @@ use crate::common::ExpandableInfo;
use leptos::prelude::ServerFnError; use leptos::prelude::ServerFnError;
use leptos::server; use leptos::server;
#[server(GetScholarshipInfo, endpoint = "/scholarship/info/get")] #[cfg(feature = "ssr")]
static SCHOLARSHIPS_TABLE: &str = "leptos-scholarships";
#[server(GetScholarshipInfo)]
pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFnError> { pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFnError> {
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
@@ -26,7 +29,7 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
log!("Getting scholarship info using id {:?}", id); log!("Getting scholarship info using id {:?}", id);
match client match client
.get_item() .get_item()
.table_name("leptos-scholarship-test") .table_name(SCHOLARSHIPS_TABLE)
.key("subject", AttributeValue::S(id.clone())) .key("subject", AttributeValue::S(id.clone()))
.send() .send()
.await .await
@@ -47,9 +50,8 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
} }
} }
#[server(CreateScholarshipInfo, endpoint = "/scholarship/info/create")] #[server(CreateScholarshipInfo)]
pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerFnError> { pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerFnError> {
use crate::pages::utils::server_utils::create_dynamo_client;
use aws_sdk_dynamodb::error::ProvideErrorMetadata; use aws_sdk_dynamodb::error::ProvideErrorMetadata;
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
@@ -58,7 +60,7 @@ pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerF
match client match client
.put_item() .put_item()
.table_name("leptos-scholarship-test") .table_name(SCHOLARSHIPS_TABLE)
.set_item(Some(serde_dynamo::to_item(&info)?)) .set_item(Some(serde_dynamo::to_item(&info)?))
.send() .send()
.await .await
@@ -71,13 +73,13 @@ pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerF
} }
} }
#[server(GetAllScholarshipInfo, endpoint = "/scholarship/info/get-all")] #[server(GetAllScholarshipInfo)]
pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnError> { pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnError> {
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
log!("Getting all scholarship info"); log!("Getting all scholarship info");
match client match client
.scan() .scan()
.table_name("leptos-scholarship-test") .table_name(SCHOLARSHIPS_TABLE)
.send() .send()
.await .await
{ {
@@ -96,7 +98,7 @@ pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnE
} }
/// Gets all scholarships that are associated with the given scholarship provider's ID. /// Gets all scholarships that are associated with the given scholarship provider's ID.
#[server(GetProviderScholarships, "/providers/scholarships/get")] #[server(GetProviderScholarships)]
pub async fn get_provider_scholarships(provider_id: String) -> Result<Vec<ExpandableInfo>, ServerFnError> { pub async fn get_provider_scholarships(provider_id: String) -> Result<Vec<ExpandableInfo>, ServerFnError> {
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
@@ -104,7 +106,7 @@ pub async fn get_provider_scholarships(provider_id: String) -> Result<Vec<Expand
match client match client
.scan() .scan()
.table_name("leptos-scholarship-test") .table_name(SCHOLARSHIPS_TABLE)
.expression_attribute_values(":id", serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?) .expression_attribute_values(":id", serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?)
.filter_expression("provider_id = :id") .filter_expression("provider_id = :id")
.send() .send()
@@ -125,21 +127,20 @@ pub async fn get_provider_scholarships(provider_id: String) -> Result<Vec<Expand
} }
/// Creates a new scholarship with a unique ID, and then returns that ID. /// Creates a new scholarship with a unique ID, and then returns that ID.
#[server(RegisterScholarship, "/providers/scholarships/create")] #[server(RegisterScholarship)]
pub async fn register_scholarship(provider_id: String, scholarship_name: String) -> Result<String, ServerFnError> { pub async fn register_scholarship(provider_id: String) -> Result<String, ServerFnError> {
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
log!("Creating scholarship for provider with ID {:?}", provider_id); log!("Creating scholarship for provider with ID {:?}", provider_id);
let mut current_uuid = "testing".to_string(); let mut current_uuid = Uuid::new_v4().to_string();
let mut item = ExpandableInfo::new(current_uuid.clone()); let mut item = ExpandableInfo::new(current_uuid.clone());
item.data.insert("provider_id".to_string(), ValueType::String(Some(provider_id))); item.data.insert("provider_id".to_string(), ValueType::String(Some(provider_id)));
item.data.insert("scholarship_name".to_string(), ValueType::String(Some(scholarship_name)));
loop { loop {
let ser_item = serde_dynamo::to_item(&item)?; let ser_item = serde_dynamo::to_item(&item)?;
match client match client
.put_item() .put_item()
.table_name("leptos-scholarship-test") .table_name(SCHOLARSHIPS_TABLE)
.set_item(Some(ser_item)) .set_item(Some(ser_item))
.condition_expression("attribute_not_exists(subject)") .condition_expression("attribute_not_exists(subject)")
.send() .send()
@@ -170,7 +171,7 @@ pub async fn register_scholarship(provider_id: String, scholarship_name: String)
} }
/// Deletes a provider's scholarship given their provider ID and scholarship ID. /// Deletes a provider's scholarship given their provider ID and scholarship ID.
#[server(DeleteProviderScholarship, "/providers/scholarships/delete")] #[server(DeleteProviderScholarship)]
pub async fn delete_provider_scholarship(provider_id: String, scholarship_id: String) -> Result<(), ServerFnError> { pub async fn delete_provider_scholarship(provider_id: String, scholarship_id: String) -> Result<(), ServerFnError> {
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
@@ -180,7 +181,7 @@ pub async fn delete_provider_scholarship(provider_id: String, scholarship_id: St
// otherwise everyone can delete anyone else's scholarships. // otherwise everyone can delete anyone else's scholarships.
match client match client
.delete_item() .delete_item()
.table_name("leptos-scholarship-test") .table_name(SCHOLARSHIPS_TABLE)
.key("subject", AttributeValue::S(scholarship_id)) .key("subject", AttributeValue::S(scholarship_id))
.expression_attribute_values(":provider_id", serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?) .expression_attribute_values(":provider_id", serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?)
.condition_expression("provider_id = :provider_id") .condition_expression("provider_id = :provider_id")
+37
View File
@@ -0,0 +1,37 @@
use leptos::prelude::*;
use leptos_oidc::AuthSignal;
use leptos_router::hooks::use_navigate;
#[component]
pub fn AuthCallbackPage() -> impl IntoView {
// This should wait for authentication, read the values in session storage, and then redirect
// to the correct page. The session storage should contain a path, which we'll need to check.
let auth = expect_context::<AuthSignal>();
let navigate = use_navigate();
Effect::new(move || {
// Wait for auth to load, then read information.
if let Some(_) = auth.get().authenticated() {
// Read the sessionStorage to get the last navigated path.
let storage_opt = window().session_storage().ok().flatten();
let path = storage_opt
.and_then(|storage| storage.get_item("post_login_path").ok().flatten())
.unwrap_or_else(|| "/".to_string());
// Clear storage in case we need to reauth again
if let Some(storage) = window().session_storage().ok().flatten() {
let _ = storage.remove_item("post_login_path");
}
// Redirect to the stored path
navigate(&path, Default::default());
}
});
view! {
<div class="flex flex-col w-full mx-auto items-center justify-center">
<p class="text-xl font-bold mt-10 mx-auto">"Logging you in..."</p>
</div>
}
}
+77 -88
View File
@@ -1,16 +1,14 @@
use super::UnauthenticatedPage; use super::UnauthenticatedPage;
use super::api::{get_all_scholarship_info, get_comparison_info};
use super::home_page::get_submission; use super::home_page::get_submission;
use crate::common::{ use crate::common::{ComparisonData, ExpandableInfo, ValueType};
ComparisonData, ExpandableInfo, ValueType,
};
use crate::components::{ActionButton, Loading, Panel, Row}; use crate::components::{ActionButton, Loading, Panel, Row};
use leptos::either::Either; use leptos::either::Either;
use leptos::leptos_dom::logging::console_log; use leptos::leptos_dom::logging::console_log;
use leptos::logging::log; use leptos::logging::log;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated}; use leptos_oidc::{AuthLoaded, Authenticated};
use crate::pages::utils::get_user_claims; use crate::utils::get_user_claims;
use super::api::{get_comparison_info, get_all_scholarship_info};
#[component] #[component]
pub fn ComparisonTestPage() -> impl IntoView { pub fn ComparisonTestPage() -> impl IntoView {
@@ -59,93 +57,84 @@ pub fn ComparisonTestPage() -> impl IntoView {
let student_info = student_resource.get(); let student_info = student_resource.get();
let comparison_info = comparison_resource.get(); let comparison_info = comparison_resource.get();
let scholarship_info = scholarship_resource.get(); let scholarship_info = scholarship_resource.get();
match (student_info, comparison_info, scholarship_info) { match (student_info, comparison_info, scholarship_info) {
(Some(student), Some(comparisons), Some(scholarships)) => Either::Right(view! { (Some(student), Some(comparisons), Some(scholarships)) => {
<Panel> Either::Right(
<Row>
<p>{result_msg}</p>
</Row>
<Row>
<ActionButton on:click=move |_| {
// Iterate over all scholarships, get the comparison IDs,
// get ComparisonData from the IDs, then perform the
// comparisons and return a boolean. Alternatively, return
// a list of comparisons that complete and a list that don't.
let comp_member = "requirements".to_string();
let comparison_results = scholarships.iter().map(|scholarship| {
let list = match scholarship.data.get(&comp_member) {
Some(ValueType::List(Some(list))) => list,
_ => {
log!("Cannot find requirements, assuming none.");
return Ok(Some(scholarship)) // students will always qualify when there are no requirements
}
};
let mut final_result = Ok(true); view! {
<Panel>
// The list should exist, and it should contain a series of strings. <Row>
for val in list { <p>{result_msg}</p>
let comp_opt = match val { </Row>
ValueType::String(Some(comp_id)) => { <Row>
// Find the comparison <ActionButton on:click=move |_| {
comparisons.iter().find(|comp| comp.id == *comp_id) let comp_member = "requirements".to_string();
} let comparison_results = scholarships
_ => None .iter()
}; .map(|scholarship| {
let list = match scholarship.data.get(&comp_member) {
let comparison_outcome = match comp_opt { Some(ValueType::List(Some(list))) => list,
Some(comp) => { _ => {
// Perform the comparison log!("Cannot find requirements, assuming none.");
comp.compare(&student) return Ok(Some(scholarship));
} }
_ => Err("".to_string()) };
}; let mut final_result = Ok(true);
for val in list {
match comparison_outcome { let comp_opt = match val {
Ok(true) => {}, ValueType::String(Some(comp_id)) => {
Ok(false) => final_result = Ok(false), comparisons.iter().find(|comp| comp.id == *comp_id)
Err(e) => { }
final_result = Err(e); _ => None,
break; };
let comparison_outcome = match comp_opt {
Some(comp) => comp.compare(&student),
_ => Err("".to_string()),
};
match comparison_outcome {
Ok(true) => {}
Ok(false) => final_result = Ok(false),
Err(e) => {
final_result = Err(e);
break;
}
}
}
match final_result {
Ok(true) => Ok(Some(scholarship)),
Ok(false) => Ok(None),
Err(e) => Err(e),
}
})
.collect::<Vec<Result<Option<&ExpandableInfo>, String>>>();
let mut strings = Vec::new();
strings.push("Scholarship results: \n".to_string());
for result in comparison_results {
match result {
Ok(Some(scholarship_info)) => {
let name_member = "name".to_string();
let scl_name = scholarship_info
.data
.get(&name_member)
.unwrap()
.as_string()
.unwrap()
.unwrap();
strings.push(scl_name);
}
Ok(None) => {}
Err(msg) => strings.push(msg),
} }
} }
} let display_text = strings.join("\n").to_string();
result_msg.set(display_text);
match final_result { }>"Test Comparison"</ActionButton>
Ok(true) => Ok(Some(scholarship)), </Row>
Ok(false) => Ok(None), </Panel>
Err(e) => Err(e) },
} )
}).collect::<Vec<Result<Option<&ExpandableInfo>, String>>>(); }
_ => Either::Left(view! { <p>"Failed API requests."</p> }),
// Build a display string
let mut strings = Vec::new();
strings.push("Scholarship results: \n".to_string());
for result in comparison_results {
match result {
Ok(Some(scholarship_info)) => {
// Get the scholarship name.
let name_member = "name".to_string();
let scl_name = scholarship_info.data.get(&name_member).unwrap()
.as_string().unwrap().unwrap();
strings.push(scl_name);
},
Ok(None) => {},
Err(msg) => strings.push(msg)
}
}
let display_text = strings.join("\n").to_string();
result_msg.set(display_text);
}>"Test Comparison"</ActionButton>
</Row>
</Panel>
}),
_ => Either::Left(view! {
<p>"Failed API requests."</p>
})
} }
}} }}
</Suspense> </Suspense>
+17 -31
View File
@@ -8,10 +8,12 @@ use std::process::{Command, Stdio};
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use std::io::Write; use std::io::Write;
use crate::common::{ComparisonData, ExpandableInfo}; use crate::common::ExpandableInfo;
use crate::pages::UnauthenticatedPage; use crate::pages::UnauthenticatedPage;
use crate::pages::utils::get_user_claims; use crate::components::{
use crate::components::{ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row, Select, TextFieldType, ValidatedForm}; ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row,
Select, TextFieldType, ValidatedForm
};
use crate::input; use crate::input;
use leptos::leptos_dom::logging::console_log; use leptos::leptos_dom::logging::console_log;
use leptos::prelude::*; use leptos::prelude::*;
@@ -22,7 +24,7 @@ use leptos::task::spawn_local;
use leptos::web_sys::HtmlAnchorElement; use leptos::web_sys::HtmlAnchorElement;
use leptos::wasm_bindgen::JsCast; use leptos::wasm_bindgen::JsCast;
use std::collections::HashMap; use std::collections::HashMap;
use crate::pages::api::get_comparisons_categorized; use crate::utils::get_user_claims;
/// # Get Student Info /// # Get Student Info
/// Gets a student's information given their `subject`. /// Gets a student's information given their `subject`.
@@ -210,28 +212,6 @@ pub fn HomePage() -> impl IntoView {
}; };
//#endregion //#endregion
let comparison_resource = Resource::new(
move || user_subject.get(),
async move |_| get_comparisons_categorized().await
);
let comparison_info = RwSignal::new(HashMap::<String, Vec<ComparisonData>>::new());
Effect::new(move || {
match comparison_resource.get() {
Some(Ok(comparisons)) => {
log!("Found comparison info: {:?}", comparisons);
comparison_info.set(comparisons);
}
Some(Err(err)) => {
log!("Error while getting comparisons: {:?}", err);
}
_ => {
log!("An unknown error occurred.");
}
}
});
view! { view! {
<AuthLoaded fallback=Loading> <AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage> <Authenticated unauthenticated=UnauthenticatedPage>
@@ -285,7 +265,9 @@ pub fn HomePage() -> impl IntoView {
disabled=elements_disabled disabled=elements_disabled
data_member="contact_email" data_member="contact_email"
data_map=form_data data_map=form_data
input_type=TextFieldType::Email input_type=TextFieldType::Email(
vec!["fakeemail.org".to_string(), "gmail.com".to_string()],
)
required=true required=true
/> />
</Row> </Row>
@@ -331,6 +313,7 @@ pub fn HomePage() -> impl IntoView {
data_member="town" data_member="town"
data_map=form_data data_map=form_data
required=true required=true
other_prompt="Other..."
/> />
</Row> </Row>
<Row> <Row>
@@ -364,8 +347,12 @@ pub fn HomePage() -> impl IntoView {
data_map=form_data data_map=form_data
data_member="community_involvement" data_member="community_involvement"
schema=vec![ schema=vec![
input!(Text, "service_name", "Activity Name:", true, "Some service activity..."), input!(
input!(Number, "service_hours", "Total Service Hours", true, "20") Text, "service_name", "Activity Name:", true, "Some service activity..."
),
input!(
Number, "service_hours", "Total Service Hours", true, "20"
),
] ]
/> />
</Row> </Row>
@@ -375,8 +362,7 @@ pub fn HomePage() -> impl IntoView {
</Row> </Row>
} }
}) })
}} }} <Row>
<Row>
<ActionButton on:click=pdf_button_click>"Get PDF"</ActionButton> <ActionButton on:click=pdf_button_click>"Get PDF"</ActionButton>
</Row> </Row>
</Suspense> </Suspense>
+69 -64
View File
@@ -213,11 +213,7 @@ pub fn LoanerShell() -> impl IntoView {
// We'll create the page here - the sidebar contains all the buttons, while the form // We'll create the page here - the sidebar contains all the buttons, while the form
// container either shows forms or shows placeholder text. // container either shows forms or shows placeholder text.
view! { view! {
<Banner <Banner title="Chromebook Loaners" logo="/PHS_Stacked_Acronym.png" path="/loaners" />
title="Chromebook Loaners"
logo="/PHS_Stacked_Acronym.png"
path="/loaners"
/>
<div class="flex m-5"> <div class="flex m-5">
<div class="flex flex-1 flex-row gap-3"> <div class="flex flex-1 flex-row gap-3">
<div id="sidebar" class="flex flex-1 flex-col gap-2"> <div id="sidebar" class="flex flex-1 flex-col gap-2">
@@ -247,16 +243,9 @@ pub fn LoanerShell() -> impl IntoView {
#[component] #[component]
pub fn LoanerFallback() -> impl IntoView { pub fn LoanerFallback() -> impl IntoView {
view! { view! {
<div <div class="flex flex-col items-center justify-center" use:animate=pop_in_out()>
class="flex flex-col items-center justify-center" <div class="text-3xl font-bold mb-2">"Chromebook Loaner Form"</div>
use:animate=pop_in_out() <div class="text-lg">"Select an option from the left to begin."</div>
>
<div class="text-3xl font-bold mb-2">
"Chromebook Loaner Form"
</div>
<div class="text-lg">
"Select an option from the left to begin."
</div>
</div> </div>
} }
} }
@@ -328,7 +317,7 @@ pub fn LoanerBorrowForm() -> impl IntoView {
disabled=elements_disabled disabled=elements_disabled
data_member="email" data_member="email"
data_map=data data_map=data
input_type=TextFieldType::Email input_type=TextFieldType::Email(vec!["region15.org".to_string()])
required=true required=true
/> />
</Row> </Row>
@@ -431,69 +420,85 @@ pub fn LoanerReturnForm() -> impl IntoView {
<dialog <dialog
use:animate=pop_in_out() use:animate=pop_in_out()
class="p-3 m-auto rounded-md shadow-lg/33 backdrop:backdrop-blur-xs backdrop:transition-backdrop-filter" class="p-3 m-auto rounded-md shadow-lg/33 backdrop:backdrop-blur-xs backdrop:transition-backdrop-filter"
node_ref=dialog_ref> node_ref=dialog_ref
>
<h2 class="text-2xl font-bold">"Confirmation"</h2> <h2 class="text-2xl font-bold">"Confirmation"</h2>
<h4 class="text-xl mb-3"> <h4 class="text-xl mb-3">
{move || chosen_student.with(|opt_student| { {move || {
if let Some(student) = opt_student.as_ref() { chosen_student
format!("Are you sure want to check in {} {}?", student.first_name, student.last_name) .with(|opt_student| {
} else { if let Some(student) = opt_student.as_ref() {
"There is no student selected.".to_string() format!(
} "Are you sure want to check in {} {}?",
})} student.first_name,
student.last_name,
)
} else {
"There is no student selected.".to_string()
}
})
}}
</h4> </h4>
<ActionButton on:click=close_dialog disabled=dialog_disabled>"Close"</ActionButton> <ActionButton on:click=close_dialog disabled=dialog_disabled>
<ActionButton on:click=dialog_check_in disabled=dialog_disabled>"Check In"</ActionButton> "Close"
</ActionButton>
<ActionButton on:click=dialog_check_in disabled=dialog_disabled>
"Check In"
</ActionButton>
</dialog> </dialog>
<div <div class="flex flex-col gap-2 m-2" use:animate=pop_in_out()>
class="flex flex-col gap-2 m-2"
use:animate=pop_in_out()
>
<div class="flex flex-col justify-center gap-2 mb-3 py-3 px-2"> <div class="flex flex-col justify-center gap-2 mb-3 py-3 px-2">
<h2 class="text-2xl font-bold">"Student List"</h2> <h2 class="text-2xl font-bold">"Student List"</h2>
<p class="text-lg">"Please select your name from the list."</p> <p class="text-lg">"Please select your name from the list."</p>
</div> </div>
<Suspense fallback=move || "Loading...".into_view()> <Suspense fallback=move || {
"Loading...".into_view()
}>
{move || { {move || {
match return_list_resource.get() { match return_list_resource.get() {
Some(loaner_list) => { Some(loaner_list) => {
let list_view = loaner_list.iter().map(|student| { let list_view = loaner_list
let on_click = { .iter()
let student_click = student.clone(); .map(|student| {
move |_| { let on_click = {
log!("Running on_click using student {:?}", student_click); let student_click = student.clone();
if let Some(dialog) = dialog_ref.get() { move |_| {
chosen_student.set(Some(student_click.clone())); log!("Running on_click using student {:?}", student_click);
dialog.show_modal().expect("Couldn't show modal dialog."); if let Some(dialog) = dialog_ref.get() {
chosen_student.set(Some(student_click.clone()));
dialog.show_modal().expect("Couldn't show modal dialog.");
}
} }
};
let naive_time = chrono::NaiveDateTime::parse_from_str(
&student.date,
"%H:%M, %m/%d/%Y",
)
.unwrap();
let time_zone = FixedOffset::west_opt(5 * 3600).unwrap();
let student_date = time_zone
.from_utc_datetime(&naive_time)
.format("%l:%M %p, %m/%d/%Y")
.to_string();
log!("Found current date {:?}", student_date);
view! {
<div
class="flex flex-row p-3 rounded-md cursor-pointer transition-shadow shadow-sm hover:shadow-lg/33"
on:click=on_click
>
<div class="flex-1">
{student.first_name.clone()}" "{student.last_name.clone()}
</div>
<div class="flex-none">{student_date.clone()}</div>
</div>
} }
}; })
.collect_view();
let naive_time = chrono::NaiveDateTime::parse_from_str(&student.date, "%H:%M, %m/%d/%Y")
.unwrap();
let time_zone = FixedOffset::west_opt(5 * 3600).unwrap();
let student_date = time_zone.from_utc_datetime(&naive_time)
.format("%l:%M %p, %m/%d/%Y")
.to_string();
log!("Found current date {:?}", student_date);
view! {
<div class="flex flex-row p-3 rounded-md cursor-pointer transition-shadow shadow-sm hover:shadow-lg/33" on:click=on_click>
<div class="flex-1">
{student.first_name.clone()}" "{student.last_name.clone()}
</div>
<div class="flex-none">
{student_date.clone()}
</div>
</div>
}
}).collect_view();
Either::Left(list_view) Either::Left(list_view)
}, }
None => Either::Right("No loaners found.".into_view()) None => Either::Right("No loaners found.".into_view()),
} }
}} }}
</Suspense> </Suspense>
+4 -2
View File
@@ -7,10 +7,12 @@ mod provider_portal;
mod scholarship_info; mod scholarship_info;
mod test_page; mod test_page;
mod unauthenticated_page; mod unauthenticated_page;
mod utils;
mod provider_contact; mod provider_contact;
mod auth_callback;
mod provider_applicants;
pub use self::{ pub use self::{
about_page::*, comparison_test::*, home_page::*, loaner_page::*, provider_portal::*, about_page::*, comparison_test::*, home_page::*, loaner_page::*, provider_portal::*,
test_page::*, unauthenticated_page::*, scholarship_info::*, provider_contact::* test_page::*, unauthenticated_page::*, scholarship_info::*, provider_contact::*,
auth_callback::*, provider_applicants::*
}; };
+15
View File
@@ -0,0 +1,15 @@
use leptos::prelude::*;
use crate::components::Banner;
#[component]
pub fn ApplicantsPageFallback() -> impl IntoView {
view! {
<Banner title="R15 Scholarships" logo="/PHS_Stacked_Acronym.png" path="/providers" />
<div class="flex flex-col gap-4 mt-3 items-center justify-center">
<h1 class="text-3xl font-bold">"Applicants Page"</h1>
<p>"This page is under construction! It will show all the students that are eligible for your scholarship(s)."</p>
<p>"Come back soon!"</p>
</div>
}
}
+34 -30
View File
@@ -1,14 +1,17 @@
use std::collections::HashMap; #[cfg(feature = "ssr")]
use crate::utils::server::{create_dynamo_client, into_attr_map};
use std::collections::HashMap;
use leptos::logging::log; use leptos::logging::log;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated}; use leptos_oidc::{AuthLoaded, Authenticated};
use crate::common::ValueType; use crate::common::ValueType;
use crate::components::{Banner, Loading, OutlinedTextField, Panel, Row, TextFieldType, Toast, ToastContext, ToastList, ValidatedForm}; use crate::components::{Banner, Loading, OutlinedTextField, Panel, Row, TextFieldType, Toast, ToastContext, ToastList, ValidatedForm};
use crate::pages::UnauthenticatedPage; use crate::pages::UnauthenticatedPage;
use crate::pages::utils::get_user_claims; use crate::utils::get_user_claims;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use crate::pages::utils::server_utils::create_dynamo_client; static PROVIDER_CONTACT_TABLE: &str = "leptos-provider-contacts";
#[server] #[server]
async fn get_provider_contact(id: String) -> Result<HashMap<String, ValueType>, ServerFnError> { async fn get_provider_contact(id: String) -> Result<HashMap<String, ValueType>, ServerFnError> {
@@ -21,7 +24,7 @@ async fn get_provider_contact(id: String) -> Result<HashMap<String, ValueType>,
match client match client
.get_item() .get_item()
.table_name("leptos-provider-contacts") .table_name(PROVIDER_CONTACT_TABLE)
.key("subject", AttributeValue::S(id)) .key("subject", AttributeValue::S(id))
.send() .send()
.await .await
@@ -49,7 +52,6 @@ async fn get_provider_contact(id: String) -> Result<HashMap<String, ValueType>,
#[server] #[server]
async fn put_provider_contact(id: String, contact_info: HashMap<String, ValueType>) -> Result<(), ServerFnError> { async fn put_provider_contact(id: String, contact_info: HashMap<String, ValueType>) -> Result<(), ServerFnError> {
use aws_sdk_dynamodb::error::ProvideErrorMetadata; use aws_sdk_dynamodb::error::ProvideErrorMetadata;
use crate::pages::utils::server_utils::into_attr_map;
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
@@ -58,7 +60,7 @@ async fn put_provider_contact(id: String, contact_info: HashMap<String, ValueTyp
match client match client
.put_item() .put_item()
.table_name("leptos-provider-contacts") .table_name(PROVIDER_CONTACT_TABLE)
.set_item(Some(item)) .set_item(Some(item))
.send() .send()
.await .await
@@ -117,23 +119,36 @@ pub fn ProviderContactPage() -> impl IntoView {
}); });
let mut toasts_context = expect_context::<ToastContext>(); let mut toasts_context = expect_context::<ToastContext>();
Effect::new(move || { Effect::watch(
submit_action.value().get().is_some() move || submit_action.value().get(),
.then(|| { move |value, _, _| {
toasts_context.toast( log!("Running submit_action effect");
Toast::new() let Some(result) = value else {
.id(uuid::Uuid::new_v4()) return;
.header("Submission Successful") };
.msg("You can go back or continue editing your responses.")
);
let toast = match result {
Ok(_) => Toast::new()
.id(uuid::Uuid::new_v4())
.header("Submission Successful")
.msg("You can go back or continue editing your responses."),
Err(err) => Toast::new()
.id(uuid::Uuid::new_v4())
.header("Submission Failed")
.msg(err.to_string())
};
untrack(move || {
submit_action.clear(); submit_action.clear();
toasts_context.toast(toast);
}); });
}); },
false
);
// Display contact form. // Display contact form.
view! { view! {
<Banner title="R15 Scholarships" logo="/PHS_Stacked_Acronym.png" path="/" /> <Banner title="R15 Scholarships" logo="/PHS_Stacked_Acronym.png" path="/providers" />
<AuthLoaded fallback=Loading> <AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage> <Authenticated unauthenticated=UnauthenticatedPage>
<Suspense fallback=Loading> <Suspense fallback=Loading>
@@ -175,7 +190,7 @@ pub fn ProviderContactPage() -> impl IntoView {
disabled=elements_disabled disabled=elements_disabled
data_member="contact_email" data_member="contact_email"
data_map=contact_info data_map=contact_info
input_type=TextFieldType::Email input_type=TextFieldType::Email(vec!["*".to_string()])
required=true required=true
/> />
</Row> </Row>
@@ -199,17 +214,6 @@ pub fn ProviderContactPage() -> impl IntoView {
required=true required=true
/> />
</Row> </Row>
<Row>
<OutlinedTextField
label="Testing Number"
placeholder="Just put a number"
disabled=elements_disabled
data_member="testing_number"
data_map=contact_info
input_type=TextFieldType::Number
required=true
/>
</Row>
} }
}) })
}} }}
+59 -34
View File
@@ -1,42 +1,67 @@
use crate::components::{Banner, DashboardButton}; use crate::components::{Banner, DashboardButton, DateList, Loading};
use leptos::prelude::*; use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated};
use crate::pages::api::get_important_dates;
use crate::pages::UnauthenticatedPage;
#[component] #[component]
pub fn ProviderPortal() -> impl IntoView { pub fn ProviderPortal() -> impl IntoView {
view! { let dates_resource = OnceResource::new(async move {
<Banner get_important_dates().await
title="Provider Dashboard" });
logo="PHS_Stacked_Acronym.png"
path="/"
/>
<div class="mx-auto px-6">
<div class="container mx-auto px-6 py-8 space-y-8">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<section class="space-y-4">
<DashboardButton
title="Profile"
description="Edit user profile"
icon="/Person_Black.png"
path="/providers/profile"
/>
<DashboardButton
title="Create Scholarship"
description="Navigate to Scholarship Creation Page"
icon="/Create_Black.png"
path="/providers/scholarships"
/>
<DashboardButton
title="Applicants"
description="View Scholarship Applicants"
icon="/Form_Black.png"
path="/"
/>
</section>
<section class="space-y-4">
</section> let dates_list = RwSignal::new(Vec::new());
Effect::new(move || {
if let Some(Ok(mut dates)) = dates_resource.get() {
dates.sort_by_key(|info| info.date.get_status());
dates_list.set(dates);
}
});
view! {
<Banner title="Provider Dashboard" logo="PHS_Stacked_Acronym.png" />
<AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage>
<div class="mx-auto px-6">
<div class="container mx-auto px-6 py-8 space-y-8">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<section class="space-y-4">
<DashboardButton
title="Profile"
description="Edit user profile"
icon="/Person_Black.png"
path="/providers/profile"
/>
<DashboardButton
title="Create Scholarship"
description="Navigate to Scholarship Creation Page"
icon="/Create_Black.png"
path="/providers/scholarships"
/>
<DashboardButton
title="Applicants"
description="View Scholarship Applicants"
icon="/Form_Black.png"
path="/providers/applicants"
/>
</section>
<section class="space-y-4">
<div class="rounded-lg shadow-lg/25 overflow-hidden">
<div class="bg-red-900 text-white px-4 py-3 shadow-lg">
<h3 class="text-white font-bold">Important Dates</h3>
</div>
<div class="p-2">
<div class="flex flex-col p-3 gap-3">
<DateList dates=dates_list />
</div>
</div>
</div>
</section>
</div>
</div>
</div> </div>
</div> </Authenticated>
</div> </AuthLoaded>
} }
} }
+172 -132
View File
@@ -5,11 +5,11 @@ use leptos::logging::log;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated}; use leptos_oidc::{AuthLoaded, Authenticated};
use leptos_router::hooks::{use_navigate, use_params}; use leptos_router::hooks::{use_navigate, use_params};
use crate::common::{ExpandableInfo, ScholarshipFormParams, SubmitStatus, ValueType}; use crate::common::{ComparisonData, ExpandableInfo, ScholarshipFormParams, SubmitStatus, ValueType};
use crate::components::{ActionButton, Banner, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row, TextFieldType, Toast, ToastContext, ToastList, ValidatedForm}; use crate::components::{ActionButton, Banner, ChipsList, Header, Loading, OutlinedTextField, Panel, RadioList, Row, TextFieldType, Toast, ToastContext, ToastList, ValidatedForm};
use super::UnauthenticatedPage; use super::UnauthenticatedPage;
use crate::pages::utils::get_user_claims; use crate::utils::get_user_claims;
use super::api::{get_provider_scholarships, get_scholarship_info, CreateScholarshipInfo, RegisterScholarship, DeleteProviderScholarship, get_comparisons_categorized, CreateTestComparisons}; use super::api::{get_provider_scholarships, get_scholarship_info, CreateScholarshipInfo, RegisterScholarship, DeleteProviderScholarship, get_comparisons_categorized};
/// # Scholarship Info Page /// # Scholarship Info Page
@@ -40,7 +40,7 @@ pub fn ScholarshipInfoPage() -> impl IntoView {
// Remember that we only want to show the page after we've got authentication information // Remember that we only want to show the page after we've got authentication information
// and if there's data for us to use. // and if there's data for us to use.
view! { view! {
<Banner title="R15 Scholarships" logo="/PHS_Stacked_Acronym.png" path="/" /> <Banner title="R15 Scholarships" logo="/PHS_Stacked_Acronym.png" path="/providers" />
<ToastList> <ToastList>
<AuthLoaded fallback=Loading> <AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage> <Authenticated unauthenticated=UnauthenticatedPage>
@@ -128,8 +128,7 @@ fn ScholarshipList(
// Button handlers // Button handlers
let create_on_click = move |_| { let create_on_click = move |_| {
create_action.dispatch(RegisterScholarship { create_action.dispatch(RegisterScholarship {
provider_id: provider_id.get().unwrap(), provider_id: provider_id.get().unwrap()
scholarship_name: "Testing Scholarship".to_string()
}); });
}; };
@@ -175,10 +174,27 @@ fn ScholarshipList(
} }
}); });
let modal_on_click_continue = move |_| {
let subject = pending_delete
.get()
.map(|s| s.subject.clone())
.unwrap_or_default();
log!("Deleting scholarship with subject {}", subject);
delete_action
.dispatch(DeleteProviderScholarship {
scholarship_id: subject,
provider_id: provider_id.get().unwrap_or_default(),
});
};
view! { view! {
<dialog node_ref=delete_dialog_ref class="m-auto p-2"> <dialog
node_ref=delete_dialog_ref
class="m-auto p-5 rounded-lg shadow-xl/50
backdrop:backdrop-blur-xs backdrop:transition-backdrop-filter"
>
<h2 class="text-2xl font-bold">"Confirm Deletion"</h2> <h2 class="text-2xl font-bold">"Confirm Deletion"</h2>
<p> <p class="mb-3">
"Are you sure you want to delete " "Are you sure you want to delete "
{move || { {move || {
pending_delete pending_delete
@@ -189,28 +205,16 @@ fn ScholarshipList(
.unwrap_or(&ValueType::String(None)) .unwrap_or(&ValueType::String(None))
.as_string() .as_string()
.unwrap_or_default() .unwrap_or_default()
.unwrap_or("<no name found>".to_string()) .unwrap_or("this unnamed scholarship".to_string())
}) })
}} "?" }} "?"
</p> </p>
<ActionButton <div class="flex mx-auto items-center justify-center">
on:click=move |_| { <ActionButton on:click=modal_on_click_continue disabled=modal_disabled>
let subject = pending_delete "Yes"
.get() </ActionButton>
.map(|s| s.subject.clone()) <ActionButton on:click=move |_| pending_delete.set(None)>"No"</ActionButton>
.unwrap_or_default(); </div>
log!("Deleting scholarship with subject {}", subject);
delete_action
.dispatch(DeleteProviderScholarship {
scholarship_id: subject,
provider_id: provider_id.get().unwrap_or_default(),
});
}
disabled=modal_disabled
>
"Confirm"
</ActionButton>
<ActionButton on:click=move |_| pending_delete.set(None)>"Cancel"</ActionButton>
</dialog> </dialog>
<div class="w-75"> <div class="w-75">
<Panel> <Panel>
@@ -298,7 +302,7 @@ fn ScholarshipListEntry(
let name = scholarship.data.get("name") let name = scholarship.data.get("name")
.unwrap_or(&ValueType::String(None)) .unwrap_or(&ValueType::String(None))
.as_string().unwrap_or_default() .as_string().unwrap_or_default()
.unwrap_or("<no name found>".to_string()); .unwrap_or("Unnamed Scholarship".to_string());
// Button click handlers // Button click handlers
let edit_click = { let edit_click = {
@@ -361,42 +365,7 @@ fn ScholarshipForm(
/// submission fails, this function is not run. /// submission fails, this function is not run.
#[prop(into)] on_submit: Callback<()> #[prop(into)] on_submit: Callback<()>
) -> impl IntoView { ) -> impl IntoView {
let submit_action = ServerAction::<CreateScholarshipInfo>::new(); //#region Form Data Setup
let submit_status = RwSignal::new(SubmitStatus::Idle);
let elements_disabled = Signal::derive(move || {
matches!(submit_action.pending().get(), true)
});
Effect::new(move || {
if submit_action.pending().get() {
submit_status.set(SubmitStatus::Sending);
return;
}
if let Some(result) = submit_action.value().get() {
match result {
Ok(()) => {
submit_status.set(SubmitStatus::Success);
on_submit.run(());
},
Err(_err) => submit_status.set(SubmitStatus::Error(()))
}
}
});
let comparison_lists = Resource::new(
move || scholarship_id.get().is_some(),
async move |_| {
get_comparisons_categorized().await
}
);
Effect::new(move || {
if let Some(Ok(map)) = comparison_lists.get() {
log!("Available categories: {:?}", map.keys());
}
});
let scholarship_info = Resource::new( let scholarship_info = Resource::new(
move || scholarship_id.get(), move || scholarship_id.get(),
@@ -416,8 +385,10 @@ fn ScholarshipForm(
if let Some(Ok(scholarship)) = scholarship_info.get() { if let Some(Ok(scholarship)) = scholarship_info.get() {
// Get the default chips data. // Get the default chips data.
let chips_default = if let Some(ValueType::Map(Some(map))) = scholarship.data.get("requirements") { let chips_default = if let Some(ValueType::Map(Some(map))) = scholarship.data.get("requirements") {
log!("Found requirements map: {:?}", map);
map.clone() map.clone()
} else { } else {
log!("Couldn't find requirements map in scholarship info. Using empty map.");
HashMap::new() HashMap::new()
}; };
@@ -426,12 +397,36 @@ fn ScholarshipForm(
} }
}); });
let get_comp_category = move |category: &str| { //#endregion
if let Some(Ok(map)) = comparison_lists.get() { //#region Comparison Logic
map.get(category).unwrap_or(&Vec::new()).clone()
} else { let comparison_lists = Resource::new(
Vec::new() move || scholarship_id.get().is_some(),
async move |_| {
get_comparisons_categorized().await
} }
);
let comparison_lists_sorted = Signal::derive(move || {
if let Some(Ok(map)) = comparison_lists.get() {
let mut map = map.clone();
map
.into_iter()
.map(|(cat, mut list)| {
list.sort_by_key(|comp| comp.display_text.clone());
(cat, list)
})
.collect::<HashMap<String, Vec<ComparisonData>>>()
} else {
HashMap::new()
}
});
let get_comp_category = move |category: &str| {
comparison_lists_sorted.get()
.get(category)
.cloned()
.unwrap_or(Vec::new())
}; };
let get_comp_ids = move |category: &str| { let get_comp_ids = move |category: &str| {
@@ -464,15 +459,42 @@ fn ScholarshipForm(
let gpa_ids = Signal::derive(move || get_comp_ids("GPA Limits")); let gpa_ids = Signal::derive(move || get_comp_ids("GPA Limits"));
let gpa_text = Signal::derive(move || get_comp_text("GPA Limits")); let gpa_text = Signal::derive(move || get_comp_text("GPA Limits"));
// We'll collect the scholarship's name, num_awards, amount_per_award, total_awards, let major_ids = Signal::derive(move || get_comp_ids("Majors"));
// fafsa_required, award_to, transcript_required, recipient_selection, essay_requirement (and prompt) let major_text = Signal::derive(move || get_comp_text("Majors"));
// award_night_remarks
//#endregion
//#region Submission Logic
let submit_action = ServerAction::<CreateScholarshipInfo>::new();
let submit_status = RwSignal::new(SubmitStatus::Idle);
let elements_disabled = Signal::derive(move || {
matches!(submit_action.pending().get(), true)
});
Effect::new(move || {
if submit_action.pending().get() {
submit_status.set(SubmitStatus::Sending);
return;
}
if let Some(result) = submit_action.value().get() {
match result {
Ok(()) => {
submit_status.set(SubmitStatus::Success);
on_submit.run(());
},
Err(_err) => submit_status.set(SubmitStatus::Error(()))
}
}
});
let mut toasts = expect_context::<ToastContext>(); let mut toasts = expect_context::<ToastContext>();
let on_submit = move |_| { let on_submit = move |_| {
let mut info = ExpandableInfo::new(scholarship_id.get().unwrap_or_default()); let mut info = ExpandableInfo::new(scholarship_id.get().unwrap_or_default());
info.data = form_data.get(); info.data = form_data.get();
info.data.insert("requirements".to_string(), ValueType::Map(Some(chips_data.get())));
log!("Map values: {:?}", info.data); log!("Map values: {:?}", info.data);
submit_action.dispatch(CreateScholarshipInfo { submit_action.dispatch(CreateScholarshipInfo {
@@ -480,21 +502,34 @@ fn ScholarshipForm(
}); });
}; };
Effect::new(move || { Effect::watch(
submit_action.value().get().is_some() move || submit_action.value().get(),
.then(|| { move |value, _, _| {
log!("Displaying toasts!"); log!("Running submit_action effect");
toasts.toast( let Some(result) = value else {
Toast::new() return;
.id(uuid::Uuid::new_v4()) };
.msg("Succesfully submitted.")
);
let toast = match result {
Ok(_) => Toast::new()
.id(uuid::Uuid::new_v4())
.header("Submission Successful")
.msg("You can go back or continue editing your responses."),
Err(err) => Toast::new()
.id(uuid::Uuid::new_v4())
.header("Submission Failed")
.msg(err.to_string())
};
untrack(move || {
submit_action.clear(); submit_action.clear();
toasts.toast(toast);
}); });
}); },
false
);
let create_comps = ServerAction::<CreateTestComparisons>::new(); //#endregion
view! { view! {
<Panel> <Panel>
@@ -505,24 +540,19 @@ fn ScholarshipForm(
} }
> >
<Suspense fallback=Loading> <Suspense fallback=Loading>
<ValidatedForm {move || {
on_submit=Callback::new(on_submit) scholarship_info
title="Scholarship Info Form" .get()
description="Create or edit a scholarship. Input all information and click Submit." .map(|res_scholarship| {
> match res_scholarship {
{move || { Ok(_) => {
scholarship_info Either::Left(
.get() view! {
.map(|res_scholarship| { <ValidatedForm
match res_scholarship { on_submit=Callback::new(on_submit)
Ok(_) => { title="Scholarship Info Form"
Either::Left( description="Create or edit a scholarship. Input all information and click Submit."
view! { >
<Row>
<h1 class="text-3xl font-bold">
"Scholarship Info Form (test)"
</h1>
</Row>
<Row> <Row>
<OutlinedTextField <OutlinedTextField
label="Scholarship Name" label="Scholarship Name"
@@ -584,32 +614,33 @@ fn ScholarshipForm(
<RadioList <RadioList
data_member="award_to" data_member="award_to"
data_map=form_data data_map=form_data
items=vec!["School".to_string(), "Student".to_string()] items=vec!["University".to_string(), "Student".to_string()]
disabled=elements_disabled disabled=elements_disabled
label="Will the award be made to the school or the student?" label="Will the award be made to the school or the student?"
/> />
</Row> </Row>
<Row>
<RadioList
data_member="essay_required"
data_map=form_data
items=vec!["Yes".to_string(), "No".to_string()]
disabled=elements_disabled
label="Do you require a student essay?"
/>
</Row>
<Row> <Row>
<RadioList <RadioList
data_member="essay_prompt" data_member="essay_prompt"
data_map=form_data data_map=form_data
items=vec!["Test 1", "Test 2", "Test 3"] items=vec![
"Write an essay describing your major/career direction. Include any abilities, interests, experiences, employment or clubs you have had that have affected your direction. If you are undecided, indicate why.",
"Why is community service important? How has it personally affected you and your goals and what role do you hope to play in it in the future?",
"How has participation in sports been beneficial to your personal, academic, and emotional growth?",
]
.iter() .iter()
.map(|s| s.to_string()) .map(|prompt| prompt.to_string())
.collect() .collect()
disabled=elements_disabled disabled=elements_disabled
label="If so, select a prompt from the list below." label="Select an essay prompt, or leave blank if none is required:"
other_prompt="Custom prompt..."
/> />
</Row> </Row>
<Header
title="Eligibility Requirements"
description="Choose any requirements that this scholarship may have. If there are no extra requirements,
leave all options blank, however please note that this will apply all students to your scholarship."
/>
<Row> <Row>
<ChipsList <ChipsList
label="GPA Requirements" label="GPA Requirements"
@@ -617,6 +648,7 @@ fn ScholarshipForm(
data_map=chips_data data_map=chips_data
values=gpa_ids values=gpa_ids
displayed_text=gpa_text displayed_text=gpa_text
allows_multiple=false
/> />
</Row> </Row>
<Row> <Row>
@@ -635,6 +667,7 @@ fn ScholarshipForm(
data_map=chips_data data_map=chips_data
values=comm_ids values=comm_ids
displayed_text=comm_text displayed_text=comm_text
allows_multiple=false
/> />
</Row> </Row>
<Row> <Row>
@@ -644,6 +677,16 @@ fn ScholarshipForm(
data_map=chips_data data_map=chips_data
values=res_ids values=res_ids
displayed_text=res_text displayed_text=res_text
allows_multiple=false
/>
</Row>
<Row>
<ChipsList
label="Majors"
data_member="major"
data_map=chips_data
values=major_ids
displayed_text=major_text
/> />
</Row> </Row>
<Row> <Row>
@@ -655,24 +698,21 @@ fn ScholarshipForm(
displayed_text=misc_text displayed_text=misc_text
/> />
</Row> </Row>
}, </ValidatedForm>
) },
} )
Err(err) => {
Either::Right(
view! {
<p>"Error while getting scholarship: "{err.to_string()}</p>
},
)
}
} }
}) Err(err) => {
.collect_view() Either::Right(
}} view! {
</ValidatedForm> <p>"Error while getting scholarship: "{err.to_string()}</p>
<ActionButton on:click=move |_| { },
create_comps.dispatch(CreateTestComparisons {}); )
}>"Create Comparisons"</ActionButton> }
}
})
.collect_view()
}}
</Suspense> </Suspense>
</Show> </Show>
</Panel> </Panel>
+69 -21
View File
@@ -1,28 +1,76 @@
use crate::components::DashboardButton; use chrono::DateTime;
use crate::components::ActionButton;
use leptos::prelude::*; use leptos::prelude::*;
use crate::common::{DateInfo, DateRange};
use crate::pages::api::{CreateDates, CreateTestComparisons};
fn get_important_dates() -> Vec<DateInfo> {
vec! [
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Provider Forms Open".to_string(),
date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-01-20T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-03-03T23:59:00-05:00").unwrap()
),
description: "".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Student Forms Open".to_string(),
date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-03-03T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-03-27T14:05:00-05:00").unwrap()
),
description: "".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Scholarship Decision Window".to_string(),
date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-04-10T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-05-01T23:59:00-05:00").unwrap()
),
description: "Start date may change if available sooner.".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "PHS Scholarship Committee".to_string(),
date: DateRange::Single(
DateTime::parse_from_rfc3339("2026-05-18T00:00:00-05:00").unwrap(),
),
description: "Time TBD".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Scholarship and Awards Night".to_string(),
date: DateRange::Single(
DateTime::parse_from_rfc3339("2026-06-09T00:00:00-05:00").unwrap(),
),
description: "Time TBD".to_string(),
},
]
}
#[component] #[component]
pub fn TestPage() -> impl IntoView { pub fn TestPage() -> impl IntoView {
let create_comparisons = ServerAction::<CreateTestComparisons>::new();
let create_dates = ServerAction::<CreateDates>::new();
let on_click_comparisons = move |_| {
create_comparisons.dispatch(CreateTestComparisons{});
};
let on_click_dates = move |_| {
create_dates.dispatch(CreateDates{dates: get_important_dates()});
};
view! { view! {
<div class="p-6 space-y-4 max-w-1/3"> <div class="mx-auto px-6">
<DashboardButton <div class="flex flex-col gap-4">
title="Home Page" <ActionButton on:click=on_click_comparisons>"Create Comparisons"</ActionButton>
description="Navigate to Home Page" <ActionButton on:click=on_click_dates>"Create Dates"</ActionButton>
icon="Book.png" </div>
path="/"
/>
<DashboardButton
title="Home Page"
description="Navigate to Home Page"
icon="Calendar.png"
path="/"
/>
<DashboardButton
title="Home Page"
description="Navigate to Home Page"
icon="Edit.png"
path="/"
/>
</div> </div>
} }.into_any()
} }
+23 -15
View File
@@ -2,35 +2,43 @@ use crate::components::ActionButton;
use leptos::logging::log; use leptos::logging::log;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_oidc::AuthSignal; use leptos_oidc::AuthSignal;
use leptos_router::hooks::{use_navigate, use_location};
#[component] #[component]
pub fn UnauthenticatedPage() -> impl IntoView { pub fn UnauthenticatedPage() -> impl IntoView {
let auth = use_context::<AuthSignal>().expect("AuthSignal not present in LoginLink"); let auth = use_context::<AuthSignal>().expect("AuthSignal not present in LoginLink");
let login_url = move || { let login_url = Memo::new(move |_| {
auth.with(|auth| { auth.with(|auth| {
auth.unauthenticated() auth.unauthenticated()
.map(|unauthenticated| unauthenticated.login_url()) .map(|unauthenticated| unauthenticated.login_url())
}) })
});
Effect::new(move || log!("Generated login url: {:?}", login_url.get()));
let navigate = use_navigate();
let location = use_location();
let on_click = move |_| {
let current_path = location.pathname.get();
let storage_opt = window().session_storage().ok().flatten();
if let Some(session_storage) = storage_opt {
let _ = session_storage.set_item("post_login_path", &current_path);
}
if let Some(url) = login_url.get() {
navigate(&url, Default::default())
}
}; };
Effect::new(move || log!("Generated login url: {:?}", login_url())); view! {
let navigate = leptos_router::hooks::use_navigate();
view! (
<div class="flex h-screen justify-center items-center pb-50"> <div class="flex h-screen justify-center items-center pb-50">
<div class="bg-white p-8 border rounded-lg shadow-lg"> <div class="bg-white p-8 border rounded-lg shadow-lg">
<h3> "You're not signed in. Please sign in with your Region 15 email." </h3> <h3>"You're not signed in. Please sign in with your Region 15 email."</h3>
<div class="flex pt-4 justify-center items-center"> <div class="flex pt-4 justify-center items-center">
<ActionButton on:click=move |_| { <ActionButton on:click=on_click>"Sign in"</ActionButton>
if let Some(url) = login_url() {
navigate(&url, Default::default())
}
}>
"Sign in"
</ActionButton>
</div> </div>
</div> </div>
</div> </div>
) }
} }
-42
View File
@@ -1,42 +0,0 @@
use leptos::prelude::{use_context, Signal, With};
use leptos_oidc::{Algorithm, AuthSignal, TokenData};
use crate::common::UserClaims;
/// # Server Utilities
///
/// These utility functions can only be used from the server. They are created to use server-side
/// dependencies like the AWS SDK. Use of these functions will crash the front end, as these
/// dependencies cannot be compiled for WASM (nor should they be).
#[cfg(feature = "ssr")]
pub mod server_utils {
use std::collections::HashMap;
use aws_config::SdkConfig;
use crate::common::ValueType;
/// Creates an [`SdkConfig`] struct for use with AWS SDK structs.
pub async fn create_aws_config() -> SdkConfig {
aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await
}
/// Creates a DynamoDB client.
pub async fn create_dynamo_client() -> aws_sdk_dynamodb::Client {
aws_sdk_dynamodb::Client::new(&create_aws_config().await)
}
pub fn into_attr_map(map: HashMap<String, ValueType>) -> HashMap<String, aws_sdk_dynamodb::types::AttributeValue> {
map.into_iter().map(|(k, v)| (k, v.into())).collect()
}
}
/// Gets the current user claims. This function should only be used in an area that has
/// access to an AuthSignal, or it will result in a total failure.
pub fn get_user_claims() -> Signal<Option<TokenData<UserClaims>>> {
let auth = use_context::<AuthSignal>().expect("Couldn't find AuthSignal.");
Signal::derive(move || {
auth.with(|auth| {
auth.authenticated().and_then(|data| {
data.decoded_access_token::<UserClaims>(Algorithm::RS256, &["account"])
})
})
})
}
+33
View File
@@ -0,0 +1,33 @@
use leptos::prelude::*;
use leptos_oidc::{Algorithm, AuthSignal, TokenData};
use crate::common::UserClaims;
pub fn use_origin() -> String {
#[cfg(target_arch = "wasm32")] {
use leptos::web_sys;
// Just get the current URL origin.
web_sys::window()
.unwrap()
.location()
.origin()
.unwrap_or("http://localhost:3000/".to_string())
}
#[cfg(not(target_arch = "wasm32"))] {
// Read the expected origin out of an environment variable.
std::env::var("LP_SITE_ORIGIN").unwrap_or("http://localhost:3000/".to_string())
}
}
/// Gets the current user claims. This function should only be used in an area that has
/// access to an AuthSignal, or it will result in a total failure.
pub fn get_user_claims() -> Signal<Option<TokenData<UserClaims>>> {
let auth = use_context::<AuthSignal>().expect("Couldn't find AuthSignal.");
Signal::derive(move || {
auth.with(|auth| {
auth.authenticated().and_then(|data| {
data.decoded_access_token::<UserClaims>(Algorithm::RS256, &["account"])
})
})
})
}
+5
View File
@@ -0,0 +1,5 @@
#[cfg(feature = "ssr")]
pub mod server;
mod common;
pub use common::*;
+17
View File
@@ -0,0 +1,17 @@
use std::collections::HashMap;
use aws_config::SdkConfig;
use crate::common::ValueType;
/// Creates an [`SdkConfig`] struct for use with AWS SDK structs.
pub async fn create_aws_config() -> SdkConfig {
aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await
}
/// Creates a DynamoDB client.
pub async fn create_dynamo_client() -> aws_sdk_dynamodb::Client {
aws_sdk_dynamodb::Client::new(&create_aws_config().await)
}
pub fn into_attr_map(map: HashMap<String, ValueType>) -> HashMap<String, aws_sdk_dynamodb::types::AttributeValue> {
map.into_iter().map(|(k, v)| (k, v.into())).collect()
}