Compare commits

..
172 Commits
Author SHA1 Message Date
Lucas Vas 5929a450ae Merge pull request #65 from R15-Software-Dev/release/v0.5.0
Release candidate (v0.5.0)
2026-03-06 07:39:50 -05:00
alphafetus 05ef850f82 style: rustfmt run 2026-03-06 07:39:28 -05:00
alphafetus 1427c51bd3 feat: create "specifics" page 2026-03-06 01:55:58 -05:00
alphafetus c6867baad4 feat: add form descriptions 2026-03-06 01:32:34 -05:00
alphafetus 111a7aea86 feat: add specific placeholders 2026-03-06 01:24:30 -05:00
alphafetus 371db711b2 feat: finish portal pages 2026-03-06 01:21:37 -05:00
alphafetus c33a587d96 feat: allow icon to be optional 2026-03-06 01:11:20 -05:00
alphafetus 80340d99b9 feat: add redirect buttons to forms 2026-03-06 01:11:07 -05:00
alphafetus 142a60bc7c feat: update fields 2026-03-06 00:58:25 -05:00
alphafetus 97707a9b80 feat: add new button and additional text 2026-03-06 00:54:45 -05:00
alphafetus 4091bd0162 feat: update fields 2026-03-06 00:44:27 -05:00
alphafetus 323116663a feat: update fields 2026-03-06 00:41:44 -05:00
alphafetus 9a6cc934de feat: use loading icon for eligibility check 2026-03-06 00:34:40 -05:00
alphafetus 6f600a4818 feat: remove testing display 2026-03-06 00:30:44 -05:00
Lucas Vas 311d5647e8 Merge pull request #64 from R15-Software-Dev/feat/close-forms
Create FormClosedPage and close provider form
2026-03-05 09:06:41 -05:00
alphafetus 3e4c287aa4 feat: create FormClosedPage and close provider form 2026-03-04 14:09:41 -05:00
Lucas Vas 9d124c4098 Merge pull request #63 from R15-Software-Dev/feat/file-type-restriction
Implement checking for file types
2026-03-03 18:20:30 -05:00
alphafetus 840be3f0e5 feat: implement checking for file types 2026-03-03 09:44:55 -05:00
Lucas Vas 53bdc96d01 Merge pull request #62 from R15-Software-Dev/feat/value-display
Implement template student portal
2026-03-02 16:38:30 -05:00
alphafetus b422e43288 feat: implement admin login page 2026-03-02 16:16:15 -05:00
alphafetus 838ae8ba0f chore: bump docker to rust:1.93 2026-03-02 15:57:50 -05:00
alphafetus fb90a9d8ac feat: implement procedural display of file drop inputs 2026-03-02 15:43:58 -05:00
alphafetus 9d156c17e9 feat: implement file list retrieval for file drop components 2026-03-02 14:25:07 -05:00
alphafetus cc09769f28 chore: style file list 2026-03-02 13:48:38 -05:00
alphafetus e8b0ceeb7b feat: create financial_page 2026-03-02 13:48:29 -05:00
alphafetus c035115126 feat: allow deletion of files from s3 2026-03-02 13:40:00 -05:00
alphafetus 6f16bbe311 feat: allow reactive updates of UI with inserted file names 2026-03-02 12:29:49 -05:00
alphafetus 1bccecdc72 feat: implement dynamo insert with file upload 2026-03-02 12:00:38 -05:00
alphafetus 5cafc7e757 chore: pc change
Started implementing delete functions.
2026-03-02 08:47:53 -05:00
alphafetus 3f53e8fda1 chore: remove unused import 2026-03-01 12:34:23 -05:00
alphafetus b932388092 feat: style file input 2026-03-01 12:34:02 -05:00
alphafetus 40d1ef2a5f feat: implement file upload functionality 2026-03-01 12:17:20 -05:00
alphafetus 64554d81bc feat: begin implementation of file drop component 2026-02-27 14:52:54 -05:00
alphafetus c98621aefa feat: implement CSV export of scholarships 2026-02-27 07:39:31 -05:00
alphafetus be54aa8fd2 feat: change Into implementation to From 2026-02-20 09:58:16 -05:00
alphafetus 1afacf6008 docs: add docs to data display 2026-02-20 09:16:55 -05:00
alphafetus c850ac208f feat: finish initial data display component 2026-02-20 08:56:13 -05:00
alphafetus 0816f2da8b feat: add indexmap dependency 2026-02-18 09:00:15 -05:00
alphafetus 04ba0ff1ea feat: begin implementation of value display 2026-02-17 13:58:01 -05:00
alphafetus f10f01534c chore: push current changes to different PC 2026-02-16 12:21:35 -05:00
alphafetus dddc9e4192 style: run rustfmt 2026-02-11 12:21:41 -05:00
Lucas Vas ebb6fe500c Merge pull request #60 from R15-Software-Dev/fix/optional-number-inputs
Make number-type inputs properly optional
2026-02-11 12:18:08 -05:00
alphafetus e1de6064d9 fix: allow users to type decimal values 2026-02-11 11:45:47 -05:00
alphafetus f0db43817c fix: prevent validation on empty string 2026-02-10 09:54:58 -05:00
alphafetus 12dfe502f4 chore: rustfmt run 2026-02-10 09:39:30 -05:00
Lucas Vas 1abedae760 Merge pull request #58 from R15-Software-Dev/feat/admin-page
PR with too many features
2026-02-10 09:31:49 -05:00
alphafetus 1f51357ca5 chore: remove admin routes 2026-02-10 09:22:12 -05:00
alphafetus 756b23c673 chore: remove unused import 2026-02-10 09:05:15 -05:00
alphafetus 17ee7bdc4b chore: change placeholder to be more provider specific 2026-02-10 09:04:42 -05:00
alphafetus 637f143ac1 chore: tidy several logs to be debug-only 2026-02-10 09:04:28 -05:00
alphafetus 7d02c6505e chore: tidy several logs to be debug-only 2026-02-10 08:55:29 -05:00
alphafetus f7d66ccd8e chore: remove unused import 2026-02-10 08:51:06 -05:00
alphafetus bbc19b88be fix: force use of json in server functions 2026-02-10 08:50:35 -05:00
alphafetus d32261fe09 feat: create eligibility page 2026-02-10 08:50:06 -05:00
alphafetus ffccaa6b04 fix: allow parsing of float values 2026-02-10 08:20:08 -05:00
alphafetus 97b8305068 fix: don't panic on missing data 2026-02-10 08:19:57 -05:00
alphafetus 4f61222f2d chore: update service hours comparisons 2026-02-10 08:18:31 -05:00
alphafetus 494f93084f chore: begin creation of scholarship export process, not done 2026-02-09 12:14:16 -05:00
alphafetus da2cca3359 chore: remove unused import 2026-02-09 12:13:01 -05:00
alphafetus 6a24c18d6f style: rustfmt run 2026-02-09 12:12:38 -05:00
alphafetus caec4fb017 chore: add Show and fix small typing errors 2026-02-07 12:49:29 -05:00
alphafetus 1b8406e09c chore: add small styling changes 2026-02-07 12:32:48 -05:00
alphafetus 838a412376 feat: link student pages to database 2026-02-05 12:15:46 -05:00
alphafetus fdc5374f17 chore: small styling changes 2026-02-05 12:11:09 -05:00
alphafetus fe5a107c7c docs: add a comment to get_uuid 2026-02-05 12:04:56 -05:00
alphafetus 8417713aa1 feat: add delete button 2026-02-05 12:03:48 -05:00
alphafetus bb743cf114 chore: small QoL change 2026-02-05 11:00:10 -05:00
alphafetus 38994088df chore: recenter after recent changes 2026-02-05 10:52:19 -05:00
alphafetus 9b3f94f3ad chore: remove unused code 2026-02-05 00:43:26 -05:00
alphafetus b11ad5ffff feat: hydrate select component properly 2026-02-05 00:42:55 -05:00
alphafetus 956c653e28 fix: allow uniquely named lists to work with multientry component 2026-02-04 22:01:44 -05:00
alphafetus 4bf0cdcb39 fix: add extra padding 2026-02-04 21:46:14 -05:00
alphafetus 15b675a595 fix: correct some styling issues 2026-02-04 21:41:38 -05:00
alphafetus 5a064bdd69 feat: begin implementation of student data 2026-02-04 13:59:20 -05:00
alphafetus d7c4c6b707 feat: add pea and music comparisons 2026-02-04 12:07:16 -05:00
alphafetus 54a0753599 feat: implement most of the family page 2026-02-03 10:13:48 -05:00
alphafetus 06364236c5 feat: implement most of the university page, waiting for more information 2026-02-03 10:05:44 -05:00
alphafetus 0ca00664b0 style: rustfmt run 2026-02-02 16:51:13 -05:00
alphafetus 7ba22e5d24 feat: add fallback text 2026-02-02 14:34:32 -05:00
alphafetus f0f22bb68b fix: use signals for select dropdown data 2026-02-02 14:07:15 -05:00
alphafetus bb5dfa2563 feat: implement student work experience page 2026-02-01 13:15:57 -05:00
alphafetus f9d9e400d3 feat: implement student extracurricular information page 2026-02-01 12:58:20 -05:00
alphafetus 8df0d10166 feat: implement student academic information page 2026-02-01 12:45:56 -05:00
alphafetus 75f77d5ddf chore: remove unused test page 2026-02-01 12:11:27 -05:00
alphafetus f51e88396b chore: remove unused import 2026-02-01 12:10:23 -05:00
alphafetus c97074f163 feat: implement student athletics page 2026-02-01 12:10:06 -05:00
alphafetus b4f7ff6382 feat: simplify tab code slightly 2026-01-31 15:00:13 -05:00
alphafetus b21887f82b feat: cleanup admin setup and add utils page 2026-01-30 21:27:24 -05:00
alphafetus dfd1594395 feat: properly animate tab groups 2026-01-30 10:10:41 -05:00
alphafetus 3f1745a589 feat: begin implementation of "sub tabs" 2026-01-29 12:15:09 -05:00
alphafetus 7ca78b607b feat: implement authentication for students 2026-01-29 11:05:51 -05:00
alphafetus 51f1acc00b chore: add commented auth logic 2026-01-28 14:42:01 -05:00
alphafetus 765322bcca feat: begin implementation of student forms 2026-01-28 14:36:48 -05:00
alphafetus 3b9e2598b4 style: rustfmt run 2026-01-28 11:31:06 -05:00
alphafetus a95e9a1034 feat: create full provider list setup 2026-01-27 14:13:55 -05:00
alphafetus 12a980f327 feat: add headers to nav list 2026-01-27 00:48:17 -05:00
alphafetus aa912673c0 feat: implement basic selectable nav list 2026-01-26 16:42:32 -05:00
alphafetus 2069beba8a feat: update unauthenticated page 2026-01-23 13:56:13 -05:00
alphafetus 9427810026 style: rustfmt run 2026-01-22 11:18:13 -05:00
alphafetus 4a2a5ca9ff feat: create sidebar tabs list 2026-01-22 01:34:58 -05:00
alphafetus 9e418dddf5 feat: update existing comparisons 2026-01-21 13:55:53 -05:00
alphafetus 5e45cc89fc feat: add new comparisons 2026-01-21 12:49:49 -05:00
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
90 changed files with 6666 additions and 1421 deletions

No files matched your search

Generated
+1490 -349
View File
File diff suppressed because it is too large. Load diff
+21 -11
View File
@@ -7,27 +7,34 @@ edition = "2024"
crate-type = ["cdylib", "rlib"]
[dependencies]
leptos = { version = "0.8" }
leptos = { version = "0.8", features = ["multipart"] }
leptos_router = { version = "0.8" }
axum = { version = "0.8", optional = true }
console_error_panic_hook = { version = "0.1", optional = true}
leptos_axum = { version = "0.8", optional = true }
leptos_meta = { version = "0.8" }
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
wasm-bindgen = { version = "=0.2.106", optional = true }
aws-sdk-dynamodb = { version = "1", optional = true }
aws-config = { version = "1.6.2", optional = true }
serde = "1.0.219"
serde_dynamo = { version = "4.2.14", features = ["aws-sdk-dynamodb+1"], optional = true }
uuid = { version = "1.17.0", features = ["serde", "v4", "js"] }
wasm-bindgen = { version = "0.2", optional = true }
aws-sdk-dynamodb = { version = "^1", optional = true }
aws-sdk-s3 = { version = "1.124.0", optional = true }
aws-config = { version = "^1.8", optional = true }
serde = "^1.0"
serde_dynamo = { version = "^4", features = ["aws-sdk-dynamodb+1"], optional = true }
uuid = { version = "^1", features = ["serde", "v4", "js"] }
leptos_oidc = "0.9.0"
base64 = "0.22.1"
chrono = "0.4.42"
regex = "1.11.2"
leptos_animate = "0.1.9"
base64 = "0.22"
chrono = "0.4"
leptos_animate = "0.1"
macros = { path = "./macros" }
traits = { path = "./traits" }
icondata = { version = "0.7.0", features = ["font-awesome"] }
leptos_icons = "0.7.1"
indexmap = { version = "2.13.0", features = ["serde"] }
csv = "1.4.0"
leptos-use = { version = "0.18.3", features = ["use_drop_zone"] }
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"], optional = true }
reqwest = { version = "0.13.2", features = ["json"] }
[features]
default = ["ssr"]
@@ -41,11 +48,14 @@ ssr = [
"dep:tokio",
"dep:leptos_axum",
"dep:aws-sdk-dynamodb",
"dep:aws-sdk-s3",
"dep:aws-config",
"dep:serde_dynamo",
"dep:jsonwebtoken",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"leptos-use/ssr"
]
# Defines a size-optimized profile for the WASM bundle in release mode
+1 -1
View File
@@ -1,4 +1,4 @@
FROM rust:1.90 AS builder
FROM rust:1.93 AS builder
# Install node for tailwind capability
RUN apt-get update -y && apt-get install -y --no-install-recommends clang libc6 mold
+103 -62
View File
@@ -1,9 +1,20 @@
use crate::pages::{AboutPage, HomePage, ProviderPortal, ComparisonTestPage, ScholarshipInfoPage, TestPage, ProviderContactPage, LoanerShell, LoanerFallback, LoanerBorrowForm, LoanerReturnForm};
use crate::components::ToastList;
use crate::components::login::ProviderLoginContext;
use crate::pages::{
AdminHomePage, AdminProviderPage, AdminScholarshipPage, AdminShell, AdminUtilsPage,
ApplicantsPageFallback, AuthCallbackPage, LoanerBorrowForm, LoanerFallback, LoanerReturnForm,
LoanerShell, ProviderContactPage, ProviderPortal,
};
use crate::pages::{FormClosedPage, student};
use leptos::prelude::*;
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge};
use leptos_router::{components::{Route, Router, Routes, ParentRoute}, path};
use crate::components::ToastList;
use leptos_oidc::AuthSignal;
use leptos_router::any_nested_route::IntoAnyNestedRoute;
use leptos_router::{
MatchNestedRoutes,
components::{ParentRoute, Route, Router, Routes},
path,
};
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
@@ -31,10 +42,10 @@ pub fn App() -> impl IntoView {
view! {
// injects a stylesheet into the document <head>
// 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
<Title text="R15 Scholarship App DEV"/>
<Title text="R15 Scholarship App DEV" />
// content for this welcome page
<Router>
@@ -43,82 +54,112 @@ 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]
pub fn AppWithRoutes() -> impl IntoView {
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! {
// injects a stylesheet into the document <head>
// id=leptos means cargo-leptos will hot-reload this stylesheet
<Stylesheet id="leptos" href="/pkg/scholarships-rs-wasm.css" />
// <AuthLoading><p>"Authentication is loading"</p></AuthLoading>
// <AuthErrorContext><AuthErrorView/></AuthErrorContext>
// sets the document title
<Title text="R15 Scholarship App DEV" />
<main>
<ToastList>
// TODO Create a 404 page
<Routes fallback=|| "Page not found.".into_view()>
<Route path=path!("") view=HomePage/>
<Route path=path!("/about") view=AboutPage/>
<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>
// <Route path=path!("/testing") view=TestPage />
<ProviderRoutes />
<LoanerRoutes />
<StudentRoutes />
<AdminRoutes />
</Routes>
</ToastList>
</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 />
<Route path=path!("/scholarships") view=FormClosedPage />
// <ParentRoute path=path!("/scholarships") view=FormClosedPage>
// <Route path=path!(":id") view=FormClosedPage />
// <Route path=path!("") view=FormClosedPage />
// </ParentRoute>
</ParentRoute>
}
.into_inner()
.into_any_nested_route()
}
#[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(transparent)]
fn AdminRoutes() -> impl MatchNestedRoutes + Clone {
view! {
<ParentRoute path=path!("admin") view=AdminShell>
<Route path=path!("callback") view=AuthCallbackPage />
<Route path=path!("home") view=AdminHomePage />
<Route path=path!("providers") view=AdminProviderPage />
<Route path=path!("scholarships") view=AdminScholarshipPage />
<Route path=path!("utilities") view=AdminUtilsPage />
</ParentRoute>
}
.into_inner()
.into_any_nested_route()
}
#[component(transparent)]
fn StudentRoutes() -> impl MatchNestedRoutes + Clone {
view! {
<ParentRoute path=path!("students") view=student::StudentShell>
<Route path=path!("callback") view=AuthCallbackPage />
<Route path=path!("home") view=student::StudentHomePage />
<Route path=path!("demographics") view=student::StudentDemographicsPage />
<Route path=path!("eligibility") view=student::StudentEligibilityPage />
<Route path=path!("additional") view=student::AdditionalPage />
<Route path=path!("additional/academics") view=student::StudentAcademicsPage />
<Route path=path!("additional/athletics") view=student::StudentAthleticsPage />
<Route
path=path!("additional/extracurriculars")
view=student::StudentExtracurricularsPage
/>
<Route
path=path!("additional/work-experience")
view=student::StudentWorkExperiencePage
/>
<Route path=path!("additional/university") view=student::StudentUniversityPage />
<Route path=path!("additional/family-info") view=student::StudentFamilyPage />
<Route path=path!("additional/financials") view=student::StudentFinancialPage />
<Route
path=path!("additional/specifics")
view=student::StudentSpecificRequirementsPage
/>
</ParentRoute>
}
.into_inner()
.into_any_nested_route()
}
#[component]
pub fn AuthErrorView() -> impl IntoView {
let auth = use_context::<AuthSignal>().expect("AuthError: RwSignal<AuthStore> was not found");
+10 -11
View File
@@ -2,7 +2,8 @@ use super::{
Comparison, MapListComparison, NestedListComparison, NumberComparison, NumberListComparison,
TextComparison, TextListComparison,
};
use crate::common::{ExpandableInfo, ValueType};
use crate::common::ValueType;
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -42,15 +43,13 @@ impl ComparisonData {
}
}
pub fn compare(&self, student_data: &ExpandableInfo) -> Result<bool, String> {
let value = student_data.data.get(&self.member).expect(
format!(
"Couldn't find member {:?}, current student data: {:?}",
self.member, student_data
pub fn compare(&self, student_data: &HashMap<String, ValueType>) -> Result<bool, String> {
student_data.get(&self.member)
.ok_or_else(||
format!("Couldn't find member {:?}, current student data: {:?}", self.member, student_data)
)
.as_str(),
);
self.comparison.evaluate(value, &self.target_value)
.and_then(|value| self.comparison.evaluate(value, &self.target_value))
.or_else(|err| Err(err.to_string()))
}
}
@@ -106,11 +105,11 @@ mod tests {
display_text: "First Name is Jane".to_string(),
};
let mut result = text_comp_success.compare(&student_data);
let mut result = text_comp_success.compare(&student_data.data);
assert!(result.is_ok());
assert_eq!(result.unwrap(), true);
result = text_comp_fail.compare(&student_data);
result = text_comp_fail.compare(&student_data.data);
assert!(result.is_err());
}
}
+4 -2
View File
@@ -17,14 +17,16 @@ impl Comparison for NumberComparison {
// The prerequisites for all number comparisons is that the target value is Some(number) and the
// comparison member points to Some(number) in the student_info.
let student_value = match comp_value {
ValueType::Number(Some(num)) => num.parse::<i32>().unwrap(),
ValueType::Number(Some(num)) => num.parse::<f32>().unwrap(),
_ => {
return Err("Student value is not a number".into());
}
};
leptos::logging::log!("Parsing value: {:?}", target_value);
let target = match target_value {
ValueType::Number(Some(num)) => num.parse::<i32>().unwrap(),
ValueType::Number(Some(num)) => num.parse::<f32>().unwrap(),
_ => {
return Err("Target value is not a number".into());
}
+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
/// 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.
#[derive(Debug, Clone, Serialize, Deserialize, Reactive, )]
#[derive(Debug, Clone, Serialize, Deserialize, Reactive)]
pub struct ExpandableInfo {
/// The main key member. Required and used to get/put the information in a database.
pub subject: String,
+55 -37
View File
@@ -26,62 +26,80 @@ pub enum InputType {
}
impl InputType {
pub fn into_view(self, data_map: RwSignal<HashMap<String, ValueType>>) -> impl IntoView {
/// Converts an `InputType` into an `impl IntoView`. A name must be specified in the case that
/// the component is a checkbox/radio list, which requires a unique name to function properly.
/// Many times, the name will be the same as the data member.
pub fn into_view(
self,
data_map: RwSignal<HashMap<String, ValueType>>,
name: String,
) -> impl IntoView {
view! {
{match self {
InputType::Text(member, label, placeholder, required) =>
InputType::Text(member, label, placeholder, required) => {
view! {
<Row>
<OutlinedTextField
label = label.clone()
placeholder = placeholder.clone()
data_member = member.clone()
data_map = data_map
required = required
label=label.clone()
placeholder=placeholder.clone()
data_member=member.clone()
data_map=data_map
required=required
/>
</Row>
}.into_any(),
InputType::Number(member, label, placeholder, required) =>
}
.into_any()
}
InputType::Number(member, label, placeholder, required) => {
view! {
<OutlinedTextField
label = label.clone()
placeholder = placeholder.clone()
data_member = member.clone()
data_map = data_map
input_type = TextFieldType::Number
required = required
label=label.clone()
placeholder=placeholder.clone()
data_member=member.clone()
data_map=data_map
input_type=TextFieldType::Number
required=required
/>
}.into_any(),
InputType::Checkbox(member, label, options, required) =>
}
.into_any()
}
InputType::Checkbox(member, label, options, required) => {
view! {
<CheckboxList
label = label.clone()
items = options.clone()
data_member = member.clone()
data_map = data_map
required = required
label=label.clone()
name=name.clone()
items=options.clone()
data_member=member.clone()
data_map=data_map
required=required
/>
}.into_any(),
InputType::Radio(member, label, options, required) =>
}
.into_any()
}
InputType::Radio(member, label, options, required) => {
view! {
<RadioList
label = label.clone()
data_member = member.clone()
data_map = data_map
items = options.clone()
required = required
label=label.clone()
data_member=member.clone()
data_map=data_map
items=options.clone()
required=required
/>
}.into_any(),
InputType::Select(member, label, options, required) =>
}
.into_any()
}
InputType::Select(member, label, options, required) => {
view! {
<Select
label = label.clone()
value_list = options.clone()
data_member = member.clone()
data_map = data_map
required = required
label=label.clone()
value_list=options.clone()
data_member=member.clone()
data_map=data_map
required=required
/>
}.into_any()
}
.into_any()
}
}}
}
}
+8 -2
View File
@@ -1,10 +1,16 @@
mod comparison;
mod date_info;
mod expandable_info;
mod form_params;
mod form_schema;
mod submit_status;
mod user;
mod value_type;
mod submit_status;
pub mod animations;
mod tab_info;
mod schema_node;
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::*, tab_info::*, schema_node::*,
};
+98
View File
@@ -0,0 +1,98 @@
use std::collections::HashMap;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum SchemaType {
String,
Number,
List,
Map
}
/// # Schema Node
///
/// This is used to create a schema that is used in the [`ValueDisplay`] component. It stores all
/// required information for the display, specifically where to find the data and how to display it.
/// It covers all `ValueType` variants, however for data storage and DX it uses a `SchemaType`
/// instead.
///
/// Some examples are as follows, using the fluent builder notation:
/// - Displaying a single `ValueType::String` from the data map at the key `example_one`:
/// ```
/// SchemaNode::new(SchemaType::String).header("The example string")
/// ```
/// - Displaying a `ValueType::List` which contains only `ValueType::String` entries:
/// ```
/// SchemaNode::new(SchemaType::List)
/// .header("The example list")
/// .item_template(SchemaNode::new(SchemaType::String).header(""))
/// ```
/// - Displaying a `ValueType::Map` which contains a name and age:
/// ```
/// SchemaType::new(SchemaType::Map)
/// .header("The example map")
/// .child("first_name", SchemaNode::new(SchemaType::String).header("First Name:"))
/// .child("age", SchemaNode::new(SchemaType::Number).header("Age:"))
/// ```
/// - Put it all together: a `ValueType::List` which contains `ValueType::Map` entries, each of
/// which also contains the name and age from the previous example:
/// ```
/// SchemaNode::new(SchemaType::List)
/// .header("The example map list")
/// .item_template(
/// SchemaNode::new(SchemaType::Map)
/// .header("The example map from earlier")
/// .child("first_name", SchemaNode::new(SchemaType::String).header("First Name:"))
/// .child("age", SchemaNode::new(SchemaType::Number).header("Age:"))
/// )
/// ```
///
/// This schema allows for definitive typing and styling for every level of the data display.
///
/// ## Important Notes
///
/// This schema does not currently support display of a single nested member, like a single map
/// within a list. This means that you will have to display any listed or mapped information as a
/// list. Consider this when designing the data.
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct SchemaNode {
/// The datatype of the data found using the `data_member` key.
pub data_type: SchemaType,
/// The header of this display.
pub header: String,
/// When used with a SchemaType::List, this determines the datatype and children for every entry
/// in the list. Some examples include a list of maps, where we use the `item_template` to define
/// the SchemaType::Map, and all its children. This is then mapped across all entries in the list,
/// where any entries that do not match the template are skipped.
pub item_template: Option<Box<SchemaNode>>,
/// When used with a SchemaType::Map, this determines what children from the map are displayed
/// and how. It is ignored with all other SchemaType variants.
pub children: IndexMap<String, SchemaNode>,
}
impl SchemaNode {
pub fn new(data_type: SchemaType) -> Self {
SchemaNode {
data_type,
header: "".to_string(),
item_template: None,
children: IndexMap::new(),
}
}
pub fn header(mut self, header: impl Into<String>) -> Self {
self.header = header.into();
self
}
pub fn child(mut self, data_member: impl Into<String>, node: SchemaNode) -> Self {
self.children.insert(data_member.into(), node);
self
}
pub fn item_template(mut self, node: SchemaNode) -> Self {
self.item_template = Some(Box::new(node));
self
}
}
+35
View File
@@ -0,0 +1,35 @@
#[derive(Debug, Clone)]
pub struct TabInfo {
pub text: String,
pub path: String,
pub sub_paths: Vec<SubTabInfo>,
}
impl TabInfo {
pub fn new(
text: impl Into<String>,
path: impl Into<String>,
sub_paths: Option<Vec<SubTabInfo>>
) -> Self {
Self {
text: text.into(),
path: path.into(),
sub_paths: sub_paths.unwrap_or_default()
}
}
}
#[derive(Debug, Clone)]
pub struct SubTabInfo {
pub text: String,
pub path: String,
}
impl SubTabInfo {
pub fn new(text: impl Into<String>, path: impl Into<String>) -> Self {
Self {
text: text.into(),
path: path.into()
}
}
}
+2
View File
@@ -22,4 +22,6 @@ pub struct UserClaims {
/// The username of the user. This should be their email address.
#[serde(rename = "username")]
pub username: String,
/// The intended use for the token.
pub token_use: String,
}
+21 -9
View File
@@ -13,7 +13,7 @@ use std::fmt::Display;
pub enum ValueType {
/// Indicates a `String` value.
String(Option<String>),
/// Indicates an `i32` value.
/// Indicates an `i32` value. /// Indicates an `f32` value.
Number(Option<String>),
/// Indicates a `Vec<T>`, where `T` can be any other `ValueType`.
List(Option<Vec<ValueType>>),
@@ -105,8 +105,8 @@ impl Default for ValueType {
impl Display for ValueType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = match self {
ValueType::String(s) => s.clone().unwrap_or("".to_owned()),
ValueType::Number(n) => n.clone().unwrap_or("".to_owned()),
ValueType::String(s) => s.clone().unwrap_or_default(),
ValueType::Number(n) => n.clone().unwrap_or_default(),
ValueType::List(l) => format!("{l:?}"),
ValueType::Map(m) => format!("{m:?}"),
};
@@ -124,20 +124,32 @@ impl From<&AttributeValue> for ValueType {
AttributeValue::S(s) => ValueType::String(Some(s.clone())),
AttributeValue::N(n) => ValueType::Number(Some(n.clone())),
AttributeValue::L(l) => ValueType::List(Some(l.iter().map(|v| v.into()).collect())),
AttributeValue::M(m) => ValueType::Map(Some(m.iter().map(|(k, v)| (k.clone(), v.into())).collect())),
AttributeValue::M(m) => {
ValueType::Map(Some(m.iter().map(|(k, v)| (k.clone(), v.into())).collect()))
}
_ => ValueType::default(),
}
}
}
#[cfg(feature = "ssr")]
impl Into<AttributeValue> for ValueType {
fn into(self) -> AttributeValue {
match self {
impl From<ValueType> for AttributeValue {
fn from(value: ValueType) -> Self {
match value {
ValueType::String(s) => AttributeValue::S(s.unwrap_or_default()),
ValueType::Number(n) => AttributeValue::N(n.unwrap_or_default()),
ValueType::List(l) => AttributeValue::L(l.unwrap_or_default().into_iter().map(|v| v.into()).collect()),
ValueType::Map(m) => AttributeValue::M(m.unwrap_or_default().into_iter().map(|(k, v)| (k, v.into())).collect()),
ValueType::List(l) => AttributeValue::L(
l.unwrap_or_default()
.into_iter()
.map(|v| v.into())
.collect(),
),
ValueType::Map(m) => AttributeValue::M(
m.unwrap_or_default()
.into_iter()
.map(|(k, v)| (k, v.into()))
.collect(),
),
}
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ pub fn ActionButton(
) -> impl IntoView {
view! {
<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-gray-600", "pointer-events-none"], move || disabled.get())
r#type=button_type
+31 -15
View File
@@ -1,5 +1,6 @@
use leptos::prelude::*;
use leptos_router::hooks::use_navigate;
use leptos_icons::Icon;
/// # Banner Component
///
@@ -21,28 +22,43 @@ pub fn Banner(
title: String,
/// The file path or URL of a logo displayed at the top left of the banner.
#[prop(into)]
logo: String,
logo: Signal<String>,
/// The route that the navigates user to a page (home page).
#[prop(into, default = String::from("/"))]
path: String,
#[prop(into, optional)]
path: Signal<String>,
) -> impl IntoView {
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! {
<div class="mx-auto">
<div class="bg-red-900 shadow-lg shadow-[inset_0_2px_4px_0_rgba(0,0,0,0.3),0_4px_6px_-1px_rgba(127,29,29,0.3),0_2px_4px_-1px_rgba(127,29,29,0.2)]">
<div class="flex items-center gap-4 p-6">
<div class="flex-shrink-0">
<img src={logo.clone()} class="h-10 w-10 cursor-pointer" alt="icon" on:click=on_click/>
</div>
<div class="flex-1">
<h2 class="text-white text-lg font-bold">{title.clone()}</h2>
</div>
<div class="w-screen bg-red-900">
<div class="flex flex-row items-center gap-4 p-6 pb-2">
<div class="flex-shrink-0">
<img src=logo class="h-10 w-10 cursor-pointer" alt="icon" on:click=on_click />
</div>
<div class="flex-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>
}
}
+13 -12
View File
@@ -27,7 +27,7 @@ pub fn DashboardButton(
#[prop(into)]
description: String,
/// The file path or URL of the icon displayed at the top of the button.
#[prop(into)]
#[prop(optional, into)]
icon: String,
/// The route that the user is navigated to when clicking the button.
#[prop(into)]
@@ -37,25 +37,26 @@ pub fn DashboardButton(
let on_click = move |_| navigate(&path, Default::default());
let icon = StoredValue::new(icon);
view! {
<button
type="button"
class="dashboard-button flex items-start gap-3 rounded-lg bg-white border-grey-300 p-6
hover:bg-gray-100 transition cursor-pointer w-full text-left
shadow-[inset_0_0_6px_rgba(0,0,0,0.12)]"
on:click=on_click>
hover:bg-gray-100 transition cursor-pointer w-full text-left
shadow-[inset_0_0_6px_rgba(0,0,0,0.12)]"
on:click=on_click
>
<div class="flex flex-col">
<img src={icon.clone()} class="h-8 w-8" alt="icon"/>
<h3 class="font-semibold text-base">
{title.clone()}
</h3>
<Show when=move || !icon.get_value().is_empty()>
<img src=icon.get_value() class="h-8 w-8" alt="icon" />
</Show>
<p class="text-sm text-gray-600 pt-6">
{description.clone()}
</p>
<h3 class="font-semibold text-base">{title.clone()}</h3>
<p class="text-sm text-gray-600 pt-6">{description.clone()}</p>
</div>
</button>
}
.into_any()
}
+263
View File
@@ -0,0 +1,263 @@
use crate::common::{SchemaNode, SchemaType, ValueType};
use indexmap::IndexMap;
use leptos::logging::debug_log;
use leptos::prelude::*;
use std::collections::HashMap;
/// # Value Display Component
/// This component displays any data from a given `HashMap<String, ValueType>`. It requires a schema
/// to tell it how to display the information, which is covered in the [`SchemaNode`] documentation.
#[component]
pub fn ValueDisplay(
#[prop(into)] schema: Signal<SchemaNode>,
#[prop(into, optional)] data_member: Signal<String>,
#[prop(into)] data_map: Signal<HashMap<String, ValueType>>,
) -> impl IntoView {
let data_value = Memo::new(move |_| {
if data_member.get().is_empty() {
ValueType::Map(Some(data_map.get()))
} else {
data_map
.get()
.get(&data_member.get())
.map(|v| v.clone())
.unwrap_or_default()
}
});
view! {
{move || {
match schema.get().data_type {
SchemaType::String | SchemaType::Number => {
view! {
<PrimitiveDisplay
header=move || schema.get().header
value=data_value
nested=true
/>
}
.into_any()
}
SchemaType::List => {
view! {
<ListDisplay
header=move || schema.get().header
value=data_value
item_template=move || {
schema
.get()
.item_template
.unwrap_or(Box::from(SchemaNode::new(SchemaType::List)))
}
nested=true
/>
}
.into_any()
}
SchemaType::Map => {
view! {
<MapDisplay
value=data_value
header=move || schema.get().header
schema=move || schema.get().children
nested=true
/>
}
.into_any()
}
}
}}
}
}
/// # Display Wrapper Component
/// This component is designed to wrap information for the [`ValueDisplay`] component. It allows
/// for simple uniform styling of the header and capsule, along with the ability for users to
/// collapse the information.
#[component]
fn DisplayWrapper(
#[prop(into, optional)] header: Signal<String>,
#[prop(into, optional)] nested: Signal<bool>,
children: Children,
) -> impl IntoView {
let expanded = RwSignal::new(true);
view! {
<div class="m-3 rounded-lg shadow-lg flex flex-col">
<div
class="p-2 px-3 font-bold rounded-t-lg cursor-pointer"
class=(["bg-gray-300"], move || !nested.get())
class=(["text-white", "bg-red-900"], move || nested.get())
class=(["rounded-lg"], move || !expanded.get())
on:click=move |_| expanded.update(|b| *b = !*b)
>
{header}
</div>
<div
class="grid transition-all ease-in-out"
style:grid-template-rows=move || if expanded.get() { "1fr" } else { "0fr" }
>
<div class="min-h-0 overflow-hidden">
<div class="p-2">{children()}</div>
</div>
</div>
</div>
}
}
/// # Primitive Value Display Component
/// This component allows for the display of a `ValueType::String` or `ValueType::Number`.
#[component]
fn PrimitiveDisplay(
#[prop(into)] header: Signal<String>,
#[prop(into)] value: Signal<ValueType>,
#[prop(into, optional)] nested: Signal<bool>,
) -> impl IntoView {
let display_string = Memo::new(move |_| {
let value = value.get();
match value {
ValueType::String(_) | ValueType::Number(_) => value.to_string(),
_ => "Value is not a string.".to_string(),
}
});
view! {
<DisplayWrapper header=header nested=nested>
<div>{display_string}</div>
</DisplayWrapper>
}
}
/// # List Value Display Component
/// This component allows for the display of a `ValueType::List`. It will show an error if passed
/// anything other than a list. It requires an item template that tells the list how to display
/// each value inside of it, most specifically the type of data inside of it.
#[component]
fn ListDisplay(
#[prop(into)] item_template: Signal<Box<SchemaNode>>,
#[prop(into)] value: Signal<ValueType>,
#[prop(into)] header: Signal<String>,
#[prop(into, optional)] nested: Signal<bool>,
) -> impl IntoView {
// Unwrap the value and check if it's the correct type. To be displayed with this component, it
// MUST be a ValueType::List.
let display_list = Memo::new(move |_| {
let ValueType::List(Some(list)) = value.get() else {
return Vec::new();
};
list.into_iter()
.enumerate()
.collect::<Vec<(usize, ValueType)>>()
});
let is_valid = Memo::new(move |_| matches!(value.get(), ValueType::List(_)));
let item_header = Memo::new(move |_| item_template.get().header);
view! {
<DisplayWrapper header=header nested=nested>
<For
each=move || display_list.get()
key=|(idx, _)| idx.clone()
children=move |(idx, entry)| {
match item_template.get().data_type {
SchemaType::String | SchemaType::Number => {
view! { <PrimitiveDisplay value=entry header=item_header /> }.into_any()
}
SchemaType::List => {
view! {
<div>"Nested lists are not currently supported for display."</div>
}
.into_any()
}
SchemaType::Map => {
let map_header = format!("{} {}", item_template.get().header, idx + 1);
view! {
<MapDisplay
value=entry
header=map_header
schema=move || item_template.get().children
/>
}
.into_any()
}
}
}
/>
</DisplayWrapper>
}
}
/// # Map Data Display Component
/// This component allows for the display of a `ValueType::Map`. The nested information must be defined
/// by a [`SchemaNode`], which tells the display what keys in the value map to search for the
/// display information.
#[component]
fn MapDisplay(
#[prop(into)] value: Signal<ValueType>,
#[prop(into)] header: Signal<String>,
#[prop(into)] schema: Signal<IndexMap<String, SchemaNode>>,
#[prop(into, optional)] nested: Signal<bool>,
) -> impl IntoView {
// Unwrap the value and check if it is the correct type. It must be a ValueType::Map.
let display_map = Memo::new(move |_| {
let ValueType::Map(Some(map)) = value.get() else {
return HashMap::new();
};
map
});
Effect::new(move || {
debug_log!("Current map values: {:?}", display_map.get());
});
let is_valid = Memo::new(move |_| matches!(value.get(), ValueType::Map(_)));
view! {
<DisplayWrapper header=header nested=nested>
<For
each=move || schema.get()
key=|(k, _)| k.clone()
children=move |(member, child_schema)| {
let display_value = Memo::new(move |_| {
display_map.get().get(&member).map(|v| v.clone()).unwrap_or_default()
});
match child_schema.data_type {
SchemaType::String | SchemaType::Number => {
view! {
<PrimitiveDisplay value=display_value header=child_schema.header />
}
.into_any()
}
SchemaType::List => {
let template = child_schema
.item_template
.unwrap_or(Box::from(SchemaNode::new(SchemaType::List)));
view! {
<ListDisplay
value=display_value
item_template=template
header=child_schema.header
/>
}
.into_any()
}
SchemaType::Map => {
view! {
<MapDisplay
value=display_value
schema=child_schema.children
header=child_schema.header
/>
}
.into_any()
}
}
}
/>
</DisplayWrapper>
}
}
+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()
}
+219
View File
@@ -0,0 +1,219 @@
use crate::pages::api::files::{DeleteFile, upload_file};
use leptos::html::{Input, Label};
use leptos::logging::debug_log;
use leptos::prelude::*;
use leptos::web_sys::{File, FormData};
use leptos_icons::Icon;
use leptos_oidc::AuthSignal;
use leptos_use::{UseDropZoneOptions, UseDropZoneReturn, use_drop_zone_with_options};
#[component]
pub fn FileDrop(
/// The name of this input. Used in file organization.
#[prop(into)]
name: String,
/// The ID of the form containing this input. Used in file organization.
#[prop(into)]
form_id: String,
/// The types that a file is allowed to be. Must be specified as the desired file extension (i.e.
/// `.pdf`, `.txt`, etc.)
#[prop(optional)]
allowed_types: Vec<String>,
/// A [`Resource`] that gives the currently uploaded files for this input.
#[prop()]
existing_files: Resource<Result<Vec<String>, ServerFnError>>,
) -> impl IntoView {
let zone_ref = NodeRef::<Label>::new();
let input_ref = NodeRef::<Input>::new();
let file_name_list = RwSignal::new(Vec::new());
let hovering = RwSignal::new(false);
let name = StoredValue::new(name);
let form_id = StoredValue::new(form_id);
let auth = expect_context::<AuthSignal>();
let upload_action = Action::new(|form_data: &FormData| {
let form_data = form_data.clone();
async move { upload_file(form_data.into()).await }
});
let delete_action = ServerAction::<DeleteFile>::new();
let uploading = upload_action.pending();
let deleting = delete_action.pending();
let verify_file_type = move |file_name: String| {
if !allowed_types.is_empty() {
let verified = allowed_types
.iter()
.any(|file_ext| file_name.ends_with(file_ext));
if !verified {
debug_log!("Blocking file with name {file_name}");
} else {
debug_log!("Allowing file with name {file_name}");
}
verified
} else {
true
}
};
let upload_files = Callback::new(move |files: Vec<File>| {
let form_data = FormData::new().unwrap();
files.iter().for_each(|file| {
form_data
.append_with_blob_and_filename(&name.get_value(), file, &file.name())
.unwrap();
});
form_data
.append_with_str("form_id", form_id.get_value().as_str())
.unwrap();
let token = auth
.try_with(|a| {
a.authenticated()
.map(|authenticated| authenticated.access_token())
})
.flatten();
if let Some(access_token) = token {
form_data
.append_with_str("access_token", access_token.as_str())
.unwrap()
}
// Upload file to server.
upload_action.dispatch(form_data);
});
let UseDropZoneReturn {
is_over_drop_zone, ..
} = use_drop_zone_with_options(
zone_ref,
UseDropZoneOptions::default()
.on_over(move |_| hovering.set(true))
.on_drop(move |ev| {
debug_log!("Received dropped file. List has length {}", ev.files.len());
ev.files
.iter()
.for_each(|file| upload_files.run(vec![file.to_owned()]));
}),
);
let on_input_change = move |_| {
debug_log!("Running on:change");
// This runs when users select a file by clicking on the element. We aren't going
// to link the file list with this input except to update it with new information.
let files = input_ref
.get()
.and_then(|input| input.files())
.map(|list| {
(0..list.length())
.filter_map(|i| list.item(i))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let verified_files = files
.iter()
.filter_map(|file| verify_file_type(file.name().to_string()).then(|| file.to_owned()))
.collect::<Vec<File>>();
// input_ref.try_update(|input| {
// if let Some(input) = input {
// input.set_files(None);
// }
// });
if !verified_files.is_empty() {
upload_files.run(verified_files);
}
};
let on_click_file_delete = move |file_name: String| {
let access_token = auth
.try_with(|a| a.authenticated().map(|a| a.access_token()))
.flatten();
delete_action.dispatch(DeleteFile {
form_id: form_id.get_value(),
input_name: name.get_value(),
access_token: access_token.unwrap_or_default(),
file_name,
});
};
// Add file names to the list when upload_action is successful.
Effect::new(move || {
if let Some(Ok(uploaded_file_name)) = upload_action.value().get() {
file_name_list.update(|list| {
list.iter()
.find(|v| **v == uploaded_file_name)
.is_none()
.then(|| list.push(uploaded_file_name));
});
}
});
// Remove file name from the list when delete_action is successful.
Effect::new(move || {
if let Some(Ok(deleted_file_name)) = delete_action.value().get() {
file_name_list.update(|list| {
list.retain(|f| *f != deleted_file_name);
});
}
});
view! {
{move || {
existing_files
.get()
.map(|result| {
if let Ok(files) = result {
file_name_list.set(files);
}
})
}}
<label
node_ref=zone_ref
class="m-1.5 p-2.5 flex flex-col transition-color duration-200 rounded-lg border-2 items-center cursor-pointer"
class=(["border-gray-400"], move || !is_over_drop_zone.get())
class=(["border-blue-500"], move || is_over_drop_zone.get())
>
<div
class="m-2 mb-4 transition-color duration-200 size-10"
class=("text-gray-400", move || !is_over_drop_zone.get())
class=("text-blue-500", move || is_over_drop_zone.get())
>
<Icon icon=icondata::FaFileCirclePlusSolid width="2.5rem" height="2.5rem" />
</div>
<div>"Drop a file here to upload"</div>
<div class="text-sm text-gray-400">"Or click to select a file"</div>
<input node_ref=input_ref type="file" class="hidden" on:change=on_input_change />
</label>
<For
each=move || file_name_list.get()
key=|file| file.clone()
children=move |file| {
let file = StoredValue::new(file);
let on_click = move |_| {
on_click_file_delete(file.get_value());
};
view! {
<div class="m-1.5 flex flex-row relative items-center">
<div class="flex-1">{file.get_value()}</div>
<div
class="text-gray-400 hover:text-red-700 right-0 cursor-pointer"
on:click=on_click
>
<Icon icon=icondata::FaTrashCanRegular />
</div>
</div>
}
}
/>
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ pub fn Header(
});
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">
{title}
</div>
+22 -17
View File
@@ -41,11 +41,11 @@ pub fn Checkbox(
#[prop(into)] value: Signal<String>,
/// The name of the checkbox. This is used to group checkboxes together, so every checkbox
/// 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.
#[prop(into)] disabled: Signal<bool>,
) -> impl IntoView {
let id = create_unique_id(&name, &value.get_untracked());
let id = create_unique_id(&name.get(), &value.get_untracked());
view! {
<label for=id class="flex items-center">
@@ -103,8 +103,12 @@ pub fn Checkbox(
/// ```
#[component]
pub fn CheckboxList(
/// The name that this list should use. This doesn't need to be specified all the time, but is
/// for use in elements that may treat this list as a sub-input (components like the [`MultiEntry`]
/// do this)
#[prop(optional, into)] name: Signal<String>,
/// The data member that this list should edit/display from the `data_map`.
#[prop(into)] data_member: String,
#[prop(into)] data_member: Signal<String>,
/// A [`Signal`] allowing access to a data store.
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
/// The available choices for this list.
@@ -134,32 +138,33 @@ pub fn CheckboxList(
let cloned = item.clone();
move || cloned.clone()
});
let checked = Signal::derive(move || {
controller.selected_list.get().contains(&item.get())
});
let on_change = move || {
// Update the data list by checking if the value is present in it. If so,
// remove it. If not, add it.
controller.selected_list.update(|list| {
let item = item.get();
if list.contains(&item) {
list.retain(|val| *val != item);
} else {
list.push(item);
}
});
controller
.selected_list
.update(|list| {
let item = item.get();
if list.contains(&item) {
list.retain(|val| *val != item);
} else {
list.push(item);
}
});
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! {
<Checkbox
checked=checked
on_change=on_change
// The actual selected values are tracked by this element, not by the checkboxes themselves.
value=item
name=data_member.clone()
name=move || format!("{}_{}", name.get(), data_member.get())
disabled=disabled
/>
}
@@ -171,7 +176,7 @@ pub fn CheckboxList(
{move || {
match controller.error.get() {
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>
+64 -50
View File
@@ -27,14 +27,14 @@ use super::utils::use_selectable_list;
pub fn Chip(
#[prop(into)] display_text: Signal<String>,
#[prop(into)] value: Signal<String>,
#[prop(into)] name: String,
#[prop(into)] name: Signal<String>,
#[prop(into)] on_change: Callback<(), ()>,
#[prop()] checked: Signal<bool>,
#[prop(optional, into)] disabled: Signal<bool>,
) -> impl IntoView {
// Generate a unique id - ensure that the value doesn't contain spaces.
// 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! {
<label for=id>
@@ -45,16 +45,17 @@ pub fn Chip(
id=id.clone()
prop:checked=checked
on:change=move |_| on_change.run(())
disabled=disabled />
disabled=disabled
/>
<div class="rounded-full p-2.5
cursor-pointer text-base
bg-transparent border-1 border-gray-300
shadow-md/50 hover:shadow-md/70
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:border-gray-700
peer-disabled:cursor-default
transition-all duration-150">
cursor-pointer text-base
bg-transparent border-1 border-gray-300
shadow-md/50 hover:shadow-md/70
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:border-gray-700
peer-disabled:cursor-default
transition-all duration-150">
<span>{display_text.clone()}</span>
</div>
</label>
@@ -71,7 +72,7 @@ pub fn Chip(
#[component]
pub fn ChipsList(
/// 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.
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
/// The list of strings that should be displayed in the list.
@@ -95,50 +96,63 @@ pub fn ChipsList(
view! {
<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>
<div class="flex flex-row flex-wrap gap-2 mb-1">
{move || displayed_text.get().into_iter().zip(values.get())
.into_iter()
.map(|(text, value)| {
let value = RwSignal::new(value);
{move || {
displayed_text
.get()
.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,
// remove it. If not, add it.
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()])));
view! {
<Chip
checked=checked
on_change=on_change
value=value
display_text=text
name=data_member
disabled=disabled
/>
}
controller.dirty.set(true);
};
view! {
<Chip
checked=checked
on_change=on_change
value=value
display_text=text
name=data_member.clone()
disabled=disabled
/>
}
})
.collect_view()
}
})
.collect_view()
}}
</div>
</div>
<Show when=move || controller.show_errors.get()>
@@ -146,7 +160,7 @@ pub fn ChipsList(
{move || {
match controller.error.get() {
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>
+3 -1
View File
@@ -1,6 +1,8 @@
mod utils;
mod checkbox;
mod chips;
mod select_nav_list;
pub use checkbox::*;
pub use chips::*;
pub use chips::*;
pub use select_nav_list::*;
@@ -0,0 +1,179 @@
use leptos::logging::log;
use leptos::prelude::*;
use leptos_router::components::A;
use crate::components::utils;
#[derive(Debug, Clone)]
pub struct NavListInfo {
pub text: Vec<String>,
pub id: String,
pub slug: String,
}
#[component]
fn NavListItem(
#[prop(into)] name: Signal<String>,
#[prop(into)] value: Signal<String>,
#[prop(into)] text: Signal<Vec<String>>,
#[prop(into)] slug: Signal<String>,
#[prop(into)] selected: Signal<bool>,
#[prop(into)] on_change: Callback<()>,
) -> impl IntoView {
let linked_info = Signal::derive(move ||
text.get()
.get(0)
.cloned()
.unwrap_or_default()
);
let other_info = Signal::derive(move ||
text.get()
.iter()
.cloned()
.skip(1)
.collect::<Vec<String>>()
);
view! {
<div
class="flex flex-row gap-2 p-1 rounded-lg"
class=(["bg-yellow-100"], move || selected.get())
>
<NavListCheckbox
name=name
value=value
on_change=move || on_change.run(())
selected=selected
/>
<A
href=move || slug.get()
{..}
class="flex flex-1 items-center underline hover:text-blue-400"
>
{linked_info}
</A>
<For each=move || other_info.get() key=|info| info.clone() let(info)>
<p class="flex flex-1 items-center">{info}</p>
</For>
</div>
}
}
#[component]
fn NavListCheckbox(
#[prop(into)] selected: Signal<bool>,
#[prop(into)] on_change: Callback<()>,
#[prop(into)] name: Signal<String>,
#[prop(into)] value: Signal<String>,
) -> impl IntoView {
let id = Memo::new(move |_| utils::create_unique_id(&name.get(), &value.get()));
view! {
<label for=id class="flex items-center">
<input
class="hidden relative peer shrink-0"
type="checkbox"
name=name
id=id
prop:checked=selected
on:change=move |_| on_change.run(())
/>
<div class="relative inline-block h-5 w-5 m-2 rounded-sm border border-gray-300 bg-white
transition-bg duration-150 peer-checked:bg-red-700 peer-checked:border-red-700
[&>span]:opacity-0 peer-checked:[&>span]:opacity-100
peer-disabled:bg-gray-600/33 peer-disabled:border-gray-600
peer-disabled:peer-checked:bg-gray-600">
// This span is the check icon. It is centered within the div and has a border on the bottom
// and right sides, which is then rotated to appear like a checkmark.
<span class="absolute left-1/2 top-1/2 block h-3 w-1.5 -translate-x-1/2 -translate-y-1/2
rotate-45 transform border-b-3 border-r-3
border-white transition-opacity duration-150"></span>
</div>
</label>
}
}
/// # Selectable Nav-List Component
///
/// This component takes a `Vec<NavListInfo>` to show a list. The list contains links that append
/// the `NavListItem::slug` to the current path, and each item is selectable. The list of selected
/// items is usable in this component's parent.
#[component]
pub fn SelectableNavList(
#[prop(into)] selected: RwSignal<Vec<String>>,
#[prop(into)] items: Signal<Vec<NavListInfo>>,
#[prop(into)] headers: Signal<Vec<String>>,
#[prop(into)] name: Signal<String>,
) -> impl IntoView {
let all_selected = RwSignal::new(false);
let all_selected_change = move || {
if all_selected.get() {
log!("Unchecking all values.");
all_selected.set(false);
selected.set(Vec::new());
} else {
log!("Checking all values.");
all_selected.set(true);
selected.set(
items
.get()
.iter()
.map(|info| info.id.clone())
.collect::<Vec<String>>()
);
}
};
view! {
<div class="flex flex-1 flex-col m-3">
// Header block - contains evenly spaced headers and the "select all" checkbox.
// Include a checkbox here that's got the same style as the items. This maintains spacing.
// All headers *must* be evenly spaced, otherwise the list starts getting confusing.
<div class="flex flex-row bg-red-800 rounded-lg p-1 gap-2">
<NavListCheckbox
selected=all_selected
name=name
value="select-all"
on_change=all_selected_change
/>
<For each=move || headers.get() key=|header| header.clone() let(header_text)>
<div class="flex flex-1 text-white font-bold items-center">{header_text}</div>
</For>
</div>
// Items block - contains constructed items. These are selectable.
<For
each=move || items.get()
key=|item| item.id.clone()
children=move |entry| {
let id = Signal::derive(move || entry.id.clone());
let text = Signal::derive(move || entry.text.clone());
let selected_sig = Signal::derive(move || selected.get().contains(&id.get()));
let on_change = move || {
selected
.update(|list| {
let id = id.get();
if list.contains(&id) {
log!("Found value {}, removing.", id);
list.retain(|s| s != &id);
} else {
log!("Adding value {} to the list.", id);
list.push(id);
}
});
all_selected.set(false);
};
view! {
<NavListItem
name=name
text=text
value=id.clone()
slug=entry.slug
selected=selected_sig
on_change=on_change
/>
}
}
/>
</div>
}
}
+32 -13
View File
@@ -23,24 +23,37 @@ fn value_type_to_vec(value_type: &ValueType) -> Vec<String> {
}
pub fn use_selectable_list(
data_member: String,
data_member: Signal<String>,
data_map: RwSignal<HashMap<String, ValueType>>,
required: Signal<bool>
) -> ListController {
//#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
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({
let data_member = data_member.clone();
move || {
// Convert selected strings into ValueTypes
let typed_list = selected_list.with(|list|
@@ -51,7 +64,7 @@ pub fn use_selectable_list(
// Update the 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
}
});
let dirty = RwSignal::new(false);
let show_errors = Signal::derive(move || dirty.get() && matches!(error.get(), ValidationState::Invalid(_)));
let validator = RwSignal::new(InputState::new(
data_member.clone(),
data_member.get(),
error.clone(),
dirty.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
ListController {
+166 -25
View File
@@ -2,6 +2,7 @@ use crate::common::ValueType;
use crate::components::utils::create_unique_id;
use leptos::prelude::*;
use std::collections::HashMap;
use leptos::html::Input;
use crate::components::{use_validation_context, InputState, ValidationState};
#[component]
@@ -20,9 +21,7 @@ pub fn Radio(
// 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"
class="relative peer shrink-0 hidden"
type="radio"
id=id.clone()
name=name
@@ -32,7 +31,7 @@ pub fn Radio(
/>
// This div displays the box for the checkbox, and applies some styling to the child
// 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
[&>span]:opacity-0 peer-checked:[&>span]:opacity-100
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
border-white transition-opacity duration-150"></span>
</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>
}
}
@@ -64,24 +150,48 @@ pub fn RadioList(
#[prop(optional, into)] disabled: Signal<bool>,
#[prop(optional, into)] label: String,
#[prop(optional, into)] required: Signal<bool>,
#[prop(optional, into)] other_prompt: Signal<String>,
#[prop(optional, into)] name: Signal<String>
) -> impl IntoView {
//#region Value Logic
// 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
// 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 || {
data_map.update(|map| {
map.insert(data_member.get(), ValueType::String(Some(selected_value.get())));
});
other_checked.get()
.then(|| selected_value.set(other_value.get()));
});
//#endregion
@@ -96,13 +206,27 @@ pub fn RadioList(
dirty.get() && matches!(error.get(), ValidationState::Invalid(_))
});
let input_state = InputState::new(
let input_state = RwSignal::new(InputState::new(
data_member.get_untracked(),
error.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
//#region Render Logic
@@ -115,18 +239,23 @@ pub fn RadioList(
.into_iter()
.map(|item| {
let item = RwSignal::new(item);
let checked_signal = Signal::derive(move || {
selected_value.get() == item.get()
selected_value.get() == item.get() && !other_checked.get()
});
let on_change = move || {
// on_change only runs when the radio button is checked, not when unchecked.
// Change the selected value to the new one.
other_checked.set(false);
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);
};
// on_change only runs when the radio button is checked, not when unchecked.
// Change the selected value to the new one.
view! {
<Radio
@@ -134,19 +263,31 @@ pub fn RadioList(
on_change=on_change
// The actual selected values are tracked by this element, not by the checkboxes themselves.
value=item
name=data_member
name=move || format!("{}_{}", name.get(), data_member.get())
disabled=disabled
/>
}
})
.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>
<Show when=move || show_errors.get()>
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
{move || {
match error.get() {
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>
+34 -48
View File
@@ -3,7 +3,6 @@ use leptos::ev::{Event, Targeted};
use leptos::prelude::*;
use leptos::web_sys::HtmlSelectElement;
use std::collections::HashMap;
use leptos::logging::{log, warn};
use crate::components::{use_validation_context, InputState, ValidationState};
fn validate(required: bool, value: String) -> ValidationState {
@@ -20,7 +19,7 @@ fn validate(required: bool, value: String) -> ValidationState {
/// by the `value` prop of the component.
#[component]
pub fn Select(
#[prop()] value_list: Vec<String>,
#[prop(into)] value_list: Signal<Vec<String>>,
#[prop(into)] data_member: Signal<String>,
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop(optional, into)] label: String,
@@ -28,53 +27,43 @@ pub fn Select(
#[prop(optional, into)] required: Signal<bool>
) -> impl IntoView {
//#region Value Logic
let default_raw_value = data_map.get_untracked()
.get(&data_member.get_untracked())
.cloned()
.unwrap_or_default()
.as_string().unwrap_or_default().unwrap_or_default();
let raw_value = RwSignal::new(default_raw_value);
Effect::new(move || {
data_map.update(|map| {
map.insert(data_member.get(), ValueType::String(Some(raw_value.get())));
});
let value = Memo::new(move |_| {
data_map.with(|map| {
map.get(&data_member.get_untracked())
.and_then(|v| v.as_string().ok().flatten())
.unwrap_or_default()
})
});
//#endregion
//#region Validation Logic
let error = RwSignal::new(
validate(required.get_untracked(), raw_value.get_untracked())
);
warn!("Current error state: {:?}", error.get_untracked());
let error = Memo::new(move |_| validate(required.get(), value.get()));
let dirty = RwSignal::new(false);
let show_errors = Signal::derive(move || dirty.get() && matches!(error.get(), ValidationState::Invalid(_)));
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()
.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
//#region Event Logic
let on_change = move |e: Targeted<Event, HtmlSelectElement>| {
let value = e.target().value();
log!("Found value {}", value);
raw_value.set(value.clone());
error.set(validate(required.get(), raw_value.get()));
dirty.set(true);
data_map.update(|map| {
if let Some(val) = map.get_mut(&data_member.get()) {
*val = ValueType::String(Some(value.clone()));
} else {
map.insert(data_member.get(), ValueType::String(Some(value)));
}
map.insert(data_member.get(), ValueType::String(Some(value)));
});
};
@@ -87,27 +76,24 @@ pub fn Select(
<span class="ml-1.5 mb-0 font-bold">{label}</span>
<select
class="relative flex-1 border-2 m-1.5 mt-0 p-1.5 rounded-md
transition-border duration-150
border-red-700 bg-transparent
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
transition-border duration-150
border-red-700 bg-transparent
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
prop:value=move || value.get()
on:change:target=on_change
disabled=disabled
>
// This closure handles the display of the options.
<option
value=""
disabled
hidden
selected={move || raw_value.get().is_empty()}
>"Select one..."</option>
{move || {
value_list.iter().map(
move |value| {
let display = value.to_owned();
view! { <option value={display}>{display.clone()}</option> }
}
).collect::<Vec<_>>()
}}
<option value="" disabled hidden selected=move || value.get().is_empty()>
"Select one..."
</option>
// We can reasonably assume that the options in the list will be distinct.
<For
each=move || value_list.get()
key=|value| value.clone()
children=move |val| view! { <option value=val>{val.clone()}</option> }
/>
</select>
</label>
</div>
@@ -116,7 +102,7 @@ pub fn Select(
{move || {
match error.get() {
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>
+1 -1
View File
@@ -3,7 +3,7 @@ use leptos::prelude::*;
#[component]
pub fn Loading() -> impl IntoView {
view! {
<div>
<div class="size-20 mx-auto mt-20">
<img src="/loading.gif" />
</div>
}
+14
View File
@@ -0,0 +1,14 @@
use crate::components::login::setup::provide_auth_context;
use leptos::prelude::*;
#[component]
pub fn AdminLoginContext(children: Children) -> impl IntoView {
provide_auth_context(
crate::utils::use_origin(),
"https://cognito-idp.us-east-1.amazonaws.com/us-east-1_rvCU4Xy4j",
"1vh1q994rid6cgi6tjf58r9jp4",
"/admin/callback",
);
view! { {children()} }
}
+7
View File
@@ -0,0 +1,7 @@
pub(crate) mod setup;
mod provider;
pub use provider::*;
mod student;
pub use student::*;
mod admin;
pub use admin::*;
+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);
}
+29
View File
@@ -0,0 +1,29 @@
use leptos::prelude::*;
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(
children: Children,
) -> 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! { {children()} }
}
+11 -6
View File
@@ -1,19 +1,24 @@
mod action_button;
mod banner;
mod dashboard_button;
mod data_display;
mod date;
mod file_drop;
mod header;
mod lists;
mod loading;
pub mod login;
mod multi_entry;
mod panel;
mod row;
mod tabs;
mod text_field;
mod toasts;
mod utils;
mod validated_form;
mod lists;
mod toasts;
mod header;
pub use self::{
action_button::*, banner::*, dashboard_button::*, lists::*,
loading::*, multi_entry::*, panel::*, row::*, text_field::*,
validated_form::*, toasts::*, header::*
action_button::*, banner::*, dashboard_button::*, data_display::*, date::*, file_drop::*,
header::*, lists::*, loading::*, multi_entry::*, panel::*, row::*, tabs::*, text_field::*,
toasts::*, validated_form::*,
};
+112 -66
View File
@@ -1,8 +1,9 @@
use crate::common::{InputType, ValueType};
use crate::components::ActionButton;
use leptos::leptos_dom::logging::console_log;
use leptos::prelude::*;
use std::collections::HashMap;
use leptos::logging::log;
use leptos_icons::Icon;
use uuid::Uuid;
// region Helper Functions
@@ -12,6 +13,14 @@ fn get_uuid_from_map(map: &HashMap<String, ValueType>) -> Option<String> {
map.get("uuid")?.as_string().unwrap_or(None)
}
/// Does the same as [`get_uuid_from_map`], but starts from a [`ValueType`] enum.
fn get_uuid(vt: ValueType) -> Option<String> {
vt.as_map().ok().flatten().unwrap_or_default()
.get("uuid")?
.as_string()
.ok().flatten()
}
/// Updates an Entry within the given `Vec`.
fn update_entry_list(list: &mut Vec<ValueType>, data: &HashMap<String, ValueType>) {
if let Some(uuid) = get_uuid_from_map(&data) {
@@ -66,81 +75,103 @@ fn new_entry() -> ValueType {
/// `first_name` and `last_name` keys within that `Entry`.
#[component]
pub fn MultiEntry(
#[prop(into)] label: Signal<String>,
#[prop(optional, into)] description: Signal<String>,
/// The data member that contains the list of entries within the `data_map`.
#[prop(into)]
data_member: String,
data_member: Signal<String>,
/// The data map that contains all the information. Data is found within this map using the
/// component's `data_member` input.
#[prop()]
data_map: RwSignal<HashMap<String, ValueType>>,
/// The type and order of the inputs for each `Entry` component within this `MultiEntry` component.
#[prop(optional)]
schema: Vec<InputType>,
#[prop(optional, into)]
schema: Signal<Vec<InputType>>,
) -> impl IntoView {
// Make the values within the list reactive. Each of these values should be a ValueType::Map,
// so we can pass these directly to the application.
let data_reactive = RwSignal::new(
data_map
.get_untracked()
.get(&data_member)
.unwrap_or(&ValueType::List(None))
.as_list()
.unwrap_or(Some(Vec::<ValueType>::new()))
.unwrap_or(Vec::new()),
);
// This effect updates the data_map with any new values from the data_reactive list, every time
// the data_reactive list is changed. This allows changes from within the Entry component to
// bubble up to the data_map effectively.
Effect::new(move |_| {
let entry_list = data_reactive.get();
data_map.update(|map| {
map.insert(data_member.clone(), ValueType::List(Some(entry_list)));
});
let data_list = Memo::new(move |_| {
data_map.with(|map| {
map.get(&data_member.get())
.and_then(|v| v.as_list().ok().flatten())
.unwrap_or_default()
})
});
// This effect updates the data_map with any new values from the data_list, every time
// the data_list is changed. This allows changes from within the Entry component to
// bubble up to the data_map effectively.
let update_parent_list = move |list| {
log!("Updating parent list using child list {:?}", list);
data_map.update(|map| {
map.insert(data_member.get(), ValueType::List(Some(list)));
});
};
let add_entry = move |_| data_reactive.update(|list| list.push(new_entry()));
let add_entry = move |_| data_map.update(|map| {
log!("Adding new entry.");
let mut list = data_list.get_untracked();
list.push(new_entry());
map.insert(data_member.get(), ValueType::List(Some(list)));
});
view! {
<div class="flex flex-col flex-1">
<div class="flex flex-col gap-2 p-2">
<span class="font-bold">{label}</span>
<Show when=move || !description.get().is_empty()>
<span>{description}</span>
</Show>
</div>
<div class="flex flex-col gap-6 p-2">
// Render the entries
<For
each=move || data_reactive.get()
key=|entry| get_uuid_from_map(&entry.as_map().unwrap().unwrap()) // Entries MUST be given a unique ID. This is absolutely ridiculous.
children=move |mut entry_map| {
// The map_signal is the map used for the internal element. This signal will
// keep track of the individual entry's data, and whenever it is changed,
// the parent data_map will also be updated to reflect these changes.
let map_signal = RwSignal::new(entry_map
.as_map()
.unwrap_or_default()
.unwrap_or_default());
// This effect updates the data_reactive list with the map_signal values, every
// time the map_signal is changed.
Effect::new(move |_| {
let child_map = map_signal.get(); // HashMap
data_reactive.update(|mut list| {
update_entry_list(&mut list, &child_map);
});
});
view! {
<Entry
data_map = map_signal
schema = schema.clone()
/>
}
<Show
when=move || !data_list.get().is_empty()
fallback=|| {
view! { <div class="mx-auto">"You haven't added any entries yet."</div> }
}
/>
>
<For
each=move || data_list.get()
// Entries MUST be given a unique ID.
key=|entry| get_uuid(entry.clone())
children=move |mut entry_map| {
let map_signal = RwSignal::new(
entry_map.as_map().ok().flatten().unwrap_or_default(),
);
let unique_id = Memo::new(move |_| {
get_uuid_from_map(&map_signal.get()).unwrap_or_default()
});
let on_delete = move || {
let mut list = data_list.get_untracked();
let uuid = unique_id.get_untracked();
list.retain(|map| {
get_uuid(map.clone()).unwrap_or_default() != uuid
});
update_parent_list(list);
};
Effect::new(move |_| {
let child_map = map_signal.get();
let mut list = data_list.get_untracked();
update_entry_list(&mut list, &child_map);
update_parent_list(list);
});
view! {
<Entry
data_map=map_signal
schema=schema
id=unique_id
on_delete=on_delete
/>
}
}
/>
</Show>
</div>
<ActionButton on:click = add_entry>"Add entry"</ActionButton>
<ActionButton on:click=move |_| {
console_log(format!("Entry list: {:?}", data_reactive.get()).as_str());
}
>"Print Info"</ActionButton>
<ActionButton on:click=add_entry>"Add entry"</ActionButton>
</div>
}
}
@@ -176,20 +207,35 @@ fn Entry(
/// The data map that contains all information. Data is found within this map using the `member`
/// props. This map should be unique to this entry; in other words, it should be contained and
/// passed down from within the entry list in the parent `MultiEntry` component.
#[prop()]
data_map: RwSignal<HashMap<String, ValueType>>,
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
/// The schema of this entry. Should contain all information about what inputs to use and what
/// data members those inputs modify.
#[prop()]
schema: Vec<InputType>,
#[prop(into)] schema: Signal<Vec<InputType>>,
/// The unique ID for this entry.
#[prop(into)] id: Signal<String>,
/// The function to run when the delete button is clicked.
#[prop(into)] on_delete: Callback<()>,
) -> impl IntoView {
view! {
<div class="flex flex-col flex-1 p-2 rounded-sm transition-shadow shadow-sm hover:shadow-lg/30">
{schema.iter()
.map(|input_type| {
input_type.clone().into_view(data_map.clone())
}).collect_view()
}
<div class="relative rounded-sm transition-shadow shadow-sm hover:shadow-lg/30">
// Delete button
<div
class="absolute rounded-full w-8 h-8 -top-4 -right-4 flex items-center justify-center bg-gray-100 text-gray-700 hover:text-red-600 transition-all cursor-pointer"
on:click=move |_| on_delete.run(())
>
<Icon icon=icondata::FaTrashCanRegular />
</div>
// Input section
<div class="flex flex-col flex-1 p-2">
{move || {
schema
.get()
.iter()
.map(|input_type| input_type.clone().into_view(data_map.clone(), id.get()))
.collect_view()
}}
</div>
</div>
}
}
+2
View File
@@ -0,0 +1,2 @@
mod sidebar;
pub use sidebar::*;
+141
View File
@@ -0,0 +1,141 @@
use leptos::prelude::*;
use leptos_router::components::{Outlet, A};
use leptos_router::hooks::use_location;
use crate::common::{SubTabInfo, TabInfo};
/// # Tab Sidebar Component
///
/// This component creates a list of tabs that correspond to the given `Vec<TabInfo>`.
/// There are a few things to note:
///
/// - This component defines an [`Outlet`], meaning that it is designed to display the route
/// information on its own. If you intend to use this on a page, the page should not also contain
/// an `Outlet` component unless it needs to render its own nested children.
/// - The "selected" appearance for the individual tabs work best when their paths are at the same
/// depth. For example:
/// ```
/// let good_list = vec! [
/// TabInfo {
/// ...,
/// path: "/default/path"
/// },
/// TabInfo {
/// ...,
/// path: "/good/path"
/// }
/// ];
///
/// let bad_list = vec! [
/// TabInfo {
/// ...,
/// path: "/default/path"
/// },
/// TabInfo {
/// ...,
/// path: "/bad/path/here"
/// }
/// ]
/// ```
///
/// Example usage in a view (assuming the good list from above):
/// ```
/// view! {
/// <TabSidebarList
/// tabs=good_list
/// />
/// // Remember, no Outlets!
/// }
/// ```
#[component]
pub fn TabSidebarList(
#[prop(into)] base_path: Signal<String>,
#[prop(into)] tabs: Signal<Vec<TabInfo>>,
) -> impl IntoView {
// Create a series of tabs based on a given list of names and paths.
view! {
<div class="flex flex-1 flex-row w-full">
<nav class="flex flex-col bg-red-900 w-64 overflow-scroll">
<For
each=move || tabs.get()
key=|info| info.text.clone()
let(TabInfo { text, path, sub_paths })
>
<SidebarTab base_path=base_path text=text path=path sub_paths=sub_paths />
</For>
</nav>
<div class="flex flex-1 overflow-scroll">
<Outlet />
</div>
</div>
}
}
#[component]
fn SidebarTab(
#[prop(into)] base_path: Signal<String>,
#[prop(into)] text: Signal<String>,
#[prop(into)] path: Signal<String>,
#[prop(into)] sub_paths: Signal<Vec<SubTabInfo>>,
) -> impl IntoView {
let location = use_location();
let full_path = Memo::new(move |_| {
format!("/{}/{}", base_path.get(), path.get())
});
let selected = Memo::new(move |_| {
let current_path = location.pathname.get();
// There's extra logic here as well, but for now this works.
current_path.starts_with(&full_path.get())
});
view! {
<div
class="transition-all duration-300"
class=(["bg-transparent", "text-white"], move || !selected.get())
class=(["bg-white", "text-black"], move || selected.get())
>
<A href=move || path.get()>
<div class="p-3 transition-bg font-bold">{text}</div>
</A>
<div
class="grid transition-all ease-in-out"
style:grid-template-rows=move || if selected.get() { "1fr" } else { "0fr" }
>
<div class="overflow-hidden min-h-0">
<For
each=move || sub_paths.get()
key=|info| info.text.clone()
let(SubTabInfo { text, path })
>
<SidebarSubTab parent_path=full_path text=text path=path />
</For>
</div>
</div>
</div>
}
}
#[component]
fn SidebarSubTab(
#[prop(into)] parent_path: Signal<String>,
#[prop(into)] text: Signal<String>,
#[prop(into)] path: Signal<String>,
) -> impl IntoView {
let full_path = Memo::new(move |_| {
format!("{}/{}", parent_path.get(), path.get())
});
view! {
<A
href=move || full_path.get()
{..}
class="block p-3 pl-10 transition-all aria-[current]:font-bold"
>
{text}
</A>
}
}
+67 -50
View File
@@ -2,6 +2,7 @@ use crate::common::ValueType;
use leptos::prelude::*;
use std::collections::HashMap;
use leptos::html::Input;
use leptos::logging::debug_log;
use crate::components::{use_validation_context, InputState};
use crate::components::validated_form::ValidationState;
@@ -13,18 +14,26 @@ pub enum TextFieldType {
Text,
/// A text field that accepts only numbers.
Number,
/// A text field that accepts only Region 15 email addresses.
/// TODO Accept more than just Region 15 addresses.
Email
/// A text field that accepts the specified email domains.
/// Values should be given as the string following the '@' in an address, or '*' for any.
///
/// 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.
fn validate(required: bool, value: &str, input_type: &TextFieldType) -> ValidationState {
if required && value.is_empty() {
ValidationState::Invalid("This field is required.".to_string())
} else if !required && value.is_empty() {
ValidationState::Valid
} else {
match input_type {
TextFieldType::Email => validate_email(value),
TextFieldType::Email(domains) => validate_email(value, domains),
TextFieldType::Number => validate_number(value),
TextFieldType::Text => ValidationState::Valid
}
@@ -32,20 +41,23 @@ fn validate(required: bool, value: &str, input_type: &TextFieldType) -> Validati
}
/// Validates an email address.
fn validate_email(input: &str) -> ValidationState {
// TODO Accept more than just Region 15 addresses.
let pattern = regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@region15\.org$").expect("Invalid regex pattern");
fn validate_email(input: &str, valid_domains: &Vec<String>) -> ValidationState {
// Check for wildcard domain. We won't check domains if it's present.
let wild = valid_domains.contains(&"*".to_string());
if pattern.is_match(input) {
ValidationState::Valid
} else {
ValidationState::Invalid("Please provide a valid Region 15 email address.".to_string())
}
input.split_once('@')
.and_then(|(_, domain)| {
(wild || valid_domains.contains(&domain.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.
fn validate_number(input: &str) -> ValidationState {
if input.parse::<i32>().is_ok() {
if input.parse::<f32>().is_ok() {
ValidationState::Valid
} else {
ValidationState::Invalid("Value is not a valid number.".to_string())
@@ -55,7 +67,7 @@ fn validate_number(input: &str) -> ValidationState {
#[component]
pub fn OutlinedTextField(
#[prop(optional, into)] placeholder: String,
#[prop(into)] data_member: String, // should this be an RwSignal??
#[prop(into)] data_member: String,
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop(optional, into)] input_type: Signal<TextFieldType>,
#[prop(optional, into)] disabled: Signal<bool>,
@@ -64,30 +76,28 @@ pub fn OutlinedTextField(
#[prop(optional, into)] required: Signal<bool>
) -> impl IntoView {
let input_ref = NodeRef::<Input>::new();
let data_member = StoredValue::new(data_member);
// Register this input's validation signal.
let validator_context = use_validation_context()
.expect("FormValidSignal was not found");
let raw_value = Signal::derive({
let data_member = data_member.clone();
move ||
data_map
.get()
.get(&data_member)
.unwrap_or(&ValueType::String(None))
.to_string()
});
let display_value = RwSignal::new(
data_map.get_untracked()
.get(&data_member.get_value())
.map(|v| v.to_string())
.unwrap_or_default()
);
let dirty = RwSignal::new(false);
let error = Signal::derive(move ||
validate(required.get(), &raw_value.get(), &input_type.get())
validate(required.get(), &display_value.get(), &input_type.get())
);
let show_errors = Signal::derive(move || {
dirty.get() && matches!(error.get(), ValidationState::Invalid(_))
});
let validator = RwSignal::new(InputState::new(data_member.clone(), error.clone(), dirty.clone()));
let validator = RwSignal::new(InputState::new(data_member.get_value(), error.clone(), dirty.clone()));
validator_context.validators.update(|list| list.push(validator));
@@ -97,35 +107,42 @@ pub fn OutlinedTextField(
});
});
let str_type = Signal::derive(move || match input_type.get_untracked() {
TextFieldType::Number => "number",
_ => "text" // This is fine even for emails, as we're doing our own validation.
});
let on_blur = move |_| {
// Check validity of input's current value. The value will be updated by on_input,
// and it should be a raw input (not parsed into a ValueType).
dirty.set(true);
};
// Syncs data from the map into the display_value, only when the map is actually changed.
Effect::new(move || {
let new_value = data_map.get()
.get(&data_member.get_value())
.map(|v| v.to_string())
.unwrap_or_default();
if new_value != display_value.get_untracked() {
debug_log!("Found value from map: {}", new_value);
display_value.set(new_value);
}
});
// This function parses the input into the correct type. It only accepts numbers or strings as
// valid types, and parses them accordingly.
let on_input = {
let data_member = data_member.clone();
move |e| {
let to_parse = event_target_value(&e);
let on_input = move |e| {
let to_parse = event_target_value(&e);
debug_log!("Setting value to display_value: {}", to_parse);
display_value.set(to_parse.clone());
// Push the new value to the data map. This happens regardless of whether the value parses
// or not.
data_map.update(|map| {
let into_map = match input_type.get() {
TextFieldType::Number => ValueType::Number(Some(to_parse)),
_ => ValueType::String(Some(to_parse))
};
// Edit the data map. Note that this should be uncoupled in the future - we don't want
// to update the map inside the component definition, but inside the page/form that
// created this input.
data_map.update(|map| {
map.insert(data_member.clone(), into_map);
});
}
map.insert(data_member.get_value(), into_map);
});
};
view! {
@@ -135,14 +152,14 @@ pub fn OutlinedTextField(
<input
node_ref=input_ref
class="border-2 m-1.5 p-1.5 mt-0 rounded-md bg-transparent relative flex-1
transition-all duration-150
border-red-700 bg-transparent
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
r#type={str_type}
disabled={disabled}
placeholder={placeholder}
transition-all duration-150
border-red-700 bg-transparent
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
r#type="text"
disabled=disabled
placeholder=placeholder
prop:name=name
prop:value=raw_value
prop:value=display_value
on:input=on_input
on:blur=on_blur
/>
@@ -151,7 +168,7 @@ pub fn OutlinedTextField(
{move || {
match error.get() {
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>
+3 -5
View File
@@ -5,6 +5,7 @@ use crate::components::{ActionButton, Header};
/// Indicates the current state of an input.
#[derive(Default, Debug, Clone)]
#[derive(PartialEq)]
pub enum ValidationState {
/// A valid input.
#[default]
@@ -149,11 +150,8 @@ pub fn ValidatedForm(
};
view! {
<form novalidate on:submit=submit_success_event>
<Header
title=title
description=description
/>
<form class="flex flex-col gap-2 py-7" novalidate on:submit=submit_success_event>
<Header title=title description=description />
{children()}
<ActionButton
on:click=move |_| {
+1
View File
@@ -2,6 +2,7 @@ pub mod app;
mod common;
mod components;
mod pages;
pub mod utils;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
+19 -4
View File
@@ -6,7 +6,12 @@ async fn main() {
use leptos::prelude::*;
use leptos_axum::{LeptosRoutes, generate_route_list};
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 addr = conf.leptos_options.site_addr;
let leptos_options = conf.leptos_options;
@@ -14,10 +19,20 @@ async fn main() {
let routes = generate_route_list(App);
let app = Router::new()
.leptos_routes(&leptos_options, routes, {
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
})
.leptos_routes_with_context(
&leptos_options,
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))
.with_state(leptos_options);
+11
View File
@@ -0,0 +1,11 @@
use leptos::prelude::*;
#[component]
pub fn AdminHomePage() -> impl IntoView {
view! {
<div class="flex flex-1 flex-col gap-4 items-center m-4">
<div class="font-bold text-3xl">"Admin Home Page"</div>
<div class="text-lg">"This is just a placeholder for now."</div>
</div>
}
}
+11
View File
@@ -0,0 +1,11 @@
mod shell;
mod home_page;
mod provider_page;
mod scholarship_page;
mod utils_page;
pub use shell::*;
pub use home_page::*;
pub use provider_page::*;
pub use scholarship_page::*;
pub use utils_page::*;
+83
View File
@@ -0,0 +1,83 @@
use leptos::logging::log;
use leptos::prelude::*;
use crate::common::ValueType;
use crate::components::{NavListInfo, SelectableNavList};
use crate::pages::api::get_all_providers;
#[component]
pub fn AdminProviderPage() -> impl IntoView {
let selected_items = RwSignal::new(Vec::new());
let provider_list = RwSignal::new(Vec::new());
let provider_trigger = Trigger::new();
let provider_resource = Resource::new(
move || provider_trigger.track(),
async move |_| {
get_all_providers().await
}
);
Effect::new(move || {
if let Some(Err(err)) = provider_resource.get() {
log!("Found error: {}", err.to_string());
}
let Some(Ok(list)) = provider_resource.get() else {
return;
};
let members_list = vec![
"contact_email".to_string(),
"first_name".to_string(),
"last_name".to_string(),
];
// Convert the list into a series of NavListInfo
let converted = list
.into_iter()
.map(|info| {
let mut text_list = vec![];
members_list.iter().for_each(|member| {
let val = info.get(member)
.unwrap_or(&ValueType::String(None))
.as_string()
.ok().flatten()
.unwrap_or_default();
text_list.push(val);
});
let subject = info.get("subject")
.unwrap_or(&ValueType::String(None))
.to_string();
NavListInfo {
text: text_list,
id: subject.clone(),
slug: subject,
}
})
.collect();
provider_list.set(converted);
});
let headers = vec![
"Email",
"First Name",
"Last Name"
]
.iter()
.map(|s| s.to_string())
.collect::<Vec<String>>();
view! {
<div class="flex flex-1 flex-col gap-4 items-stretch mt-4">
<div class="font-bold text-3xl self-center">"Provider View"</div>
<SelectableNavList
selected=selected_items
name="providers-nav-list"
items=provider_list
headers=headers
/>
</div>
}
}
+11
View File
@@ -0,0 +1,11 @@
use leptos::prelude::*;
#[component]
pub fn AdminScholarshipPage() -> impl IntoView {
view! {
<div class="flex flex-1 flex-col gap-4">
<div class="font-bold text-3xl">"Scholarships View"</div>
<div>"This is a placeholder for now. It will show a list of all scholarships."</div>
</div>
}
}
+34
View File
@@ -0,0 +1,34 @@
use crate::common::TabInfo;
use crate::components::login::AdminLoginContext;
use crate::components::{Banner, Loading, TabSidebarList};
use crate::pages::UnauthenticatedPage;
use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated};
/// # Admin Shell Component
///
/// This shell should keep track of pretty much everything that changes across the admin page. It
/// will provide the login context, the banner, and the tabs.
#[component]
pub fn AdminShell() -> impl IntoView {
// Create login context
view! {
<AdminLoginContext>
<Banner title="R15 Scholarships Admin" logo="/PHS_Stacked_Acronym.png" />
<AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage>
// We'll put the tabs here.
<TabSidebarList
base_path="admin"
tabs=vec![
TabInfo::new("Home", "home", None),
TabInfo::new("Providers", "providers", None),
TabInfo::new("Scholarships", "scholarships", None),
TabInfo::new("Utilities", "utilities", None),
]
/>
</Authenticated>
</AuthLoaded>
</AdminLoginContext>
}
}
+115
View File
@@ -0,0 +1,115 @@
use crate::common::{DateInfo, DateRange};
use crate::components::ActionButton;
use crate::pages::api::exports::get_scholarship_csv;
use crate::pages::api::{CreateDates, CreateTestComparisons, exports::GetScholarshipCsv};
use chrono::DateTime;
use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos::wasm_bindgen::JsCast;
use leptos::web_sys::{Blob, HtmlAnchorElement, Url, js_sys};
fn get_important_dates() -> Vec<DateInfo> {
vec![
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Provider Forms Open".to_string(),
date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-01-20T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-03-03T23:59:00-05:00").unwrap(),
),
description: "".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Student Forms Open".to_string(),
date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-03-03T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-03-27T14:05:00-05:00").unwrap(),
),
description: "".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Scholarship Decision Window".to_string(),
date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-04-10T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-05-01T23:59:00-05:00").unwrap(),
),
description: "Start date may change if available sooner.".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "PHS Scholarship Committee".to_string(),
date: DateRange::Single(
DateTime::parse_from_rfc3339("2026-05-18T00:00:00-05:00").unwrap(),
),
description: "Time TBD".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Scholarship and Awards Night".to_string(),
date: DateRange::Single(
DateTime::parse_from_rfc3339("2026-06-09T00:00:00-05:00").unwrap(),
),
description: "Time TBD".to_string(),
},
]
}
#[component]
pub fn AdminUtilsPage() -> impl IntoView {
let create_comparisons = ServerAction::<CreateTestComparisons>::new();
let create_dates = ServerAction::<CreateDates>::new();
let create_export = ServerAction::<GetScholarshipCsv>::new();
let on_click_comparisons = move |_| {
create_comparisons.dispatch(CreateTestComparisons {});
};
let on_click_dates = move |_| {
create_dates.dispatch(CreateDates {
dates: get_important_dates(),
});
};
let on_click_export = move |_| {
spawn_local(async move {
let file_bytes = get_scholarship_csv().await.unwrap_or_default();
let uint_arr = js_sys::Uint8Array::new_with_length(file_bytes.len() as u32);
uint_arr.copy_from(&file_bytes);
let blob_parts = js_sys::Array::new();
blob_parts.push(&uint_arr);
let blob = Blob::new_with_u8_array_sequence(&blob_parts).unwrap();
let blob_url = Url::create_object_url_with_blob(&blob).unwrap();
let window = window();
let document = window.document().unwrap();
let a: HtmlAnchorElement = document.create_element("a").unwrap().dyn_into().unwrap();
a.set_href(&blob_url);
a.set_download("scholarships.csv");
document.body().unwrap().append_child(&a).unwrap();
a.click();
document.body().unwrap().remove_child(&a).unwrap();
Url::revoke_object_url(&blob_url).unwrap();
});
};
view! {
<div class="mx-auto px-6">
<div class="flex flex-col gap-4">
<div class="self-centered text-lg mt-6">
"This utility page provides buttons to initialize the corresponding lists of information."
</div>
<ActionButton on:click=on_click_comparisons>"Create Comparisons"</ActionButton>
<ActionButton on:click=on_click_dates>"Create Dates"</ActionButton>
<ActionButton on:click=on_click_export>"Scholarship Export"</ActionButton>
</div>
</div>
}.into_any()
}
+44
View File
@@ -0,0 +1,44 @@
use leptos::prelude::*;
use std::collections::HashMap;
use crate::common::ValueType;
use leptos::logging::log;
#[cfg(feature = "ssr")]
mod server_logic {
pub use crate::utils::server::create_dynamo_client;
pub use super::super::PROVIDER_CONTACT_TABLE;
}
#[server]
pub async fn get_all_providers() -> Result<Vec<HashMap<String, ValueType>>, ServerFnError> {
use server_logic::*;
// We want to get all provider information from the contacts table.
// This will be migrated to the new table structure when it happens.
let client = create_dynamo_client().await;
log!("Scanning all provider information from {}", PROVIDER_CONTACT_TABLE);
client
.scan()
.table_name(PROVIDER_CONTACT_TABLE)
.send()
.await
.map(|output| {
if let Some(items) = output.items {
items
.into_iter()
.map(|item| {
item.into_iter().map(|(key, attr)| {
let val = ValueType::from(&attr);
(key, val)
}).collect()
})
.collect::<Vec<HashMap<String, ValueType>>>()
} else {
Vec::new()
}
})
.map_err(ServerFnError::from)
}
+134 -30
View File
@@ -1,6 +1,5 @@
use std::collections::HashMap;
#[cfg(feature = "ssr")]
use crate::pages::utils::server_utils::create_dynamo_client;
use crate::utils::server::create_dynamo_client;
#[cfg(feature = "ssr")]
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
@@ -8,13 +7,18 @@ use aws_sdk_dynamodb::error::ProvideErrorMetadata;
#[cfg(feature = "ssr")]
use leptos::logging::log;
#[cfg(feature = "ssr")]
use super::COMPARISONS_TABLE;
use crate::common::{
ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison,
TextComparison, TextListComparison, ValueType,
};
use std::collections::HashMap;
use leptos::prelude::ServerFnError;
use leptos::server;
/// 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.
#[allow(unused)]
@@ -185,18 +189,87 @@ fn create_sports_comparisons() -> Vec<ComparisonData> {
"Sports Participation",
"Tennis",
),
ComparisonData::new(
"sports_baseball",
"sports_participation",
ComparisonType::MapList(MapListComparison::FlattenToTextList(
"sport_name".to_string(),
Box::new(TextListComparison::Contains),
)),
ValueType::String(Some("Baseball".to_string())),
"Sports Participation",
"Baseball",
),
]
}
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)]
fn make_comp_list() -> Vec<ComparisonData> {
let gpa_3 = ComparisonData::new(
"gpa_3",
"gpa_top_third",
"weighted_gpa",
ComparisonType::Number(NumberComparison::GreaterThanOrEqual),
ValueType::Number(Some(3.0.to_string())),
ValueType::Number(Some(6.264.to_string())),
"GPA Limits",
"GPA >= 3.0",
"Top Third GPA",
);
let test_comp = ComparisonData::new(
@@ -210,13 +283,10 @@ fn make_comp_list() -> Vec<ComparisonData> {
let service_hours_20 = ComparisonData::new(
"service_hours_20",
"community_involvement",
ComparisonType::MapList(MapListComparison::FlattenToNumberList(
"service_hours".to_string(),
Box::new(NumberListComparison::Sum(Box::new(
NumberComparison::GreaterThanOrEqual,
))),
)),
"service_hours",
ComparisonType::Number(
NumberComparison::GreaterThanOrEqual
),
ValueType::Number(Some(20.to_string())),
"Community Service",
"20+ service hours",
@@ -224,13 +294,10 @@ fn make_comp_list() -> Vec<ComparisonData> {
let service_hours_25 = ComparisonData::new(
"service_hours_25",
"community_involvement",
ComparisonType::MapList(MapListComparison::FlattenToNumberList(
"service_hours".to_string(),
Box::new(NumberListComparison::Sum(Box::new(
NumberComparison::GreaterThanOrEqual,
))),
)),
"service_hours",
ComparisonType::Number(
NumberComparison::GreaterThanOrEqual
),
ValueType::Number(Some(25.to_string())),
"Community Service",
"25+ service hours",
@@ -238,13 +305,10 @@ fn make_comp_list() -> Vec<ComparisonData> {
let service_hours_30 = ComparisonData::new(
"service_hours_30",
"community_involvement",
ComparisonType::MapList(MapListComparison::FlattenToNumberList(
"service_hours".to_string(),
Box::new(NumberListComparison::Sum(Box::new(
NumberComparison::GreaterThanOrEqual,
))),
)),
"service_hours",
ComparisonType::Number(
NumberComparison::GreaterThanOrEqual
),
ValueType::Number(Some(30.to_string())),
"Community Service",
"30+ service hours",
@@ -295,8 +359,44 @@ fn make_comp_list() -> Vec<ComparisonData> {
"Family with Military Service",
);
let youth_baseball = ComparisonData::new(
"youth_baseball",
"youth_baseball",
ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())),
"Additional Eligibility Factors",
"Participated in Pomperaug Youth Baseball"
);
let aquatic_club = ComparisonData::new(
"aquatic_club",
"aquatic_club",
ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())),
"Additional Eligibility Factors",
"Participated in Panthers Aquatic Club"
);
let pea_member = ComparisonData::new(
"pea_member",
"pea_member",
ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())),
"Additional Eligibility Factors",
"Has a Parent in the PEA"
);
let music_program = ComparisonData::new(
"music_program",
"music_program",
ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())),
"Sports Participation",
"PHS Music Program"
);
let mut sports_comps = create_sports_comparisons();
let mut major_comps = Vec::<ComparisonData>::new();
let mut major_comps = create_major_comparisons();
let mut comp_list = vec![
gpa_3,
@@ -309,6 +409,10 @@ fn make_comp_list() -> Vec<ComparisonData> {
attended_bas,
midd_south,
family_military,
youth_baseball,
aquatic_club,
pea_member,
music_program
];
comp_list.append(&mut sports_comps);
@@ -328,7 +432,7 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
for comparison in comp_list {
if let Err(err) = client
.put_item()
.table_name("leptos-comparison-test")
.table_name(COMPARISONS_TABLE)
.set_item(Some(serde_dynamo::to_item(&comparison)?))
.send()
.await
@@ -351,7 +455,7 @@ pub async fn get_comparison_info() -> Result<Vec<ComparisonData>, ServerFnError>
// id and display text, but we'll return the whole thing.
match client
.scan()
.table_name("leptos-comparison-test")
.table_name(COMPARISONS_TABLE)
.send()
.await
{
@@ -377,7 +481,7 @@ pub async fn get_comparisons_categorized() -> Result<HashMap<String, Vec<Compari
match client
.scan()
.table_name("leptos-comparison-test")
.table_name(COMPARISONS_TABLE)
.send()
.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")]
use super::DATES_TABLE;
#[cfg(feature = "ssr")]
use aws_sdk_dynamodb::{
types::{WriteRequest, PutRequest},
Client as DynamoClient
};
#[cfg(feature = "ssr")]
use crate::utils::server::create_dynamo_client;
#[cfg(feature = "ssr")]
use std::sync::Arc;
#[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)
}
+237
View File
@@ -0,0 +1,237 @@
#[cfg(feature = "ssr")]
mod imports {
pub use super::super::{COMPARISONS_TABLE, PROVIDER_CONTACT_TABLE, SCHOLARSHIPS_TABLE};
pub use crate::common::ValueType;
pub use crate::utils::server::create_dynamo_client;
pub use aws_sdk_dynamodb::Client;
pub use aws_sdk_dynamodb::error::ProvideErrorMetadata;
pub use indexmap::IndexMap;
pub use std::collections::HashMap;
}
use crate::common::ExpandableInfo;
use leptos::prelude::*;
#[server]
pub async fn get_scholarship_csv() -> Result<Vec<u8>, ServerFnError> {
use imports::*;
use leptos::logging::debug_log;
#[derive(serde::Serialize)]
struct ResolvedScholarshipInfo {
contact_email: String,
first_name: String,
last_name: String,
scholarship_name: String,
sports_requirements: String,
gpa_requirements: String,
major_requirements: String,
additional_requirements: String,
community_requirements: String,
residency_requirements: String,
}
// Get the scholarships and comparisons
let client = create_dynamo_client().await;
let scan_table = async |client: &Client, table_name| {
client
.scan()
.table_name(table_name)
.send()
.await
.map(|output| output.items)
.ok()
.flatten()
.unwrap_or_default()
.into_iter()
.map(|item| {
serde_dynamo::from_item(item).unwrap_or(ExpandableInfo::new("".to_string()))
// item.iter()
// .map(|(k, v)| (k.to_owned(), ValueType::from(v)))
// .collect::<HashMap<String, ValueType>>()
})
.collect::<Vec<ExpandableInfo>>()
};
let scan_table_hash_map = async |client: &Client, table_name| {
client
.scan()
.table_name(table_name)
.send()
.await
.map(|output| output.items)
.ok()
.flatten()
.unwrap_or_default()
.into_iter()
.map(|item| {
// serde_dynamo::from_item(item).unwrap_or(ExpandableInfo::new("".to_string()))
item.iter()
.map(|(k, v)| (k.to_owned(), ValueType::from(v)))
.collect::<HashMap<String, ValueType>>()
})
.collect::<Vec<HashMap<String, ValueType>>>()
};
let master_scholarships = scan_table(&client, SCHOLARSHIPS_TABLE).await;
let master_contacts = scan_table_hash_map(&client, PROVIDER_CONTACT_TABLE).await;
let master_relations = scan_table_hash_map(&client, COMPARISONS_TABLE).await;
// We now want to map the relations to the scholarships, as well as the contacts.
// This should take the information from the current scholarship, then relate the IDs of the
// requirements in the scholarship to the IDs of the relations in the master list.
let resolved_scholarships = master_scholarships
.into_iter()
.map(|scholarship| {
// debug_log!("Scholarship: {:?}", scholarship);
// We want the requirements IDs and the provider ID.
let requirements_map = scholarship
.data
.get("requirements")
.unwrap_or(&ValueType::Map(None))
.as_map()
.ok()
.flatten()
.unwrap_or_default();
let provider_id = scholarship
.data
.get("provider_id")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
// Search for the provider ID in the master list.
let contact_info = master_contacts
.iter()
.find(|contact| {
let subject = contact
.get("subject")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
debug_log!("Comparing {:?} and {:?}", subject, provider_id);
subject == provider_id
})
.cloned()
.unwrap_or_default();
// Resolve the requirements - this should resolve as another category-separated map,
// but with requirement display text instead of IDs.
let resolved_requirements = requirements_map
.iter()
.map(|(category, selected_val)| {
let selected_list = selected_val.as_list().ok().flatten().unwrap_or_default();
let resolved_names = selected_list
.into_iter()
.map(|id_val| {
let id = id_val.as_string().ok().flatten().unwrap_or_default();
let resolved_relation = master_relations
.iter()
.find(|item| {
let comp_id = item
.get("id")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
comp_id == id
})
.cloned()
.map(|v| {
v.get("display_text").map(|text_opt| {
text_opt.as_string().ok().flatten().unwrap_or_default()
})
})
.flatten()
.unwrap_or_default();
resolved_relation
})
.collect::<Vec<String>>();
(category.clone(), resolved_names)
})
.collect::<IndexMap<String, Vec<String>>>();
debug_log!("Resolved requirements: {:?}", resolved_requirements);
let contact_email = contact_info
.get("contact_email")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
let first_name = contact_info
.get("first_name")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
let last_name = contact_info
.get("last_name")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
let scholarship_name = scholarship
.data
.get("name")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
debug_log!(
"{:?}, {:?}, {:?}, {:?}",
scholarship_name,
contact_email,
first_name,
last_name
);
// We want to create some structure that's got the correct information. Specifically,
// we only need the provider contact info, the scholarship's name, and the requirements
// that they chose in plain text.
ResolvedScholarshipInfo {
first_name,
last_name,
contact_email,
scholarship_name,
sports_requirements: resolved_requirements
.get("sports_participation")
.map(|list| list.join("; "))
.unwrap_or_default(),
gpa_requirements: resolved_requirements
.get("gpa")
.map(|list| list.join("; "))
.unwrap_or_default(),
major_requirements: resolved_requirements
.get("major")
.map(|list| list.join("; "))
.unwrap_or_default(),
additional_requirements: resolved_requirements
.get("misc")
.map(|list| list.join("; "))
.unwrap_or_default(),
community_requirements: resolved_requirements
.get("community_service")
.map(|list| list.join("; "))
.unwrap_or_default(),
residency_requirements: resolved_requirements
.get("residency")
.map(|list| list.join("; "))
.unwrap_or_default(),
}
})
.collect::<Vec<ResolvedScholarshipInfo>>();
// Now that we have a series of scholarships, we need to serialize them into CSV and send the
// file to the client.
let mut writer = csv::Writer::from_writer(Vec::new());
resolved_scholarships.iter().for_each(|scholarship| {
_ = writer.serialize(scholarship);
});
writer.flush()?;
let result = writer.into_inner()?;
debug_log!("{:?}", String::from_utf8(result.clone()));
Ok(result)
}
+234
View File
@@ -0,0 +1,234 @@
use leptos::logging::debug_log;
use leptos::prelude::*;
use server_fn::codec::{MultipartData, MultipartFormData};
#[cfg(feature = "ssr")]
mod imports {
pub use super::super::{MAIN_TABLE_NAME, S3_BUCKET_NAME};
pub use crate::pages::api::tokens::validate_and_get_token_info;
pub use crate::utils::server::create_aws_config;
pub use aws_sdk_dynamodb::types::{AttributeValue, ReturnValue, Select};
}
#[server(input = MultipartFormData)]
pub async fn upload_file(data: MultipartData) -> Result<String, ServerFnError> {
use imports::*;
let mut multipart = data.into_inner().unwrap();
let mut file_name = String::new();
let mut file_bytes = Vec::<u8>::new();
let mut form_id = String::new();
let mut access_token = String::new();
let mut input_name = String::new();
while let Ok(Some(field)) = multipart.next_field().await {
let field_name = field.name().unwrap_or_default();
match field_name {
"access_token" => access_token = field.text().await?,
"form_id" => form_id = field.text().await?,
_ => {
// NOTE: If there are multiple files put here the function will only upload the last
// one in the data.
// We should likely use chunk streaming in the future, for safety.
input_name = field
.name()
.ok_or(ServerFnError::new("Couldn't find input name"))?
.to_owned();
file_name = field
.file_name()
.ok_or(ServerFnError::new("Couldn't find file name"))?
.to_owned();
file_bytes = field.bytes().await?.to_vec();
}
}
}
if access_token.is_empty() {
return Err(ServerFnError::new("Missing access token"));
}
if form_id.is_empty() {
return Err(ServerFnError::new("Missing form ID"));
}
if file_name.is_empty() {
return Err(ServerFnError::new("Missing file"));
}
if input_name.is_empty() {
return Err(ServerFnError::new("Missing input file"));
}
let user_claims = validate_and_get_token_info(access_token).await?;
let subject = user_claims.subject;
let key = format!("{form_id}/{subject}/{input_name}/{file_name}");
debug_log!("Adding file to S3: {:?}", key);
let s3_client = aws_sdk_s3::Client::new(&create_aws_config().await);
s3_client
.put_object()
.bucket(S3_BUCKET_NAME)
.key(&key)
.body(file_bytes.into())
.send()
.await
.map(|_| ())
.map_err(|e| ServerFnError::new(format!("Couldn't put file to S3: {}", e.to_string())))?;
// Besides uploading the file to S3, we also want to store a FILE entry in Dynamo.
// This will allow the server to quickly return a list of all files that a specific user has
// uploaded, or a list of all files submitted for a specific question with their keys for
// additional access.
let dynamo_client = aws_sdk_dynamodb::Client::new(&create_aws_config().await);
let dynamo_result = dynamo_client
.put_item()
.table_name(MAIN_TABLE_NAME)
.item(
"HK".to_string(),
AttributeValue::S(format!("STUDENT#{subject}")),
)
.item(
"SK".to_string(),
AttributeValue::S(format!("FILE#{form_id}#{input_name}#{file_name}")),
)
.item(
"file_name".to_string(),
AttributeValue::S(file_name.clone()),
)
.item("file_key".to_string(), AttributeValue::S(key.clone()))
.send()
.await
.map_err(|e| {
ServerFnError::new(format!(
"Couldn't put file to Dynamo, file rolled back: {}",
e.to_string()
))
});
// If Dynamo fails, we want to handle the error by rolling back the file. Then, we return a failure.
if let Err(err) = dynamo_result {
s3_client
.delete_object()
.bucket("leptos-scholarships")
.key(&key)
.send()
.await
.map_err(|e| {
ServerFnError::new(format!(
"Couldn't roll back S3 operation: {}",
e.to_string()
))
})?;
return Err(err);
}
Ok(file_name)
}
#[server]
pub async fn delete_file(
access_token: String,
form_id: String,
input_name: String,
file_name: String,
) -> Result<String, ServerFnError> {
use imports::*;
let user_claims = validate_and_get_token_info(access_token).await?;
let subject = user_claims.subject;
let entry_hk = format!("STUDENT#{subject}");
let entry_sk = format!("FILE#{form_id}#{input_name}#{file_name}");
let key = format!("{form_id}/{subject}/{input_name}/{file_name}");
let config = create_aws_config().await;
let s3_client = aws_sdk_s3::Client::new(&config);
let dynamo_client = aws_sdk_dynamodb::Client::new(&config);
let previous_values = dynamo_client
.delete_item()
.table_name(MAIN_TABLE_NAME)
.key("HK".to_string(), AttributeValue::S(entry_hk))
.key("SK".to_string(), AttributeValue::S(entry_sk))
.return_values(ReturnValue::AllOld)
.send()
.await?
.attributes
.unwrap_or_default();
let s3_result = s3_client
.delete_object()
.bucket(S3_BUCKET_NAME)
.key(key)
.send()
.await
.map_err(ServerFnError::from);
if let Err(err) = s3_result {
// Add the key back to Dynamo and return an error.
dynamo_client
.put_item()
.table_name(MAIN_TABLE_NAME)
.set_item(Some(previous_values))
.send()
.await
.map_err(|err| {
ServerFnError::new(format!(
"Couldn't roll back Dynamo operation: {}",
err.to_string()
))
})?;
return Err(err);
}
Ok(file_name)
}
#[server]
pub async fn list_files(
access_token: String,
form_id: String,
input_name: String,
) -> Result<Vec<String>, ServerFnError> {
use imports::*;
let user_claims = validate_and_get_token_info(access_token).await?;
let subject = user_claims.subject;
let entry_hk = format!("STUDENT#{subject}");
let entry_sk = format!("FILE#{form_id}#{input_name}");
debug_log!("Getting file list: HK = {}, SK = {}", entry_hk, entry_sk);
let dynamo_client = aws_sdk_dynamodb::Client::new(&create_aws_config().await);
dynamo_client
.query()
.table_name(MAIN_TABLE_NAME)
.key_condition_expression("HK = :hk AND begins_with(SK, :sk)")
.expression_attribute_values(":hk".to_string(), AttributeValue::S(entry_hk))
.expression_attribute_values(":sk".to_string(), AttributeValue::S(entry_sk))
.send()
.await
.map(|output| {
let Some(items) = output.items else {
return vec![];
};
items
.into_iter()
.filter_map(|item| {
item.get("file_name")
.map(|v| v.as_s().ok().cloned())
.flatten()
})
.collect::<Vec<String>>()
})
.map_err(|e| ServerFnError::new(format!("Couldn't list files: {}", e.to_string())))
}
+30 -1
View File
@@ -1,4 +1,33 @@
mod admin;
mod comparisons;
mod dates;
pub mod exports;
mod providers;
mod scholarships;
pub mod students;
pub mod files;
mod tokens;
pub use self::{comparisons::*, scholarships::*};
pub use admin::*;
pub use comparisons::*;
pub use dates::*;
pub use providers::*;
pub use scholarships::*;
#[cfg(feature = "ssr")]
pub static COMPARISONS_TABLE: &str = "leptos-comparisons";
#[cfg(feature = "ssr")]
pub static DATES_TABLE: &str = "leptos-dates";
#[cfg(feature = "ssr")]
pub static SCHOLARSHIPS_TABLE: &str = "leptos-scholarships";
#[cfg(feature = "ssr")]
pub static PROVIDER_CONTACT_TABLE: &str = "leptos-provider-contacts";
#[cfg(feature = "ssr")]
pub static MAIN_TABLE_NAME: &str = "scholarships-main";
#[cfg(feature = "ssr")]
pub static S3_BUCKET_NAME: &str = "leptos-scholarships";
+71
View File
@@ -0,0 +1,71 @@
use leptos::prelude::*;
use leptos::logging::debug_log;
use std::collections::HashMap;
use leptos::server_fn::codec::Json;
use crate::common::ValueType;
#[cfg(feature = "ssr")]
use crate::utils::server::{create_dynamo_client, into_attr_map};
#[cfg(feature = "ssr")]
static PROVIDER_CONTACT_TABLE: &str = "leptos-provider-contacts";
#[server]
pub async fn get_provider_contact(id: String) -> Result<HashMap<String, ValueType>, ServerFnError> {
use aws_sdk_dynamodb::types::AttributeValue;
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
let client = create_dynamo_client().await;
debug_log!("Getting contact info for provider {}", id);
match client
.get_item()
.table_name(PROVIDER_CONTACT_TABLE)
.key("subject", AttributeValue::S(id))
.send()
.await
{
Ok(output) => {
let Some(item) = output.item else {
return Ok(HashMap::new());
};
let map = item.iter().map(|(key, val)| {
let val_type = ValueType::from(val);
(key.clone(), val_type)
}).collect::<HashMap<String, ValueType>>();
Ok(map)
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred.");
Err(ServerFnError::new(msg))
}
}
}
#[server(input = Json)]
pub async fn put_provider_contact(id: String, contact_info: HashMap<String, ValueType>) -> Result<(), ServerFnError> {
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
let client = create_dynamo_client().await;
let mut item = into_attr_map(contact_info);
item.insert("subject".to_owned(), aws_sdk_dynamodb::types::AttributeValue::S(id));
match client
.put_item()
.table_name(PROVIDER_CONTACT_TABLE)
.set_item(Some(item))
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred.");
Err(ServerFnError::new(msg))
}
}
}
+68 -33
View File
@@ -1,24 +1,26 @@
#[cfg(feature = "ssr")]
use aws_sdk_dynamodb::{
types::AttributeValue,
error::ProvideErrorMetadata,
};
#[cfg(feature = "ssr")]
use leptos::leptos_dom::log;
#[cfg(feature = "ssr")]
use uuid::Uuid;
#[cfg(feature = "ssr")]
use crate::pages::utils::server_utils::create_dynamo_client;
#[cfg(feature = "ssr")]
use crate::common::ValueType;
mod imports {
pub use leptos::logging::debug_log;
pub use aws_sdk_dynamodb::{
types::AttributeValue,
error::ProvideErrorMetadata,
};
pub use leptos::leptos_dom::log;
pub use uuid::Uuid;
pub use crate::utils::server::{create_dynamo_client};
pub use crate::common::ValueType;
pub use super::super::SCHOLARSHIPS_TABLE;
}
use crate::common::ExpandableInfo;
use leptos::prelude::ServerFnError;
use leptos::server;
use leptos::server_fn::codec::Json;
#[server(GetScholarshipInfo, endpoint = "/scholarship/info/get")]
#[server(GetScholarshipInfo)]
pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
// Perform the operation - we just want to return all data that's contained in this entry,
@@ -26,7 +28,7 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
log!("Getting scholarship info using id {:?}", id);
match client
.get_item()
.table_name("leptos-scholarship-test")
.table_name(SCHOLARSHIPS_TABLE)
.key("subject", AttributeValue::S(id.clone()))
.send()
.await
@@ -47,9 +49,9 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
}
}
#[server(CreateScholarshipInfo, endpoint = "/scholarship/info/create")]
#[server(CreateScholarshipInfo, input = Json)]
pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerFnError> {
use crate::pages::utils::server_utils::create_dynamo_client;
use imports::*;
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
let client = create_dynamo_client().await;
@@ -58,7 +60,7 @@ pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerF
match client
.put_item()
.table_name("leptos-scholarship-test")
.table_name(SCHOLARSHIPS_TABLE)
.set_item(Some(serde_dynamo::to_item(&info)?))
.send()
.await
@@ -71,13 +73,15 @@ 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> {
use imports::*;
let client = create_dynamo_client().await;
log!("Getting all scholarship info");
debug_log!("Getting all scholarship info");
match client
.scan()
.table_name("leptos-scholarship-test")
.table_name(SCHOLARSHIPS_TABLE)
.send()
.await
{
@@ -96,15 +100,17 @@ pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnE
}
/// Gets all scholarships that are associated with the given scholarship provider's ID.
#[server(GetProviderScholarships, "/providers/scholarships/get")]
#[server(GetProviderScholarships)]
pub async fn get_provider_scholarships(provider_id: String) -> Result<Vec<ExpandableInfo>, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
log!("Getting provider scholarships for provider with ID {:?}", provider_id);
debug_log!("Getting provider scholarships for provider with ID {:?}", provider_id);
match client
.scan()
.table_name("leptos-scholarship-test")
.table_name(SCHOLARSHIPS_TABLE)
.expression_attribute_values(":id", serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?)
.filter_expression("provider_id = :id")
.send()
@@ -125,21 +131,22 @@ pub async fn get_provider_scholarships(provider_id: String) -> Result<Vec<Expand
}
/// Creates a new scholarship with a unique ID, and then returns that ID.
#[server(RegisterScholarship, "/providers/scholarships/create")]
pub async fn register_scholarship(provider_id: String, scholarship_name: String) -> Result<String, ServerFnError> {
#[server(RegisterScholarship)]
pub async fn register_scholarship(provider_id: String) -> Result<String, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
log!("Creating scholarship for provider with ID {:?}", provider_id);
debug_log!("Creating scholarship for provider with ID {:?}", provider_id);
let mut current_uuid = "testing".to_string();
let mut current_uuid = Uuid::new_v4().to_string();
let mut item = ExpandableInfo::new(current_uuid.clone());
item.data.insert("provider_id".to_string(), ValueType::String(Some(provider_id)));
item.data.insert("scholarship_name".to_string(), ValueType::String(Some(scholarship_name)));
loop {
let ser_item = serde_dynamo::to_item(&item)?;
match client
.put_item()
.table_name("leptos-scholarship-test")
.table_name(SCHOLARSHIPS_TABLE)
.set_item(Some(ser_item))
.condition_expression("attribute_not_exists(subject)")
.send()
@@ -170,17 +177,19 @@ pub async fn register_scholarship(provider_id: String, scholarship_name: String)
}
/// 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> {
use imports::*;
let client = create_dynamo_client().await;
log!("Deleting scholarship with ID {:?} for provider with ID {:?}", scholarship_id, provider_id);
debug_log!("Deleting scholarship with ID {:?} for provider with ID {:?}", scholarship_id, provider_id);
// When we delete a scholarship, we need to ensure that the provider's ID matches the scholarship,
// otherwise everyone can delete anyone else's scholarships.
match client
.delete_item()
.table_name("leptos-scholarship-test")
.table_name(SCHOLARSHIPS_TABLE)
.key("subject", AttributeValue::S(scholarship_id))
.expression_attribute_values(":provider_id", serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?)
.condition_expression("provider_id = :provider_id")
@@ -196,3 +205,29 @@ pub async fn delete_provider_scholarship(provider_id: String, scholarship_id: St
}
}
}
#[server]
pub async fn get_all_scholarships() -> Result<Vec<std::collections::HashMap<String, crate::common::ValueType>>, ServerFnError> {
use imports::*;
use std::collections::HashMap;
let client = create_dynamo_client().await;
client
.scan()
.table_name(SCHOLARSHIPS_TABLE)
.send()
.await
.map(|output| {
let Some(items) = output.items else {
return Vec::new();
};
items.iter().map(|item| {
// Each item should be simply converted from an AttributeValue to a ValueType.
item.iter().map(|(k, v)| (k.clone(), ValueType::from(v)))
.collect::<HashMap<String, ValueType>>()
}).collect::<Vec<_>>()
})
.map_err(ServerFnError::from)
}
+114
View File
@@ -0,0 +1,114 @@
use leptos::prelude::*;
use leptos::server_fn::codec::Json;
#[cfg(feature = "ssr")]
mod imports {
pub use crate::utils::server::*;
pub use aws_sdk_dynamodb::types::AttributeValue;
pub use super::super::MAIN_TABLE_NAME;
pub use crate::common::ValueType;
pub use std::collections::HashMap;
pub use leptos::logging::debug_log;
}
#[server(input = Json)]
pub async fn put_student_data(
subject: String,
data_type: String,
data_map: std::collections::HashMap<String, crate::common::ValueType>
) -> Result<(), ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
let mut data_map_attr = into_attr_map(data_map);
data_map_attr.insert(
"HK".into(),
AttributeValue::S(format!("STUDENT#{}", subject))
);
data_map_attr.insert(
"SK".into(),
AttributeValue::S(data_type.to_uppercase())
);
debug_log!("Inserting this item: {:?}", data_map_attr);
client
.put_item()
.table_name(MAIN_TABLE_NAME)
.set_item(Some(data_map_attr))
.send()
.await
.map(|_| ())
.map_err(ServerFnError::from)
}
#[server]
pub async fn get_student_data(
subject: String,
data_type: String,
) -> Result<std::collections::HashMap<String, crate::common::ValueType>, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
client
.query()
.table_name(MAIN_TABLE_NAME)
.key_condition_expression("HK = :hk AND SK = :sk")
.expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{}", subject)))
.expression_attribute_values(":sk", AttributeValue::S(data_type.to_uppercase()))
.send()
.await
.map(|output| {
// Map the output to a HashMap<String, ValueType>
let Some(list) = output.items else {
return HashMap::new();
};
let mut map = HashMap::new();
let _ = list
.into_iter()
.flatten()
.for_each(|(k, v)| {
map.insert(k, ValueType::from(&v));
});
map
})
.map_err(ServerFnError::from)
}
/// Gets and flattens all of a student's information from the database, regardless of their defined
/// sort key.
///
/// Note: if sorted data from the `put_student_data` function contains the same fields, this
/// function will have unexpected behavior. For example, if a `first_name` field appears in multiple
/// locations in the database, all for this single student, they will overwrite each other in no
/// specific order.
#[server]
pub async fn get_all_student_data(
subject: String
) -> Result<std::collections::HashMap<String, crate::common::ValueType>, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
client.query()
.table_name(MAIN_TABLE_NAME)
.key_condition_expression("HK = :hk")
.expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{}", subject)))
.send()
.await
.map(|output| {
let Some(items) = output.items else {
return HashMap::new();
};
items.iter().flatten()
.map(|(|k, v)| (k.clone(), ValueType::from(v)))
.collect::<HashMap<String, ValueType>>()
})
.map_err(ServerFnError::from)
}
+50
View File
@@ -0,0 +1,50 @@
#[cfg(feature = "ssr")]
use jsonwebtoken::{jwk::JwkSet, DecodingKey};
use leptos::prelude::*;
use crate::common::UserClaims;
#[cfg(feature = "ssr")]
pub async fn fetch_jwks(user_pool_id: String, region: String) -> Result<JwkSet, ServerFnError> {
let request_url = format!(
"https://cognito-idp.{}.amazonaws.com/{}/.well-known/jwks.json",
region,
user_pool_id
);
let jwks = reqwest::get(&request_url).await?
.json::<JwkSet>()
.await?;
Ok(jwks)
}
/// Validates a JWT, and, if successful, decodes it into a series of user claims.
#[cfg(feature = "ssr")]
pub async fn validate_and_get_token_info(token: String) -> Result<UserClaims, ServerFnError> {
use jsonwebtoken::{decode, Algorithm, Validation};
let jwks = fetch_jwks("us-east-1_Lfjuy5zaM".into(), "us-east-1".into()).await?;
let validation = Validation::new(Algorithm::RS256);
// validation.set_audience(&["scholarships-rs"]);
let header = jsonwebtoken::decode_header(&token)
.map_err(|e| ServerFnError::new(format!("Invalid token header: {e}")))?;
let kid = header.kid.ok_or_else(|| ServerFnError::new("Missing kid header"))?;
let jwk = jwks.find(&kid)
.ok_or_else(|| ServerFnError::new("Couldn't find matching key in JWKS"))?;
let token_data = decode::<UserClaims>(
&token,
&DecodingKey::from_jwk(&jwk)?,
&validation
).map_err(|_| ServerFnError::new("Invalid token found".to_string()))?;
if token_data.claims.token_use != "access" {
return Err(ServerFnError::new("Token type mismatch"));
}
Ok(token_data.claims)
}
+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::api::{get_all_scholarship_info, get_comparison_info};
use super::home_page::get_submission;
use crate::common::{
ComparisonData, ExpandableInfo, ValueType,
};
use crate::common::{ComparisonData, ExpandableInfo, ValueType};
use crate::components::{ActionButton, Loading, Panel, Row};
use leptos::either::Either;
use leptos::leptos_dom::logging::console_log;
use leptos::logging::log;
use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated};
use crate::pages::utils::get_user_claims;
use super::api::{get_comparison_info, get_all_scholarship_info};
use crate::utils::get_user_claims;
#[component]
pub fn ComparisonTestPage() -> impl IntoView {
@@ -59,93 +57,84 @@ pub fn ComparisonTestPage() -> impl IntoView {
let student_info = student_resource.get();
let comparison_info = comparison_resource.get();
let scholarship_info = scholarship_resource.get();
match (student_info, comparison_info, scholarship_info) {
(Some(student), Some(comparisons), Some(scholarships)) => Either::Right(view! {
<Panel>
<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
}
};
(Some(student), Some(comparisons), Some(scholarships)) => {
Either::Right(
let mut final_result = Ok(true);
// The list should exist, and it should contain a series of strings.
for val in list {
let comp_opt = match val {
ValueType::String(Some(comp_id)) => {
// Find the comparison
comparisons.iter().find(|comp| comp.id == *comp_id)
}
_ => None
};
let comparison_outcome = match comp_opt {
Some(comp) => {
// Perform the comparison
comp.compare(&student)
}
_ => Err("".to_string())
};
match comparison_outcome {
Ok(true) => {},
Ok(false) => final_result = Ok(false),
Err(e) => {
final_result = Err(e);
break;
view! {
<Panel>
<Row>
<p>{result_msg}</p>
</Row>
<Row>
<ActionButton on:click=move |_| {
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));
}
};
let mut final_result = Ok(true);
for val in list {
let comp_opt = match val {
ValueType::String(Some(comp_id)) => {
comparisons.iter().find(|comp| comp.id == *comp_id)
}
_ => None,
};
let comparison_outcome = match comp_opt {
Some(comp) => comp.compare(&student.data),
_ => 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),
}
}
}
match final_result {
Ok(true) => Ok(Some(scholarship)),
Ok(false) => Ok(None),
Err(e) => Err(e)
}
}).collect::<Vec<Result<Option<&ExpandableInfo>, String>>>();
// 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>
})
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>
+19
View File
@@ -0,0 +1,19 @@
use crate::components::Banner;
use leptos::prelude::*;
#[component]
pub fn FormClosedPage() -> impl IntoView {
// This page simply shows a message saying a form is closed. It does not make any distinction
// about which form is closed right now.
view! {
<Banner title="R15 Scholarships" logo="/PHS_Stacked_Acronym.png" path="/providers" />
<div class="flex flex-1 flex-col gap-4 items-center m-4">
<div class="font-bold text-3xl">"This form has been closed!"</div>
<div class="text-lg">
"If you believe this is an error, please contact Sara Smith at "
<a href="mailto:ssmith@region15.org">"ssmith@region15.org"</a>
</div>
</div>
}
}
+20 -46
View File
@@ -8,10 +8,12 @@ use std::process::{Command, Stdio};
#[cfg(feature = "ssr")]
use std::io::Write;
use crate::common::{ComparisonData, ExpandableInfo};
use crate::common::ExpandableInfo;
use crate::pages::UnauthenticatedPage;
use crate::pages::utils::get_user_claims;
use crate::components::{ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row, Select, TextFieldType, ValidatedForm};
use crate::components::{
ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row,
TextFieldType, ValidatedForm
};
use crate::input;
use leptos::leptos_dom::logging::console_log;
use leptos::prelude::*;
@@ -22,7 +24,7 @@ use leptos::task::spawn_local;
use leptos::web_sys::HtmlAnchorElement;
use leptos::wasm_bindgen::JsCast;
use std::collections::HashMap;
use crate::pages::api::get_comparisons_categorized;
use crate::utils::get_user_claims;
/// # Get Student Info
/// Gets a student's information given their `subject`.
@@ -156,7 +158,7 @@ pub fn HomePage() -> impl IntoView {
async move |subject| get_submission(subject.unwrap_or_default()).await
);
let submit_action = ServerAction::<CreateSampleSubmission>::new();
Effect::new(move || {
match server_resource.get() {
Some(Ok(submission)) => {
@@ -210,28 +212,6 @@ pub fn HomePage() -> impl IntoView {
};
//#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! {
<AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage>
@@ -245,7 +225,7 @@ pub fn HomePage() -> impl IntoView {
<Row>
<div class="flex flex-col flex-1" />
<Panel>
<ValidatedForm
<ValidatedForm
on_submit=Callback::new(on_submit)
title="Home Page Test Form"
description="This really is just a test."
@@ -285,7 +265,9 @@ pub fn HomePage() -> impl IntoView {
disabled=elements_disabled
data_member="contact_email"
data_map=form_data
input_type=TextFieldType::Email
input_type=TextFieldType::Email(
vec!["fakeemail.org".to_string(), "gmail.com".to_string()],
)
required=true
/>
</Row>
@@ -331,6 +313,7 @@ pub fn HomePage() -> impl IntoView {
data_member="town"
data_map=form_data
required=true
other_prompt="Other..."
/>
</Row>
<Row>
@@ -346,26 +329,18 @@ pub fn HomePage() -> impl IntoView {
required=true
/>
</Row>
<Row>
<Select
label="Gender:"
value_list=vec!["Male", "Female", "Prefer not to answer"]
.into_iter()
.map(|s| s.to_owned())
.collect()
disabled=elements_disabled
data_member="gender"
data_map=form_data
required=true
/>
</Row>
<Row>
<MultiEntry
label="Testing community involvement:"
data_map=form_data
data_member="community_involvement"
schema=vec![
input!(Text, "service_name", "Activity Name:", true, "Some service activity..."),
input!(Number, "service_hours", "Total Service Hours", true, "20")
input!(
Text, "service_name", "Activity Name:", true, "Some service activity..."
),
input!(
Number, "service_hours", "Total Service Hours", true, "20"
),
]
/>
</Row>
@@ -375,8 +350,7 @@ pub fn HomePage() -> impl IntoView {
</Row>
}
})
}}
<Row>
}} <Row>
<ActionButton on:click=pdf_button_click>"Get PDF"</ActionButton>
</Row>
</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
// container either shows forms or shows placeholder text.
view! {
<Banner
title="Chromebook Loaners"
logo="/PHS_Stacked_Acronym.png"
path="/loaners"
/>
<Banner title="Chromebook Loaners" logo="/PHS_Stacked_Acronym.png" path="/loaners" />
<div class="flex m-5">
<div class="flex flex-1 flex-row gap-3">
<div id="sidebar" class="flex flex-1 flex-col gap-2">
@@ -247,16 +243,9 @@ pub fn LoanerShell() -> impl IntoView {
#[component]
pub fn LoanerFallback() -> impl IntoView {
view! {
<div
class="flex flex-col items-center justify-center"
use:animate=pop_in_out()
>
<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 class="flex flex-col items-center justify-center" use:animate=pop_in_out()>
<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>
}
}
@@ -328,7 +317,7 @@ pub fn LoanerBorrowForm() -> impl IntoView {
disabled=elements_disabled
data_member="email"
data_map=data
input_type=TextFieldType::Email
input_type=TextFieldType::Email(vec!["region15.org".to_string()])
required=true
/>
</Row>
@@ -431,69 +420,85 @@ pub fn LoanerReturnForm() -> impl IntoView {
<dialog
use:animate=pop_in_out()
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>
<h4 class="text-xl mb-3">
{move || chosen_student.with(|opt_student| {
if let Some(student) = opt_student.as_ref() {
format!("Are you sure want to check in {} {}?", student.first_name, student.last_name)
} else {
"There is no student selected.".to_string()
}
})}
{move || {
chosen_student
.with(|opt_student| {
if let Some(student) = opt_student.as_ref() {
format!(
"Are you sure want to check in {} {}?",
student.first_name,
student.last_name,
)
} else {
"There is no student selected.".to_string()
}
})
}}
</h4>
<ActionButton on:click=close_dialog disabled=dialog_disabled>"Close"</ActionButton>
<ActionButton on:click=dialog_check_in disabled=dialog_disabled>"Check In"</ActionButton>
<ActionButton on:click=close_dialog disabled=dialog_disabled>
"Close"
</ActionButton>
<ActionButton on:click=dialog_check_in disabled=dialog_disabled>
"Check In"
</ActionButton>
</dialog>
<div
class="flex flex-col gap-2 m-2"
use:animate=pop_in_out()
>
<div 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">
<h2 class="text-2xl font-bold">"Student List"</h2>
<p class="text-lg">"Please select your name from the list."</p>
</div>
<Suspense fallback=move || "Loading...".into_view()>
<Suspense fallback=move || {
"Loading...".into_view()
}>
{move || {
match return_list_resource.get() {
Some(loaner_list) => {
let list_view = loaner_list.iter().map(|student| {
let on_click = {
let student_click = student.clone();
move |_| {
log!("Running on_click using student {:?}", student_click);
if let Some(dialog) = dialog_ref.get() {
chosen_student.set(Some(student_click.clone()));
dialog.show_modal().expect("Couldn't show modal dialog.");
let list_view = loaner_list
.iter()
.map(|student| {
let on_click = {
let student_click = student.clone();
move |_| {
log!("Running on_click using student {:?}", student_click);
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>
}
};
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();
})
.collect_view();
Either::Left(list_view)
},
None => Either::Right("No loaners found.".into_view())
}
None => Either::Right("No loaners found.".into_view()),
}
}}
</Suspense>
+8 -4
View File
@@ -1,16 +1,20 @@
mod about_page;
mod api;
pub mod api;
mod comparison_test;
mod home_page;
mod loaner_page;
mod provider_portal;
mod scholarship_info;
mod test_page;
mod unauthenticated_page;
mod utils;
mod provider_contact;
mod auth_callback;
mod provider_applicants;
mod admin;
pub(crate) mod student;
mod form_closed_page;
pub use self::{
about_page::*, comparison_test::*, home_page::*, loaner_page::*, provider_portal::*,
test_page::*, unauthenticated_page::*, scholarship_info::*, provider_contact::*
unauthenticated_page::*, scholarship_info::*, provider_contact::*,
auth_callback::*, provider_applicants::*, admin::*, student::*, form_closed_page::*,
};
+17
View File
@@ -0,0 +1,17 @@
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>
}
}
+33 -95
View File
@@ -2,74 +2,10 @@
use leptos::logging::log;
use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated};
use crate::common::ValueType;
use crate::components::{Banner, Loading, OutlinedTextField, Panel, Row, TextFieldType, Toast, ToastContext, ToastList, ValidatedForm};
use crate::components::{Banner, Loading, OutlinedTextField, Panel, Row, TextFieldType, Toast, ToastContext, ValidatedForm};
use crate::pages::UnauthenticatedPage;
use crate::pages::utils::get_user_claims;
#[cfg(feature = "ssr")]
use crate::pages::utils::server_utils::create_dynamo_client;
#[server]
async fn get_provider_contact(id: String) -> Result<HashMap<String, ValueType>, ServerFnError> {
use aws_sdk_dynamodb::types::AttributeValue;
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
let client = create_dynamo_client().await;
log!("Getting contact info for provider {}", id);
match client
.get_item()
.table_name("leptos-provider-contacts")
.key("subject", AttributeValue::S(id))
.send()
.await
{
Ok(output) => {
let Some(item) = output.item else {
return Ok(HashMap::new());
};
let map = item.iter().map(|(key, val)| {
let val_type = ValueType::from(val);
(key.clone(), val_type)
}).collect::<HashMap<String, ValueType>>();
Ok(map)
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred.");
Err(ServerFnError::new(msg))
}
}
}
#[server]
async fn put_provider_contact(id: String, contact_info: HashMap<String, ValueType>) -> Result<(), ServerFnError> {
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
use crate::pages::utils::server_utils::into_attr_map;
let client = create_dynamo_client().await;
let mut item = into_attr_map(contact_info);
item.insert("subject".to_owned(), aws_sdk_dynamodb::types::AttributeValue::S(id));
match client
.put_item()
.table_name("leptos-provider-contacts")
.set_item(Some(item))
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred.");
Err(ServerFnError::new(msg))
}
}
}
use crate::utils::get_user_claims;
use super::api::{get_provider_contact, PutProviderContact};
#[component]
pub fn ProviderContactPage() -> impl IntoView {
@@ -117,30 +53,43 @@ pub fn ProviderContactPage() -> impl IntoView {
});
let mut toasts_context = expect_context::<ToastContext>();
Effect::new(move || {
submit_action.value().get().is_some()
.then(|| {
toasts_context.toast(
Toast::new()
.id(uuid::Uuid::new_v4())
.header("Submission Successful")
.msg("You can go back or continue editing your responses.")
);
Effect::watch(
move || submit_action.value().get(),
move |value, _, _| {
log!("Running submit_action effect");
let Some(result) = value else {
return;
};
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();
toasts_context.toast(toast);
});
});
// Display contact form.
},
false
);
// Display contact form.
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>
<Authenticated unauthenticated=UnauthenticatedPage>
<Suspense fallback=Loading>
<div class="flex flex-row">
<div class="flex flex-col flex-1" />
<Panel>
<ValidatedForm
<ValidatedForm
on_submit=on_submit
title="Contact Form"
description="Enter your contact information and click submit."
@@ -171,11 +120,11 @@ pub fn ProviderContactPage() -> impl IntoView {
<Row>
<OutlinedTextField
label="Contact Email:"
placeholder="student@region15.org"
placeholder="example@mymailservice.com"
disabled=elements_disabled
data_member="contact_email"
data_map=contact_info
input_type=TextFieldType::Email
input_type=TextFieldType::Email(vec!["*".to_string()])
required=true
/>
</Row>
@@ -199,17 +148,6 @@ pub fn ProviderContactPage() -> impl IntoView {
required=true
/>
</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_oidc::{AuthLoaded, Authenticated};
use crate::pages::api::get_important_dates;
use crate::pages::UnauthenticatedPage;
#[component]
pub fn ProviderPortal() -> impl IntoView {
view! {
<Banner
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">
let dates_resource = OnceResource::new(async move {
get_important_dates().await
});
</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>
</AuthLoaded>
}
}
+180 -140
View File
@@ -1,15 +1,15 @@
use std::collections::HashMap;
use leptos::either::Either;
use leptos::html::Dialog;
use leptos::logging::log;
use leptos::logging::debug_log;
use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated};
use leptos_router::hooks::{use_navigate, use_params};
use crate::common::{ExpandableInfo, ScholarshipFormParams, SubmitStatus, ValueType};
use crate::components::{ActionButton, Banner, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row, TextFieldType, Toast, ToastContext, ToastList, ValidatedForm};
use crate::common::{ComparisonData, ExpandableInfo, ScholarshipFormParams, SubmitStatus, ValueType};
use crate::components::{ActionButton, Banner, ChipsList, Header, Loading, OutlinedTextField, Panel, RadioList, Row, TextFieldType, Toast, ToastContext, ToastList, ValidatedForm};
use super::UnauthenticatedPage;
use crate::pages::utils::get_user_claims;
use super::api::{get_provider_scholarships, get_scholarship_info, CreateScholarshipInfo, RegisterScholarship, DeleteProviderScholarship, get_comparisons_categorized, CreateTestComparisons};
use crate::utils::get_user_claims;
use super::api::{get_provider_scholarships, get_scholarship_info, CreateScholarshipInfo, RegisterScholarship, DeleteProviderScholarship, get_comparisons_categorized};
/// # 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
// and if there's data for us to use.
view! {
<Banner title="R15 Scholarships" logo="/PHS_Stacked_Acronym.png" path="/" />
<Banner title="R15 Scholarships" logo="/PHS_Stacked_Acronym.png" path="/providers" />
<ToastList>
<AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage>
@@ -111,7 +111,7 @@ fn ScholarshipList(
(refresh_token.get(), provider_id.get())
},
|(_, provider_id)| async move {
log!("Fetching from scholarship API.");
debug_log!("Fetching from scholarship API.");
let Some(provider_id) = provider_id else {
return Ok(Vec::new());
};
@@ -128,8 +128,7 @@ fn ScholarshipList(
// Button handlers
let create_on_click = move |_| {
create_action.dispatch(RegisterScholarship {
provider_id: provider_id.get().unwrap(),
scholarship_name: "Testing Scholarship".to_string()
provider_id: provider_id.get().unwrap()
});
};
@@ -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();
debug_log!("Deleting scholarship with subject {}", subject);
delete_action
.dispatch(DeleteProviderScholarship {
scholarship_id: subject,
provider_id: provider_id.get().unwrap_or_default(),
});
};
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>
<p>
<p class="mb-3">
"Are you sure you want to delete "
{move || {
pending_delete
@@ -189,28 +205,16 @@ fn ScholarshipList(
.unwrap_or(&ValueType::String(None))
.as_string()
.unwrap_or_default()
.unwrap_or("<no name found>".to_string())
.unwrap_or("this unnamed scholarship".to_string())
})
}} "?"
</p>
<ActionButton
on:click=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(),
});
}
disabled=modal_disabled
>
"Confirm"
</ActionButton>
<ActionButton on:click=move |_| pending_delete.set(None)>"Cancel"</ActionButton>
<div class="flex mx-auto items-center justify-center">
<ActionButton on:click=modal_on_click_continue disabled=modal_disabled>
"Yes"
</ActionButton>
<ActionButton on:click=move |_| pending_delete.set(None)>"No"</ActionButton>
</div>
</dialog>
<div class="w-75">
<Panel>
@@ -298,7 +302,7 @@ fn ScholarshipListEntry(
let name = scholarship.data.get("name")
.unwrap_or(&ValueType::String(None))
.as_string().unwrap_or_default()
.unwrap_or("<no name found>".to_string());
.unwrap_or("Unnamed Scholarship".to_string());
// Button click handlers
let edit_click = {
@@ -361,42 +365,7 @@ fn ScholarshipForm(
/// submission fails, this function is not run.
#[prop(into)] on_submit: Callback<()>
) -> impl IntoView {
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 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());
}
});
//#region Form Data Setup
let scholarship_info = Resource::new(
move || scholarship_id.get(),
@@ -416,8 +385,10 @@ fn ScholarshipForm(
if let Some(Ok(scholarship)) = scholarship_info.get() {
// Get the default chips data.
let chips_default = if let Some(ValueType::Map(Some(map))) = scholarship.data.get("requirements") {
debug_log!("Found requirements map: {:?}", map);
map.clone()
} else {
debug_log!("Couldn't find requirements map in scholarship info. Using empty map.");
HashMap::new()
};
@@ -426,12 +397,36 @@ fn ScholarshipForm(
}
});
let get_comp_category = move |category: &str| {
if let Some(Ok(map)) = comparison_lists.get() {
map.get(category).unwrap_or(&Vec::new()).clone()
} else {
Vec::new()
//#endregion
//#region Comparison Logic
let comparison_lists = Resource::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| {
@@ -464,37 +459,77 @@ fn ScholarshipForm(
let gpa_ids = Signal::derive(move || get_comp_ids("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,
// fafsa_required, award_to, transcript_required, recipient_selection, essay_requirement (and prompt)
// award_night_remarks
let major_ids = Signal::derive(move || get_comp_ids("Majors"));
let major_text = Signal::derive(move || get_comp_text("Majors"));
//#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 on_submit = move |_| {
let mut info = ExpandableInfo::new(scholarship_id.get().unwrap_or_default());
info.data = form_data.get();
info.data.insert("requirements".to_string(), ValueType::Map(Some(chips_data.get())));
log!("Map values: {:?}", info.data);
debug_log!("Map values: {:?}", info.data);
submit_action.dispatch(CreateScholarshipInfo {
info
});
};
Effect::new(move || {
submit_action.value().get().is_some()
.then(|| {
log!("Displaying toasts!");
toasts.toast(
Toast::new()
.id(uuid::Uuid::new_v4())
.msg("Succesfully submitted.")
);
submit_action.clear();
});
});
let create_comps = ServerAction::<CreateTestComparisons>::new();
Effect::watch(
move || submit_action.value().get(),
move |value, _, _| {
debug_log!("Running submit_action effect");
let Some(result) = value else {
return;
};
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();
toasts.toast(toast);
});
},
false
);
//#endregion
view! {
<Panel>
@@ -505,24 +540,19 @@ fn ScholarshipForm(
}
>
<Suspense fallback=Loading>
<ValidatedForm
on_submit=Callback::new(on_submit)
title="Scholarship Info Form"
description="Create or edit a scholarship. Input all information and click Submit."
>
{move || {
scholarship_info
.get()
.map(|res_scholarship| {
match res_scholarship {
Ok(_) => {
Either::Left(
view! {
<Row>
<h1 class="text-3xl font-bold">
"Scholarship Info Form (test)"
</h1>
</Row>
{move || {
scholarship_info
.get()
.map(|res_scholarship| {
match res_scholarship {
Ok(_) => {
Either::Left(
view! {
<ValidatedForm
on_submit=Callback::new(on_submit)
title="Scholarship Info Form"
description="Create or edit a scholarship. Input all information and click Submit."
>
<Row>
<OutlinedTextField
label="Scholarship Name"
@@ -584,32 +614,33 @@ fn ScholarshipForm(
<RadioList
data_member="award_to"
data_map=form_data
items=vec!["School".to_string(), "Student".to_string()]
items=vec!["University".to_string(), "Student".to_string()]
disabled=elements_disabled
label="Will the award be made to the school or the student?"
/>
</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>
<RadioList
data_member="essay_prompt"
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()
.map(|s| s.to_string())
.map(|prompt| prompt.to_string())
.collect()
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>
<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>
<ChipsList
label="GPA Requirements"
@@ -617,11 +648,12 @@ fn ScholarshipForm(
data_map=chips_data
values=gpa_ids
displayed_text=gpa_text
allows_multiple=false
/>
</Row>
<Row>
<ChipsList
label="Sports Participation"
label="Sports and Activities"
data_member="sports_participation"
data_map=chips_data
values=sports_ids
@@ -635,6 +667,7 @@ fn ScholarshipForm(
data_map=chips_data
values=comm_ids
displayed_text=comm_text
allows_multiple=false
/>
</Row>
<Row>
@@ -644,6 +677,16 @@ fn ScholarshipForm(
data_map=chips_data
values=res_ids
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>
@@ -655,24 +698,21 @@ fn ScholarshipForm(
displayed_text=misc_text
/>
</Row>
},
)
}
Err(err) => {
Either::Right(
view! {
<p>"Error while getting scholarship: "{err.to_string()}</p>
},
)
}
</ValidatedForm>
},
)
}
})
.collect_view()
}}
</ValidatedForm>
<ActionButton on:click=move |_| {
create_comps.dispatch(CreateTestComparisons {});
}>"Create Comparisons"</ActionButton>
Err(err) => {
Either::Right(
view! {
<p>"Error while getting scholarship: "{err.to_string()}</p>
},
)
}
}
})
.collect_view()
}}
</Suspense>
</Show>
</Panel>
+73
View File
@@ -0,0 +1,73 @@
use crate::components::{Loading, OutlinedTextField, TextFieldType, ValidatedForm};
use crate::pages::student::form_setup::use_student_form;
use leptos::prelude::*;
#[component]
pub fn StudentAcademicsPage() -> impl IntoView {
let controller = use_student_form("academics");
view! {
<Suspense fallback=Loading>
{move || {
controller
.data_resource
.get()
.map(|_| {
view! {
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm
title="Academic Information"
description="This information will allow you to be eligible for any scholarships that have specific academic/grade requirements."
on_submit=controller.submit_action
>
<OutlinedTextField
label="Unweighted GPA:"
data_map=controller.data_map
data_member="unweighted_gpa"
placeholder="4.0"
required=true
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Weighted GPA:"
data_map=controller.data_map
data_member="weighted_gpa"
placeholder="4.5"
required=true
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Highest SAT Score:"
data_map=controller.data_map
data_member="sat_score"
placeholder="1600"
required=false
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Highest ACT Score:"
data_map=controller.data_map
data_member="act_score"
placeholder="36"
required=false
input_type=TextFieldType::Number
/>
<OutlinedTextField
label="Academic Honors:"
data_map=controller.data_map
data_member="academic_honors"
placeholder="High Honors, etc."
required=false
input_type=TextFieldType::Text
/>
</ValidatedForm>
</div>
<div class="flex flex-1" />
}
})
.collect_view()
}}
</Suspense>
}
}
+65
View File
@@ -0,0 +1,65 @@
use leptos::prelude::*;
use crate::components::DashboardButton;
#[component]
pub fn AdditionalPage() -> impl IntoView {
view! {
<div class="flex-1" />
<div class="flex flex-2 flex-col gap-4 items-center m-4">
<div class="font-bold text-3xl">"Student Additional Information Forms"</div>
<div class="text-lg">
"All forms listed under this tab are for additional eligibility factors that some scholarships require."
</div>
<div class="text-lg">
"None of these forms are required, but they may increase the total number of scholarships that you are eligible to be applied to."
</div>
<DashboardButton
title="Scholarship Book"
description="View scholarship requirements"
icon="/Book.png"
path="https://docs.google.com/document/d/1qVs0Lwvwl1bVGHznocoev3AF56KlhTfs/view"
/>
<DashboardButton
title="Academic Information"
description="Grades, tests, and more"
path="/students/additional/academics"
/>
<DashboardButton
title="Athletics"
description="Sports and outdoors"
path="/students/additional/athletics"
/>
<DashboardButton
title="Work Experience"
description="Add job information"
path="/students/additional/work-experience"
/>
<DashboardButton
title="Extracurriculars"
description="Clubs and activities"
path="/students/additional/extracurriculars"
/>
<DashboardButton
title="University Information"
description="Where are you going to school?"
path="/students/additional/university"
/>
<DashboardButton
title="Family Information"
description="General parent and sibling information"
path="/students/additional/family-info"
/>
<DashboardButton
title="Scholarship Specifics"
description="Scholarship-specific questions"
path="/students/additional/specifics"
/>
<DashboardButton
title="Financials"
description="FAFSA SAR Upload"
path="/students/additional/financials"
/>
</div>
<div class="flex-1" />
}
}
+63
View File
@@ -0,0 +1,63 @@
use crate::components::{Loading, MultiEntry, ValidatedForm};
use crate::input;
use crate::pages::student::form_setup::use_student_form;
use leptos::prelude::*;
#[component]
pub fn StudentAthleticsPage() -> impl IntoView {
let controller = use_student_form("athletics");
view! {
<Show when=move || controller.data_resource.get().is_some() fallback=Loading>
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm
title="Athletics Information"
description="This information will allow you to be eligible for any scholarships that have specific sports requirements."
on_submit=controller.submit_action
>
<MultiEntry
label="Athletics Activities:"
description="Leave blank if not applicable."
data_map=controller.data_map
data_member="sports_participation"
schema=vec![
input!(
Select, "sport_name", "Sport Name:", true, [
"Football",
"Soccer",
"Cheerleading",
"Field Hockey",
"Swimming",
"Golf",
"Basketball",
"Track",
"Gymnastics",
"Ice Hockey",
"Ski",
"Wrestling",
"Lacrosse",
"Softball",
"Tennis",
"Baseball"
]
),
input!(
Checkbox, "grades", "Grades Participated:", true, [
"9th",
"10th",
"11th",
"12th"
]
),
input!(
Text, "achievements", "Special Achievements:", false, "Example 1, Example 2, etc..."
),
]
/>
</ValidatedForm>
</div>
<div class="flex flex-1" />
</Show>
}
}
+102
View File
@@ -0,0 +1,102 @@
use crate::components::{Loading, OutlinedTextField, Select, TextFieldType, ValidatedForm};
use crate::pages::student::form_setup::use_student_form;
use leptos::prelude::*;
#[component]
pub fn StudentDemographicsPage() -> impl IntoView {
// This page should show a form that the students need to fill out.
// It's a simple form, really nothing more than a contact form.
// I'm going to test out creating this form without using a panel - we'll just use the page as
// it is with a specially spaced invisible div instead of a shaded container.
let controller = use_student_form("demographics");
view! {
<Show when=move || controller.data_resource.get().is_some() fallback=Loading>
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm
title="Student Demographic Form"
description="Here you'll set up some basic information about yourself."
on_submit=controller.submit_action
>
<OutlinedTextField
label="First Name:"
data_map=controller.data_map
data_member="first_name"
placeholder="John"
input_type=TextFieldType::Text
required=true
/>
<OutlinedTextField
label="Last Name:"
data_map=controller.data_map
data_member="last_name"
placeholder="Smith"
input_type=TextFieldType::Text
required=true
/>
<OutlinedTextField
label="Date of Birth:"
data_map=controller.data_map
data_member="dob"
placeholder="MM/DD/YYYY"
input_type=TextFieldType::Text
required=true
/>
<Select
label="Gender:"
data_map=controller.data_map
data_member="dob"
required=true
value_list=vec![
"Male".to_string(),
"Female".to_string(),
"Prefer not to answer/Other".to_string(),
]
/>
<OutlinedTextField
label="Student ID Number:"
data_map=controller.data_map
data_member="id_number"
placeholder="Your 7 digit number..."
input_type=TextFieldType::Number
required=true
/>
<OutlinedTextField
label="Preferred Email:"
data_map=controller.data_map
data_member="email"
placeholder="me@example.com"
input_type=TextFieldType::Email(vec!["*".to_string()])
required=true
/>
<OutlinedTextField
label="Preferred Phone Number:"
data_map=controller.data_map
data_member="phone_number"
placeholder="000-000-0000"
input_type=TextFieldType::Text
required=true
/>
<OutlinedTextField
label="Street Address:"
data_map=controller.data_map
data_member="street_address"
placeholder="234 Judd Rd"
input_type=TextFieldType::Text
required=true
/>
<Select
label="Town:"
value_list=vec!["Southbury".to_string(), "Middlebury".to_string()]
data_map=controller.data_map
data_member="town"
required=true
/>
</ValidatedForm>
</div>
<div class="flex flex-1" />
</Show>
}
}
+215
View File
@@ -0,0 +1,215 @@
use crate::common::{ComparisonData, ExpandableInfo, ValueType};
use crate::components::{FileDrop, Header, Loading};
use crate::pages::api::files::list_files;
use crate::pages::api::students::get_all_student_data;
use crate::pages::api::{get_all_scholarship_info, get_comparison_info};
use crate::utils::get_user_claims;
use leptos::logging::debug_log;
use leptos::prelude::*;
use leptos_oidc::AuthSignal;
#[component]
pub fn StudentEligibilityPage() -> impl IntoView {
// This page will show all scholarships that the student is eligible for.
// The scholarships are obviously pulled using a readonly API, and we'll also need to resolve
// all of the relation IDs that are stored inside of it.
// Then, for each scholarship found, we'll check every relation and filter out scholarships that
// do not pass the check. We'll show some sort of simple list to indicate what scholarships passed
// the check, and another to indicate which ones didn't.
// My only reservation with showing the scholarships that aren't valid is that people will want
// to know why. That's hard information to keep track of right now.
let auth = expect_context::<AuthSignal>();
let user_claims = get_user_claims();
let user_id = Memo::new(move |_| user_claims.get().map(|info| info.claims.subject.clone()));
let form_id = StoredValue::new("scholarship_essays".to_string());
let refresh_trigger = Trigger::new();
let scholarships_resource = Resource::new(
move || refresh_trigger.track(),
async move |_| get_all_scholarship_info().await,
);
let relations_resource = Resource::new(
move || refresh_trigger.track(),
async move |_| get_comparison_info().await,
);
let student_resource = Resource::new(
move || (user_id.get().unwrap_or_default(), refresh_trigger.track()),
async move |(user_id, _)| get_all_student_data(user_id).await,
);
view! {
<div class="flex-1" />
<div class="flex-2 mb-4 flex flex-col">
<Suspense fallback=Loading>
{move || {
let Some(Ok(scholarships_list)) = scholarships_resource.get() else {
return view! {}.into_any();
};
let Some(Ok(relations_list)) = relations_resource.get() else {
return view! {}.into_any();
};
let Some(Ok(student_info)) = student_resource.get() else {
return view! {}.into_any();
};
let valid_list = scholarships_list
.iter()
.filter_map(|scholarship| {
let scholarship_name = scholarship
.data
.get("name")
.unwrap_or(&ValueType::String(None))
.as_string()
.ok()
.flatten()
.unwrap_or_default();
debug_log!("Checking scholarship: {}", scholarship_name);
let requirements_map = scholarship
.data
.get("requirements")
.unwrap_or(&ValueType::Map(None))
.as_map()
.ok()
.flatten()
.unwrap_or_default()
.values()
.cloned()
.collect::<Vec<ValueType>>();
let resolved_requirements = requirements_map
.iter()
.map(|list_val| {
let list = list_val
.as_list()
.ok()
.flatten()
.unwrap_or_default();
list.iter()
.filter_map(|v| {
let id_string = v
.as_string()
.ok()
.flatten()
.unwrap_or_default();
relations_list
.iter()
.find(|relation| relation.id == id_string)
})
.cloned()
.collect::<Vec<ComparisonData>>()
})
.collect::<Vec<Vec<ComparisonData>>>();
debug_log!(
"Resolved requirements: {:?}", resolved_requirements.iter().flatten().map(|rel| rel.display_text.clone()).collect::<Vec<String>>()
);
let valid = resolved_requirements
.iter()
.all(|list| {
list.iter()
.fold(
false,
|prev, requirement| {
let result = requirement
.compare(&student_info)
.unwrap_or(false);
if prev { prev } else { result }
},
)
});
if valid {
debug_log!("Scholarship is valid.");
let scholarship_id = StoredValue::new(scholarship.subject.clone());
let resource = Resource::new(
move || (
auth
.try_with(|a| a.authenticated().map(|a| a.access_token()))
.flatten(),
form_id.get_value(),
scholarship_id.get_value(),
),
async move |(access_token, form_id, scholarship_id)| {
list_files(
access_token.unwrap_or_default(),
form_id,
scholarship_id,
)
.await
},
);
Some((scholarship.clone(), resource))
} else {
None
}
})
.collect::<
Vec<(ExpandableInfo, Resource<Result<Vec<String>, ServerFnError>>)>,
>();
view! {
<Header
title="Eligible Scholarships"
description="This is the list of scholarships that you are eligible for, as well as those that require essays.
Once you have fulfilled the essay requirement, you will be automatically applied for that scholarship.
If there is no essay requirement, you have already been applied."
/>
<div>
<For
each=move || valid_list.clone()
key=|(scholarship, _)| { scholarship.subject.clone() }
children=move |(scholarship, resource)| {
let scholarship_id = StoredValue::new(
scholarship.subject.clone(),
);
let scholarship_name = StoredValue::new(
scholarship
.data
.get("name")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default(),
);
let scholarship_essay = StoredValue::new(
scholarship
.data
.get("essay_prompt")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default(),
);
view! {
<div class="m-1.5 flex flex-col gap-2 shadow-lg rounded-lg">
<div class="rounded-t-lg bg-red-900 p-2 text-white font-bold">
{scholarship_name.get_value()}
</div>
<div class="p-2 flex flex-col gap-2">
<Show
when=move || !scholarship_essay.get_value().is_empty()
fallback=move || {
view! {
<div>"This scholarship does not require an essay."</div>
}
}
>
<div>{scholarship_essay.get_value()}</div>
<FileDrop
form_id=form_id.get_value()
name=scholarship_id.get_value()
existing_files=resource
/>
</Show>
</div>
</div>
}
}
/>
</div>
}
.into_any()
}}
</Suspense>
</div>
<div class="flex-1" />
}
}
@@ -0,0 +1,52 @@
use leptos::prelude::*;
use crate::components::{Loading, MultiEntry, OutlinedTextField, TextFieldType, ValidatedForm};
use crate::input;
use crate::pages::student::form_setup::use_student_form;
#[component]
pub fn StudentExtracurricularsPage() -> impl IntoView {
let controller = use_student_form("demographics");
view! {
<Show when=move || controller.data_resource.get().is_some() fallback=Loading>
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm
title="Extracurricular Information"
description="This information will allow you to be eligible for any scholarships that have specific extracurricular requirements."
on_submit=controller.submit_action
>
// Inputs here.
<OutlinedTextField
label="Total number of service hours:"
data_map=controller.data_map
data_member="service_hours"
placeholder="Any number..."
input_type=TextFieldType::Number
required=true
/>
<MultiEntry
label="Extracurricular Activities:"
description="Leave blank if not applicable."
data_map=controller.data_map
data_member="extracurricular"
schema=vec![
input!(
Text, "activity_name", "Activity Name:", true, "Some activity..."
),
input!(Number, "num_hours", "Number of hours completed:", true, "40"),
input!(Number, "num_weeks", "Number of weeks participated:", true, "3"),
input!(
Text, "special_involvement", "Any special involvement:", false, "Club president, etc."
),
input!(
Checkbox, "grades", "Grades Participated:", true, ["9th", "10th", "11th", "12th"]
),
]
/>
</ValidatedForm>
</div>
<div class="flex flex-1" />
</Show>
}
}
+107
View File
@@ -0,0 +1,107 @@
use leptos::prelude::*;
use crate::components::{Loading, OutlinedTextField, TextFieldType, ValidatedForm};
use crate::pages::student::form_setup::use_student_form;
#[component]
pub fn StudentFamilyPage() -> impl IntoView {
let controller = use_student_form("family");
view! {
<Show when=move || controller.data_resource.get().is_some() fallback=Loading>
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm
title="Family Information"
description="General family information. You must fill out information about at least one parent."
on_submit=controller.submit_action
>
// Inputs here.
<OutlinedTextField
label="Total number of children in family:"
data_map=controller.data_map
data_member="num_children"
placeholder="Any number..."
input_type=TextFieldType::Number
required=true
/>
<OutlinedTextField
label="Total number of children currently attending college:"
data_map=controller.data_map
data_member="num_children_college"
placeholder="Any number..."
input_type=TextFieldType::Number
required=true
/>
<OutlinedTextField
label="Parent/Guardian 1 Name:"
data_map=controller.data_map
data_member="parent_one_name"
placeholder="John Smith"
input_type=TextFieldType::Text
required=true
/>
// TODO Could this be a select dropdown?
<OutlinedTextField
label="Parent/Guardian 1 Relationship:"
data_map=controller.data_map
data_member="parent_one_relationship"
placeholder="Mother/Father"
input_type=TextFieldType::Text
required=true
/>
// TODO Get a better example
<OutlinedTextField
label="Parent/Guardian 1 Occupation:"
data_map=controller.data_map
data_member="parent_one_occupation"
placeholder="Milkman"
input_type=TextFieldType::Text
required=true
/>
<OutlinedTextField
label="Parent/Guardian 1 Employer:"
data_map=controller.data_map
data_member="parent_one_employer"
placeholder="Example Employer"
input_type=TextFieldType::Text
required=true
/>
<OutlinedTextField
label="Parent/Guardian 2 Name:"
data_map=controller.data_map
data_member="parent_two_name"
placeholder="John Smith"
input_type=TextFieldType::Text
required=false
/>
<OutlinedTextField
label="Parent/Guardian 2 Relationship:"
data_map=controller.data_map
data_member="parent_two_relationship"
placeholder="Mother/Father"
input_type=TextFieldType::Text
required=false
/>
<OutlinedTextField
label="Parent/Guardian 2 Occupation:"
data_map=controller.data_map
data_member="parent_two_occupation"
placeholder="Milkman"
input_type=TextFieldType::Text
required=false
/>
<OutlinedTextField
label="Parent/Guardian 2 Employer:"
data_map=controller.data_map
data_member="parent_two_employer"
placeholder="Example Employer"
input_type=TextFieldType::Text
required=false
/>
</ValidatedForm>
</div>
<div class="flex flex-1" />
</Show>
}
}
+47
View File
@@ -0,0 +1,47 @@
use crate::components::{FileDrop, Header, Loading};
use crate::pages::api::files::list_files;
use leptos::prelude::*;
use leptos_oidc::AuthSignal;
#[component]
pub fn StudentFinancialPage() -> impl IntoView {
// This is where we'll get information about the files that have been uploaded.
// For now, we'll leave it blank.
let auth = expect_context::<AuthSignal>();
let files_resource = Resource::new(
move || {
auth.try_with(|a| a.authenticated().map(|a| a.access_token()))
.flatten()
},
async move |access_token| {
list_files(
access_token.unwrap_or_default(),
"financial_info".to_string(),
"fafsa".to_string(),
)
.await
},
);
view! {
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<Header
title="Financial Information"
description="Please upload your FAFSA SAR (Student Aid Report) here. This information is NOT required,
but some scholarships may recognize this as a requirement."
/>
<Suspense fallback=Loading>
<FileDrop
name="fafsa"
form_id="financial_info"
existing_files=files_resource
allowed_types=vec![".pdf".to_string()]
/>
</Suspense>
</div>
<div class="flex flex-1" />
}
}
+63
View File
@@ -0,0 +1,63 @@
use std::collections::HashMap;
use leptos::prelude::*;
use crate::common::ValueType;
use crate::pages::api::students::{get_student_data, PutStudentData};
use crate::utils::get_user_claims;
pub struct StudentFormInfo {
pub data_map: RwSignal<HashMap<String, ValueType>>,
pub user_id: Memo<Option<String>>,
pub submit_action: Callback<()>,
pub refresh_trigger: Trigger,
pub data_resource: Resource<Result<HashMap<String, ValueType>, ServerFnError>>
}
pub fn use_student_form(
form_type: impl Into<Signal<String>>
) -> StudentFormInfo {
let form_type = form_type.into();
let user_claims = get_user_claims();
let user_id = Memo::new(move |_| {
user_claims.get()
.map(|info| info.claims.subject.clone())
});
let data_map = RwSignal::new(HashMap::new());
let refresh_trigger = Trigger::new();
let data_resource = Resource::new(
move || (user_id.get().unwrap_or_default(), form_type.get(), refresh_trigger.track()),
async move |(id, form_type, _)| get_student_data(
id,
form_type,
).await
);
Effect::new(move || {
data_resource.with(|map_opt| {
let Some(Ok(map)) = map_opt else {
return;
};
data_map.set(map.to_owned());
});
});
let submit_action = ServerAction::<PutStudentData>::new();
let on_submit = move || {
submit_action.dispatch(PutStudentData {
subject: user_id.get().unwrap_or_default(),
data_type: form_type.get(),
data_map: data_map.get()
});
};
StudentFormInfo {
data_map,
refresh_trigger,
data_resource,
user_id,
submit_action: on_submit.into(),
}
}
+38
View File
@@ -0,0 +1,38 @@
use crate::components::DashboardButton;
use leptos::prelude::*;
#[component]
pub fn StudentHomePage() -> impl IntoView {
view! {
<div class="flex-1" />
<div class="flex flex-2 flex-col gap-4 items-center m-4 mt-10">
<div class="font-bold text-3xl">"Region 15 Scholarship Application"</div>
<div class="text-lg">
"Welcome to the R15 general scholarship application! If you have any questions, please direct them to Sara Smith at "
<a class="text-blue-500 underline" href="mailto:ssmith@region15.org">
"ssmith@region15.org"
</a>"."
</div>
<div class="text-lg">"Here are some links to get you started:"</div>
<DashboardButton
title="Scholarship Book"
description="View scholarship requirements"
icon="/Book.png"
path="https://docs.google.com/document/d/1qVs0Lwvwl1bVGHznocoev3AF56KlhTfs/view"
/>
<DashboardButton
title="Demographics Form"
description="Basic student information"
icon="/Person_Black.png"
path="/students/demographics"
/>
<DashboardButton
title="Additional Forms"
description="Extra eligibility factors"
icon="/Form_Black.png"
path="/students/additional"
/>
</div>
<div class="flex-1" />
}
}
+30
View File
@@ -0,0 +1,30 @@
mod academics_page;
mod additional_page;
mod athletics_page;
mod demographics_page;
mod eligibility_page;
mod extracurriculars_page;
mod family_info_page;
mod financial_page;
mod form_setup;
mod home_page;
mod shell;
mod test_display;
mod university_page;
mod work_experience_page;
mod specific_requirements_page;
pub use academics_page::*;
pub use additional_page::*;
pub use athletics_page::*;
pub use demographics_page::*;
pub use eligibility_page::*;
pub use extracurriculars_page::*;
pub use family_info_page::*;
pub use financial_page::*;
pub use home_page::*;
pub use shell::*;
pub use test_display::*;
pub use university_page::*;
pub use work_experience_page::*;
pub use specific_requirements_page::*;
+52
View File
@@ -0,0 +1,52 @@
use crate::common::{SubTabInfo, TabInfo};
use crate::components::login::StudentLoginContext;
use crate::components::{Banner, Loading, TabSidebarList};
use crate::pages::UnauthenticatedPage;
use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated};
#[component]
pub fn StudentShell() -> impl IntoView {
// Create login context
view! {
<StudentLoginContext>
<div class="h-screen flex flex-col overflow-hidden">
<Banner
title="R15 Student Scholarship Application"
logo="/PHS_Stacked_Acronym.png"
/>
<div class="flex flex-1 min-h-0">
<AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage>
<TabSidebarList
base_path="students"
tabs=vec![
TabInfo::new("Home", "home", None),
TabInfo::new("Demographics", "demographics", None),
TabInfo::new(
"Additional Info",
"additional",
Some(
vec![
SubTabInfo::new("Academics", "academics"),
SubTabInfo::new("Athletics", "athletics"),
SubTabInfo::new("Work Experience", "work-experience"),
SubTabInfo::new("Extracurriculars", "extracurriculars"),
SubTabInfo::new("University Info", "university"),
SubTabInfo::new("Family Info", "family-info"),
SubTabInfo::new("Specifics", "specifics"),
SubTabInfo::new("Financials", "financials"),
],
),
),
TabInfo::new("Eligible Scholarships", "eligibility", None),
]
/>
</Authenticated>
</AuthLoaded>
</div>
</div>
</StudentLoginContext>
}
}
@@ -0,0 +1,83 @@
use crate::components::{Loading, RadioList, ValidatedForm};
use crate::pages::student::form_setup::use_student_form;
use leptos::prelude::*;
#[component]
pub fn StudentSpecificRequirementsPage() -> impl IntoView {
let controller = use_student_form("specifics");
view! {
<Suspense fallback=Loading>
{move || {
controller
.data_resource
.get()
.map(move |_| {
view! {
<div class="flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm
title="Scholarship-Specific Eligibility Requirements"
description="These questions are specific to some scholarships. Indicate yes or no for each question."
on_submit=controller.submit_action
>
<RadioList
label="Have you attended BAS?"
data_map=controller.data_map
data_member="attend_bas"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
<RadioList
label="Are you a member of Midd-South Catholic Church?"
data_map=controller.data_map
data_member="middsouth_church"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
<RadioList
label="Do you have a family member that is or has served in the US military? (Note: you may be asked for proof of service)"
data_map=controller.data_map
data_member="family_military_service"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
<RadioList
label="Have you participated in Pomperaug Youth Baseball?"
data_map=controller.data_map
data_member="youth_baseball"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
<RadioList
label="Have you participated in the Panthers Aquatic Club?"
data_map=controller.data_map
data_member="aquatic_club"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
<RadioList
label="Are at least one of your parents a member of the Region 15 PEA?"
data_map=controller.data_map
data_member="pea_member"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
<RadioList
label="Have you participated in the PHS Music Program (Band, Chorus, etc)?"
data_map=controller.data_map
data_member="music_program"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
</ValidatedForm>
</div>
<div class="flex-1" />
}
.into_any()
})
.collect_view()
}}
</Suspense>
}
}
+77
View File
@@ -0,0 +1,77 @@
use crate::common::{SchemaNode, SchemaType};
use crate::components::{Loading, ValueDisplay};
use crate::pages::api::students::get_all_student_data;
use crate::utils::get_user_claims;
use leptos::prelude::*;
#[component]
pub fn StudentTestDisplay() -> impl IntoView {
let create_student_display_schema = move || {
SchemaNode::new(SchemaType::Map)
.header("Student Information")
.child(
"first_name",
SchemaNode::new(SchemaType::String).header("First Name"),
)
.child(
"last_name",
SchemaNode::new(SchemaType::String).header("Last Name"),
)
.child(
"sports_participation",
SchemaNode::new(SchemaType::List)
.header("Sports Activities")
.item_template(
SchemaNode::new(SchemaType::Map)
.header("Sport")
.child(
"sport_name",
SchemaNode::new(SchemaType::String).header("Sport Name"),
)
.child(
"achievements",
SchemaNode::new(SchemaType::String).header("Special Achievements"),
),
),
)
};
let user_claims = get_user_claims();
let user_id = Memo::new(move |_| user_claims.get().map(|info| info.claims.subject.clone()));
let resource = Resource::new(
move || user_id.get().unwrap_or_default(),
async move |id| get_all_student_data(id).await,
);
let schema = StoredValue::new(create_student_display_schema());
view! {
<div class="flex flex-1" />
<Suspense fallback=Loading>
<div class="flex flex-2 flex-col">
{move || {
resource
.get()
.map(|data_map| {
let Ok(map) = data_map else {
return view! {}.into_any();
};
let data_map = StoredValue::new(map);
view! {
<div>
<ValueDisplay
schema=schema.get_value()
data_map=data_map.get_value()
/>
</div>
}
.into_any()
})
}}
</div>
</Suspense>
<div class="flex flex-1" />
}
}
+108
View File
@@ -0,0 +1,108 @@
use leptos::prelude::*;
use crate::components::{Loading, OutlinedTextField, Select, TextFieldType, ValidatedForm};
use crate::pages::student::form_setup::use_student_form;
#[component]
pub fn StudentUniversityPage() -> impl IntoView {
let controller = use_student_form("university");
// college name, state, city, zip
// major, study field, intended career, college_acceptance
view! {
<Show when=move || controller.data_resource.get().is_some() fallback=Loading>
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm
title="University Information"
description="Here you may fill out general information about the university you plan to attend."
on_submit=controller.submit_action
>
// Inputs here.
<OutlinedTextField
label="University Name:"
data_map=controller.data_map
data_member="college_name"
placeholder="University of Example"
input_type=TextFieldType::Text
required=true
/>
<OutlinedTextField
label="University Street Address:"
data_map=controller.data_map
data_member="college_city"
placeholder="123 Example Rd"
input_type=TextFieldType::Text
required=true
/>
<OutlinedTextField
label="University State:"
data_map=controller.data_map
data_member="college_state"
placeholder="CT, AZ, etc."
input_type=TextFieldType::Text
required=true
/>
<OutlinedTextField
label="University ZIP:"
data_map=controller.data_map
data_member="college_zip"
placeholder="12345"
input_type=TextFieldType::Text
required=true
/>
<Select
label="Have you been sent an acceptance to the university?"
data_map=controller.data_map
data_member="college_acceptance"
required=true
value_list=vec!["Yes".to_string(), "No".to_string()]
/>
<Select
label="Chosen Major:"
data_map=controller.data_map
data_member="major"
required=true
value_list=vec![
"Music".to_string(),
"Education".to_string(),
"Special Education".to_string(),
"Speech Pathology".to_string(),
"School Psychology".to_string(),
"School Counseling".to_string(),
"Occupational Therapy".to_string(),
"Physical Therapy".to_string(),
"Nursing".to_string(),
"Allied Health".to_string(),
"Fine/Performing Arts".to_string(),
"Writing/Communication".to_string(),
"History".to_string(),
"Government".to_string(),
"Political Science".to_string(),
"Social Work".to_string(),
"Sports Medicine".to_string(),
"Athletic Training".to_string(),
"Horticulture".to_string(),
"Conservation Studies".to_string(),
"Ecology".to_string(),
"Environmental Studies".to_string(),
"Urban Planning".to_string(),
"Landscaping".to_string(),
"Legal Studies".to_string(),
"Criminal Justice".to_string(),
]
/>
<OutlinedTextField
label="Intended Career:"
data_map=controller.data_map
data_member="intended_career"
placeholder="Engineer, Artist, etc."
input_type=TextFieldType::Text
required=false
/>
</ValidatedForm>
</div>
<div class="flex flex-1" />
</Show>
}
}
+45
View File
@@ -0,0 +1,45 @@
use crate::components::{Loading, MultiEntry, ValidatedForm};
use crate::input;
use crate::pages::student::form_setup::use_student_form;
use leptos::prelude::*;
#[component]
pub fn StudentWorkExperiencePage() -> impl IntoView {
let controller = use_student_form("workexp");
view! {
<Show when=move || controller.data_resource.get().is_some() fallback=Loading>
<div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm
title="Student Work Experience"
description="Here you may add information about your past/current workplace."
on_submit=controller.submit_action
>
<MultiEntry
label="Work Experience:"
description="Leave blank if not applicable."
data_map=controller.data_map
data_member="extracurricular"
schema=vec![
input!(
Text, "job_title", "Job Title:", true, "Waiter, Cashier, etc..."
),
input!(Text, "employer", "Employer:", true, "Your employer's name..."),
input!(
Text, "start_date", "Approximate start date:", true, "01/01/2000"
),
input!(
Text, "end_date", "End date:", true, "01/01/2026, or current if not applicable"
),
input!(
Number, "num_hours", "Approximate number of hours per week:", true, "25"
),
]
/>
</ValidatedForm>
</div>
<div class="flex flex-1" />
</Show>
}
}
-28
View File
@@ -1,28 +0,0 @@
use crate::components::DashboardButton;
use leptos::prelude::*;
#[component]
pub fn TestPage() -> impl IntoView {
view! {
<div class="p-6 space-y-4 max-w-1/3">
<DashboardButton
title="Home Page"
description="Navigate to Home Page"
icon="Book.png"
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>
}
}
+24 -16
View File
@@ -2,35 +2,43 @@ use crate::components::ActionButton;
use leptos::logging::log;
use leptos::prelude::*;
use leptos_oidc::AuthSignal;
use leptos_router::hooks::{use_navigate, use_location};
#[component]
pub fn UnauthenticatedPage() -> impl IntoView {
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.unauthenticated()
.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()));
let navigate = leptos_router::hooks::use_navigate();
view! (
<div class="flex h-screen justify-center items-center pb-50">
view! {
<div class="flex mx-auto h-screen justify-center items-center pb-50">
<div class="bg-white p-8 border rounded-lg shadow-lg">
<h3> "You're not signed in. Please sign in with your Region 15 email." </h3>
<h3>"You're not signed in. Please sign in first."</h3>
<div class="flex pt-4 justify-center items-center">
<ActionButton on:click=move |_| {
if let Some(url) = login_url() {
navigate(&url, Default::default())
}
}>
"Sign in"
</ActionButton>
<ActionButton on:click=on_click>"Sign in"</ActionButton>
</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 = expect_context::<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()
}