Compare commits

...
111 Commits
Author SHA1 Message Date
Lucas Vas 2e0e47d72b Merge pull request #81 from R15-Software-Dev/fix/admin-file-errors
Insert trailing commas after lists of strings
2026-04-27 15:02:16 -04:00
alphafetus 287598bb79 fix: insert trailing commas after lists of strings
This was already present for lists of dictionaries, but lists that contained a single string also failed to parse properly.
2026-04-27 14:57:31 -04:00
Lucas Vas fff1854067 Merge pull request #80 from R15-Software-Dev/fix/admin-file-errors
Allow essays and fafsa to be requested by administrators
2026-04-27 14:24:20 -04:00
alphafetus 6d6862a680 fix: allow essays and fafsa to be requested by administrators 2026-04-27 14:08:14 -04:00
Lucas Vas 61b0c8b83b Merge pull request #79 from R15-Software-Dev/feat/admin-applicants
Add new admin APIs and applicants page, new PDF downloads
2026-04-27 03:30:22 -04:00
alphafetus e9a42aa0fa fix: update dockerfile to build correctly 2026-04-27 03:29:58 -04:00
alphafetus 5b44bfb914 chore: style admin applicants list layout 2026-04-27 02:53:17 -04:00
alphafetus 8282ba4302 feat: add pdf gen capability 2026-04-27 02:52:44 -04:00
alphafetus 3cc67cb9e6 feat: add new admin APIs and applicants page 2026-04-25 18:06:38 -04:00
Lucas Vas 26b984904b Merge pull request #78 from R15-Software-Dev/feat/api-file-name
Name student files to be more recognizable
2026-04-21 09:30:37 -04:00
alphafetus 4f1d1c5b1c feat: name student files to be more recognizable 2026-04-21 08:19:43 -04:00
Lucas Vas 39178d05a5 Merge pull request #77 from R15-Software-Dev/feat/applicants-page
Create provider-side applicant forms
2026-04-13 02:35:26 -04:00
alphafetus 4b398c8445 fix: remove unnecessary module import
File was removed in previous commit, git somehow broke.
2026-04-13 02:15:07 -04:00
Lucas Vas 6294b114df Update mod.rs 2026-04-13 01:41:50 -04:00
Lucas Vas 48f711a84d Merge branch 'main' into feat/applicants-page 2026-04-13 01:33:28 -04:00
alphafetus 21c0df0ded feat: implement the rest of the required forms 2026-04-13 01:31:03 -04:00
alphafetus eae8d2a26a feat: implement new data display component 2026-04-13 00:20:49 -04:00
alphafetus 07c1679e1a feat: style list of eligible students 2026-04-10 17:25:56 -04:00
alphafetus 2b255ce968 feat: changed zipped file names and disabled download file names 2026-04-10 13:44:54 -04:00
alphafetus 5cda2f8070 feat: add extra styling to student dialog 2026-04-10 00:36:07 -04:00
alphafetus f8d399f9ae feat: create file download capability 2026-04-09 23:49:31 -04:00
alphafetus c5b11481b3 feat: make scholarship filtering work on fafsa and essay requirements 2026-04-09 20:53:47 -04:00
alphafetus 3cf5e6342b feat: create closed form page and close all student forms 2026-04-09 11:08:28 -04:00
alphafetus 93a4b54d42 feat: create file name apis 2026-04-09 10:40:14 -04:00
alphafetus c6386e54fc feat: create get_student_file server function 2026-04-08 14:37:30 -04:00
alphafetus 469f192feb feat: implement basic structure of provider applicants page 2026-04-08 13:55:07 -04:00
alphafetus 3baee8cac7 feat: add applicants list to the routes 2026-04-01 13:51:25 -04:00
alphafetus 17e339ea00 feat: add cognito:groups field 2026-04-01 13:50:49 -04:00
alphafetus 152c01124b feat: improve error logging when JSON does not parse 2026-04-01 13:50:31 -04:00
alphafetus f8502ba781 Merge remote-tracking branch 'origin/feat/applicants-page' into feat/applicants-page 2026-03-31 07:14:56 -04:00
alphafetus 23c0029317 style: rustfmt run 2026-03-31 07:14:44 -04:00
alphafetus 8a44513a04 Merge branch 'main' into feat/applicants-page
# Conflicts:
#	src/app.rs
#	src/pages/student/eligibility_page.rs
2026-03-31 07:14:13 -04:00
alphafetus 2db09af903 Merge branch 'main' into feat/applicants-page
# Conflicts:
#	src/pages/student/eligibility_page.rs
2026-03-27 14:28:28 -04:00
Lucas Vas 467f8c9020 Merge pull request #76 from R15-Software-Dev/chore/api-err-return
Make student eligibility return an error
2026-03-25 13:42:25 -04:00
alphafetus 909f33b044 chore: make student eligibility return an error on missing access token 2026-03-25 13:41:10 -04:00
Lucas Vas 44450f407c Merge pull request #75 from R15-Software-Dev/chore/close-providers
Close provider forms
2026-03-25 13:31:14 -04:00
Lucas Vas 70a86f4545 Merge pull request #74 from R15-Software-Dev/feat/file-input-errors
Create error message display for file inputs
2026-03-25 13:28:23 -04:00
alphafetus e6409cc423 chore: close provider forms 2026-03-25 13:20:58 -04:00
alphafetus 4e3161a05f feat: create error message display for file inputs 2026-03-25 13:17:43 -04:00
Lucas Vas 51dfa013df Merge pull request #73 from R15-Software-Dev/fix/blank-id-requests
Fix for blank IDs in submissions
2026-03-25 12:58:55 -04:00
alphafetus 1d41b6022e fix: hopeful fix for blank IDs in submissions 2026-03-25 12:39:50 -04:00
Lucas Vas f9d5b25436 Merge pull request #72 from R15-Software-Dev/fix/filtering-bugs
Check requirement return value on Ok variant
2026-03-23 12:05:22 -04:00
alphafetus 891e324186 fix: check requirement return value on Ok variant 2026-03-23 11:47:06 -04:00
Lucas Vas b502c2f96d Merge pull request #71 from R15-Software-Dev/fix/comparison-filtering
Fix comparison logic for filtering
2026-03-19 13:49:06 -04:00
alphafetus 681da8c326 fix: use correct comparison logic
Was originally short-circuiting on empty lists of requirements.
2026-03-19 13:37:11 -04:00
alphafetus 05b865c608 feat: implement applicant page shell 2026-03-18 13:48:26 -04:00
Lucas Vas 628b607074 Merge pull request #70 from R15-Software-Dev/fix/api-errors
Significantly improve logging for api requests
2026-03-18 12:30:19 -04:00
alphafetus 97be249826 fix: import ProvideErrorMetadata correctly 2026-03-18 12:16:44 -04:00
alphafetus e1bd472a36 feat: significantly improve logging for api requests 2026-03-18 12:08:41 -04:00
alphafetus 2e134fb693 temp: begin implementation of applicants page 2026-03-18 12:01:06 -04:00
alphafetus db99f39716 style: rustfmt run 2026-03-18 11:39:07 -04:00
Lucas Vas dd19631e33 Merge pull request #69 from R15-Software-Dev/fix/comparison-changes
Small comparison changes
2026-03-18 10:38:26 -04:00
alphafetus a0b206a554 fix: do not display scholarships that are not completed 2026-03-12 10:41:32 -04:00
alphafetus e9b0452d38 feat: improve error messages for client side comparison check 2026-03-11 14:24:25 -04:00
Lucas Vas 448e8bbb38 Merge pull request #68 from R15-Software-Dev/fix/comparison-changes
Comparison and sports changes
2026-03-11 12:49:53 -04:00
alphafetus ae7a17ad92 chore: add volleyball option 2026-03-11 11:54:34 -04:00
alphafetus 750059e9ad fix: attended_bas checks correct data member 2026-03-11 11:53:50 -04:00
alphafetus 5a371ae574 chore: temporarily reopen provider forms (for real this time) 2026-03-11 11:53:05 -04:00
alphafetus d7f44463c6 chore: temporarily reopen provider forms 2026-03-11 11:33:30 -04:00
alphafetus 5872002538 feat: improve error messages in files api 2026-03-11 11:28:52 -04:00
alphafetus 957fc0c5eb chore: change title of form 2026-03-09 12:16:41 -04:00
Lucas Vas 8a2d5b0494 Merge pull request #67 from R15-Software-Dev/hotfix/66-minor-changes
Minor changes to student forms
2026-03-06 13:58:33 -05:00
alphafetus 07fae5878f feat: add toasts and reactive feedback to forms on submission
Users were not able to tell that they had submitted before this.
2026-03-06 13:46:35 -05:00
alphafetus def5c8696b chore: update intended major field 2026-03-06 13:23:51 -05:00
alphafetus 7c1e281749 chore: add due date reminder to home page 2026-03-06 13:22:28 -05:00
alphafetus 49bed2f1e1 fix: correct gender data member 2026-03-06 13:21:18 -05:00
Lucas Vas 5929a450ae Merge pull request #65 from R15-Software-Dev/release/v0.5.0
Release candidate (v0.5.0)
2026-03-06 07:39:50 -05:00
alphafetus 05ef850f82 style: rustfmt run 2026-03-06 07:39:28 -05:00
alphafetus 1427c51bd3 feat: create "specifics" page 2026-03-06 01:55:58 -05:00
alphafetus c6867baad4 feat: add form descriptions 2026-03-06 01:32:34 -05:00
alphafetus 111a7aea86 feat: add specific placeholders 2026-03-06 01:24:30 -05:00
alphafetus 371db711b2 feat: finish portal pages 2026-03-06 01:21:37 -05:00
alphafetus c33a587d96 feat: allow icon to be optional 2026-03-06 01:11:20 -05:00
alphafetus 80340d99b9 feat: add redirect buttons to forms 2026-03-06 01:11:07 -05:00
alphafetus 142a60bc7c feat: update fields 2026-03-06 00:58:25 -05:00
alphafetus 97707a9b80 feat: add new button and additional text 2026-03-06 00:54:45 -05:00
alphafetus 4091bd0162 feat: update fields 2026-03-06 00:44:27 -05:00
alphafetus 323116663a feat: update fields 2026-03-06 00:41:44 -05:00
alphafetus 9a6cc934de feat: use loading icon for eligibility check 2026-03-06 00:34:40 -05:00
alphafetus 6f600a4818 feat: remove testing display 2026-03-06 00:30:44 -05:00
Lucas Vas 311d5647e8 Merge pull request #64 from R15-Software-Dev/feat/close-forms
Create FormClosedPage and close provider form
2026-03-05 09:06:41 -05:00
alphafetus 3e4c287aa4 feat: create FormClosedPage and close provider form 2026-03-04 14:09:41 -05:00
Lucas Vas 9d124c4098 Merge pull request #63 from R15-Software-Dev/feat/file-type-restriction
Implement checking for file types
2026-03-03 18:20:30 -05:00
alphafetus 840be3f0e5 feat: implement checking for file types 2026-03-03 09:44:55 -05:00
Lucas Vas 53bdc96d01 Merge pull request #62 from R15-Software-Dev/feat/value-display
Implement template student portal
2026-03-02 16:38:30 -05:00
alphafetus b422e43288 feat: implement admin login page 2026-03-02 16:16:15 -05:00
alphafetus 838ae8ba0f chore: bump docker to rust:1.93 2026-03-02 15:57:50 -05:00
alphafetus fb90a9d8ac feat: implement procedural display of file drop inputs 2026-03-02 15:43:58 -05:00
alphafetus 9d156c17e9 feat: implement file list retrieval for file drop components 2026-03-02 14:25:07 -05:00
alphafetus cc09769f28 chore: style file list 2026-03-02 13:48:38 -05:00
alphafetus e8b0ceeb7b feat: create financial_page 2026-03-02 13:48:29 -05:00
alphafetus c035115126 feat: allow deletion of files from s3 2026-03-02 13:40:00 -05:00
alphafetus 6f16bbe311 feat: allow reactive updates of UI with inserted file names 2026-03-02 12:29:49 -05:00
alphafetus 1bccecdc72 feat: implement dynamo insert with file upload 2026-03-02 12:00:38 -05:00
alphafetus 5cafc7e757 chore: pc change
Started implementing delete functions.
2026-03-02 08:47:53 -05:00
alphafetus 3f53e8fda1 chore: remove unused import 2026-03-01 12:34:23 -05:00
alphafetus b932388092 feat: style file input 2026-03-01 12:34:02 -05:00
alphafetus 40d1ef2a5f feat: implement file upload functionality 2026-03-01 12:17:20 -05:00
alphafetus 64554d81bc feat: begin implementation of file drop component 2026-02-27 14:52:54 -05:00
alphafetus c98621aefa feat: implement CSV export of scholarships 2026-02-27 07:39:31 -05:00
alphafetus be54aa8fd2 feat: change Into implementation to From 2026-02-20 09:58:16 -05:00
alphafetus 1afacf6008 docs: add docs to data display 2026-02-20 09:16:55 -05:00
alphafetus c850ac208f feat: finish initial data display component 2026-02-20 08:56:13 -05:00
alphafetus 0816f2da8b feat: add indexmap dependency 2026-02-18 09:00:15 -05:00
alphafetus 04ba0ff1ea feat: begin implementation of value display 2026-02-17 13:58:01 -05:00
alphafetus f10f01534c chore: push current changes to different PC 2026-02-16 12:21:35 -05:00
alphafetus dddc9e4192 style: run rustfmt 2026-02-11 12:21:41 -05:00
Lucas Vas ebb6fe500c Merge pull request #60 from R15-Software-Dev/fix/optional-number-inputs
Make number-type inputs properly optional
2026-02-11 12:18:08 -05:00
alphafetus e1de6064d9 fix: allow users to type decimal values 2026-02-11 11:45:47 -05:00
alphafetus f0db43817c fix: prevent validation on empty string 2026-02-10 09:54:58 -05:00
alphafetus 12dfe502f4 chore: rustfmt run 2026-02-10 09:39:30 -05:00
52 changed files with 6675 additions and 1137 deletions

No files matched your search

Generated
+1473 -346
View File
File diff suppressed because it is too large. Load diff
+23 -11
View File
@@ -7,28 +7,35 @@ edition = "2024"
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
leptos = { version = "0.8" } leptos = { version = "0.8", features = ["multipart"] }
leptos_router = { version = "0.8" } leptos_router = { version = "0.8" }
axum = { version = "0.8", optional = true } axum = { version = "0.8", optional = true }
console_error_panic_hook = { version = "0.1", optional = true} console_error_panic_hook = { version = "0.1", optional = true}
leptos_axum = { version = "0.8", optional = true } leptos_axum = { version = "0.8", optional = true }
leptos_meta = { version = "0.8" } leptos_meta = { version = "0.8" }
tokio = { version = "1", features = ["rt-multi-thread"], optional = true } tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
wasm-bindgen = { version = "=0.2.106", optional = true } wasm-bindgen = { version = "0.2", optional = true }
aws-sdk-dynamodb = { version = "1", optional = true } aws-sdk-dynamodb = { version = "^1", optional = true }
aws-config = { version = "1.6.2", optional = true } aws-sdk-s3 = { version = "1.124.0", optional = true }
serde = "1.0.219" aws-config = { version = "^1.8", optional = true }
serde_dynamo = { version = "4.2.14", features = ["aws-sdk-dynamodb+1"], optional = true } serde = "^1.0"
uuid = { version = "1.17.0", features = ["serde", "v4", "js"] } serde_dynamo = { version = "^4", features = ["aws-sdk-dynamodb+1"], optional = true }
uuid = { version = "^1", features = ["serde", "v4", "js"] }
leptos_oidc = "0.9.0" leptos_oidc = "0.9.0"
base64 = "0.22.1" base64 = "0.22"
chrono = "0.4.42" chrono = "0.4"
leptos_animate = "0.1.9" leptos_animate = "0.1"
macros = { path = "./macros" } macros = { path = "./macros" }
traits = { path = "./traits" } traits = { path = "./traits" }
icondata = { version = "0.7.0", features = ["font-awesome"] } icondata = { version = "0.7.0", features = ["font-awesome"] }
leptos_icons = "0.7.1" leptos_icons = "0.7.1"
indexmap = { version = "2.13.0", features = ["serde"] }
csv = "1.4.0"
leptos-use = { version = "0.18.3", features = ["use_drop_zone"] }
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"], optional = true }
reqwest = { version = "0.13.2", features = ["json"] }
zip = { version = "8.5.1", optional = true }
futures = { version = "0.3.32", optional = true }
[features] [features]
default = ["ssr"] default = ["ssr"]
@@ -42,11 +49,16 @@ ssr = [
"dep:tokio", "dep:tokio",
"dep:leptos_axum", "dep:leptos_axum",
"dep:aws-sdk-dynamodb", "dep:aws-sdk-dynamodb",
"dep:aws-sdk-s3",
"dep:aws-config", "dep:aws-config",
"dep:serde_dynamo", "dep:serde_dynamo",
"dep:jsonwebtoken",
"dep:zip",
"dep:futures",
"leptos/ssr", "leptos/ssr",
"leptos_meta/ssr", "leptos_meta/ssr",
"leptos_router/ssr", "leptos_router/ssr",
"leptos-use/ssr"
] ]
# Defines a size-optimized profile for the WASM bundle in release mode # Defines a size-optimized profile for the WASM bundle in release mode
+7 -3
View File
@@ -1,4 +1,4 @@
FROM rust:1.90 AS builder FROM rust:1.93 AS builder
# Install node for tailwind capability # Install node for tailwind capability
RUN apt-get update -y && apt-get install -y --no-install-recommends clang libc6 mold RUN apt-get update -y && apt-get install -y --no-install-recommends clang libc6 mold
@@ -27,14 +27,18 @@ RUN cargo binstall fnm -y \
# Start second phase of build - install ca-certs and copy all build outputs from previous step into a # Start second phase of build - install ca-certs and copy all build outputs from previous step into a
# Debian image in the /app directory. # Debian image in the /app directory.
FROM debian:trixie-slim AS runtime FROM rust:1.95-slim-trixie AS runtime
WORKDIR /app WORKDIR /app
RUN apt-get update -y \ RUN apt-get update -y \
&& apt-get install -y --no-install-recommends openssl ca-certificates \ && apt-get install -y --no-install-recommends openssl ca-certificates curl \
&& apt-get autoremove -y \ && apt-get autoremove -y \
&& apt-get clean -y \ && apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Cargo, then binstall, then typst installation.
RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
RUN cargo binstall typst-cli -y
# Remember to replace project_name with the actual project name # Remember to replace project_name with the actual project name
COPY --from=builder /app/target/release/scholarships-rs /app/ COPY --from=builder /app/target/release/scholarships-rs /app/
+141
View File
@@ -0,0 +1,141 @@
#set page(margin: 40pt)
#set block(breakable: false)
#let map_block(header, val) = {
block(
width: 100%,
fill: rgb(200, 0, 0),
radius: 4pt,
below: 0pt,
above: 4pt,
inset: 2pt,
[
#show heading: set text(white)
#show heading: set text(size: 15pt)
#stack(dir: ttb,
block(
inset: 7pt,
align(horizon)[= #header]
),
align(center)[#block(
width: 100%,
fill: white,
inset: 8pt,
radius: 3pt,
align(left)[#val]
)],
)
]
)
}
#let info_line(subject, key, dict: student) = [
*#subject* #dict.at(key, default: "N/A")
]
#let info_line_list(subject, key, dict: student) = [
*#subject* #dict.at(key, default: "N/A").join(", ")
]
#show heading: set align(center)
#show heading: set text(size: 20pt)
#v(10cm)
= #student.first_name #student.last_name
#show heading: set text(size: 17pt)
== Region 15 General Scholarship Application
#pagebreak()
#show heading: set block(below: 20pt, above: 30pt)
#show heading: set text(size: 20pt)
= Student Demographic Information
#info_line("First Name:", "first_name")
#info_line("Last Name:", "last_name")
#info_line("Date of Birth:", "dob")
#info_line("Gender:", "gender")
#info_line("Phone Number:", "phone_number")
#info_line("Email:","email")
#info_line("Street Address:", "street_address")
#info_line("Town:", "town")
= Academic Information
#info_line("Unweighted GPA:", "unweighted_gpa")
#info_line("Weighted GPA:", "weighted_gpa")
#info_line("Highest SAT Score:", "sat_score")
#info_line("Highest ACT Score:", "act_score")
#info_line("Academic Honors:", "academic_honors")
= Sports Participation
#if student.at("sports_participation", default: ()).len() == 0 [
Student did not enter any sports participation.
] else {
for pair in student.sports_participation {
map_block(pair.at("sport_name"), [
#info_line("Special Achievements:", "achievements", dict: pair)
#info_line_list("Grades Participated:", "grades", dict: pair)
])
}
}
= Extracurricular Activities
#if student.at("extracurricular", default: ()).len() == 0 [
Student did not enter any extracurricular activities.
] else {
for pair in student.extracurricular {
map_block(pair.at("activity_name"), [
#info_line("Number of hours per week:", "num_hours", dict: pair)
#info_line("Number of weeks participated:", "num_weeks", dict: pair)
#info_line("Special Involvement:", "special_involvement", dict: pair)
])
}
}
= Work Experience
#if student.at("work_experience", default: ()).len() == 0 [
Student did not enter any work experience.
] else {
for pair in student.work_experience {
map_block(pair.at("employer"), [
#info_line("Job Title:", "job_title", dict: pair)
#info_line("Start Date:", "start_date", dict: pair)
#info_line("End Date:", "end_date", dict: pair)
#info_line("Number of hours per week:", "num_hours", dict: pair)
])
}
}
= University Information
#info_line("University Name:", "college_name")
#info_line("University Street Address:", "college_city")
#info_line("University State:", "college_state")
#info_line("University ZIP:", "college_zip")
#info_line("Has the student received an acceptance:", "college_acceptance")
#info_line("Student's chosen major:", "major")
#info_line("Student's intended career:", "intended_career")
= Family Information
#info_line("Total number of children in student's family:", "num_children")
#info_line("Total number of children currently attending college:", "num_children_college")
#info_line("Parent/Guardian 1 Name:", "parent_one_name")
#info_line("Parent/Guardian 1 Relationship:", "parent_one_relationship")
#info_line("Parent/Guardian 1 Occupation:", "parent_one_occupation")
#info_line("Parent/Guardian 1 Employer:", "parent_one_employer")
#info_line("Parent/Guardian 2 Name:", "parent_two_name")
#info_line("Parent/Guardian 2 Relationship:", "parent_two_relationship")
#info_line("Parent/Guardian 2 Occupation:", "parent_two_occupation")
#info_line("Parent/Guardian 2 Employer:", "parent_two_employer")
= Extra Eligibility Information
#info_line("Did the student attend BAS:", "attend_bas")
#info_line("Is the student a member of Midd-South Catholic Church:", "middsouth_church")
#info_line("Does the student have a family member who has served in the US Military:", "family_military_service")
#info_line("Has the student participated in Pomperaug Youth Baseball:", "youth_baseball")
#info_line("Has the student participated in the Panthers Aquatic Club:", "aquatic_club")
#info_line("Does the student have a family member in the Region 15 PEA:", "pea_member")
#info_line("Has the student participated in the PHS Music Program:", "music_program")
+39 -35
View File
@@ -1,12 +1,16 @@
use crate::pages::{ProviderPortal, ScholarshipInfoPage, ProviderContactPage, LoanerShell, LoanerFallback, LoanerBorrowForm, LoanerReturnForm, AuthCallbackPage, ApplicantsPageFallback, AdminShell, AdminHomePage, AdminProviderPage, AdminScholarshipPage, AdminUtilsPage}; use crate::components::ToastList;
use crate::pages::student; use crate::components::login::ProviderLoginContext;
use crate::pages::{AdminApplicantsPageFallback, AdminApplicantsPageShell, AdminApplicantsStudentList, AdminHomePage, AdminProviderPage, AdminScholarshipPage, AdminShell, AdminUtilsPage, ApplicantsPageFallback, ApplicantsStudentList, AuthCallbackPage, LoanerBorrowForm, LoanerFallback, LoanerReturnForm, LoanerShell, ProviderContactPage, ProviderPortal, ScholarshipInfoPage};
use crate::pages::{ApplicantsPageShell, student};
use leptos::prelude::*; use leptos::prelude::*;
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
use leptos_oidc::AuthSignal; use leptos_oidc::AuthSignal;
use leptos_router::{components::{Route, Router, Routes, ParentRoute}, path, MatchNestedRoutes};
use leptos_router::any_nested_route::IntoAnyNestedRoute; use leptos_router::any_nested_route::IntoAnyNestedRoute;
use crate::components::login::ProviderLoginContext; use leptos_router::{
use crate::components::ToastList; MatchNestedRoutes,
components::{ParentRoute, Route, Router, Routes},
path,
};
pub fn shell(options: LeptosOptions) -> impl IntoView { pub fn shell(options: LeptosOptions) -> impl IntoView {
view! { view! {
@@ -66,6 +70,7 @@ pub fn AppWithRoutes() -> impl IntoView {
<ProviderRoutes /> <ProviderRoutes />
<LoanerRoutes /> <LoanerRoutes />
<StudentRoutes /> <StudentRoutes />
<AdminRoutes />
</Routes> </Routes>
</ToastList> </ToastList>
</main> </main>
@@ -77,17 +82,21 @@ fn ProviderRoutes() -> impl MatchNestedRoutes + Clone {
view! { view! {
<ParentRoute path=path!("/providers") view=ProviderLoginContext> <ParentRoute path=path!("/providers") view=ProviderLoginContext>
<Route path=path!("") view=ProviderPortal /> <Route path=path!("") view=ProviderPortal />
<Route path=path!("/applicants") view=ApplicantsPageFallback /> <ParentRoute path=path!("/applicants") view=ApplicantsPageShell>
<Route path=path!("") view=ApplicantsPageFallback />
<Route path=path!("/:scholarship_id") view=ApplicantsStudentList />
</ParentRoute>
<Route path=path!("/callback") view=AuthCallbackPage /> <Route path=path!("/callback") view=AuthCallbackPage />
<Route path=path!("/profile") view=ProviderContactPage /> <Route path=path!("/profile") view=ProviderContactPage />
// <Route path=path!("/scholarships") view=FormClosedPage />
<ParentRoute path=path!("/scholarships") view=ScholarshipInfoPage> <ParentRoute path=path!("/scholarships") view=ScholarshipInfoPage>
<Route path=path!(":id") view=ScholarshipInfoPage /> <Route path=path!(":id") view=ScholarshipInfoPage />
<Route path=path!("") view=ScholarshipInfoPage /> <Route path=path!("") view=ScholarshipInfoPage />
</ParentRoute> </ParentRoute>
</ParentRoute> </ParentRoute>
} }
.into_inner() .into_inner()
.into_any_nested_route() .into_any_nested_route()
} }
#[component(transparent)] #[component(transparent)]
@@ -99,22 +108,27 @@ fn LoanerRoutes() -> impl MatchNestedRoutes + Clone {
<Route path=path!("returning") view=LoanerReturnForm /> <Route path=path!("returning") view=LoanerReturnForm />
</ParentRoute> </ParentRoute>
} }
.into_inner() .into_inner()
.into_any_nested_route() .into_any_nested_route()
} }
#[component(transparent)] #[component(transparent)]
fn AdminRoutes() -> impl MatchNestedRoutes + Clone { fn AdminRoutes() -> impl MatchNestedRoutes + Clone {
view! { view! {
<ParentRoute path=path!("admin") view=AdminShell> <ParentRoute path=path!("admin") view=AdminShell>
<Route path=path!("callback") view=AuthCallbackPage />
<Route path=path!("home") view=AdminHomePage /> <Route path=path!("home") view=AdminHomePage />
<Route path=path!("providers") view=AdminProviderPage /> <Route path=path!("providers") view=AdminProviderPage />
<Route path=path!("scholarships") view=AdminScholarshipPage /> <Route path=path!("scholarships") view=AdminScholarshipPage />
<Route path=path!("utilities") view=AdminUtilsPage /> <Route path=path!("utilities") view=AdminUtilsPage />
<ParentRoute path=path!("applicants") view=AdminApplicantsPageShell>
<Route path=path!("") view=AdminApplicantsPageFallback />
<Route path=path!("/:scholarship_id") view=AdminApplicantsStudentList />
</ParentRoute>
</ParentRoute> </ParentRoute>
} }
.into_inner() .into_inner()
.into_any_nested_route() .into_any_nested_route()
} }
#[component(transparent)] #[component(transparent)]
@@ -123,31 +137,21 @@ fn StudentRoutes() -> impl MatchNestedRoutes + Clone {
<ParentRoute path=path!("students") view=student::StudentShell> <ParentRoute path=path!("students") view=student::StudentShell>
<Route path=path!("callback") view=AuthCallbackPage /> <Route path=path!("callback") view=AuthCallbackPage />
<Route path=path!("home") view=student::StudentHomePage /> <Route path=path!("home") view=student::StudentHomePage />
<Route path=path!("demographics") view=student::StudentDemographicsPage /> <Route path=path!("demographics") view=student::ClosedForm />
<Route path=path!("eligibility") view=student::StudentEligibilityPage /> <Route path=path!("eligibility") view=student::ClosedForm />
<Route path=path!("additional") view=student::AdditionalPage /> <Route path=path!("additional") view=student::ClosedForm />
<Route path=path!("additional/academics") view=student::StudentAcademicsPage /> <Route path=path!("additional/academics") view=student::ClosedForm />
<Route path=path!("additional/athletics") view=student::StudentAthleticsPage /> <Route path=path!("additional/athletics") view=student::ClosedForm />
<Route <Route path=path!("additional/extracurriculars") view=student::ClosedForm />
path=path!("additional/extracurriculars") <Route path=path!("additional/work-experience") view=student::ClosedForm />
view=student::StudentExtracurricularsPage <Route path=path!("additional/university") view=student::ClosedForm />
/> <Route path=path!("additional/family-info") view=student::ClosedForm />
<Route <Route path=path!("additional/financials") view=student::ClosedForm />
path=path!("additional/work-experience") <Route path=path!("additional/specifics") view=student::ClosedForm />
view=student::StudentWorkExperiencePage
/>
<Route
path=path!("additional/university")
view=student::StudentUniversityPage
/>
<Route
path=path!("additional/family-info")
view=student::StudentFamilyPage
/>
</ParentRoute> </ParentRoute>
} }
.into_inner() .into_inner()
.into_any_nested_route() .into_any_nested_route()
} }
#[component] #[component]
+1 -1
View File
@@ -15,7 +15,7 @@ use std::collections::HashMap;
/// now, the table will simply create a single massive `Map` field that contains all the stored /// now, the table will simply create a single massive `Map` field that contains all the stored
/// information in the `HashMap`. We'd prefer to be able to get single values if possible, /// information in the `HashMap`. We'd prefer to be able to get single values if possible,
/// especially for interoperability with other applications, should they be created. /// especially for interoperability with other applications, should they be created.
#[derive(Debug, Clone, Serialize, Deserialize, Reactive)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Reactive)]
pub struct ExpandableInfo { pub struct ExpandableInfo {
/// The main key member. Required and used to get/put the information in a database. /// The main key member. Required and used to get/put the information in a database.
pub subject: String, pub subject: String,
+47 -37
View File
@@ -36,60 +36,70 @@ impl InputType {
) -> impl IntoView { ) -> impl IntoView {
view! { view! {
{match self { {match self {
InputType::Text(member, label, placeholder, required) => InputType::Text(member, label, placeholder, required) => {
view! { view! {
<Row> <Row>
<OutlinedTextField <OutlinedTextField
label = label.clone() label=label.clone()
placeholder = placeholder.clone() placeholder=placeholder.clone()
data_member = member.clone() data_member=member.clone()
data_map = data_map data_map=data_map
required = required required=required
/> />
</Row> </Row>
}.into_any(), }
InputType::Number(member, label, placeholder, required) => .into_any()
}
InputType::Number(member, label, placeholder, required) => {
view! { view! {
<OutlinedTextField <OutlinedTextField
label = label.clone() label=label.clone()
placeholder = placeholder.clone() placeholder=placeholder.clone()
data_member = member.clone() data_member=member.clone()
data_map = data_map data_map=data_map
input_type = TextFieldType::Number input_type=TextFieldType::Number
required = required required=required
/> />
}.into_any(), }
InputType::Checkbox(member, label, options, required) => .into_any()
}
InputType::Checkbox(member, label, options, required) => {
view! { view! {
<CheckboxList <CheckboxList
label = label.clone() label=label.clone()
name = name.clone() name=name.clone()
items = options.clone() items=options.clone()
data_member = member.clone() data_member=member.clone()
data_map = data_map data_map=data_map
required = required required=required
/> />
}.into_any(), }
InputType::Radio(member, label, options, required) => .into_any()
}
InputType::Radio(member, label, options, required) => {
view! { view! {
<RadioList <RadioList
label = label.clone() label=label.clone()
data_member = member.clone() data_member=member.clone()
data_map = data_map data_map=data_map
items = options.clone() items=options.clone()
required = required required=required
/> />
}.into_any(), }
InputType::Select(member, label, options, required) => .into_any()
}
InputType::Select(member, label, options, required) => {
view! { view! {
<Select <Select
label = label.clone() label=label.clone()
value_list = options.clone() value_list=options.clone()
data_member = member.clone() data_member=member.clone()
data_map = data_map data_map=data_map
required = required required=required
/> />
}.into_any() }
.into_any()
}
}} }}
} }
} }
+2 -1
View File
@@ -8,8 +8,9 @@ mod user;
mod value_type; mod value_type;
pub mod animations; pub mod animations;
mod tab_info; mod tab_info;
mod schema_node;
pub use self::{ pub use self::{
comparison::*, date_info::*, expandable_info::*, form_params::*, form_schema::*, comparison::*, date_info::*, expandable_info::*, form_params::*, form_schema::*,
submit_status::*, user::*, value_type::*, tab_info::*, submit_status::*, user::*, value_type::*, tab_info::*, schema_node::*,
}; };
+253
View File
@@ -0,0 +1,253 @@
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
pub enum SchemaType {
/// Indicates a `ValueType::String` value.
#[default]
String,
/// Indicates a `ValueType::Number` value.
Number,
/// Indicates a `ValueType::List` containing only strings or numbers.
PrimitiveList,
/// Indicates a `ValueType::List` containing only maps.
MapList,
/// Indicates a `ValueType::Map`.
Map,
}
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
pub enum SchemaContainerStyle {
#[default]
Header,
Capsule,
}
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
pub enum SchemaHeaderStyle {
#[default]
None,
MainHeader,
SubsectionHeader,
Bold,
}
/// # 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(Serialize, Deserialize, Default, Debug, Clone)]
pub struct SchemaNode {
/// The display style of the data. Refer to [`SchemaContainerStyle`] for more information.
pub container_style: SchemaContainerStyle,
/// The datatype of the data found using the `data_member` key.
pub data_type: SchemaType,
/// The header of this display.
pub header: String,
/// The style of the header's display.
pub header_style: SchemaHeaderStyle,
/// 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>,
/// The primary key for iteration over maps. Used when there is a list of maps.
pub primary_key: Option<String>,
}
impl SchemaNode {
/// Creates a new [`SchemaBuilder`].
pub fn builder() -> SchemaBuilder {
SchemaBuilder::default()
}
}
#[derive(Default, Debug, Clone)]
pub struct SchemaBuilder {
container_style: SchemaContainerStyle,
header: String,
header_style: SchemaHeaderStyle,
}
impl SchemaBuilder {
pub fn container(mut self, style: SchemaContainerStyle) -> Self {
self.container_style = style;
self
}
pub fn header(mut self, header: impl Into<String>) -> Self {
self.header = header.into();
self
}
pub fn header_style(mut self, header_style: SchemaHeaderStyle) -> Self {
self.header_style = header_style;
self
}
pub fn primitive_list(self) -> SchemaPrimitiveListBuilder {
SchemaPrimitiveListBuilder::new(self)
}
pub fn map_list(self) -> SchemaMapListBuilder {
SchemaMapListBuilder::new(self)
}
pub fn map(self) -> SchemaMapBuilder {
SchemaMapBuilder::new(self)
}
pub fn string(self) -> SchemaPrimitiveBuilder {
SchemaPrimitiveBuilder::new(self, SchemaType::String)
}
pub fn number(self) -> SchemaPrimitiveBuilder {
SchemaPrimitiveBuilder::new(self, SchemaType::Number)
}
fn into_node(self) -> SchemaNode {
SchemaNode {
container_style: self.container_style,
header: self.header,
header_style: self.header_style,
..Default::default()
}
}
}
#[derive(Default, Debug, Clone)]
pub struct SchemaPrimitiveListBuilder {
builder: SchemaBuilder,
}
impl SchemaPrimitiveListBuilder {
fn new(builder: SchemaBuilder) -> Self {
Self { builder }
}
}
#[derive(Default, Debug, Clone)]
pub struct SchemaMapListBuilder {
builder: SchemaBuilder,
item_template: Option<Box<SchemaNode>>,
primary_key: String,
}
impl SchemaMapListBuilder {
fn new(builder: SchemaBuilder) -> Self {
Self {
builder,
..Default::default()
}
}
pub fn item_template(mut self, node: SchemaNode) -> Self {
self.item_template = Some(Box::new(node));
self
}
pub fn primary_key(mut self, primary_key: impl Into<String>) -> Self {
self.primary_key = primary_key.into();
self
}
pub fn build(self) -> SchemaNode {
SchemaNode {
data_type: SchemaType::MapList,
item_template: self.item_template,
primary_key: Some(self.primary_key),
..self.builder.into_node()
}
}
}
#[derive(Default, Debug, Clone)]
pub struct SchemaMapBuilder {
builder: SchemaBuilder,
children: IndexMap<String, SchemaNode>,
}
impl SchemaMapBuilder {
fn new(builder: SchemaBuilder) -> Self {
Self {
builder,
..Default::default()
}
}
pub fn child(mut self, data_member: impl Into<String>, node: SchemaNode) -> Self {
self.children.insert(data_member.into(), node);
self
}
pub fn build(self) -> SchemaNode {
SchemaNode {
data_type: SchemaType::Map,
children: self.children,
..self.builder.into_node()
}
}
}
#[derive(Default, Debug, Clone)]
pub struct SchemaPrimitiveBuilder {
builder: SchemaBuilder,
data_type: SchemaType,
}
impl SchemaPrimitiveBuilder {
fn new(builder: SchemaBuilder, data_type: SchemaType) -> Self {
Self { data_type, builder }
}
pub fn build(self) -> SchemaNode {
SchemaNode {
data_type: self.data_type,
..self.builder.into_node()
}
}
}
+5
View File
@@ -22,4 +22,9 @@ pub struct UserClaims {
/// The username of the user. This should be their email address. /// The username of the user. This should be their email address.
#[serde(rename = "username")] #[serde(rename = "username")]
pub username: String, pub username: String,
/// The intended use for the token.
pub token_use: String,
/// The list of the names of user pool groups that have this user as a member.
#[serde(rename = "cognito:groups", default = "Vec::new")]
pub groups: Vec<String>,
} }
+21 -9
View File
@@ -13,7 +13,7 @@ use std::fmt::Display;
pub enum ValueType { pub enum ValueType {
/// Indicates a `String` value. /// Indicates a `String` value.
String(Option<String>), String(Option<String>),
/// Indicates an `i32` value. /// Indicates an `i32` value. /// Indicates an `f32` value.
Number(Option<String>), Number(Option<String>),
/// Indicates a `Vec<T>`, where `T` can be any other `ValueType`. /// Indicates a `Vec<T>`, where `T` can be any other `ValueType`.
List(Option<Vec<ValueType>>), List(Option<Vec<ValueType>>),
@@ -105,8 +105,8 @@ impl Default for ValueType {
impl Display for ValueType { impl Display for ValueType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = match self { let str = match self {
ValueType::String(s) => s.clone().unwrap_or("".to_owned()), ValueType::String(s) => s.clone().unwrap_or_default(),
ValueType::Number(n) => n.clone().unwrap_or("".to_owned()), ValueType::Number(n) => n.clone().unwrap_or_default(),
ValueType::List(l) => format!("{l:?}"), ValueType::List(l) => format!("{l:?}"),
ValueType::Map(m) => format!("{m:?}"), ValueType::Map(m) => format!("{m:?}"),
}; };
@@ -124,20 +124,32 @@ impl From<&AttributeValue> for ValueType {
AttributeValue::S(s) => ValueType::String(Some(s.clone())), AttributeValue::S(s) => ValueType::String(Some(s.clone())),
AttributeValue::N(n) => ValueType::Number(Some(n.clone())), AttributeValue::N(n) => ValueType::Number(Some(n.clone())),
AttributeValue::L(l) => ValueType::List(Some(l.iter().map(|v| v.into()).collect())), AttributeValue::L(l) => ValueType::List(Some(l.iter().map(|v| v.into()).collect())),
AttributeValue::M(m) => ValueType::Map(Some(m.iter().map(|(k, v)| (k.clone(), v.into())).collect())), AttributeValue::M(m) => {
ValueType::Map(Some(m.iter().map(|(k, v)| (k.clone(), v.into())).collect()))
}
_ => ValueType::default(), _ => ValueType::default(),
} }
} }
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
impl Into<AttributeValue> for ValueType { impl From<ValueType> for AttributeValue {
fn into(self) -> AttributeValue { fn from(value: ValueType) -> Self {
match self { match value {
ValueType::String(s) => AttributeValue::S(s.unwrap_or_default()), ValueType::String(s) => AttributeValue::S(s.unwrap_or_default()),
ValueType::Number(n) => AttributeValue::N(n.unwrap_or_default()), ValueType::Number(n) => AttributeValue::N(n.unwrap_or_default()),
ValueType::List(l) => AttributeValue::L(l.unwrap_or_default().into_iter().map(|v| v.into()).collect()), ValueType::List(l) => AttributeValue::L(
ValueType::Map(m) => AttributeValue::M(m.unwrap_or_default().into_iter().map(|(k, v)| (k, v.into())).collect()), l.unwrap_or_default()
.into_iter()
.map(|v| v.into())
.collect(),
),
ValueType::Map(m) => AttributeValue::M(
m.unwrap_or_default()
.into_iter()
.map(|(k, v)| (k, v.into()))
.collect(),
),
} }
} }
} }
+2 -2
View File
@@ -8,13 +8,13 @@ pub fn ActionButton(
) -> impl IntoView { ) -> impl IntoView {
view! { view! {
<button <button
class="m-1.5 p-2.5 min-w-20 rounded-lg font-semibold text-white transition-all duration-400 cursor-pointer" class="flex-1 m-1.5 p-2.5 min-w-20 rounded-lg font-semibold text-white transition-all duration-400 cursor-pointer"
class=(["bg-red-800", "hover:bg-red-900"], move || !disabled.get()) class=(["bg-red-800", "hover:bg-red-900"], move || !disabled.get())
class=(["bg-gray-600", "pointer-events-none"], move || disabled.get()) class=(["bg-gray-600", "pointer-events-none"], move || disabled.get())
r#type=button_type r#type=button_type
disabled=move || disabled.get() disabled=move || disabled.get()
> >
{ children() } {children()}
</button> </button>
} }
} }
+11 -7
View File
@@ -27,7 +27,7 @@ pub fn DashboardButton(
#[prop(into)] #[prop(into)]
description: String, description: String,
/// The file path or URL of the icon displayed at the top of the button. /// The file path or URL of the icon displayed at the top of the button.
#[prop(into)] #[prop(optional, into)]
icon: String, icon: String,
/// The route that the user is navigated to when clicking the button. /// The route that the user is navigated to when clicking the button.
#[prop(into)] #[prop(into)]
@@ -37,22 +37,26 @@ pub fn DashboardButton(
let on_click = move |_| navigate(&path, Default::default()); let on_click = move |_| navigate(&path, Default::default());
let icon = StoredValue::new(icon);
view! { view! {
<button <button
type="button" type="button"
class="dashboard-button flex items-start gap-3 rounded-lg bg-white border-grey-300 p-6 class="dashboard-button flex items-start gap-3 rounded-lg bg-white border-grey-300 p-6
hover:bg-gray-100 transition cursor-pointer w-full text-left hover:bg-gray-100 transition cursor-pointer w-full text-left
shadow-[inset_0_0_6px_rgba(0,0,0,0.12)]" shadow-[inset_0_0_6px_rgba(0,0,0,0.12)]"
on:click=on_click on:click=on_click
> >
<div class="flex flex-col"> <div class="flex flex-col">
<img src=icon.clone() class="h-8 w-8" alt="icon" /> <Show when=move || !icon.get_value().is_empty()>
<h3 class="font-semibold text-base">{title.clone()}</h3> <img src=icon.get_value() class="h-8 w-8" alt="icon" />
</Show>
<h3 class="font-semibold text-base">{title.clone()}</h3>
<p class="text-sm text-gray-600 pt-6">{description.clone()}</p> <p class="text-sm text-gray-600 pt-6">{description.clone()}</p>
</div> </div>
</button> </button>
}.into_any() }
.into_any()
} }
+253
View File
@@ -0,0 +1,253 @@
use crate::common::{SchemaContainerStyle, SchemaHeaderStyle, SchemaNode, SchemaType, ValueType};
use leptos::prelude::*;
use std::collections::HashMap;
#[component]
pub fn DataDisplay(
/// The schema that this component should use to display the given data.
#[prop(into)]
schema: Signal<SchemaNode>,
/// The starting key in the data map.
#[prop(into, optional)]
data_member: Signal<String>,
/// A `HashMap` containing all the data that should be displayed.
#[prop(into)]
data_map: Signal<HashMap<String, ValueType>>,
) -> impl IntoView {
// The schema should contain information about the header type and styling, along with the
// information display. This is rendered as part of the DataDisplayWrapper, which is simply a
// wrapper around the information to be displayed.
// The children are all SchemaNodes as well, meaning that this component needs to call itself
// for each of these children. They will of course contain all the same information that we
// expect here.
// The actual displayed information is controlled through a DataDisplayView, which will accept
// settings for how the information is parsed. For example, rendering a list may be done by
// a string join operation, or it may be a list of maps, each of which needs to be displayed with
// a specific set of children. The SchemaNode must be able to handle this, and these options
// should be given to the DataDisplayView somehow.
// As the display_value here is actually an Option<ValueType>, we want to be able to handle cases
// where the display is given no values. In this case, the SchemaNode needs to specify whether
// a missing value is expected or an error.
let display_value = Memo::new(move |_| {
let member = data_member.get();
if member.is_empty() {
Some(ValueType::Map(Some(data_map.get())))
} else {
data_map.get().get(&member).cloned()
}
});
view! {
<DataDisplayWrapper
container_style=schema.get().container_style
header_style=schema.get().header_style
header_text=schema.get().header
>
<DataDisplayView display_data=display_value schema=schema />
</DataDisplayWrapper>
}
}
#[component]
fn DataDisplayWrapper(
#[prop(into)] container_style: SchemaContainerStyle,
#[prop(into)] header_style: SchemaHeaderStyle,
#[prop(into)] header_text: Signal<String>,
children: Children,
) -> impl IntoView {
// This component takes a wrapper style and displays the "chrome" of the interface. It has two
// options: Text Headers or Capsules. This will then render a DataDisplayHeader, which handles
// the styling for the text inside the header specifically.
// The capsule style is a colored header and a rounded container.
// The text header style is an invisible container.
let expanded = RwSignal::new(true);
match container_style {
SchemaContainerStyle::Header => {
// Creates a simple container that only has a header and a series of children.
// The spacing between the header and children is controlled by the header style.
// This type of container is not collapsable at the moment.
view! {
<div class="flex flex-col w-full text-left">
<div class="self-start w-full">
<DataDisplayHeader header_text=header_text header_style=header_style />
<div class="p-2 flex flex-col gap-1">{children()}</div>
</div>
</div>
}
.into_any()
}
SchemaContainerStyle::Capsule => {
// Creates a capsule container - it is collapsable, with rounded corners and a colored
// header.
view! {
<div class="self-stretch rounded-lg shadow-lg flex flex-col w-full text-center">
<div
class="p-2 px-3 rounded-t-lg cursor-pointer bg-red-800 text-white"
class=(["rounded-lg"], move || !expanded.get())
on:click=move |_| expanded.update(|b| *b = !*b)
>
<DataDisplayHeader header_text=header_text header_style=header_style />
</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 flex flex-col gap-1 flex-1">{children()}</div>
</div>
</div>
</div>
}
.into_any()
}
}
}
#[component]
fn DataDisplayHeader(
#[prop(into)] header_style: SchemaHeaderStyle,
#[prop(into)] header_text: Signal<String>,
) -> impl IntoView {
// This component will take a header style and displays the corresponding text. It has a few
// options, all of which are defined in the HeaderStyle enum.
match header_style {
SchemaHeaderStyle::MainHeader => {
view! { <div class="text-3xl font-bold my-2">{header_text}</div> }.into_any()
}
SchemaHeaderStyle::SubsectionHeader => {
view! { <div class="text-xl font-bold">{header_text}</div> }.into_any()
}
SchemaHeaderStyle::Bold => view! { <div class="font-bold">{header_text}</div> }.into_any(),
SchemaHeaderStyle::None => view! {}.into_any(),
}
}
#[component]
fn DataDisplayView(
#[prop(into)] display_data: Signal<Option<ValueType>>,
#[prop(into)] schema: Signal<SchemaNode>,
) -> impl IntoView {
// This is the part of the data display that does most of the work. It will take the values
// found in the DataDisplay component and perform the logic that actually displays the
// information. This component is only displayed if there is Some(value) in the DataDisplay.
// In the case that this is displaying a Map or List, it will recursively call the DataDisplay
// component.
// If this is displaying a PrimitiveList, it will flatten and join all the values into a single
// string, then display that string.
let data_type = schema.get().data_type;
// Matches the specific data type. Displays errors if the given display data does not match.
match data_type {
SchemaType::String => {
let value = Signal::derive(move || {
display_data
.get()
.map(|v| v.as_string().ok().flatten())
.flatten()
.unwrap_or("N/A".to_string())
});
view! { <div>{value}</div> }.into_any()
}
SchemaType::Number => {
let value = Signal::derive(move || {
display_data
.get()
.map(|v| v.as_number().ok().flatten())
.flatten()
.unwrap_or("N/A".to_string())
});
view! { <div>{value}</div> }.into_any()
}
SchemaType::PrimitiveList => {
let value = Signal::derive(move || {
display_data
.get()
.map(|v| v.as_list().ok().flatten())
.flatten()
.unwrap_or_default()
.iter()
.map(|v| v.to_string())
.collect::<Vec<String>>()
.join(", ")
});
view! { <div>{value}</div> }.into_any()
}
SchemaType::MapList => {
let list = Signal::derive(move || {
display_data
.get()
.map(|v| v.as_list().ok().flatten())
.flatten()
.unwrap_or_default()
.iter()
.filter_map(|v| v.as_map().ok().flatten().clone())
.collect::<Vec<HashMap<String, ValueType>>>()
});
let primary_key = StoredValue::new(schema.get().primary_key.unwrap_or_default());
view! {
<Show
when=move || !list.get().is_empty()
fallback=|| view! { <div class="w-full text-center">"List is empty."</div> }
>
<For
each=move || list.get()
key=move |map| {
map.get(&primary_key.get_value())
.map(|v| v.to_string())
.unwrap_or_default()
}
children=move |map| {
view! {
<DataDisplay
schema=*schema.get().item_template.unwrap_or_default()
data_map=map
/>
}
}
/>
</Show>
}
.into_any()
}
SchemaType::Map => {
let map = Signal::derive(move || {
display_data
.get()
.map(|v| v.as_map().ok().flatten())
.flatten()
});
view! {
<Show
when=move || map.get().is_some()
fallback=|| view! { <div class="text-red-500">"Map was not found."</div> }
>
<For
each=move || schema.get().children
key=|(key, _)| key.clone()
children=move |(key, schema)| {
view! {
<DataDisplay
schema=schema
data_member=key
data_map=Signal::derive(move || map.get().unwrap_or_default())
/>
}
}
/>
</Show>
}
.into_any()
}
}
}
+231
View File
@@ -0,0 +1,231 @@
use crate::pages::api::files::{DeleteFile, upload_file};
use leptos::html::{Input, Label};
use leptos::logging::{debug_error, debug_log};
use leptos::prelude::*;
use leptos::web_sys::{File, FormData};
use leptos_icons::Icon;
use leptos_oidc::AuthSignal;
use leptos_use::{UseDropZoneOptions, UseDropZoneReturn, use_drop_zone_with_options};
#[component]
pub fn FileDrop(
/// The name of this input. Used in file organization.
#[prop(into)]
name: String,
/// The ID of the form containing this input. Used in file organization.
#[prop(into)]
form_id: String,
/// The types that a file is allowed to be. Must be specified as the desired file extension (i.e.
/// `.pdf`, `.txt`, etc.)
#[prop(optional)]
allowed_types: Vec<String>,
/// A [`Resource`] that gives the currently uploaded files for this input.
#[prop()]
existing_files: Resource<Result<Vec<String>, ServerFnError>>,
) -> impl IntoView {
let zone_ref = NodeRef::<Label>::new();
let input_ref = NodeRef::<Input>::new();
let file_name_list = RwSignal::new(Vec::new());
let hovering = RwSignal::new(false);
let error_msg = RwSignal::new(String::new());
let name = StoredValue::new(name);
let form_id = StoredValue::new(form_id);
let auth = expect_context::<AuthSignal>();
let upload_action = Action::new(|form_data: &FormData| {
let form_data = form_data.clone();
async move { upload_file(form_data.into()).await }
});
let delete_action = ServerAction::<DeleteFile>::new();
let uploading = upload_action.pending();
let deleting = delete_action.pending();
let verify_file_type = move |file_name: String| {
if !allowed_types.is_empty() {
let verified = allowed_types
.iter()
.any(|file_ext| file_name.ends_with(file_ext));
if !verified {
debug_log!("Blocking file with name {file_name}");
} else {
debug_log!("Allowing file with name {file_name}");
}
verified
} else {
true
}
};
let upload_files = Callback::new(move |files: Vec<File>| {
let form_data = FormData::new().unwrap();
files.iter().for_each(|file| {
form_data
.append_with_blob_and_filename(&name.get_value(), file, &file.name())
.unwrap();
});
form_data
.append_with_str("form_id", form_id.get_value().as_str())
.unwrap();
let token = auth
.try_with(|a| {
a.authenticated()
.map(|authenticated| authenticated.access_token())
})
.flatten();
if let Some(access_token) = token {
form_data
.append_with_str("access_token", access_token.as_str())
.unwrap()
}
// Upload file to server.
upload_action.dispatch(form_data);
});
let UseDropZoneReturn {
is_over_drop_zone, ..
} = use_drop_zone_with_options(
zone_ref,
UseDropZoneOptions::default()
.on_over(move |_| hovering.set(true))
.on_drop(move |ev| {
debug_log!("Received dropped file. List has length {}", ev.files.len());
ev.files
.iter()
.for_each(|file| upload_files.run(vec![file.to_owned()]));
}),
);
let on_input_change = move |_| {
debug_log!("Running on:change");
// This runs when users select a file by clicking on the element. We aren't going
// to link the file list with this input except to update it with new information.
let files = input_ref
.get()
.and_then(|input| input.files())
.map(|list| {
(0..list.length())
.filter_map(|i| list.item(i))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let verified_files = files
.iter()
.filter_map(|file| verify_file_type(file.name().to_string()).then(|| file.to_owned()))
.collect::<Vec<File>>();
// input_ref.try_update(|input| {
// if let Some(input) = input {
// input.set_files(None);
// }
// });
if !verified_files.is_empty() {
upload_files.run(verified_files);
}
};
let on_click_file_delete = move |file_name: String| {
let access_token = auth
.try_with(|a| a.authenticated().map(|a| a.access_token()))
.flatten();
delete_action.dispatch(DeleteFile {
form_id: form_id.get_value(),
input_name: name.get_value(),
access_token: access_token.unwrap_or_default(),
file_name,
});
};
// Add file names to the list when upload_action is successful.
Effect::new(move || {
if let Some(Ok(uploaded_file_name)) = upload_action.value().get() {
file_name_list.update(|list| {
list.iter()
.find(|v| **v == uploaded_file_name)
.is_none()
.then(|| list.push(uploaded_file_name));
});
}
});
// Remove file name from the list when delete_action is successful.
Effect::new(move || {
if let Some(Ok(deleted_file_name)) = delete_action.value().get() {
file_name_list.update(|list| {
list.retain(|f| *f != deleted_file_name);
});
}
});
view! {
{move || {
existing_files
.get()
.map(|result| {
match result {
Ok(files) => {
error_msg.set("".to_string());
file_name_list.set(files);
}
Err(err) => {
let msg = err.to_string();
debug_error!("Error while getting files: {}", msg);
error_msg.set(msg);
}
}
});
}}
<label
node_ref=zone_ref
class="m-1.5 p-2.5 flex flex-col transition-color duration-200 rounded-lg border-2 items-center cursor-pointer"
class=(["border-gray-400"], move || !is_over_drop_zone.get())
class=(["border-blue-500"], move || is_over_drop_zone.get())
>
<div
class="m-2 mb-4 transition-color duration-200 size-10"
class=("text-gray-400", move || !is_over_drop_zone.get())
class=("text-blue-500", move || is_over_drop_zone.get())
>
<Icon icon=icondata::FaFileCirclePlusSolid width="2.5rem" height="2.5rem" />
</div>
<div>"Drop a file here to upload"</div>
<div class="text-sm text-gray-400">"Or click to select a file"</div>
<input node_ref=input_ref type="file" class="hidden" on:change=on_input_change />
</label>
<Show when=move || !error_msg.get().is_empty()>
<div class="flex-1">{error_msg}</div>
</Show>
<For
each=move || file_name_list.get()
key=|file| file.clone()
children=move |file| {
let file = StoredValue::new(file);
let on_click = move |_| {
on_click_file_delete(file.get_value());
};
view! {
<div class="m-1.5 flex flex-row relative items-center">
<div class="flex-1">{file.get_value()}</div>
<div
class="text-gray-400 hover:text-red-700 right-0 cursor-pointer"
on:click=on_click
>
<Icon icon=icondata::FaTrashCanRegular />
</div>
</div>
}
}
/>
}
}
+7 -9
View File
@@ -1,16 +1,14 @@
use leptos::prelude::*; use crate::components::login::setup::provide_auth_context;
use crate::components::login::setup::provide_auth_context; use leptos::prelude::*;
#[component] #[component]
pub fn AdminLoginContext( pub fn AdminLoginContext(children: Children) -> impl IntoView {
children: Children
) -> impl IntoView {
provide_auth_context( provide_auth_context(
crate::utils::use_origin(), crate::utils::use_origin(),
"https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Lfjuy5zaM", "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_rvCU4Xy4j",
"56c2bqvl021rv8d5mq36blt7jv", "1vh1q994rid6cgi6tjf58r9jp4",
"/providers/callback" "/admin/callback",
); );
view! { {children()} } view! { {children()} }
} }
+10 -8
View File
@@ -1,22 +1,24 @@
mod action_button; mod action_button;
mod banner; mod banner;
mod dashboard_button; mod dashboard_button;
mod data_display;
mod date; mod date;
mod file_drop;
mod header;
mod lists;
mod loading; mod loading;
pub mod login;
mod multi_entry; mod multi_entry;
mod panel; mod panel;
mod row; mod row;
mod tabs;
mod text_field; mod text_field;
mod toasts;
mod utils; mod utils;
mod validated_form; mod validated_form;
mod lists;
mod toasts;
mod header;
pub mod login;
mod tabs;
pub use self::{ pub use self::{
action_button::*, banner::*, dashboard_button::*, lists::*, action_button::*, banner::*, dashboard_button::*, data_display::*, date::*, file_drop::*,
loading::*, multi_entry::*, panel::*, row::*, text_field::*, header::*, lists::*, loading::*, multi_entry::*, panel::*, row::*, tabs::*, text_field::*,
validated_form::*, toasts::*, header::*, date::*, tabs::* toasts::*, validated_form::*,
}; };
+40 -32
View File
@@ -2,6 +2,7 @@ use crate::common::ValueType;
use leptos::prelude::*; use leptos::prelude::*;
use std::collections::HashMap; use std::collections::HashMap;
use leptos::html::Input; use leptos::html::Input;
use leptos::logging::debug_log;
use crate::components::{use_validation_context, InputState}; use crate::components::{use_validation_context, InputState};
use crate::components::validated_form::ValidationState; use crate::components::validated_form::ValidationState;
@@ -28,6 +29,8 @@ pub enum TextFieldType {
fn validate(required: bool, value: &str, input_type: &TextFieldType) -> ValidationState { fn validate(required: bool, value: &str, input_type: &TextFieldType) -> ValidationState {
if required && value.is_empty() { if required && value.is_empty() {
ValidationState::Invalid("This field is required.".to_string()) ValidationState::Invalid("This field is required.".to_string())
} else if !required && value.is_empty() {
ValidationState::Valid
} else { } else {
match input_type { match input_type {
TextFieldType::Email(domains) => validate_email(value, domains), TextFieldType::Email(domains) => validate_email(value, domains),
@@ -54,7 +57,7 @@ fn validate_email(input: &str, valid_domains: &Vec<String>) -> ValidationState {
/// Validates a number. /// Validates a number.
fn validate_number(input: &str) -> ValidationState { fn validate_number(input: &str) -> ValidationState {
if input.parse::<i32>().is_ok() { if input.parse::<f32>().is_ok() {
ValidationState::Valid ValidationState::Valid
} else { } else {
ValidationState::Invalid("Value is not a valid number.".to_string()) ValidationState::Invalid("Value is not a valid number.".to_string())
@@ -64,7 +67,7 @@ fn validate_number(input: &str) -> ValidationState {
#[component] #[component]
pub fn OutlinedTextField( pub fn OutlinedTextField(
#[prop(optional, into)] placeholder: String, #[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()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop(optional, into)] input_type: Signal<TextFieldType>, #[prop(optional, into)] input_type: Signal<TextFieldType>,
#[prop(optional, into)] disabled: Signal<bool>, #[prop(optional, into)] disabled: Signal<bool>,
@@ -73,30 +76,28 @@ pub fn OutlinedTextField(
#[prop(optional, into)] required: Signal<bool> #[prop(optional, into)] required: Signal<bool>
) -> impl IntoView { ) -> impl IntoView {
let input_ref = NodeRef::<Input>::new(); let input_ref = NodeRef::<Input>::new();
let data_member = StoredValue::new(data_member);
// Register this input's validation signal. // Register this input's validation signal.
let validator_context = use_validation_context() let validator_context = use_validation_context()
.expect("FormValidSignal was not found"); .expect("FormValidSignal was not found");
let raw_value = Signal::derive({ let display_value = RwSignal::new(
let data_member = data_member.clone(); data_map.get_untracked()
move || .get(&data_member.get_value())
data_map .map(|v| v.to_string())
.get() .unwrap_or_default()
.get(&data_member) );
.unwrap_or(&ValueType::String(None))
.to_string()
});
let dirty = RwSignal::new(false); let dirty = RwSignal::new(false);
let error = Signal::derive(move || 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 || { let show_errors = Signal::derive(move || {
dirty.get() && matches!(error.get(), ValidationState::Invalid(_)) 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)); validator_context.validators.update(|list| list.push(validator));
@@ -106,35 +107,42 @@ pub fn OutlinedTextField(
}); });
}); });
let str_type = Signal::derive(move || match input_type.get_untracked() {
TextFieldType::Number => "number",
_ => "text" // This is fine even for emails, as we're doing our own validation.
});
let on_blur = move |_| { let on_blur = move |_| {
// Check validity of input's current value. The value will be updated by on_input, // 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). // and it should be a raw input (not parsed into a ValueType).
dirty.set(true); 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 // This function parses the input into the correct type. It only accepts numbers or strings as
// valid types, and parses them accordingly. // valid types, and parses them accordingly.
let on_input = { let on_input = move |e| {
let data_member = data_member.clone(); let to_parse = event_target_value(&e);
move |e| { debug_log!("Setting value to display_value: {}", to_parse);
let to_parse = event_target_value(&e); 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() { let into_map = match input_type.get() {
TextFieldType::Number => ValueType::Number(Some(to_parse)), TextFieldType::Number => ValueType::Number(Some(to_parse)),
_ => ValueType::String(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 map.insert(data_member.get_value(), into_map);
// 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);
});
}
}; };
view! { view! {
@@ -147,11 +155,11 @@ pub fn OutlinedTextField(
transition-all duration-150 transition-all duration-150
border-red-700 bg-transparent border-red-700 bg-transparent
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33" disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
r#type=str_type r#type="text"
disabled=disabled disabled=disabled
placeholder=placeholder placeholder=placeholder
prop:name=name prop:name=name
prop:value=raw_value prop:value=display_value
on:input=on_input on:input=on_input
on:blur=on_blur on:blur=on_blur
/> />
+3
View File
@@ -111,6 +111,8 @@ pub fn ValidatedForm(
#[prop(into)] title: Signal<String>, #[prop(into)] title: Signal<String>,
/// The description for the included header. /// The description for the included header.
#[prop(optional, into)] description: Signal<String>, #[prop(optional, into)] description: Signal<String>,
/// A `Signal` that determines when the submit button should be disabled.
#[prop(optional, into)] disabled: Signal<bool>,
) -> impl IntoView { ) -> impl IntoView {
let validators = RwSignal::new(vec![]); let validators = RwSignal::new(vec![]);
@@ -154,6 +156,7 @@ pub fn ValidatedForm(
<Header title=title description=description /> <Header title=title description=description />
{children()} {children()}
<ActionButton <ActionButton
disabled=disabled
on:click=move |_| { on:click=move |_| {
log!("{:?}", can_submit.get()); log!("{:?}", can_submit.get());
} }
+577
View File
@@ -0,0 +1,577 @@
use crate::common::{
ComparisonData, ExpandableInfo, SchemaContainerStyle, SchemaHeaderStyle, SchemaNode,
SchemaType, ValueType,
};
use crate::components::{ActionButton, Banner, DataDisplay, Loading};
use crate::pages::{StudentInformationDialog, UnauthenticatedPage};
use crate::pages::api::get_comparison_info;
use crate::pages::api::students::{GetStudentFiles, admin_get_all_input_files, get_student_data};
use base64::Engine;
use leptos::ev::{Event, MouseEvent};
use leptos::html::Dialog;
use leptos::logging::debug_log;
use leptos::prelude::*;
use leptos::wasm_bindgen::JsCast;
use leptos::web_sys;
use leptos::web_sys::{HtmlAnchorElement, ScrollBehavior};
use leptos_icons::Icon;
use leptos_oidc::{AuthLoaded, AuthSignal, Authenticated};
use leptos_router::components::Outlet;
use leptos_router::hooks::use_navigate;
use std::collections::HashMap;
use leptos_router::NavigateOptions;
#[component]
pub fn AdminApplicantsPageFallback() -> impl IntoView {
view! {
<div class="flex flex-col flex-1 gap-2">
<h1 class="flex-1 text-center font-bold text-2xl">"Scholarship Applicants"</h1>
<p class="text-center text-lg">
"Please select a scholarship from the left to view the eligible applicants."
</p>
</div>
}
}
#[component]
pub fn AdminApplicantsPageShell() -> impl IntoView {
// This shell needs to simply contain everything that will stay consistent across paths.
// The path will change to contain each scholarship's id, which will directly affect the content
// shown in a subroute for this page.
// As a result, this page likely only needs to contain the banner and the list of scholarships.
// The content will be shown within the right side, but only as a result of some subroutes,
// each of which is still considered a single page.
view! {
<div class="flex-1" />
<div class="flex-3 flex flex-row items-start">
<AdminApplicantsScholarshipList />
<div class="flex-3 text-center">
<Outlet />
</div>
</div>
<div class="flex-1" />
}
}
#[component]
fn AdminApplicantsScholarshipList() -> impl IntoView {
use crate::common::ExpandableInfo;
#[server]
async fn admin_get_provider_scholarships(
access_token: String,
) -> Result<Vec<ExpandableInfo>, ServerFnError> {
use crate::pages::api::SCHOLARSHIPS_TABLE;
use crate::pages::api::tokens::validate_and_get_token_info;
let claims = validate_and_get_token_info(access_token, "us-east-1_rvCU4Xy4j", "us-east-1").await?;
// Get the information from the database.
let client = crate::utils::server::create_dynamo_client().await;
let output = client
.scan()
.table_name(SCHOLARSHIPS_TABLE)
.send()
.await?
.items
.unwrap_or_default()
.into_iter()
.filter_map(|item| serde_dynamo::from_item(item).ok())
.collect::<Vec<ExpandableInfo>>();
Ok(output)
}
// Use access token for user identification
let auth = expect_context::<AuthSignal>();
let access_token =
Memo::new(move |_| auth.with(|auth| auth.authenticated().map(|a| a.access_token())));
// We need to get the scholarships from the API. We don't need the whole scholarship, just the
// name and ID.
let trigger = Trigger::new();
let scholarships_res = Resource::new(
move || (trigger.track(), access_token.get()),
async move |(_, access_token)| {
admin_get_provider_scholarships(access_token.unwrap_or_default()).await
},
);
let navigate = use_navigate();
// This closure returns another closure that's using the correct scholarship ID.
// It avoids having to define it within the Transition component. It's wrapped in a Callback
// because this makes it Send + Sync.
let on_view_click = Callback::new(move |scholarship_id: String| {
let path = format!("/admin/applicants/{}", scholarship_id);
navigate(&*path, Default::default());
});
view! {
<div class="flex flex-col flex-1 rounded-md shadow-lg/33 p-2 gap-3">
<h2 class="text-xl font-bold flex-1 text-center">"Scholarships"</h2>
<Transition fallback=Loading>
{move || {
scholarships_res
.get()
.map(|items_res| {
let items = match items_res {
Ok(items) => items,
Err(e) => {
return view! {
<div>
{format!("Couldn't get scholarships: {}", e.to_string())}
</div>
}
.into_any();
}
};
view! {
<For
each=move || items.clone()
key=|item| item.subject.clone()
let(item)
>
<ApplicantsScholarshipEntry
item=item
on_view_click=on_view_click
/>
</For>
}
.into_any()
})
.collect_view()
}}
</Transition>
</div>
}
}
#[component]
fn ApplicantsScholarshipEntry(
#[prop()] item: ExpandableInfo,
#[prop(into)] on_view_click: Callback<String, ()>,
) -> impl IntoView {
// Store the item's subject.
let subject = StoredValue::new(item.subject);
let scholarship_name = Memo::new(move |_| {
item.data
.get("name")
.map(|name| name.as_string().ok().flatten())
.flatten()
.unwrap_or("<unnamed scholarship>".to_string())
});
let on_click = move |_| {
on_view_click.run(subject.get_value());
};
view! {
<div class="flex flex-col rounded-md shadow-md hover:shadow-lg/33 transition-shadow">
<div class="text-center p-2">{scholarship_name}</div>
<div
class="flex-1 font-bold text-white rounded-b-md text-center cursor-pointer bg-red-800 hover:bg-red-900 p-2 transition-bg"
on:click=on_click
>
"View Applicants"
</div>
</div>
}
}
#[component]
pub fn AdminApplicantsStudentList() -> impl IntoView {
use crate::pages::api::get_scholarship_info;
use crate::pages::api::students::admin_get_completed_students;
use leptos_router::hooks::use_params;
use leptos_router::params::Params;
/// The parameters for the page that displays this component. This may be moved to the page-level
/// rather than the component-level.
#[derive(Params, PartialEq)]
struct StudentListParams {
scholarship_id: Option<String>,
}
// This is in charge of finding all student information and running the comparisons.
// It will then display all the eligible students.
let params = use_params::<StudentListParams>();
let auth = expect_context::<AuthSignal>();
let access_token =
Memo::new(move |_| auth.with(|a| a.authenticated().map(|a| a.access_token())));
let scholarship_id = Memo::new(move |_| {
params
.read()
.as_ref()
.ok()
.and_then(|params| params.scholarship_id.clone())
});
#[server]
async fn get_eligibility_info(
token: String,
scholarship_id: String,
) -> Result<
(
HashMap<String, HashMap<String, ValueType>>,
ExpandableInfo,
Vec<ComparisonData>,
),
ServerFnError,
> {
let (students, scholarships, requirements_list) = tokio::join!(
admin_get_completed_students(token),
get_scholarship_info(scholarship_id),
get_comparison_info()
);
Ok((students?, scholarships?, requirements_list?))
}
let resource = Resource::new(
move || (access_token.get(), scholarship_id.get()),
move |(access_token, scholarship_id)| async move {
let Some(token) = access_token else {
return Err(ServerFnError::new("Couldn't find access token"));
};
let Some(scholarship_id) = scholarship_id else {
return Err(ServerFnError::new("Couldn't find scholarship ID"));
};
get_eligibility_info(token, scholarship_id).await
},
);
view! {
<div class="flex flex-col flex-1">
<Suspense fallback=Loading>
{move || {
resource
.get()
.map(|result| {
match result {
Ok((students, scholarship, requirements_list)) => {
view! {
<h1 class="text-2xl font-bold">"Eligible Students"</h1>
<div class="text-lg">
"Click on a student's name to view their information and download any requested files."
</div>
<div class="text-lg">
"If your scholarship requires a transcript, please send an email to Sara Smith at "
<a
class="text-blue-500 underline"
href="mailto:ssmith@region15.org"
>
"ssmith@region15.org"
</a>"."
</div>
<AdminApplicantsStudentListView
access_token=access_token
students=students
scholarship=scholarship
requirements=requirements_list
/>
}
.into_any()
}
Err(e) => {
view! {
<div>
"Error while getting eligible students: "{e.to_string()}
</div>
}
.into_any()
}
}
})
}}
</Suspense>
</div>
}
}
#[component]
fn AdminApplicantsStudentListView(
#[prop(into)] access_token: Signal<Option<String>>,
#[prop(into)] students: Signal<HashMap<String, HashMap<String, ValueType>>>,
#[prop(into)] scholarship: Signal<ExpandableInfo>,
#[prop(into)] requirements: Signal<Vec<ComparisonData>>,
) -> impl IntoView {
let eligible_students = RwSignal::new(HashMap::new());
let error_msg = RwSignal::new(String::new());
// let (current_student_id, set_current_student_id) = query_signal::<String>("student_id");
let current_student_id = RwSignal::new(None);
let navigate_student = Callback::new(move |id: String| {
let options = web_sys::ScrollToOptions::new();
options.set_top(0.0);
options.set_behavior(ScrollBehavior::Smooth);
if let Some(window) = web_sys::window() {
window.scroll_to_with_scroll_to_options(&options);
}
current_student_id.set(Some(id));
});
let dialog_visible =
Signal::derive(move || !current_student_id.get().unwrap_or_default().is_empty());
let on_dialog_close = Callback::new(move |_| {
current_student_id.set(None);
});
let fafsa_required = Memo::new(move |_| {
let required = scholarship
.get()
.data
.get("fafsa_required")
.map(|v| v.as_string().ok().flatten())
.flatten()
.unwrap_or_default();
required == "Yes"
});
let essay_required = Memo::new(move |_| {
let prompt = scholarship
.get()
.data
.get("essay_prompt")
.map(|v| v.as_string().ok().flatten())
.flatten()
.unwrap_or_default();
!prompt.is_empty()
});
let fafsa_resource = Resource::new(
move || (fafsa_required.get(), access_token.get()),
move |(fafsa_required, access_token)| async move {
// Only get this information if the FAFSA is required. Otherwise, return a blank result.
let Some(access_token) = access_token else {
return Err(ServerFnError::new("Couldn't find access token"));
};
if !fafsa_required {
return Ok(HashMap::new());
}
admin_get_all_input_files(
access_token,
"financial_info".to_string(),
"fafsa".to_string(),
)
.await
},
);
let essay_resource = Resource::new(
move || (essay_required.get(), access_token.get()),
move |(essay_required, access_token)| async move {
// Only get this information if the scholarship requested it. Otherwise, return a blank
// result.
let Some(access_token) = access_token else {
return Err(ServerFnError::new("Couldn't find access token"));
};
if !essay_required {
return Ok(HashMap::new());
}
admin_get_all_input_files(
access_token,
"scholarship_essays".to_string(),
scholarship.get().subject,
)
.await
},
);
fn get_eligible_students(
students: HashMap<String, HashMap<String, ValueType>>,
scholarship: ExpandableInfo,
requirements: Vec<ComparisonData>,
essay_required: bool,
fafsa_required: bool,
fafsa_list: HashMap<String, Vec<String>>,
essay_list: HashMap<String, Vec<String>>,
) -> Result<HashMap<String, HashMap<String, ValueType>>, Error> {
// Get the full map of requirements. Each key contains a list of IDs.
let scholarship_requirements = scholarship
.data
.get("requirements")
.map(|val| val.as_map().ok()?)
.flatten()
.unwrap_or_default();
// Get the lists of requirements. Maps into string IDs and discards invalid ValueTypes.
let requirement_lists = scholarship_requirements
.into_iter()
.filter_map(|(_, list_val)| {
let list = list_val.as_list().ok().flatten()?;
let string_list = list
.into_iter()
.filter_map(|v| v.as_string().ok().flatten())
.collect::<Vec<String>>();
Some(string_list)
})
.collect::<Vec<Vec<String>>>();
// Resolve the requirement IDs. Discards all IDs that do not resolve.
let resolved_requirements = requirement_lists
.into_iter()
.map(|list| {
list.into_iter()
.filter_map(|id| {
requirements
.iter()
.cloned()
.find(|requirement| requirement.id == id)
})
.collect::<Vec<ComparisonData>>()
})
.collect::<Vec<Vec<ComparisonData>>>();
// Check student eligibility. These are all students that have completed the demographics
// forms.
let eligible_students = students
.into_iter()
.filter_map(|(id, student)| {
// The student needs to pass one requirement from each list.
// The easiest ones to check first are whether the scholarship requires a student
// essay and/or their fafsa information. If the student does not have these things,
// they are skipped.
if fafsa_required && !fafsa_list.contains_key(&id) {
// Get the student files related to their fafsa and continue. Return false if not there.
debug_log!(
"Student with id {id} failed - FAFSA is required but was not found."
);
return None;
}
if essay_required && !essay_list.contains_key(&id) {
// Get the student's files related to their essays and continue. Return false if not there.
debug_log!(
"Student with id {id} failed - essay is required but was not found."
);
return None;
}
let result = resolved_requirements.iter().all(|list| {
if list.is_empty() {
return true;
}
list.iter().any(|requirement| {
let result = requirement.compare(&student).unwrap_or(false);
if !result {
debug_log!(
"Student with id {id} failed - requirement with id {} failed.",
requirement.id
);
}
result
})
});
if result { Some((id, student)) } else { None }
})
.collect::<HashMap<String, HashMap<String, ValueType>>>();
// debug_log!("Total number of eligible students: {}", eligible_students.len());
Ok(eligible_students)
}
Effect::new(move || {
// We have to wait until we've gotten fafsa/essay requirements.
debug_log!("Waiting on essay and FAFSA resources...");
if let (Some(fafsa_list), Some(essay_list)) = (fafsa_resource.get(), essay_resource.get()) {
debug_log!(
"Running eligibility check on {} students",
students.get().len()
);
let eligible_res = get_eligible_students(
students.get(),
scholarship.get(),
requirements.get(),
essay_required.get(),
fafsa_required.get(),
fafsa_list.unwrap_or_default(),
essay_list.unwrap_or_default(),
);
match eligible_res {
Ok(list) => {
debug_log!("Total number of eligible students: {}", list.len());
eligible_students.set(list);
}
Err(e) => error_msg.set(format!("Failed to get eligible students list: {e}")),
}
}
});
// This is actually just a display of all the information from the students list.
// As defined in the ApplicantsStudentList component, this will already be wrapped in a
// flex container.
view! {
<StudentInformationDialog
scholarship_id=scholarship.get().subject
student_id=current_student_id
visible=dialog_visible
on_close=on_dialog_close
essay_required=essay_required
fafsa_required=fafsa_required
/>
<Show when=move || !error_msg.get().is_empty()>
<div>"Error checking student eligibility: "{error_msg}</div>
</Show>
<div class="m-3 flex flex-col">
<div class="flex flex-row p-1 border-b-1 border-black">
<div class="pl-5 flex-1 text-lg font-bold text-left">"Last Name"</div>
<div class="pr-5 flex-1 text-lg font-bold text-left">"First Name"</div>
</div>
<For
each=move || eligible_students.get()
key=|(id, _)| id.clone()
children=move |(student_id, student)| {
let student = StoredValue::new(student);
let first_name = Memo::new(move |_| {
student
.get_value()
.get("first_name")
.map(|n| n.as_string().ok().flatten())
.unwrap_or_default()
});
let last_name = Memo::new(move |_| {
student
.get_value()
.get("last_name")
.map(|n| n.as_string().ok().flatten())
.unwrap_or_default()
});
let on_click = move |_| {
navigate_student.run(student_id.clone());
};
// For now, we'll just display each student's first and last name.
// Now I'll add a container with some links. These links will just be appended
// to the end of the current path.
view! {
<div
class="flex flex-row p-1 border-y-1 border-gray-200 cursor-pointer hover:bg-yellow-100 transition-all duration-200"
on:click=on_click
>
<div class="pl-5 flex-1 text-lg text-left">{last_name}</div>
<div class="pr-5 flex-1 text-lg text-left">{first_name}</div>
</div>
}
}
/>
</div>
}
}
+2
View File
@@ -3,9 +3,11 @@ mod home_page;
mod provider_page; mod provider_page;
mod scholarship_page; mod scholarship_page;
mod utils_page; mod utils_page;
mod applicants_list;
pub use shell::*; pub use shell::*;
pub use home_page::*; pub use home_page::*;
pub use provider_page::*; pub use provider_page::*;
pub use scholarship_page::*; pub use scholarship_page::*;
pub use utils_page::*; pub use utils_page::*;
pub use applicants_list::*;
+2 -6
View File
@@ -4,12 +4,8 @@ use leptos::prelude::*;
pub fn AdminScholarshipPage() -> impl IntoView { pub fn AdminScholarshipPage() -> impl IntoView {
view! { view! {
<div class="flex flex-1 flex-col gap-4"> <div class="flex flex-1 flex-col gap-4">
<div class="font-bold text-3xl"> <div class="font-bold text-3xl">"Scholarships View"</div>
"Scholarships View" <div>"This is a placeholder for now. It will show a list of all scholarships."</div>
</div>
<div>
"This is a placeholder for now. It will show a list of all scholarships."
</div>
</div> </div>
} }
} }
+20 -13
View File
@@ -1,7 +1,9 @@
use leptos::prelude::*; use crate::common::TabInfo;
use crate::common::TabInfo;
use crate::components::{Banner, TabSidebarList};
use crate::components::login::AdminLoginContext; use crate::components::login::AdminLoginContext;
use crate::components::{Banner, Loading, TabSidebarList};
use crate::pages::UnauthenticatedPage;
use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated};
/// # Admin Shell Component /// # Admin Shell Component
/// ///
@@ -13,16 +15,21 @@ pub fn AdminShell() -> impl IntoView {
view! { view! {
<AdminLoginContext> <AdminLoginContext>
<Banner title="R15 Scholarships Admin" logo="/PHS_Stacked_Acronym.png" /> <Banner title="R15 Scholarships Admin" logo="/PHS_Stacked_Acronym.png" />
// We'll put the tabs here. <AuthLoaded fallback=Loading>
<TabSidebarList <Authenticated unauthenticated=UnauthenticatedPage>
base_path="admin" // We'll put the tabs here.
tabs=vec![ <TabSidebarList
TabInfo::new("Home", "home", None), base_path="admin"
TabInfo::new("Providers", "providers", None), tabs=vec![
TabInfo::new("Scholarships", "scholarships", None), TabInfo::new("Home", "home", None),
TabInfo::new("Utilities", "utilities", None), TabInfo::new("Providers", "providers", None),
] TabInfo::new("Scholarships", "scholarships", None),
/> TabInfo::new("Utilities", "utilities", None),
TabInfo::new("Scholarship Applicants", "applicants", None),
]
/>
</Authenticated>
</AuthLoaded>
</AdminLoginContext> </AdminLoginContext>
} }
} }
+73 -116
View File
@@ -1,17 +1,22 @@
use chrono::DateTime; use crate::common::{DateInfo, DateRange};
use crate::components::ActionButton; use crate::components::ActionButton;
use crate::pages::api::exports::get_scholarship_csv;
use crate::pages::api::{CreateDates, CreateTestComparisons, exports::GetScholarshipCsv};
use chrono::DateTime;
use leptos::prelude::*; use leptos::prelude::*;
use crate::common::{DateInfo, DateRange}; use leptos::task::spawn_local;
use crate::pages::api::{CreateDates, CreateTestComparisons}; use leptos::wasm_bindgen::JsCast;
use leptos::web_sys::{Blob, HtmlAnchorElement, Url, js_sys};
use crate::pages::api::students::{get_student_info_json, get_student_pdf};
fn get_important_dates() -> Vec<DateInfo> { fn get_important_dates() -> Vec<DateInfo> {
vec! [ vec![
DateInfo { DateInfo {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
title: "Provider Forms Open".to_string(), title: "Provider Forms Open".to_string(),
date: DateRange::Range( date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-01-20T07:15:00-05:00").unwrap(), DateTime::parse_from_rfc3339("2026-01-20T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-03-03T23:59:00-05:00").unwrap() DateTime::parse_from_rfc3339("2026-03-03T23:59:00-05:00").unwrap(),
), ),
description: "".to_string(), description: "".to_string(),
}, },
@@ -20,7 +25,7 @@ fn get_important_dates() -> Vec<DateInfo> {
title: "Student Forms Open".to_string(), title: "Student Forms Open".to_string(),
date: DateRange::Range( date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-03-03T07:15:00-05:00").unwrap(), DateTime::parse_from_rfc3339("2026-03-03T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-03-27T14:05:00-05:00").unwrap() DateTime::parse_from_rfc3339("2026-03-27T14:05:00-05:00").unwrap(),
), ),
description: "".to_string(), description: "".to_string(),
}, },
@@ -29,7 +34,7 @@ fn get_important_dates() -> Vec<DateInfo> {
title: "Scholarship Decision Window".to_string(), title: "Scholarship Decision Window".to_string(),
date: DateRange::Range( date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-04-10T07:15:00-05:00").unwrap(), DateTime::parse_from_rfc3339("2026-04-10T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-05-01T23:59:00-05:00").unwrap() DateTime::parse_from_rfc3339("2026-05-01T23:59:00-05:00").unwrap(),
), ),
description: "Start date may change if available sooner.".to_string(), description: "Start date may change if available sooner.".to_string(),
}, },
@@ -52,127 +57,78 @@ fn get_important_dates() -> Vec<DateInfo> {
] ]
} }
#[server]
async fn create_scholarship_export() -> Result<Vec<u8>, ServerFnError> {
// Exports olarship information - specifically the name of the scholarship, the name of the
// provider, and the scholarship's requirements.
// This means we'll need to get all the relations, all the scholarships, and all the contact info.
use crate::utils::server::create_dynamo_client;
use aws_sdk_dynamodb::client::Client;
use crate::pages::api::{get_comparison_info, SCHOLARSHIPS_TABLE, PROVIDER_CONTACT_TABLE};
use std::collections::HashMap;
use crate::common::ValueType;
let scan_table_items = async |client: &Client, table_name: &str| {
client.scan()
.table_name(table_name)
.send()
.await
.ok().and_then(|res| res.items)
.unwrap_or_default()
};
#[derive(serde::Serialize, serde::Deserialize, Debug)]
struct ScholarshipInfo {
name: String,
provider_name: String,
relations: Vec<String>,
}
let client = create_dynamo_client().await;
let scholarships = dbg!(scan_table_items(&client, SCHOLARSHIPS_TABLE)
.await
.iter().map(|item| {
item.into_iter()
.map(|(k, v)| (k.clone(), ValueType::from(v)))
.collect()
}).collect::<Vec<HashMap<String, ValueType>>>()
);
let contacts = dbg!(
scan_table_items(&client, PROVIDER_CONTACT_TABLE).await
.iter().map(|item| {
item.into_iter()
.map(|(k, v)| (k.clone(), ValueType::from(v)))
.collect()
}).collect::<Vec<HashMap<String, ValueType>>>()
);
let db_relations = dbg!(
get_comparison_info().await.unwrap_or_default()
);
// Build a new structure containing some of the scholarship's information.
let resolved = scholarships.into_iter()
.map(|scholarship| {
// We just want the scholarship's name, the relations, and the provider's name.
// In here, we're going to resolve all the scholarship's information to provide this in
// a readable format.
let name = scholarship.get("name")
.unwrap_or(&ValueType::String(None))
.to_string();
// The list of relations - we're going to search the master list for each comparison's
// name. It's structured as a map of lists, or a ValueType::Map(Option<HashMap<String, ValueType::List(Option<Vec<ValueType::String()>>)>>)
let relations = scholarship.get("requirements")
.unwrap_or(&ValueType::Map(None))
.as_map().ok().flatten()
.unwrap_or_default() // We have our map of categories, now we need all the values inside each category.
.iter()
.map(|(_, list)| {
list.as_list().ok().flatten()
.unwrap_or_default()
})
.flatten()
.collect::<Vec<ValueType>>() // This is a Vec<ValueType::String>
.iter().map(|val| val.clone().as_string().ok().flatten().unwrap_or_default())
.collect::<Vec<String>>();
// The provider ID, which we'll use to search for the provider's name.
let provider_id = scholarship.get("provider_id")
.unwrap_or(&ValueType::String(None))
.to_string();
// This is a mess, we'll clean it up later.
let parsed_relations = relations
.iter()
.filter_map(|relation| {
db_relations.iter().find(|search| {
search.id == *relation
})
.and_then(|search| Some(search.display_text.clone()))
})
.collect::<Vec<String>>();
ScholarshipInfo {
name,
provider_name: provider_id,
relations: parsed_relations,
}
})
.collect::<Vec<ScholarshipInfo>>();
// TODO Convert into CSV and send to client. For now I think I'm just going to serialize to json and print it.
leptos::logging::log!("Scholarships list: {:?}", resolved);
Ok(Vec::new())
}
#[component] #[component]
pub fn AdminUtilsPage() -> impl IntoView { pub fn AdminUtilsPage() -> impl IntoView {
let create_comparisons = ServerAction::<CreateTestComparisons>::new(); let create_comparisons = ServerAction::<CreateTestComparisons>::new();
let create_dates = ServerAction::<CreateDates>::new(); let create_dates = ServerAction::<CreateDates>::new();
let create_export = ServerAction::<CreateScholarshipExport>::new(); let create_export = ServerAction::<GetScholarshipCsv>::new();
let on_click_comparisons = move |_| { let on_click_comparisons = move |_| {
create_comparisons.dispatch(CreateTestComparisons{}); create_comparisons.dispatch(CreateTestComparisons {});
}; };
let on_click_dates = move |_| { let on_click_dates = move |_| {
create_dates.dispatch(CreateDates{dates: get_important_dates()}); create_dates.dispatch(CreateDates {
dates: get_important_dates(),
});
}; };
let on_click_export = move |_| { let on_click_export = move |_| {
create_export.dispatch(CreateScholarshipExport{}); spawn_local(async move {
let file_bytes = get_scholarship_csv().await.unwrap_or_default();
let uint_arr = js_sys::Uint8Array::new_with_length(file_bytes.len() as u32);
uint_arr.copy_from(&file_bytes);
let blob_parts = js_sys::Array::new();
blob_parts.push(&uint_arr);
let blob = Blob::new_with_u8_array_sequence(&blob_parts).unwrap();
let blob_url = Url::create_object_url_with_blob(&blob).unwrap();
let window = window();
let document = window.document().unwrap();
let a: HtmlAnchorElement = document.create_element("a").unwrap().dyn_into().unwrap();
a.set_href(&blob_url);
a.set_download("scholarships.csv");
document.body().unwrap().append_child(&a).unwrap();
a.click();
document.body().unwrap().remove_child(&a).unwrap();
Url::revoke_object_url(&blob_url).unwrap();
});
};
let on_click_json = move |_| {
spawn_local(async move {
let (file_name, file_bytes) = get_student_pdf("54d82438-c051-709d-30f7-c60e97f5112a".to_string())
.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(&file_name);
document.body().unwrap().append_child(&a).unwrap();
a.click();
document.body().unwrap().remove_child(&a).unwrap();
Url::revoke_object_url(&blob_url).unwrap();
});
}; };
view! { view! {
@@ -184,6 +140,7 @@ pub fn AdminUtilsPage() -> impl IntoView {
<ActionButton on:click=on_click_comparisons>"Create Comparisons"</ActionButton> <ActionButton on:click=on_click_comparisons>"Create Comparisons"</ActionButton>
<ActionButton on:click=on_click_dates>"Create Dates"</ActionButton> <ActionButton on:click=on_click_dates>"Create Dates"</ActionButton>
<ActionButton on:click=on_click_export>"Scholarship Export"</ActionButton> <ActionButton on:click=on_click_export>"Scholarship Export"</ActionButton>
<ActionButton on:click=on_click_json>"Get student JSON"</ActionButton>
</div> </div>
</div> </div>
}.into_any() }.into_any()
+37 -44
View File
@@ -14,10 +14,9 @@ use crate::common::{
ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison, ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison,
TextComparison, TextListComparison, ValueType, TextComparison, TextListComparison, ValueType,
}; };
use std::collections::HashMap;
use leptos::prelude::ServerFnError; use leptos::prelude::ServerFnError;
use leptos::server; use leptos::server;
use std::collections::HashMap;
/// Helper function to create all sports comparisons. Only used to create initial /// Helper function to create all sports comparisons. Only used to create initial
/// comparison data, and should be removed upon completion of a comparison editor page. /// comparison data, and should be removed upon completion of a comparison editor page.
@@ -250,14 +249,18 @@ fn create_major_comparisons() -> Vec<ComparisonData> {
let ids = display_texts let ids = display_texts
.iter() .iter()
.map(|text| .map(|text| {
format!("{}_{}", "major", text.to_lowercase().replace(" ", "_").replace("/", "_")) format!(
) "{}_{}",
"major",
text.to_lowercase().replace(" ", "_").replace("/", "_")
)
})
.collect::<Vec<String>>(); .collect::<Vec<String>>();
ids.iter().zip(display_texts).map(|(id, display_text)| { ids.iter()
create_major_comparison(id, display_text) .zip(display_texts)
}) .map(|(id, display_text)| create_major_comparison(id, display_text))
.collect::<Vec<ComparisonData>>() .collect::<Vec<ComparisonData>>()
} }
@@ -280,13 +283,11 @@ fn make_comp_list() -> Vec<ComparisonData> {
"Academic Information", "Academic Information",
"Math SAT Score > 650", "Math SAT Score > 650",
); );
let service_hours_20 = ComparisonData::new( let service_hours_20 = ComparisonData::new(
"service_hours_20", "service_hours_20",
"service_hours", "service_hours",
ComparisonType::Number( ComparisonType::Number(NumberComparison::GreaterThanOrEqual),
NumberComparison::GreaterThanOrEqual
),
ValueType::Number(Some(20.to_string())), ValueType::Number(Some(20.to_string())),
"Community Service", "Community Service",
"20+ service hours", "20+ service hours",
@@ -295,9 +296,7 @@ fn make_comp_list() -> Vec<ComparisonData> {
let service_hours_25 = ComparisonData::new( let service_hours_25 = ComparisonData::new(
"service_hours_25", "service_hours_25",
"service_hours", "service_hours",
ComparisonType::Number( ComparisonType::Number(NumberComparison::GreaterThanOrEqual),
NumberComparison::GreaterThanOrEqual
),
ValueType::Number(Some(25.to_string())), ValueType::Number(Some(25.to_string())),
"Community Service", "Community Service",
"25+ service hours", "25+ service hours",
@@ -306,9 +305,7 @@ fn make_comp_list() -> Vec<ComparisonData> {
let service_hours_30 = ComparisonData::new( let service_hours_30 = ComparisonData::new(
"service_hours_30", "service_hours_30",
"service_hours", "service_hours",
ComparisonType::Number( ComparisonType::Number(NumberComparison::GreaterThanOrEqual),
NumberComparison::GreaterThanOrEqual
),
ValueType::Number(Some(30.to_string())), ValueType::Number(Some(30.to_string())),
"Community Service", "Community Service",
"30+ service hours", "30+ service hours",
@@ -334,7 +331,7 @@ fn make_comp_list() -> Vec<ComparisonData> {
let attended_bas = ComparisonData::new( let attended_bas = ComparisonData::new(
"attended_bas", "attended_bas",
"attended_bas", "attend_bas",
ComparisonType::Text(TextComparison::Matches), ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())), ValueType::String(Some("Yes".to_string())),
"Additional Eligibility Factors", "Additional Eligibility Factors",
@@ -365,7 +362,7 @@ fn make_comp_list() -> Vec<ComparisonData> {
ComparisonType::Text(TextComparison::Matches), ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())), ValueType::String(Some("Yes".to_string())),
"Additional Eligibility Factors", "Additional Eligibility Factors",
"Participated in Pomperaug Youth Baseball" "Participated in Pomperaug Youth Baseball",
); );
let aquatic_club = ComparisonData::new( let aquatic_club = ComparisonData::new(
@@ -374,25 +371,25 @@ fn make_comp_list() -> Vec<ComparisonData> {
ComparisonType::Text(TextComparison::Matches), ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())), ValueType::String(Some("Yes".to_string())),
"Additional Eligibility Factors", "Additional Eligibility Factors",
"Participated in Panthers Aquatic Club" "Participated in Panthers Aquatic Club",
); );
let pea_member = ComparisonData::new( let pea_member = ComparisonData::new(
"pea_member", "pea_member",
"pea_member", "pea_member",
ComparisonType::Text(TextComparison::Matches), ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())), ValueType::String(Some("Yes".to_string())),
"Additional Eligibility Factors", "Additional Eligibility Factors",
"Has a Parent in the PEA" "Has a Parent in the PEA",
); );
let music_program = ComparisonData::new( let music_program = ComparisonData::new(
"music_program", "music_program",
"music_program", "music_program",
ComparisonType::Text(TextComparison::Matches), ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())), ValueType::String(Some("Yes".to_string())),
"Sports Participation", "Sports Participation",
"PHS Music Program" "PHS Music Program",
); );
let mut sports_comps = create_sports_comparisons(); let mut sports_comps = create_sports_comparisons();
@@ -412,7 +409,7 @@ fn make_comp_list() -> Vec<ComparisonData> {
youth_baseball, youth_baseball,
aquatic_club, aquatic_club,
pea_member, pea_member,
music_program music_program,
]; ];
comp_list.append(&mut sports_comps); comp_list.append(&mut sports_comps);
@@ -438,6 +435,7 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
.await .await
{ {
let msg = err.message().unwrap_or("An unknown error occurred"); let msg = err.message().unwrap_or("An unknown error occurred");
leptos::logging::error!("{}", msg);
return Err(ServerFnError::new(msg)); return Err(ServerFnError::new(msg));
} }
} }
@@ -453,15 +451,9 @@ pub async fn get_comparison_info() -> Result<Vec<ComparisonData>, ServerFnError>
// Query the database for all comparisons. The client is only going to use the // Query the database for all comparisons. The client is only going to use the
// id and display text, but we'll return the whole thing. // id and display text, but we'll return the whole thing.
match client match client.scan().table_name(COMPARISONS_TABLE).send().await {
.scan()
.table_name(COMPARISONS_TABLE)
.send()
.await
{
Ok(output) => { Ok(output) => {
if let Some(items) = output.items { if let Some(items) = output.items {
log!("Found comparisons from API: {:?}", items);
Ok(serde_dynamo::from_items(items)?) Ok(serde_dynamo::from_items(items)?)
} else { } else {
Ok(vec![]) Ok(vec![])
@@ -469,22 +461,19 @@ pub async fn get_comparison_info() -> Result<Vec<ComparisonData>, ServerFnError>
} }
Err(err) => { Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred"); let msg = err.message().unwrap_or("An unknown error occurred");
leptos::logging::error!("{}", msg);
Err(ServerFnError::new(msg)) Err(ServerFnError::new(msg))
} }
} }
} }
#[server] #[server]
pub async fn get_comparisons_categorized() -> Result<HashMap<String, Vec<ComparisonData>>, ServerFnError> { pub async fn get_comparisons_categorized()
-> Result<HashMap<String, Vec<ComparisonData>>, ServerFnError> {
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
log!("Getting all comparisons from the database, by category."); log!("Getting all comparisons from the database, by category.");
match client match client.scan().table_name(COMPARISONS_TABLE).send().await {
.scan()
.table_name(COMPARISONS_TABLE)
.send()
.await
{
Ok(output) => { Ok(output) => {
if let Some(items) = output.items { if let Some(items) = output.items {
log!("Found comparisons, categorizing."); log!("Found comparisons, categorizing.");
@@ -492,23 +481,27 @@ pub async fn get_comparisons_categorized() -> Result<HashMap<String, Vec<Compari
let items: Vec<ComparisonData> = serde_dynamo::from_items(items)?; let items: Vec<ComparisonData> = serde_dynamo::from_items(items)?;
let categorized = items.iter().fold( let categorized = items.iter().fold(
HashMap::<String, Vec<ComparisonData>>::new(), |mut map, comp| { HashMap::<String, Vec<ComparisonData>>::new(),
// Load each comparison into a map, then return that map. |mut map, comp| {
// Load each comparison into a map, then return that map.
map.entry(comp.category.clone()) map.entry(comp.category.clone())
.and_modify(|vec| vec.push(comp.clone())) .and_modify(|vec| vec.push(comp.clone()))
.or_insert(vec![comp.clone()]); .or_insert(vec![comp.clone()]);
map map
} },
); );
Ok(categorized) Ok(categorized)
} else { } else {
Err(ServerFnError::new("Couldn't find any comparisons.")) let msg = "Couldn't find any comparisons.";
leptos::logging::error!("{}", msg);
Err(ServerFnError::new(msg))
} }
} }
Err(err) => { Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred"); let msg = err.message().unwrap_or("An unknown error occurred");
leptos::logging::error!("{}", msg);
Err(ServerFnError::new(msg)) Err(ServerFnError::new(msg))
} }
} }
+32 -32
View File
@@ -1,61 +1,60 @@
use std::collections::HashMap;
use leptos::logging::log;
use leptos::prelude::*;
use crate::common::DateInfo; use crate::common::DateInfo;
use leptos::logging::{error, log};
use leptos::prelude::*;
use std::collections::HashMap;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use super::DATES_TABLE; use super::DATES_TABLE;
#[cfg(feature = "ssr")]
use aws_sdk_dynamodb::{
types::{WriteRequest, PutRequest},
Client as DynamoClient
};
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use crate::utils::server::create_dynamo_client; use crate::utils::server::create_dynamo_client;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use std::sync::Arc; use aws_sdk_dynamodb::{
Client as DynamoClient,
error::ProvideErrorMetadata,
types::{PutRequest, WriteRequest},
};
#[server] #[server]
pub async fn create_dates(dates: Vec<DateInfo>) -> Result<(), ServerFnError> { pub async fn create_dates(dates: Vec<DateInfo>) -> Result<(), ServerFnError> {
// Serialize and put dates into the database. // Serialize and put dates into the database.
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
log!("Creating dates: {:?}", dates); log!("Creating dates: {:?}", dates);
let requests = dates let requests = dates
.into_iter() .into_iter()
.map(|date| { .filter_map(|date| {
let item = serde_dynamo::to_item(&date) let item = serde_dynamo::to_item(&date).unwrap_or_default();
.unwrap_or_default();
let put_request = PutRequest::builder().set_item(Some(item)).build().ok()?;
let put_request = PutRequest::builder()
.set_item(Some(item)) Some(WriteRequest::builder().put_request(put_request).build())
.build()
.expect("Couldn't build request, item is not present");
WriteRequest::builder().put_request(put_request).build()
}) })
.collect::<Vec<WriteRequest>>(); .collect::<Vec<WriteRequest>>();
let mut request_map = HashMap::new(); let mut request_map = HashMap::new();
request_map.insert(DATES_TABLE.to_string(), requests); request_map.insert(DATES_TABLE.to_string(), requests);
client client
.batch_write_item() .batch_write_item()
.set_request_items(Some(request_map)) .set_request_items(Some(request_map))
.send() .send()
.await .await
.map(|_| ()) .map(|_| ())
.map_err(ServerFnError::from) .map_err(|err| {
let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})
} }
#[server] #[server]
pub async fn get_important_dates() -> Result<Vec<DateInfo>, ServerFnError> { pub async fn get_important_dates() -> Result<Vec<DateInfo>, ServerFnError> {
log!("Getting important dates list");
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
client client
.scan() .scan()
.table_name(DATES_TABLE) .table_name(DATES_TABLE)
@@ -65,14 +64,15 @@ pub async fn get_important_dates() -> Result<Vec<DateInfo>, ServerFnError> {
if let Some(items) = output.items { if let Some(items) = output.items {
items items
.into_iter() .into_iter()
.filter_map(|item| { .filter_map(|item| serde_dynamo::from_item::<_, DateInfo>(item).ok())
serde_dynamo::from_item::<_, DateInfo>(item)
.ok()
})
.collect::<Vec<DateInfo>>() .collect::<Vec<DateInfo>>()
} else { } else {
Vec::new() Vec::new()
} }
}) })
.map_err(ServerFnError::from) .map_err(|err| {
let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})
} }
+236
View File
@@ -0,0 +1,236 @@
#[cfg(feature = "ssr")]
mod imports {
pub use super::super::{COMPARISONS_TABLE, PROVIDER_CONTACT_TABLE, SCHOLARSHIPS_TABLE};
pub use crate::common::ValueType;
pub use crate::utils::server::create_dynamo_client;
pub use aws_sdk_dynamodb::Client;
pub use aws_sdk_dynamodb::error::ProvideErrorMetadata;
pub use indexmap::IndexMap;
pub use std::collections::HashMap;
}
use crate::common::ExpandableInfo;
use leptos::prelude::*;
#[server]
pub async fn get_scholarship_csv() -> Result<Vec<u8>, ServerFnError> {
use imports::*;
use leptos::logging::debug_log;
#[derive(serde::Serialize)]
struct ResolvedScholarshipInfo {
contact_email: String,
first_name: String,
last_name: String,
scholarship_name: String,
sports_requirements: String,
gpa_requirements: String,
major_requirements: String,
additional_requirements: String,
community_requirements: String,
residency_requirements: String,
}
// Get the scholarships and comparisons
let client = create_dynamo_client().await;
let scan_table = async |client: &Client, table_name| {
client
.scan()
.table_name(table_name)
.send()
.await
.map(|output| output.items)
.ok()
.flatten()
.unwrap_or_default()
.into_iter()
.map(|item| {
serde_dynamo::from_item(item).unwrap_or(ExpandableInfo::new("".to_string()))
// item.iter()
// .map(|(k, v)| (k.to_owned(), ValueType::from(v)))
// .collect::<HashMap<String, ValueType>>()
})
.collect::<Vec<ExpandableInfo>>()
};
let scan_table_hash_map = async |client: &Client, table_name| {
client
.scan()
.table_name(table_name)
.send()
.await
.map(|output| output.items)
.ok()
.flatten()
.unwrap_or_default()
.into_iter()
.map(|item| {
// serde_dynamo::from_item(item).unwrap_or(ExpandableInfo::new("".to_string()))
item.iter()
.map(|(k, v)| (k.to_owned(), ValueType::from(v)))
.collect::<HashMap<String, ValueType>>()
})
.collect::<Vec<HashMap<String, ValueType>>>()
};
let master_scholarships = scan_table(&client, SCHOLARSHIPS_TABLE).await;
let master_contacts = scan_table_hash_map(&client, PROVIDER_CONTACT_TABLE).await;
let master_relations = scan_table_hash_map(&client, COMPARISONS_TABLE).await;
// We now want to map the relations to the scholarships, as well as the contacts.
// This should take the information from the current scholarship, then relate the IDs of the
// requirements in the scholarship to the IDs of the relations in the master list.
let resolved_scholarships = master_scholarships
.into_iter()
.map(|scholarship| {
// debug_log!("Scholarship: {:?}", scholarship);
// We want the requirements IDs and the provider ID.
let requirements_map = scholarship
.data
.get("requirements")
.unwrap_or(&ValueType::Map(None))
.as_map()
.ok()
.flatten()
.unwrap_or_default();
let provider_id = scholarship
.data
.get("provider_id")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
// Search for the provider ID in the master list.
let contact_info = master_contacts
.iter()
.find(|contact| {
let subject = contact
.get("subject")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
debug_log!("Comparing {:?} and {:?}", subject, provider_id);
subject == provider_id
})
.cloned()
.unwrap_or_default();
// Resolve the requirements - this should resolve as another category-separated map,
// but with requirement display text instead of IDs.
let resolved_requirements = requirements_map
.iter()
.map(|(category, selected_val)| {
let selected_list = selected_val.as_list().ok().flatten().unwrap_or_default();
let resolved_names = selected_list
.into_iter()
.map(|id_val| {
let id = id_val.as_string().ok().flatten().unwrap_or_default();
let resolved_relation = master_relations
.iter()
.find(|item| {
let comp_id = item
.get("id")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
comp_id == id
})
.cloned()
.map(|v| {
v.get("display_text").map(|text_opt| {
text_opt.as_string().ok().flatten().unwrap_or_default()
})
})
.flatten()
.unwrap_or_default();
resolved_relation
})
.collect::<Vec<String>>();
(category.clone(), resolved_names)
})
.collect::<IndexMap<String, Vec<String>>>();
debug_log!("Resolved requirements: {:?}", resolved_requirements);
let contact_email = contact_info
.get("contact_email")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
let first_name = contact_info
.get("first_name")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
let last_name = contact_info
.get("last_name")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
let scholarship_name = scholarship
.data
.get("name")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default();
debug_log!(
"{:?}, {:?}, {:?}, {:?}",
scholarship_name,
contact_email,
first_name,
last_name
);
// We want to create some structure that's got the correct information. Specifically,
// we only need the provider contact info, the scholarship's name, and the requirements
// that they chose in plain text.
ResolvedScholarshipInfo {
first_name,
last_name,
contact_email,
scholarship_name,
sports_requirements: resolved_requirements
.get("sports_participation")
.map(|list| list.join("; "))
.unwrap_or_default(),
gpa_requirements: resolved_requirements
.get("gpa")
.map(|list| list.join("; "))
.unwrap_or_default(),
major_requirements: resolved_requirements
.get("major")
.map(|list| list.join("; "))
.unwrap_or_default(),
additional_requirements: resolved_requirements
.get("misc")
.map(|list| list.join("; "))
.unwrap_or_default(),
community_requirements: resolved_requirements
.get("community_service")
.map(|list| list.join("; "))
.unwrap_or_default(),
residency_requirements: resolved_requirements
.get("residency")
.map(|list| list.join("; "))
.unwrap_or_default(),
}
})
.collect::<Vec<ResolvedScholarshipInfo>>();
// Now that we have a series of scholarships, we need to serialize them into CSV and send the
// file to the client.
let mut writer = csv::Writer::from_writer(Vec::new());
resolved_scholarships.iter().for_each(|scholarship| {
_ = writer.serialize(scholarship);
});
writer.flush()?;
let result = writer.into_inner()?;
Ok(result)
}
+269
View File
@@ -0,0 +1,269 @@
use leptos::leptos_dom::error;
use leptos::logging::debug_log;
use leptos::prelude::*;
use server_fn::codec::{MultipartData, MultipartFormData};
#[cfg(feature = "ssr")]
mod imports {
pub use super::super::{MAIN_TABLE_NAME, S3_BUCKET_NAME};
pub use crate::pages::api::tokens::validate_and_get_token_info;
pub use crate::utils::server::create_aws_config;
pub use aws_sdk_dynamodb::{
error::ProvideErrorMetadata as _,
types::{AttributeValue, ReturnValue, Select},
};
pub use aws_sdk_s3::error::ProvideErrorMetadata as _;
}
#[server(input = MultipartFormData)]
pub async fn upload_file(data: MultipartData) -> Result<String, ServerFnError> {
use imports::*;
let mut multipart = data.into_inner().unwrap();
let mut file_name = String::new();
let mut file_bytes = Vec::<u8>::new();
let mut form_id = String::new();
let mut access_token = String::new();
let mut input_name = String::new();
while let Ok(Some(field)) = multipart.next_field().await {
let field_name = field.name().unwrap_or_default();
match field_name {
"access_token" => access_token = field.text().await?,
"form_id" => form_id = field.text().await?,
_ => {
// NOTE: If there are multiple files put here the function will only upload the last
// one in the data.
// We should likely use chunk streaming in the future, for safety.
input_name = field
.name()
.ok_or(ServerFnError::new("Couldn't find input name"))?
.to_owned();
file_name = field
.file_name()
.ok_or(ServerFnError::new("Couldn't find file name"))?
.to_owned();
file_bytes = field.bytes().await?.to_vec();
}
}
}
if access_token.is_empty() {
return Err(ServerFnError::new("Missing access token"));
}
if form_id.is_empty() {
return Err(ServerFnError::new("Missing form ID"));
}
if file_name.is_empty() {
return Err(ServerFnError::new("Missing file"));
}
if input_name.is_empty() {
return Err(ServerFnError::new("Missing input file"));
}
if file_bytes.is_empty() {
return Err(ServerFnError::new("Missing file contents"));
}
let user_claims = validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM", "us-east-1").await?;
let subject = user_claims.subject;
let key = format!("{form_id}/{subject}/{input_name}/{file_name}");
debug_log!("Adding file to S3: {:?}", key);
let s3_client = aws_sdk_s3::Client::new(&create_aws_config().await);
s3_client
.put_object()
.bucket(S3_BUCKET_NAME)
.key(&key)
.body(file_bytes.into())
.send()
.await
.map(|_| ())
.map_err(|e| {
let msg = format!(
"Couldn't put file to S3: {}",
e.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})?;
// Besides uploading the file to S3, we also want to store a FILE entry in Dynamo.
// This will allow the server to quickly return a list of all files that a specific user has
// uploaded, or a list of all files submitted for a specific question with their keys for
// additional access.
let dynamo_client = aws_sdk_dynamodb::Client::new(&create_aws_config().await);
let dynamo_result = dynamo_client
.put_item()
.table_name(MAIN_TABLE_NAME)
.item(
"HK".to_string(),
AttributeValue::S(format!("STUDENT#{subject}")),
)
.item(
"SK".to_string(),
AttributeValue::S(format!("FILE#{form_id}#{input_name}#{file_name}")),
)
.item(
"file_name".to_string(),
AttributeValue::S(file_name.clone()),
)
.item("file_key".to_string(), AttributeValue::S(key.clone()))
.send()
.await
.map_err(|err| {
let msg = format!(
"Couldn't put file to Dynamo, file rolled back: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
});
// If Dynamo fails, we want to handle the error by rolling back the file. Then, we return a failure.
if let Err(err) = dynamo_result {
s3_client
.delete_object()
.bucket("leptos-scholarships")
.key(&key)
.send()
.await
.map_err(|err| {
let msg = format!(
"Failed to rollback Dynamo entry: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})?;
return Err(err);
}
Ok(file_name)
}
#[server]
pub async fn delete_file(
access_token: String,
form_id: String,
input_name: String,
file_name: String,
) -> Result<String, ServerFnError> {
use imports::*;
let user_claims = validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM", "us-east-1").await?;
let subject = user_claims.subject;
let entry_hk = format!("STUDENT#{subject}");
let entry_sk = format!("FILE#{form_id}#{input_name}#{file_name}");
let key = format!("{form_id}/{subject}/{input_name}/{file_name}");
let config = create_aws_config().await;
let s3_client = aws_sdk_s3::Client::new(&config);
let dynamo_client = aws_sdk_dynamodb::Client::new(&config);
let previous_values = dynamo_client
.delete_item()
.table_name(MAIN_TABLE_NAME)
.key("HK".to_string(), AttributeValue::S(entry_hk))
.key("SK".to_string(), AttributeValue::S(entry_sk))
.return_values(ReturnValue::AllOld)
.send()
.await?
.attributes
.unwrap_or_default();
let s3_result = s3_client
.delete_object()
.bucket(S3_BUCKET_NAME)
.key(key)
.send()
.await
.map_err(|err| {
let msg = format!(
"Failed to put file into S3: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
});
if let Err(err) = s3_result {
// Add the key back to Dynamo and return an error.
dynamo_client
.put_item()
.table_name(MAIN_TABLE_NAME)
.set_item(Some(previous_values))
.send()
.await
.map_err(|err| {
let msg = format!(
"Failed to rollback Dynamo entry: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})?;
return Err(err);
}
Ok(file_name)
}
#[server]
pub async fn list_files(
access_token: String,
form_id: String,
input_name: String,
) -> Result<Vec<String>, ServerFnError> {
use imports::*;
let user_claims = validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM", "us-east-1").await?;
let subject = user_claims.subject;
let entry_hk = format!("STUDENT#{subject}");
let entry_sk = format!("FILE#{form_id}#{input_name}");
debug_log!("Getting file list: HK = {}, SK = {}", entry_hk, entry_sk);
let dynamo_client = aws_sdk_dynamodb::Client::new(&create_aws_config().await);
dynamo_client
.query()
.table_name(MAIN_TABLE_NAME)
.key_condition_expression("HK = :hk AND begins_with(SK, :sk)")
.expression_attribute_values(":hk".to_string(), AttributeValue::S(entry_hk))
.expression_attribute_values(":sk".to_string(), AttributeValue::S(entry_sk))
.send()
.await
.map(|output| {
let Some(items) = output.items else {
return vec![];
};
items
.into_iter()
.filter_map(|item| {
item.get("file_name")
.map(|v| v.as_s().ok().cloned())
.flatten()
})
.collect::<Vec<String>>()
})
.map_err(|err| {
let msg = format!(
"Failed to get files: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})
}
+12 -6
View File
@@ -1,15 +1,18 @@
mod comparisons;
mod scholarships;
mod dates;
mod admin; mod admin;
mod comparisons;
mod dates;
pub mod exports;
pub mod files;
mod providers; mod providers;
mod scholarships;
pub mod students; pub mod students;
pub(crate) mod tokens;
pub use comparisons::*;
pub use scholarships::*;
pub use dates::*;
pub use admin::*; pub use admin::*;
pub use comparisons::*;
pub use dates::*;
pub use providers::*; pub use providers::*;
pub use scholarships::*;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub static COMPARISONS_TABLE: &str = "leptos-comparisons"; pub static COMPARISONS_TABLE: &str = "leptos-comparisons";
@@ -25,3 +28,6 @@ pub static PROVIDER_CONTACT_TABLE: &str = "leptos-provider-contacts";
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub static MAIN_TABLE_NAME: &str = "scholarships-main"; pub static MAIN_TABLE_NAME: &str = "scholarships-main";
#[cfg(feature = "ssr")]
pub static S3_BUCKET_NAME: &str = "leptos-scholarships";
+23 -12
View File
@@ -1,8 +1,8 @@
use leptos::prelude::*; use crate::common::ValueType;
use leptos::logging::debug_log; use leptos::logging::{debug_log, error};
use std::collections::HashMap; use leptos::prelude::*;
use leptos::server_fn::codec::Json; use leptos::server_fn::codec::Json;
use crate::common::ValueType; use std::collections::HashMap;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use crate::utils::server::{create_dynamo_client, into_attr_map}; use crate::utils::server::{create_dynamo_client, into_attr_map};
@@ -12,8 +12,8 @@ static PROVIDER_CONTACT_TABLE: &str = "leptos-provider-contacts";
#[server] #[server]
pub async fn get_provider_contact(id: String) -> Result<HashMap<String, ValueType>, ServerFnError> { pub async fn get_provider_contact(id: String) -> Result<HashMap<String, ValueType>, ServerFnError> {
use aws_sdk_dynamodb::types::AttributeValue;
use aws_sdk_dynamodb::error::ProvideErrorMetadata; use aws_sdk_dynamodb::error::ProvideErrorMetadata;
use aws_sdk_dynamodb::types::AttributeValue;
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
@@ -31,29 +31,39 @@ pub async fn get_provider_contact(id: String) -> Result<HashMap<String, ValueTyp
return Ok(HashMap::new()); return Ok(HashMap::new());
}; };
let map = item.iter().map(|(key, val)| { let map = item
let val_type = ValueType::from(val); .iter()
.map(|(key, val)| {
let val_type = ValueType::from(val);
(key.clone(), val_type) (key.clone(), val_type)
}).collect::<HashMap<String, ValueType>>(); })
.collect::<HashMap<String, ValueType>>();
Ok(map) Ok(map)
} }
Err(err) => { Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred."); let msg = err.message().unwrap_or("An unknown error occurred.");
error!("{}", msg);
Err(ServerFnError::new(msg)) Err(ServerFnError::new(msg))
} }
} }
} }
#[server(input = Json)] #[server(input = Json)]
pub async fn put_provider_contact(id: String, contact_info: HashMap<String, ValueType>) -> Result<(), ServerFnError> { pub async fn put_provider_contact(
id: String,
contact_info: HashMap<String, ValueType>,
) -> Result<(), ServerFnError> {
use aws_sdk_dynamodb::error::ProvideErrorMetadata; use aws_sdk_dynamodb::error::ProvideErrorMetadata;
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
let mut item = into_attr_map(contact_info); let mut item = into_attr_map(contact_info);
item.insert("subject".to_owned(), aws_sdk_dynamodb::types::AttributeValue::S(id)); item.insert(
"subject".to_owned(),
aws_sdk_dynamodb::types::AttributeValue::S(id),
);
match client match client
.put_item() .put_item()
@@ -65,7 +75,8 @@ pub async fn put_provider_contact(id: String, contact_info: HashMap<String, Valu
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(err) => { Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred."); let msg = err.message().unwrap_or("An unknown error occurred.");
error!("{}", msg);
Err(ServerFnError::new(msg)) Err(ServerFnError::new(msg))
} }
} }
} }
+90 -56
View File
@@ -1,15 +1,11 @@
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
mod imports { mod imports {
pub use leptos::logging::debug_log;
pub use aws_sdk_dynamodb::{
types::AttributeValue,
error::ProvideErrorMetadata,
};
pub use leptos::leptos_dom::log;
pub use uuid::Uuid;
pub use crate::utils::server::{create_dynamo_client};
pub use crate::common::ValueType;
pub use super::super::SCHOLARSHIPS_TABLE; pub use super::super::SCHOLARSHIPS_TABLE;
pub use crate::common::ValueType;
pub use crate::utils::server::create_dynamo_client;
pub use aws_sdk_dynamodb::{error::ProvideErrorMetadata, types::AttributeValue};
pub use leptos::logging::{debug_log, error, log};
pub use uuid::Uuid;
} }
use crate::common::ExpandableInfo; use crate::common::ExpandableInfo;
@@ -20,7 +16,7 @@ use leptos::server_fn::codec::Json;
#[server(GetScholarshipInfo)] #[server(GetScholarshipInfo)]
pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFnError> { pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFnError> {
use imports::*; use imports::*;
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
// Perform the operation - we just want to return all data that's contained in this entry, // Perform the operation - we just want to return all data that's contained in this entry,
@@ -39,11 +35,14 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
Ok(serde_dynamo::from_item(item)?) Ok(serde_dynamo::from_item(item)?)
} else { } else {
log!("Couldn't find scholarship with ID {:?}", id); log!("Couldn't find scholarship with ID {:?}", id);
Err(ServerFnError::new("Couldn't find scholarship with given ID.")) Err(ServerFnError::new(
"Couldn't find scholarship with given ID.",
))
} }
} }
Err(err) => { Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred"); let msg = err.message().unwrap_or("An unknown error occurred");
error!("{}", msg);
Err(ServerFnError::new(msg)) Err(ServerFnError::new(msg))
} }
} }
@@ -51,12 +50,15 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
#[server(CreateScholarshipInfo, input = Json)] #[server(CreateScholarshipInfo, input = Json)]
pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerFnError> { pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerFnError> {
use imports::*;
use aws_sdk_dynamodb::error::ProvideErrorMetadata; use aws_sdk_dynamodb::error::ProvideErrorMetadata;
use imports::*;
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
log!("Creating or updating scholarship with ID {:?}", info.subject); log!(
"Creating or updating scholarship with ID {:?}",
info.subject
);
match client match client
.put_item() .put_item()
@@ -68,6 +70,7 @@ pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerF
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(err) => { Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred"); let msg = err.message().unwrap_or("An unknown error occurred");
error!("{}", msg);
Err(ServerFnError::new(msg)) Err(ServerFnError::new(msg))
} }
} }
@@ -76,24 +79,20 @@ pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerF
#[server(GetAllScholarshipInfo)] #[server(GetAllScholarshipInfo)]
pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnError> { pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnError> {
use imports::*; use imports::*;
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
debug_log!("Getting all scholarship info"); debug_log!("Getting all scholarship info");
match client match client.scan().table_name(SCHOLARSHIPS_TABLE).send().await {
.scan()
.table_name(SCHOLARSHIPS_TABLE)
.send()
.await
{
Ok(output) => { Ok(output) => {
if let Some(items) = output.items { if let Some(items) = output.items {
Ok(serde_dynamo::from_items(items)?) Ok(serde_dynamo::from_items(items)?)
} else { } else {
Ok(Vec::new()) Ok(Vec::new())
} }
}, }
Err(err) => { Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred"); let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
Err(ServerFnError::new(msg)) Err(ServerFnError::new(msg))
} }
} }
@@ -101,17 +100,25 @@ pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnE
/// Gets all scholarships that are associated with the given scholarship provider's ID. /// Gets all scholarships that are associated with the given scholarship provider's ID.
#[server(GetProviderScholarships)] #[server(GetProviderScholarships)]
pub async fn get_provider_scholarships(provider_id: String) -> Result<Vec<ExpandableInfo>, ServerFnError> { pub async fn get_provider_scholarships(
provider_id: String,
) -> Result<Vec<ExpandableInfo>, ServerFnError> {
use imports::*; use imports::*;
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
debug_log!("Getting provider scholarships for provider with ID {:?}", provider_id); debug_log!(
"Getting provider scholarships for provider with ID {:?}",
provider_id
);
match client match client
.scan() .scan()
.table_name(SCHOLARSHIPS_TABLE) .table_name(SCHOLARSHIPS_TABLE)
.expression_attribute_values(":id", serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?) .expression_attribute_values(
":id",
serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?,
)
.filter_expression("provider_id = :id") .filter_expression("provider_id = :id")
.send() .send()
.await .await
@@ -124,7 +131,8 @@ pub async fn get_provider_scholarships(provider_id: String) -> Result<Vec<Expand
} }
} }
Err(err) => { Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred"); let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
Err(ServerFnError::new(msg)) Err(ServerFnError::new(msg))
} }
} }
@@ -134,14 +142,20 @@ pub async fn get_provider_scholarships(provider_id: String) -> Result<Vec<Expand
#[server(RegisterScholarship)] #[server(RegisterScholarship)]
pub async fn register_scholarship(provider_id: String) -> Result<String, ServerFnError> { pub async fn register_scholarship(provider_id: String) -> Result<String, ServerFnError> {
use imports::*; use imports::*;
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
debug_log!("Creating scholarship for provider with ID {:?}", provider_id); debug_log!(
"Creating scholarship for provider with ID {:?}",
provider_id
);
let mut current_uuid = Uuid::new_v4().to_string(); let mut current_uuid = Uuid::new_v4().to_string();
let mut item = ExpandableInfo::new(current_uuid.clone()); let mut item = ExpandableInfo::new(current_uuid.clone());
item.data.insert("provider_id".to_string(), ValueType::String(Some(provider_id))); item.data.insert(
"provider_id".to_string(),
ValueType::String(Some(provider_id)),
);
loop { loop {
let ser_item = serde_dynamo::to_item(&item)?; let ser_item = serde_dynamo::to_item(&item)?;
match client match client
@@ -165,10 +179,12 @@ pub async fn register_scholarship(provider_id: String) -> Result<String, ServerF
log!("Current error: {:?}", err); log!("Current error: {:?}", err);
current_uuid = Uuid::new_v4().to_string(); current_uuid = Uuid::new_v4().to_string();
item.subject = current_uuid.clone(); item.subject = current_uuid.clone();
continue continue;
} }
_ => { _ => {
return Err(ServerFnError::new(err.message().unwrap_or("An unknown error occurred"))); return Err(ServerFnError::new(
err.message().unwrap_or("Unknown error occurred"),
));
} }
} }
} }
@@ -178,41 +194,51 @@ pub async fn register_scholarship(provider_id: String) -> Result<String, ServerF
/// Deletes a provider's scholarship given their provider ID and scholarship ID. /// Deletes a provider's scholarship given their provider ID and scholarship ID.
#[server(DeleteProviderScholarship)] #[server(DeleteProviderScholarship)]
pub async fn delete_provider_scholarship(provider_id: String, scholarship_id: String) -> Result<(), ServerFnError> { pub async fn delete_provider_scholarship(
provider_id: String,
scholarship_id: String,
) -> Result<(), ServerFnError> {
use imports::*; use imports::*;
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
debug_log!("Deleting scholarship with ID {:?} for provider with ID {:?}", scholarship_id, provider_id); debug_log!(
"Deleting scholarship with ID {:?} for provider with ID {:?}",
scholarship_id,
provider_id
);
// When we delete a scholarship, we need to ensure that the provider's ID matches the scholarship, // 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. // otherwise everyone can delete anyone else's scholarships.
match client match client
.delete_item() .delete_item()
.table_name(SCHOLARSHIPS_TABLE) .table_name(SCHOLARSHIPS_TABLE)
.key("subject", AttributeValue::S(scholarship_id)) .key("subject", AttributeValue::S(scholarship_id))
.expression_attribute_values(":provider_id", serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?) .expression_attribute_values(
":provider_id",
serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?,
)
.condition_expression("provider_id = :provider_id") .condition_expression("provider_id = :provider_id")
.send() .send()
.await .await
{ {
Ok(_) => { Ok(_) => Ok(()),
Ok(())
}
Err(err) => { Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred"); let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
Err(ServerFnError::new(msg)) Err(ServerFnError::new(msg))
} }
} }
} }
#[server] #[server]
pub async fn get_all_scholarships() -> Result<Vec<std::collections::HashMap<String, crate::common::ValueType>>, ServerFnError> { pub async fn get_all_scholarships()
-> Result<Vec<std::collections::HashMap<String, crate::common::ValueType>>, ServerFnError> {
use imports::*; use imports::*;
use std::collections::HashMap; use std::collections::HashMap;
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
client client
.scan() .scan()
.table_name(SCHOLARSHIPS_TABLE) .table_name(SCHOLARSHIPS_TABLE)
@@ -222,12 +248,20 @@ pub async fn get_all_scholarships() -> Result<Vec<std::collections::HashMap<Stri
let Some(items) = output.items else { let Some(items) = output.items else {
return Vec::new(); return Vec::new();
}; };
items.iter().map(|item| { items
// Each item should be simply converted from an AttributeValue to a ValueType. .iter()
item.iter().map(|(k, v)| (k.clone(), ValueType::from(v))) .map(|item| {
.collect::<HashMap<String, ValueType>>() // Each item should be simply converted from an AttributeValue to a ValueType.
}).collect::<Vec<_>>() item.iter()
.map(|(k, v)| (k.clone(), ValueType::from(v)))
.collect::<HashMap<String, ValueType>>()
})
.collect::<Vec<_>>()
})
.map_err(|err| {
let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
}) })
.map_err(ServerFnError::from)
} }
+603 -37
View File
@@ -1,38 +1,45 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::server_fn::codec::Json; use leptos::server_fn::codec::Json;
use std::collections::HashMap;
use std::io::Write;
use std::process::Stdio;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
mod imports { mod imports {
pub use crate::utils::server::*;
pub use aws_sdk_dynamodb::types::AttributeValue;
pub use super::super::MAIN_TABLE_NAME; pub use super::super::MAIN_TABLE_NAME;
pub use crate::common::ValueType; pub use crate::common::ValueType;
pub use crate::pages::api::tokens::validate_and_get_token_info;
pub use crate::utils::server::*;
pub use aws_sdk_dynamodb::error::ProvideErrorMetadata;
pub use aws_sdk_dynamodb::types::AttributeValue;
pub use leptos::logging::{debug_log, error};
pub use leptos::serde_json;
pub use std::collections::HashMap; pub use std::collections::HashMap;
pub use leptos::logging::debug_log; pub use std::process::Command;
pub use zip::write::SimpleFileOptions;
} }
static PDF_TEMPLATE: &str = include_str!("../../../pdf_template.typ");
#[server(input = Json)] #[server(input = Json)]
pub async fn put_student_data( pub async fn put_student_data(
subject: String, subject: String,
data_type: String, data_type: String,
data_map: std::collections::HashMap<String, crate::common::ValueType> data_map: HashMap<String, crate::common::ValueType>,
) -> Result<(), ServerFnError> { ) -> Result<(), ServerFnError> {
use imports::*; use imports::*;
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
let mut data_map_attr = into_attr_map(data_map); let mut data_map_attr = into_attr_map(data_map);
data_map_attr.insert( data_map_attr.insert(
"HK".into(), "HK".into(),
AttributeValue::S(format!("STUDENT#{}", subject)) AttributeValue::S(format!("STUDENT#{}", subject)),
); );
data_map_attr.insert( data_map_attr.insert("SK".into(), AttributeValue::S(data_type.to_uppercase()));
"SK".into(),
AttributeValue::S(data_type.to_uppercase())
);
debug_log!("Inserting this item: {:?}", data_map_attr); debug_log!("Inserting this item: {:?}", data_map_attr);
client client
.put_item() .put_item()
.table_name(MAIN_TABLE_NAME) .table_name(MAIN_TABLE_NAME)
@@ -40,18 +47,25 @@ pub async fn put_student_data(
.send() .send()
.await .await
.map(|_| ()) .map(|_| ())
.map_err(ServerFnError::from) .map_err(|err| {
let msg = format!(
"Couldn't put item into Dynamo: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})
} }
#[server] #[server]
pub async fn get_student_data( pub async fn get_student_data(
subject: String, subject: String,
data_type: String, data_type: String,
) -> Result<std::collections::HashMap<String, crate::common::ValueType>, ServerFnError> { ) -> Result<HashMap<String, crate::common::ValueType>, ServerFnError> {
use imports::*; use imports::*;
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
client client
.query() .query()
.table_name(MAIN_TABLE_NAME) .table_name(MAIN_TABLE_NAME)
@@ -65,37 +79,42 @@ pub async fn get_student_data(
let Some(list) = output.items else { let Some(list) = output.items else {
return HashMap::new(); return HashMap::new();
}; };
let mut map = HashMap::new(); let mut map = HashMap::new();
let _ = list let _ = list.into_iter().flatten().for_each(|(k, v)| {
.into_iter() map.insert(k, ValueType::from(&v));
.flatten() });
.for_each(|(k, v)| {
map.insert(k, ValueType::from(&v));
});
map map
}) })
.map_err(ServerFnError::from) .map_err(|err| {
let msg = format!(
"Couldn't get item from Dynamo: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})
} }
/// Gets and flattens all of a student's information from the database, regardless of their defined /// Gets and flattens all of a student's information from the database, regardless of their defined
/// sort key. /// sort key.
/// ///
/// Note: if sorted data from the `put_student_data` function contains the same fields, this /// 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 /// 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 /// locations in the database, all for this single student, they will overwrite each other in no
/// specific order. /// specific order.
#[server] #[server]
pub async fn get_all_student_data( pub async fn get_all_student_data(
subject: String subject: String,
) -> Result<std::collections::HashMap<String, crate::common::ValueType>, ServerFnError> { ) -> Result<HashMap<String, crate::common::ValueType>, ServerFnError> {
use imports::*; use imports::*;
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
client.query() client
.query()
.table_name(MAIN_TABLE_NAME) .table_name(MAIN_TABLE_NAME)
.key_condition_expression("HK = :hk") .key_condition_expression("HK = :hk")
.expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{}", subject))) .expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{}", subject)))
@@ -105,10 +124,557 @@ pub async fn get_all_student_data(
let Some(items) = output.items else { let Some(items) = output.items else {
return HashMap::new(); return HashMap::new();
}; };
items.iter().flatten() items
.map(|(|k, v)| (k.clone(), ValueType::from(v))) .iter()
.flatten()
.map(|(k, v)| (k.clone(), ValueType::from(v)))
.collect::<HashMap<String, ValueType>>() .collect::<HashMap<String, ValueType>>()
}) })
.map_err(ServerFnError::from) .map_err(|err| {
let msg = format!(
"Couldn't get data from Dynamo: {}",
err.message().unwrap_or("Unknown error occurred")
);
error!("{}", msg);
ServerFnError::new(msg)
})
}
#[server]
pub async fn provider_get_completed_students(
access_token: String,
) -> Result<HashMap<String, HashMap<String, crate::common::ValueType>>, ServerFnError> {
use imports::*;
let claims =
validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM", "us-east-1").await?;
if !claims.groups.contains(&"ScholarshipProviders".to_string()) {
return Err(ServerFnError::new(
"User is not in the ScholarshipProviders group",
));
}
get_completed_students().await
}
#[server]
pub async fn admin_get_completed_students(
access_token: String,
) -> Result<HashMap<String, HashMap<String, crate::common::ValueType>>, ServerFnError> {
use imports::*;
let _ = validate_and_get_token_info(access_token, "us-east-1_rvCU4Xy4j", "us-east-1").await?;
get_completed_students().await
}
#[server]
async fn get_completed_students()
-> Result<HashMap<String, HashMap<String, crate::common::ValueType>>, ServerFnError> {
use imports::*;
// We want to get all student information. The requirements are that the students have completed
// the demographics form - everything else may bed from this.
// The easiest way is to get all the information and filter on this side, instead of bookkeeping
// on the database's side.
let client = create_dynamo_client().await;
let response = client
.scan()
.table_name(MAIN_TABLE_NAME)
.send()
.await
.map_err(|err| {
let msg = err.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})?;
let Some(items) = response.items else {
return Ok(HashMap::new());
};
let mut output = HashMap::<String, HashMap<String, ValueType>>::new();
items.into_iter().for_each(|mut form_info| {
let student_id_full = form_info
.get("HK")
.map(|v| v.as_s().cloned().unwrap_or_default())
.unwrap_or_default()
.to_owned();
let student_id = student_id_full.split("STUDENT#").collect::<String>();
form_info.remove("HK");
form_info.remove("SK");
let form_info_convert = form_info
.into_iter()
.map(|(k, v)| (k, ValueType::from(&v)))
.collect::<HashMap<String, ValueType>>();
// We want to insert all the remaining information into the output map
output
.entry(student_id)
.and_modify(|v| {
v.extend(form_info_convert.clone());
})
.or_insert(form_info_convert);
});
// Don't love this, but it does verify that the student has completed the demographics form
let output = output
.into_iter()
.filter_map(|(k, student_info)| {
student_info
.get("first_name")
.and_then(|_| Some((k, student_info.clone())))
})
.collect();
Ok(output)
}
/// # Get File by Key API
/// This function gets a file from the corresponding S3 file key. If the subject found from the
/// `access_token` does not match the owner of the requested file, the requesting user must have
/// provider-level access or higher.
///
/// ## Possible errors
/// Please check the [`validate_and_get_token_info`] function for all token parsing errors. Otherwise,
/// if the requesting user does not have the correct permissions, it will return a message indicating
/// such. If the file is not found, an error containing "File not found" will be returned.
#[server]
pub async fn get_file_by_key(
access_token: String,
file_key: String,
) -> Result<Vec<u8>, ServerFnError> {
use imports::*;
let user_claims =
validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM", "us-east-1").await?;
// Check if the user's subject is contained in the file_key (since all files are keyed by the
// user's subject)
if !file_key.contains(&user_claims.subject) {
// Check that the user is a provider.
if !user_claims
.groups
.contains(&"ScholarshipProviders".to_string())
{
return Err(ServerFnError::new(
"Access denied: user is not the student or a provider",
));
}
}
// We now just need to get the actual file.
let client = aws_sdk_s3::Client::new(&create_aws_config().await);
let result = client
.get_object()
.key(file_key)
.send()
.await
.map_err(|e| {
let msg = e.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})?;
let bytes = result.body.collect().await?;
Ok(bytes.to_vec())
}
/// # Get Student File Names API
/// Gets a list of file names given a student's ID and the corresponding input ID from the forms.
/// These file names can be used to construct a file key for S3.
///
/// This API is only accessible by provider-level users.
#[server]
pub async fn get_student_files(
access_token: String,
student_id: String,
form_name: String,
question_id: String,
file_name_postfix: String,
) -> Result<(String, Vec<u8>), ServerFnError> {
use imports::*;
match validate_and_get_token_info(access_token.clone(), "us-east-1_Lfjuy5zaM", "us-east-1")
.await
{
Ok(claims) => {
if !claims.groups.contains(&"ScholarshipProviders".to_string()) {
return Err(ServerFnError::new("Access denied: user is not a provider"));
}
}
Err(_) => {
let _ = validate_and_get_token_info(access_token, "us-east-1_rvCU4Xy4j", "us-east-1")
.await?;
}
};
let client = create_dynamo_client().await;
let demographics_res = client
.query()
.table_name(MAIN_TABLE_NAME)
.expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}")))
.expression_attribute_values(":sk", AttributeValue::S("DEMOGRAPHICS".to_string()))
.key_condition_expression("HK = :hk and SK = :sk")
.send()
.await
.map_err(|e| {
let msg = e.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})?;
let student_demographics = demographics_res
.items
.unwrap_or_default()
.into_iter()
.next()
.ok_or(ServerFnError::new(
"Failed to find student demographic information.",
))?;
let first_name = student_demographics
.get("first_name")
.and_then(|v| v.as_s().ok().cloned())
.unwrap_or_default();
let last_name = student_demographics
.get("last_name")
.and_then(|v| v.as_s().ok().cloned())
.unwrap_or_default();
let file_keys = client
.query()
.table_name(MAIN_TABLE_NAME)
.expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}")))
.expression_attribute_values(
":sk",
AttributeValue::S(format!("FILE#{form_name}#{question_id}")),
)
.key_condition_expression("HK = :hk and begins_with(SK, :sk)")
.send()
.await
.map_err(|e| {
let msg = e.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})
.map(|output| {
let Some(items) = output.items else {
return Vec::new();
};
items
.into_iter()
.filter_map(|item| item.get("file_key")?.as_s().ok().cloned())
.collect::<Vec<String>>()
})?;
debug_log!("Await student file futures...");
let futures: Vec<_> = file_keys
.into_iter()
.map(|file_key| async move {
debug_log!("Requesting file from API with key {file_key}");
// Get all files from S3. This is a batch operation.
let s3_client = aws_sdk_s3::Client::new(&create_aws_config().await);
let file_output = s3_client
.get_object()
.bucket("leptos-scholarships")
.key(&file_key)
.send()
.await
.ok()?;
let bytes = file_output.body.collect().await.ok()?.to_vec();
Some((file_key, bytes))
})
.collect();
let results = futures::future::join_all(futures)
.await
.into_iter()
.flatten()
.collect::<Vec<(String, Vec<u8>)>>();
let cur = std::io::Cursor::new(Vec::new());
let mut writer = zip::ZipWriter::new(cur);
debug_log!("Writing {} files to zip file...", results.len());
for (key, file_bytes) in results {
let file_name = key
.split('/')
.last()
.ok_or(ServerFnError::new("Failed to parse file name"))?
.split(".")
.next()
.unwrap_or_default();
let file_ext = key
.split('.')
.last()
.ok_or(ServerFnError::new("Failed to parse file extension"))?;
// Construct the real file name. This name should be under 150 characters.
let temp = format!("{first_name} {last_name} - {file_name}");
let file_name_no_ext = temp.chars().take(150).collect::<String>();
let file_name_complete = format!("{file_name_no_ext}.{file_ext}");
writer
.start_file(file_name_complete, SimpleFileOptions::default())
.map_err(ServerFnError::new)?;
writer.write_all(&file_bytes).map_err(ServerFnError::new)?;
}
debug_log!("Getting finished file");
let finished = writer.finish().map_err(ServerFnError::new)?;
let file_name_hint = format!("{first_name}{last_name}_{file_name_postfix}.zip");
Ok((file_name_hint, finished.into_inner()))
}
/// # Get All Input Files API
/// Gets all files that have been uploaded to the given input. For example, getting all the FAFSA
/// files that were uploaded to the FAFSA file input on the Financial Info page.
///
/// Returns a `HashMap` keyed by the submitting user's ID, with a value of all files that were
/// uploaded by that user.
///
/// This is only usable by users with provider-level access.
#[server]
async fn get_all_input_files(
form_name: String,
input_name: String,
) -> Result<HashMap<String, Vec<String>>, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
let res = client
.scan()
.table_name(MAIN_TABLE_NAME)
.filter_expression("begins_with(SK, :sk)")
.expression_attribute_values(
":sk",
AttributeValue::S(format!("FILE#{form_name}#{input_name}")),
)
.send()
.await
.map_err(|e| {
let msg = e.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})?;
let mut result_map = HashMap::<String, Vec<String>>::new();
res.items.unwrap_or_default().iter().for_each(|item| {
let id = item
.get("HK")
.map(|v| v.as_s().cloned().unwrap_or_default())
.unwrap_or_default()
.split("STUDENT#")
.collect::<String>();
let file_key = item
.get("file_key")
.map(|v| v.as_s().cloned().unwrap_or_default())
.unwrap_or_default();
result_map
.entry(id)
.and_modify(|v| v.push(file_key.clone()))
.or_insert(vec![file_key]);
});
Ok(result_map)
}
#[server]
pub async fn admin_get_all_input_files(
access_token: String,
form_name: String,
input_name: String,
) -> Result<HashMap<String, Vec<String>>, ServerFnError> {
use imports::*;
let _ = validate_and_get_token_info(access_token, "us-east-1_rvCU4Xy4j", "us-east-1").await?;
get_all_input_files(form_name, input_name).await
}
#[server]
pub async fn provider_get_all_input_files(
access_token: String,
form_name: String,
input_name: String,
) -> Result<HashMap<String, Vec<String>>, ServerFnError> {
use imports::*;
let claims =
validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM", "us-east-1").await?;
if !claims.groups.contains(&"ScholarshipProviders".to_string()) {
return Err(ServerFnError::new("Access denied: user is not a provider"));
}
get_all_input_files(form_name, input_name).await
}
#[server]
pub async fn get_student_pdf(student_id: String) -> Result<(String, Vec<u8>), ServerFnError> {
use imports::*;
// NOTE: this API requires that the server has the typst-cli available ON PATH.
let student_info_str = get_student_info_json(student_id.clone()).await?;
let student_info = get_student_data(student_id, "DEMOGRAPHICS".to_string()).await?;
let first_name = student_info
.get("first_name")
.map(|v| v.to_string())
.unwrap_or_default();
let last_name = student_info
.get("last_name")
.map(|v| v.to_string())
.unwrap_or_default();
let typst_string = student_info_str
.replace("{", "(")
.replace("}", ")")
.replace("[", "(")
.replace("]", ")")
// Ensures trailing commas for lists of dictionaries
.replace("))", "),)")
.replace("\")", "\",)");
debug_log!("New string: {}", typst_string);
let pdf_string = format!("#let student = {typst_string}\n{PDF_TEMPLATE}");
debug_log!("PDF string: {}", pdf_string);
let mut child = Command::new("typst")
.arg("compile")
.arg("-")
.arg("-")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
{
let stdin = child.stdin.as_mut().unwrap();
stdin.write_all(pdf_string.as_bytes())?;
stdin.flush()?;
}
let output = child.wait_with_output()?;
if !output.status.success() {
let msg = String::from_utf8_lossy(&output.stderr).to_string();
error!("Failed to generate PDF: {}", msg);
Err(ServerFnError::new(format!("Failed to generate PDF: {msg}")))
} else {
let bytes = output.stdout;
let file_name = format!("{first_name}{last_name}_Application.pdf");
Ok((file_name, bytes))
}
}
#[server]
pub async fn get_student_info_json(student_id: String) -> Result<String, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
let response = client
.query()
.table_name(MAIN_TABLE_NAME)
.expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}")))
.key_condition_expression("HK = :hk")
.send()
.await
.map_err(|e| {
let msg = e.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})?;
let work_exp = client
.query()
.table_name(MAIN_TABLE_NAME)
.expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}")))
.expression_attribute_values(":sk", AttributeValue::S("WORKEXP".to_string()))
.key_condition_expression("HK = :hk AND SK = :sk")
.send()
.await
.map_err(|e| {
let msg = e.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})
.map(|output| {
let items = output.items.unwrap_or_default();
debug_log!("Number of items: {}", items.len());
items
.into_iter()
.next()
.unwrap_or_default()
.get("extracurricular")
.cloned()
.unwrap_or(AttributeValue::S("".to_string()))
})?;
let extracurricular = client
.query()
.table_name(MAIN_TABLE_NAME)
.expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}")))
.expression_attribute_values(":sk", AttributeValue::S("DEMOGRAPHICS".to_string()))
.key_condition_expression("HK = :hk AND SK = :sk")
.send()
.await
.map_err(|e| {
let msg = e.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})
.map(|output| {
let items = output.items.unwrap_or_default();
debug_log!("Number of items: {}", items.len());
items
.into_iter()
.next()
.unwrap_or_default()
.get("extracurricular")
.cloned()
.unwrap_or(AttributeValue::S("".to_string()))
})?;
let Some(items) = response.items else {
return Err(ServerFnError::new("Failed to find student information."));
};
// Join items together into a single object.
let mut data = items.into_iter().fold(HashMap::new(), |mut map, item| {
item.into_iter().for_each(|(k, v)| {
map.insert(k, v);
});
map
});
data.insert("work_experience".to_string(), work_exp);
data.insert("extracurricular".to_string(), extracurricular);
let json_value: serde_json::Value = serde_dynamo::from_item(data)?;
let json_string = serde_json::to_string(&json_value)?;
println!("{}", json_string);
Ok(json_string)
} }
+58
View File
@@ -0,0 +1,58 @@
use crate::common::UserClaims;
#[cfg(feature = "ssr")]
use jsonwebtoken::{DecodingKey, jwk::JwkSet};
use leptos::prelude::*;
#[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,
pool_id: impl Into<String>,
pool_region: impl Into<String>
) -> Result<UserClaims, ServerFnError> {
use jsonwebtoken::{Algorithm, Validation, decode};
let pool_id = pool_id.into();
leptos::logging::debug_log!("Validating token using group {}", pool_id);
// let jwks = fetch_jwks("us-east-1_Lfjuy5zaM".into(), "us-east-1".into()).await?;
let jwks = fetch_jwks(pool_id, pool_region.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"))?;
leptos::logging::debug_log!("Parsing token: {:?}", token);
let token_data = decode::<UserClaims>(&token, &DecodingKey::from_jwk(&jwk)?, &validation)
.map_err(|err| ServerFnError::new(format!("Invalid token found: {err}")))?;
if token_data.claims.token_use != "access" {
return Err(ServerFnError::new("Token type mismatch"));
}
Ok(token_data.claims)
}
+19
View File
@@ -0,0 +1,19 @@
use crate::components::Banner;
use leptos::prelude::*;
#[component]
pub fn FormClosedPage() -> impl IntoView {
// This page simply shows a message saying a form is closed. It does not make any distinction
// about which form is closed right now.
view! {
<Banner title="R15 Scholarships" logo="/PHS_Stacked_Acronym.png" path="/providers" />
<div class="flex flex-1 flex-col gap-4 items-center m-4">
<div class="font-bold text-3xl">"This form has been closed!"</div>
<div class="text-lg">
"If you believe this is an error, please contact Sara Smith at "
<a href="mailto:ssmith@region15.org">"ssmith@region15.org"</a>
</div>
</div>
}
}
+3 -2
View File
@@ -1,5 +1,5 @@
mod about_page; mod about_page;
mod api; pub mod api;
mod comparison_test; mod comparison_test;
mod home_page; mod home_page;
mod loaner_page; mod loaner_page;
@@ -11,9 +11,10 @@ mod auth_callback;
mod provider_applicants; mod provider_applicants;
mod admin; mod admin;
pub(crate) mod student; pub(crate) mod student;
mod form_closed_page;
pub use self::{ pub use self::{
about_page::*, comparison_test::*, home_page::*, loaner_page::*, provider_portal::*, about_page::*, comparison_test::*, home_page::*, loaner_page::*, provider_portal::*,
unauthenticated_page::*, scholarship_info::*, provider_contact::*, unauthenticated_page::*, scholarship_info::*, provider_contact::*,
auth_callback::*, provider_applicants::*, admin::*, student::*, auth_callback::*, provider_applicants::*, admin::*, student::*, form_closed_page::*,
}; };
File diff suppressed because it is too large. Load diff
+72 -55
View File
@@ -1,62 +1,79 @@
use leptos::prelude::*; use crate::components::{Loading, OutlinedTextField, TextFieldType, ValidatedForm};
use crate::components::{Loading, OutlinedTextField, TextFieldType, ValidatedForm};
use crate::pages::student::form_setup::use_student_form; use crate::pages::student::form_setup::use_student_form;
use leptos::prelude::*;
#[component] #[component]
pub fn StudentAcademicsPage() -> impl IntoView { pub fn StudentAcademicsPage() -> impl IntoView {
let controller = use_student_form("academics"); let controller = use_student_form("academics", true);
view! { view! {
<Show <Suspense fallback=Loading>
when=move || controller.data_resource.get().is_some() {move || {
fallback=Loading controller
> .data_resource
<div class="flex flex-1" /> .get()
<div class="flex flex-col flex-2 mt-6"> .map(|_| {
<ValidatedForm title="Academic Information" on_submit=controller.submit_action> view! {
<OutlinedTextField <div class="flex flex-1" />
label="Unweighted GPA:" <div class="flex flex-col flex-2 mt-6">
data_map=controller.data_map <ValidatedForm
data_member="unweighted_gpa" title="Academic Information"
placeholder="4.0" description="This information will allow you to be eligible for any scholarships that have specific academic/grade requirements."
required=true on_submit=controller.submit_action
input_type=TextFieldType::Number disabled=controller.submit_pending
/> >
<OutlinedTextField <OutlinedTextField
label="Weighted GPA:" label="Unweighted GPA:"
data_map=controller.data_map disabled=controller.submit_pending
data_member="weighted_gpa" data_map=controller.data_map
placeholder="4.5" data_member="unweighted_gpa"
required=true placeholder="4.0"
input_type=TextFieldType::Number required=true
/> input_type=TextFieldType::Number
<OutlinedTextField />
label="Highest SAT Score:" <OutlinedTextField
data_map=controller.data_map label="Weighted GPA:"
data_member="sat_score" disabled=controller.submit_pending
placeholder="1600" data_map=controller.data_map
required=false data_member="weighted_gpa"
input_type=TextFieldType::Number placeholder="4.5"
/> required=true
<OutlinedTextField input_type=TextFieldType::Number
label="Highest ACT Score:" />
data_map=controller.data_map <OutlinedTextField
data_member="act_score" label="Highest SAT Score:"
placeholder="36" disabled=controller.submit_pending
required=false data_map=controller.data_map
input_type=TextFieldType::Number data_member="sat_score"
/> placeholder="1600"
<OutlinedTextField required=false
label="Academic Honors:" input_type=TextFieldType::Number
data_map=controller.data_map />
data_member="academic_honors" <OutlinedTextField
placeholder="TBD" label="Highest ACT Score:"
required=false disabled=controller.submit_pending
input_type=TextFieldType::Text data_map=controller.data_map
/> data_member="act_score"
</ValidatedForm> placeholder="36"
</div> required=false
<div class="flex flex-1" /> input_type=TextFieldType::Number
</Show> />
<OutlinedTextField
label="Academic Honors:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="academic_honors"
placeholder="High Honors, etc."
required=false
input_type=TextFieldType::Text
/>
</ValidatedForm>
</div>
<div class="flex flex-1" />
}
})
.collect_view()
}}
</Suspense>
} }
} }
+56 -4
View File
@@ -1,13 +1,65 @@
use leptos::prelude::*; use leptos::prelude::*;
use crate::components::DashboardButton;
#[component] #[component]
pub fn AdditionalPage() -> impl IntoView { pub fn AdditionalPage() -> impl IntoView {
view! { view! {
<div class="flex flex-1 flex-col gap-4 items-center m-4"> <div class="flex-1" />
<div class="font-bold text-3xl">"Student Additional Forms"</div> <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"> <div class="text-lg">
"This is just a placeholder for now. We'll show redirect buttons to each form." "All forms listed under this tab are for additional eligibility factors that some scholarships require."
</div> </div>
<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>
<div class="flex-1" />
} }
} }
+13 -10
View File
@@ -1,20 +1,22 @@
use leptos::prelude::*; use crate::components::{Loading, MultiEntry, ValidatedForm};
use crate::components::{Loading, MultiEntry, ValidatedForm};
use crate::input; use crate::input;
use crate::pages::student::form_setup::use_student_form; use crate::pages::student::form_setup::use_student_form;
use leptos::prelude::*;
#[component] #[component]
pub fn StudentAthleticsPage() -> impl IntoView { pub fn StudentAthleticsPage() -> impl IntoView {
let controller = use_student_form("athletics"); let controller = use_student_form("athletics", true);
view! { view! {
<Show <Show when=move || controller.data_resource.get().is_some() fallback=Loading>
when=move || controller.data_resource.get().is_some()
fallback=Loading
>
<div class="flex flex-1" /> <div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6"> <div class="flex flex-col flex-2 mt-6">
<ValidatedForm title="Athletics Information" on_submit=controller.submit_action> <ValidatedForm
title="Athletics Information"
description="This information will allow you to be eligible for any scholarships that have specific sports requirements."
on_submit=controller.submit_action
disabled=controller.submit_pending
>
<MultiEntry <MultiEntry
label="Athletics Activities:" label="Athletics Activities:"
description="Leave blank if not applicable." description="Leave blank if not applicable."
@@ -38,7 +40,8 @@ pub fn StudentAthleticsPage() -> impl IntoView {
"Lacrosse", "Lacrosse",
"Softball", "Softball",
"Tennis", "Tennis",
"Baseball" "Baseball",
"Volleyball"
] ]
), ),
input!( input!(
@@ -59,4 +62,4 @@ pub fn StudentAthleticsPage() -> impl IntoView {
<div class="flex flex-1" /> <div class="flex flex-1" />
</Show> </Show>
} }
} }
+13
View File
@@ -0,0 +1,13 @@
use leptos::prelude::*;
#[component]
pub fn ClosedForm() -> impl IntoView {
view! {
<div class="flex flex-col items-center mx-auto mt-10">
<h1 class="text-2xl font-bold">"Form Closed"</h1>
<p class="text-lg">
"This form is now closed. If you believe this is an error, please contact the guidance office."
</p>
</div>
}
}
+38 -7
View File
@@ -1,24 +1,29 @@
use leptos::prelude::*;
use crate::components::{Loading, OutlinedTextField, Select, TextFieldType, ValidatedForm}; use crate::components::{Loading, OutlinedTextField, Select, TextFieldType, ValidatedForm};
use crate::pages::student::form_setup::use_student_form; use crate::pages::student::form_setup::use_student_form;
use leptos::prelude::*;
#[component] #[component]
pub fn StudentDemographicsPage() -> impl IntoView { pub fn StudentDemographicsPage() -> impl IntoView {
// This page should show a form that the students need to fill out. // 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. // It's a simple form, really nothing more than a contact form.
// I'm going to test out creating this form without using a panel - we'll just use the page as // I'm going to test out creating this form without using a panel - we'll just use the page as
// it is with a specially spaced invisible div instead of a shaded container. // it is with a specially spaced invisible div instead of a shaded container.
let controller = use_student_form("demographics"); let controller = use_student_form("demographics", true);
view! { view! {
<Show when=move || controller.data_resource.get().is_some() fallback=Loading> <Show when=move || controller.data_resource.get().is_some() fallback=Loading>
<div class="flex flex-1" /> <div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6"> <div class="flex flex-col flex-2 mt-6">
<ValidatedForm title="Student Demographic Form" on_submit=controller.submit_action> <ValidatedForm
// Inputs here. title="Student Demographic Form"
description="Here you'll set up some basic information about yourself."
on_submit=controller.submit_action
disabled=controller.submit_pending
>
<OutlinedTextField <OutlinedTextField
label="First Name:" label="First Name:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="first_name" data_member="first_name"
placeholder="John" placeholder="John"
@@ -27,15 +32,37 @@ pub fn StudentDemographicsPage() -> impl IntoView {
/> />
<OutlinedTextField <OutlinedTextField
label="Last Name:" label="Last Name:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="last_name" data_member="last_name"
placeholder="Smith" placeholder="Smith"
input_type=TextFieldType::Text input_type=TextFieldType::Text
required=true required=true
/> />
// There must be a date of birth selection field here. <OutlinedTextField
label="Date of Birth:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="dob"
placeholder="MM/DD/YYYY"
input_type=TextFieldType::Text
required=true
/>
<Select
label="Gender:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="gender"
required=true
value_list=vec![
"Male".to_string(),
"Female".to_string(),
"Prefer not to answer/Other".to_string(),
]
/>
<OutlinedTextField <OutlinedTextField
label="Student ID Number:" label="Student ID Number:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="id_number" data_member="id_number"
placeholder="Your 7 digit number..." placeholder="Your 7 digit number..."
@@ -44,6 +71,7 @@ pub fn StudentDemographicsPage() -> impl IntoView {
/> />
<OutlinedTextField <OutlinedTextField
label="Preferred Email:" label="Preferred Email:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="email" data_member="email"
placeholder="me@example.com" placeholder="me@example.com"
@@ -52,6 +80,7 @@ pub fn StudentDemographicsPage() -> impl IntoView {
/> />
<OutlinedTextField <OutlinedTextField
label="Preferred Phone Number:" label="Preferred Phone Number:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="phone_number" data_member="phone_number"
placeholder="000-000-0000" placeholder="000-000-0000"
@@ -60,6 +89,7 @@ pub fn StudentDemographicsPage() -> impl IntoView {
/> />
<OutlinedTextField <OutlinedTextField
label="Street Address:" label="Street Address:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="street_address" data_member="street_address"
placeholder="234 Judd Rd" placeholder="234 Judd Rd"
@@ -68,6 +98,7 @@ pub fn StudentDemographicsPage() -> impl IntoView {
/> />
<Select <Select
label="Town:" label="Town:"
disabled=controller.submit_pending
value_list=vec!["Southbury".to_string(), "Middlebury".to_string()] value_list=vec!["Southbury".to_string(), "Middlebury".to_string()]
data_map=controller.data_map data_map=controller.data_map
data_member="town" data_member="town"
+164 -110
View File
@@ -1,9 +1,12 @@
use leptos::logging::debug_log;
use leptos::prelude::*;
use crate::common::{ComparisonData, ExpandableInfo, ValueType}; use crate::common::{ComparisonData, ExpandableInfo, ValueType};
use crate::pages::api::{get_all_scholarship_info, get_comparison_info}; use crate::components::{FileDrop, Header, Loading};
use crate::pages::api::files::list_files;
use crate::pages::api::students::get_all_student_data; use crate::pages::api::students::get_all_student_data;
use crate::pages::api::{get_all_scholarship_info, get_comparison_info};
use crate::utils::get_user_claims; use crate::utils::get_user_claims;
use leptos::logging::{debug_error, debug_log};
use leptos::prelude::*;
use leptos_oidc::AuthSignal;
#[component] #[component]
pub fn StudentEligibilityPage() -> impl IntoView { pub fn StudentEligibilityPage() -> impl IntoView {
@@ -13,43 +16,36 @@ pub fn StudentEligibilityPage() -> impl IntoView {
// Then, for each scholarship found, we'll check every relation and filter out scholarships that // 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 // 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. // 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 // My only reservation with showing the scholarships that aren't valid is that people will want
// to know why. That's hard information to keep track of right now. // to know why. That's hard information to keep track of right now.
let auth = expect_context::<AuthSignal>();
let user_claims = get_user_claims(); let user_claims = get_user_claims();
let user_id = Memo::new(move |_| { let user_id = Memo::new(move |_| user_claims.get().map(|info| info.claims.subject.clone()));
user_claims.get() let access_token = Memo::new(move |_| auth.get().authenticated().map(|a| a.access_token()));
.map(|info| info.claims.subject.clone())
}); let form_id = StoredValue::new("scholarship_essays".to_string());
let refresh_trigger = Trigger::new(); let refresh_trigger = Trigger::new();
let scholarships_resource = Resource::new( let scholarships_resource = Resource::new(
move || refresh_trigger.track(), move || refresh_trigger.track(),
async move |_| { async move |_| get_all_scholarship_info().await,
get_all_scholarship_info().await
}
); );
let relations_resource = Resource::new( let relations_resource = Resource::new(
move || refresh_trigger.track(), move || refresh_trigger.track(),
async move |_| { async move |_| get_comparison_info().await,
get_comparison_info().await
}
); );
let student_resource = Resource::new( let student_resource = Resource::new(
move || (user_id.get().unwrap_or_default(), refresh_trigger.track()), move || (user_id.get().unwrap_or_default(), refresh_trigger.track()),
async move |(user_id, _)| { async move |(user_id, _)| get_all_student_data(user_id).await,
get_all_student_data(user_id).await
}
); );
view! { view! {
<div class="flex-1" /> <div class="flex-1" />
<div> <div class="flex-2 mb-4 flex flex-col">
<Suspense fallback=move || { <Suspense fallback=Loading>
view! { <div>"Checking eligibility..."</div> }
}>
{move || { {move || {
let Some(Ok(scholarships_list)) = scholarships_resource.get() else { let Some(Ok(scholarships_list)) = scholarships_resource.get() else {
return view! {}.into_any(); return view! {}.into_any();
@@ -60,110 +56,168 @@ pub fn StudentEligibilityPage() -> impl IntoView {
let Some(Ok(student_info)) = student_resource.get() else { let Some(Ok(student_info)) = student_resource.get() else {
return view! {}.into_any(); return view! {}.into_any();
}; };
let valid_list = scholarships_list
let valid_list = scholarships_list.iter() .iter()
.filter_map(|scholarship| { .filter_map(|scholarship| {
let scholarship_name = scholarship.data.get("name") let scholarship_name = scholarship
.unwrap_or(&ValueType::String(None)) .data
.as_string().ok().flatten().unwrap_or_default(); .get("name")
.unwrap_or(&ValueType::String(None))
.as_string()
.ok()?
.unwrap_or_default();
debug_log!("Checking scholarship: {}", scholarship_name); debug_log!("Checking scholarship: {}", scholarship_name);
let requirements_map = scholarship
// Each scholarship will return Some(itself), or None. This is will be automatically .data
// filtered based on the information. Invalid scholarships are None, along with those .get("requirements")
// that fail validation for any reason. Scholarships that are valid or did not choose
// any requirements are Some(scholarship).
let requirements_map = scholarship.data.get("requirements")
.unwrap_or(&ValueType::Map(None)) .unwrap_or(&ValueType::Map(None))
.as_map().ok().flatten() .as_map()
.ok()?
.unwrap_or_default() .unwrap_or_default()
.values().cloned() .values()
.cloned()
.collect::<Vec<ValueType>>(); .collect::<Vec<ValueType>>();
let resolved_requirements = requirements_map let resolved_requirements = requirements_map
.iter() .iter()
.map(|list_val| { .map(|list_val| {
// We want to cast to a list, and then map the list to a new let list = list_val
// one that contains the correct ComparisonData. .as_list()
let list = list_val.as_list().ok().flatten().unwrap_or_default(); .ok()
list.iter().filter_map(|v| { .flatten()
let id_string = v.as_string().ok().flatten().unwrap_or_default(); .unwrap_or_default();
relations_list.iter() list.iter()
.find(|relation| relation.id == id_string) .filter_map(|v| {
}) let id_string = v
.cloned() .as_string()
.collect::<Vec<ComparisonData>>() .ok()
.flatten()
.unwrap_or_default();
relations_list
.iter()
.find(|relation| relation.id == id_string)
})
.cloned()
.collect::<Vec<ComparisonData>>()
}) })
.collect::<Vec<Vec<ComparisonData>>>(); .collect::<Vec<Vec<ComparisonData>>>();
debug_log!(
debug_log!("Resolved requirements: {:?}", resolved_requirements.iter().flatten().map(|rel| rel.display_text.clone()).collect::<Vec<String>>()); "Resolved requirements: {:?}", resolved_requirements.iter().flatten().map(|rel| rel.display_text.clone()).collect::<Vec<String>>()
);
let valid = resolved_requirements.iter() let valid = !scholarship_name.is_empty()
.all(|list| { && resolved_requirements
list.iter().fold(false, |prev, requirement| { .iter()
let result = requirement.compare(&student_info) .all(|list| {
.unwrap_or(false); debug_log!("Checking list: {:?}", list);
if list.is_empty() {
if prev { prev } else { result } true
}) } else {
}); list.iter()
.any(|requirement| {
let result = requirement.compare(&student_info);
match result.as_ref() {
Ok(status) => {
debug_log!(
"Requirement with id {} is Ok({})", requirement.id, status
);
status.clone()
}
Err(e) => {
debug_error!(
"Requirement with id {} failed: {}", requirement.id, e
);
false
}
}
})
}
});
if valid { if valid {
debug_log!("Scholarship is valid."); debug_log!("Scholarship is valid.");
Some(scholarship.clone()) let scholarship_id = StoredValue::new(scholarship.subject.clone());
let resource = Resource::new(
move || (
access_token.get(),
form_id.get_value(),
scholarship_id.get_value(),
),
async move |(access_token, form_id, scholarship_id)| {
let Some(access_token) = access_token else {
return Err(
ServerFnError::new("Couldn't find access token"),
);
};
list_files(access_token, form_id, scholarship_id).await
},
);
Some((scholarship.clone(), resource))
} else { } else {
None None
} }
}) })
.collect::<Vec<ExpandableInfo>>(); .collect::<
Vec<(ExpandableInfo, Resource<Result<Vec<String>, ServerFnError>>)>,
>();
view! { view! {
<div>{format!("Valid scholarships: {}", valid_list.len())}</div> <Header
<For title="Eligible Scholarships"
each=move || valid_list.clone() description="This is the list of scholarships that you are eligible for, as well as those that require essays.
key=|scholarship| { Once you have fulfilled the essay requirement, you will be automatically applied for that scholarship.
scholarship If there is no essay requirement, you have already been applied."
.subject
.clone()
}
children=move |scholarship| {
let scholarship = StoredValue::new(scholarship);
let scholarship_name = Memo::new(move |_| {
scholarship
.read_value()
.data
.get("name")
.unwrap_or(&ValueType::String(None))
.as_string()
.ok()
.flatten()
.unwrap_or_default()
});
let scholarship_essay_prompt = Memo::new(move |_| {
scholarship
.read_value()
.data
.get("essay_prompt")
.unwrap_or(&ValueType::String(None))
.as_string()
.ok()
.flatten()
.unwrap_or_default()
});
let essay_required = Memo::new(move |_| {
!scholarship_essay_prompt.get().is_empty()
});
view! {
<div class="flex flex-1 flex-row gap-2 p-3">
<div>{scholarship_name}</div>
<Show when=move || essay_required.get()>
<div>{scholarship_essay_prompt}</div>
</Show>
</div>
}
}
/> />
}.into_any() <div>
<For
each=move || valid_list.clone()
key=|(scholarship, _)| { scholarship.subject.clone() }
children=move |(scholarship, resource)| {
let scholarship_id = StoredValue::new(
scholarship.subject.clone(),
);
let scholarship_name = StoredValue::new(
scholarship
.data
.get("name")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default(),
);
let scholarship_essay = StoredValue::new(
scholarship
.data
.get("essay_prompt")
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
.unwrap_or_default(),
);
view! {
<div class="m-1.5 flex flex-col gap-2 shadow-lg rounded-lg">
<div class="rounded-t-lg bg-red-900 p-2 text-white font-bold">
{scholarship_name.get_value()}
</div>
<div class="p-2 flex flex-col gap-2">
<Show
when=move || !scholarship_essay.get_value().is_empty()
fallback=move || {
view! {
<div>"This scholarship does not require an essay."</div>
}
}
>
<div>{scholarship_essay.get_value()}</div>
<FileDrop
form_id=form_id.get_value()
name=scholarship_id.get_value()
existing_files=resource
/>
</Show>
</div>
</div>
}
}
/>
</div>
}
.into_any()
}} }}
</Suspense> </Suspense>
</div> </div>
+6 -3
View File
@@ -5,19 +5,22 @@ use crate::pages::student::form_setup::use_student_form;
#[component] #[component]
pub fn StudentExtracurricularsPage() -> impl IntoView { pub fn StudentExtracurricularsPage() -> impl IntoView {
let controller = use_student_form("demographics"); let controller = use_student_form("demographics", true);
view! { view! {
<Show when=move || controller.data_resource.get().is_some() fallback=Loading> <Show when=move || controller.data_resource.get().is_some() fallback=Loading>
<div class="flex flex-1" /> <div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6"> <div class="flex flex-col flex-2 mt-6">
<ValidatedForm <ValidatedForm
title="Extracurricular Information" title="Extracurricular and Community Service Information"
description="This information will allow you to be eligible for any scholarships that have specific extracurricular requirements."
on_submit=controller.submit_action on_submit=controller.submit_action
disabled=controller.submit_pending
> >
// Inputs here. // Inputs here.
<OutlinedTextField <OutlinedTextField
label="Total number of service hours:" label="Total number of service hours:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="service_hours" data_member="service_hours"
placeholder="Any number..." placeholder="Any number..."
@@ -36,7 +39,7 @@ pub fn StudentExtracurricularsPage() -> impl IntoView {
input!(Number, "num_hours", "Number of hours completed:", true, "40"), input!(Number, "num_hours", "Number of hours completed:", true, "40"),
input!(Number, "num_weeks", "Number of weeks participated:", true, "3"), input!(Number, "num_weeks", "Number of weeks participated:", true, "3"),
input!( input!(
Text, "special_involvement", "Any special involvement:", false, "" Text, "special_involvement", "Any special involvement:", false, "Club president, etc."
), ),
input!( input!(
Checkbox, "grades", "Grades Participated:", true, ["9th", "10th", "11th", "12th"] Checkbox, "grades", "Grades Participated:", true, ["9th", "10th", "11th", "12th"]
+20 -7
View File
@@ -1,19 +1,25 @@
use leptos::prelude::*;
use crate::components::{Loading, OutlinedTextField, TextFieldType, ValidatedForm}; use crate::components::{Loading, OutlinedTextField, TextFieldType, ValidatedForm};
use crate::pages::student::form_setup::use_student_form; use crate::pages::student::form_setup::use_student_form;
use leptos::prelude::*;
#[component] #[component]
pub fn StudentFamilyPage() -> impl IntoView { pub fn StudentFamilyPage() -> impl IntoView {
let controller = use_student_form("family"); let controller = use_student_form("family", true);
view! { view! {
<Show when=move || controller.data_resource.get().is_some() fallback=Loading> <Show when=move || controller.data_resource.get().is_some() fallback=Loading>
<div class="flex flex-1" /> <div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6"> <div class="flex flex-col flex-2 mt-6">
<ValidatedForm title="Family Information" on_submit=controller.submit_action> <ValidatedForm
title="Family Information"
description="General family information. You must fill out information about at least one parent."
on_submit=controller.submit_action
disabled=controller.submit_pending
>
// Inputs here. // Inputs here.
<OutlinedTextField <OutlinedTextField
label="Total number of children in family:" label="Total number of children in family:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="num_children" data_member="num_children"
placeholder="Any number..." placeholder="Any number..."
@@ -22,6 +28,7 @@ pub fn StudentFamilyPage() -> impl IntoView {
/> />
<OutlinedTextField <OutlinedTextField
label="Total number of children currently attending college:" label="Total number of children currently attending college:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="num_children_college" data_member="num_children_college"
placeholder="Any number..." placeholder="Any number..."
@@ -30,6 +37,7 @@ pub fn StudentFamilyPage() -> impl IntoView {
/> />
<OutlinedTextField <OutlinedTextField
label="Parent/Guardian 1 Name:" label="Parent/Guardian 1 Name:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="parent_one_name" data_member="parent_one_name"
placeholder="John Smith" placeholder="John Smith"
@@ -39,6 +47,7 @@ pub fn StudentFamilyPage() -> impl IntoView {
// TODO Could this be a select dropdown? // TODO Could this be a select dropdown?
<OutlinedTextField <OutlinedTextField
label="Parent/Guardian 1 Relationship:" label="Parent/Guardian 1 Relationship:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="parent_one_relationship" data_member="parent_one_relationship"
placeholder="Mother/Father" placeholder="Mother/Father"
@@ -48,6 +57,7 @@ pub fn StudentFamilyPage() -> impl IntoView {
// TODO Get a better example // TODO Get a better example
<OutlinedTextField <OutlinedTextField
label="Parent/Guardian 1 Occupation:" label="Parent/Guardian 1 Occupation:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="parent_one_occupation" data_member="parent_one_occupation"
placeholder="Milkman" placeholder="Milkman"
@@ -56,6 +66,7 @@ pub fn StudentFamilyPage() -> impl IntoView {
/> />
<OutlinedTextField <OutlinedTextField
label="Parent/Guardian 1 Employer:" label="Parent/Guardian 1 Employer:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="parent_one_employer" data_member="parent_one_employer"
placeholder="Example Employer" placeholder="Example Employer"
@@ -65,24 +76,25 @@ pub fn StudentFamilyPage() -> impl IntoView {
<OutlinedTextField <OutlinedTextField
label="Parent/Guardian 2 Name:" label="Parent/Guardian 2 Name:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="parent_two_name" data_member="parent_two_name"
placeholder="John Smith" placeholder="John Smith"
input_type=TextFieldType::Text input_type=TextFieldType::Text
required=false required=false
/> />
// TODO Could this be a select dropdown?
<OutlinedTextField <OutlinedTextField
label="Parent/Guardian 2 Relationship:" label="Parent/Guardian 2 Relationship:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="parent_two_relationship" data_member="parent_two_relationship"
placeholder="Mother/Father" placeholder="Mother/Father"
input_type=TextFieldType::Text input_type=TextFieldType::Text
required=false required=false
/> />
// TODO Get a better example
<OutlinedTextField <OutlinedTextField
label="Parent/Guardian 2 Occupation:" label="Parent/Guardian 2 Occupation:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="parent_two_occupation" data_member="parent_two_occupation"
placeholder="Milkman" placeholder="Milkman"
@@ -91,6 +103,7 @@ pub fn StudentFamilyPage() -> impl IntoView {
/> />
<OutlinedTextField <OutlinedTextField
label="Parent/Guardian 2 Employer:" label="Parent/Guardian 2 Employer:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="parent_two_employer" data_member="parent_two_employer"
placeholder="Example Employer" placeholder="Example Employer"
@@ -102,4 +115,4 @@ pub fn StudentFamilyPage() -> impl IntoView {
<div class="flex flex-1" /> <div class="flex flex-1" />
</Show> </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" />
}
}
+55 -22
View File
@@ -1,37 +1,40 @@
use std::collections::HashMap;
use leptos::prelude::*;
use crate::common::ValueType; use crate::common::ValueType;
use crate::pages::api::students::{get_student_data, PutStudentData}; use crate::components::{Toast, ToastContext};
use crate::pages::api::students::{PutStudentData, get_student_data};
use crate::utils::get_user_claims; use crate::utils::get_user_claims;
use leptos::prelude::*;
use std::collections::HashMap;
use uuid::Uuid;
pub struct StudentFormInfo { pub struct StudentFormInfo {
pub data_map: RwSignal<HashMap<String, ValueType>>, pub data_map: RwSignal<HashMap<String, ValueType>>,
pub user_id: Memo<Option<String>>, pub user_id: Memo<Option<String>>,
pub submit_action: Callback<()>, pub submit_action: Callback<()>,
pub refresh_trigger: Trigger, pub refresh_trigger: Trigger,
pub data_resource: Resource<Result<HashMap<String, ValueType>, ServerFnError>> pub data_resource: Resource<Result<HashMap<String, ValueType>, ServerFnError>>,
pub submit_pending: Memo<bool>,
} }
pub fn use_student_form( pub fn use_student_form(
form_type: impl Into<Signal<String>> form_type: impl Into<Signal<String>>,
enable_toasts: bool,
) -> StudentFormInfo { ) -> StudentFormInfo {
let form_type = form_type.into(); let form_type = form_type.into();
let user_claims = get_user_claims(); let user_claims = get_user_claims();
let user_id = Memo::new(move |_| { let user_id = Memo::new(move |_| user_claims.get().map(|info| info.claims.subject.clone()));
user_claims.get()
.map(|info| info.claims.subject.clone())
});
let data_map = RwSignal::new(HashMap::new()); let data_map = RwSignal::new(HashMap::new());
let refresh_trigger = Trigger::new(); let refresh_trigger = Trigger::new();
let data_resource = Resource::new( let data_resource = Resource::new(
move || (user_id.get().unwrap_or_default(), form_type.get(), refresh_trigger.track()), move || (user_id.get(), form_type.get(), refresh_trigger.track()),
async move |(id, form_type, _)| get_student_data( async move |(id, form_type, _)| {
id, let Some(id) = id else {
form_type, return Ok(HashMap::new());
).await };
get_student_data(id, form_type).await
},
); );
Effect::new(move || { Effect::new(move || {
@@ -46,18 +49,48 @@ pub fn use_student_form(
let submit_action = ServerAction::<PutStudentData>::new(); let submit_action = ServerAction::<PutStudentData>::new();
let on_submit = move || { let on_submit = move || {
submit_action.dispatch(PutStudentData { if let Some(id) = user_id.get() {
subject: user_id.get().unwrap_or_default(), submit_action.dispatch(PutStudentData {
data_type: form_type.get(), subject: id,
data_map: data_map.get() data_type: form_type.get(),
}); data_map: data_map.get(),
});
}
}; };
if enable_toasts {
let mut toasts = expect_context::<ToastContext>();
Effect::watch(
move || submit_action.value().get(),
move |value, _, _| {
let Some(result) = value else {
return;
};
let toast = match result {
Ok(_) => Toast::new()
.id(Uuid::new_v4())
.header("Submission Successful")
.msg("You may continue editing or fill out another form."),
Err(e) => Toast::new()
.id(Uuid::new_v4())
.header("Submission Failed")
.msg(e.to_string()),
};
toasts.toast(toast);
untrack(move || submit_action.clear());
},
false,
);
}
StudentFormInfo { StudentFormInfo {
data_map, data_map,
refresh_trigger, refresh_trigger,
data_resource, data_resource,
user_id, user_id,
submit_action: on_submit.into(), submit_action: on_submit.into(),
submit_pending: submit_action.pending(),
} }
} }
+29 -45
View File
@@ -1,55 +1,39 @@
use std::collections::HashMap; use crate::components::DashboardButton;
use leptos::logging::log;
use leptos::prelude::*; use leptos::prelude::*;
use crate::common::ValueType;
use crate::components::ActionButton;
use crate::pages::api::students::{GetStudentData, PutStudentData};
use crate::utils::get_user_claims;
#[component] #[component]
pub fn StudentHomePage() -> impl IntoView { pub fn StudentHomePage() -> impl IntoView {
let user_claims = get_user_claims();
let user_id = Memo::new(move |_| {
user_claims.get()
.map(|info| info.claims.subject.clone())
});
let create_student_data = ServerAction::<PutStudentData>::new();
let get_student_data = ServerAction::<GetStudentData>::new();
let on_click_student_test = move |_| {
create_student_data.dispatch(PutStudentData {
subject: user_id.get().unwrap_or_default(),
data_type: "TESTING".into(),
data_map: HashMap::from([
("testing_info".into(), ValueType::String(Some("hello!".to_string())))
]),
});
};
let on_click_get_student = move |_| {
get_student_data.dispatch(GetStudentData {
subject: user_id.get().unwrap_or_default(),
data_type: "TESTING".into()
});
};
Effect::new(move || {
let Some(value) = get_student_data.value().get() else {
return;
};
log!("{:?}", value);
});
view! { view! {
<div class="flex flex-1 flex-col gap-4 items-center m-4"> <div class="flex-1" />
<div class="font-bold text-3xl">"Student Home Page"</div> <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"> <div class="text-lg">
"This is just a placeholder for now. We'll show some redirect buttons." "Welcome to the R15 general scholarship application! If you have any questions, please direct them to Sara Smith at "
<a class="text-blue-500 underline" href="mailto:ssmith@region15.org">
"ssmith@region15.org"
</a>"."
</div> </div>
<ActionButton on:click=on_click_student_test>"Test Put Student Function"</ActionButton> <div class="text-lg">"The due date for this form is on April 2nd, 2026 at 2:05pm."</div>
<ActionButton on:click=on_click_get_student>"Test Get Student Function"</ActionButton> <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>
<div class="flex-1" />
} }
} }
+22 -16
View File
@@ -1,24 +1,30 @@
mod shell; mod academics_page;
mod home_page;
mod demographics_page;
mod additional_page; mod additional_page;
mod athletics_page; mod athletics_page;
mod academics_page; mod closed_form;
mod extracurriculars_page; mod demographics_page;
mod work_experience_page;
mod university_page;
mod family_info_page;
mod form_setup;
mod eligibility_page; mod eligibility_page;
mod extracurriculars_page;
mod family_info_page;
mod financial_page;
mod form_setup;
mod home_page;
mod shell;
mod specific_requirements_page;
mod university_page;
mod work_experience_page;
pub use shell::*; pub use academics_page::*;
pub use home_page::*;
pub use demographics_page::*;
pub use additional_page::*; pub use additional_page::*;
pub use athletics_page::*; pub use athletics_page::*;
pub use academics_page::*; pub use closed_form::*;
pub use demographics_page::*;
pub use eligibility_page::*;
pub use extracurriculars_page::*; pub use extracurriculars_page::*;
pub use work_experience_page::*;
pub use university_page::*;
pub use family_info_page::*; pub use family_info_page::*;
pub use eligibility_page::*; pub use financial_page::*;
pub use home_page::*;
pub use shell::*;
pub use specific_requirements_page::*;
pub use university_page::*;
pub use work_experience_page::*;
+8 -6
View File
@@ -1,9 +1,9 @@
use crate::common::{SubTabInfo, TabInfo};
use crate::components::login::StudentLoginContext;
use crate::components::{Banner, Loading, TabSidebarList};
use crate::pages::UnauthenticatedPage;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated}; use leptos_oidc::{AuthLoaded, Authenticated};
use crate::common::{SubTabInfo, TabInfo};
use crate::components::{Banner, Loading, TabSidebarList};
use crate::components::login::StudentLoginContext;
use crate::pages::UnauthenticatedPage;
#[component] #[component]
pub fn StudentShell() -> impl IntoView { pub fn StudentShell() -> impl IntoView {
@@ -15,7 +15,7 @@ pub fn StudentShell() -> impl IntoView {
title="R15 Student Scholarship Application" title="R15 Student Scholarship Application"
logo="/PHS_Stacked_Acronym.png" logo="/PHS_Stacked_Acronym.png"
/> />
<div class="flex flex-1 min-h-0"> <div class="flex flex-1 min-h-0">
<AuthLoaded fallback=Loading> <AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage> <Authenticated unauthenticated=UnauthenticatedPage>
@@ -35,10 +35,12 @@ pub fn StudentShell() -> impl IntoView {
SubTabInfo::new("Extracurriculars", "extracurriculars"), SubTabInfo::new("Extracurriculars", "extracurriculars"),
SubTabInfo::new("University Info", "university"), SubTabInfo::new("University Info", "university"),
SubTabInfo::new("Family Info", "family-info"), SubTabInfo::new("Family Info", "family-info"),
SubTabInfo::new("Specifics", "specifics"),
SubTabInfo::new("Financials", "financials"),
], ],
), ),
), ),
TabInfo::new("Eligible Scholarships", "eligibility", None) TabInfo::new("Eligible Scholarships", "eligibility", None),
] ]
/> />
</Authenticated> </Authenticated>
@@ -0,0 +1,91 @@
use crate::components::{Loading, RadioList, ValidatedForm};
use crate::pages::student::form_setup::use_student_form;
use leptos::prelude::*;
#[component]
pub fn StudentSpecificRequirementsPage() -> impl IntoView {
let controller = use_student_form("specifics", true);
view! {
<Suspense fallback=Loading>
{move || {
controller
.data_resource
.get()
.map(move |_| {
view! {
<div class="flex-1" />
<div class="flex flex-col flex-2 mt-6">
<ValidatedForm
title="Scholarship-Specific Eligibility Requirements"
description="These questions are specific to some scholarships. Indicate yes or no for each question."
on_submit=controller.submit_action
disabled=controller.submit_pending
>
<RadioList
label="Have you attended BAS?"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="attend_bas"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
<RadioList
label="Are you a member of Midd-South Catholic Church?"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="middsouth_church"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
<RadioList
label="Do you have a family member that is or has served in the US military? (Note: you may be asked for proof of service)"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="family_military_service"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
<RadioList
label="Have you participated in Pomperaug Youth Baseball?"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="youth_baseball"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
<RadioList
label="Have you participated in the Panthers Aquatic Club?"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="aquatic_club"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
<RadioList
label="Are at least one of your parents a member of the Region 15 PEA?"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="pea_member"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
<RadioList
label="Have you participated in the PHS Music Program (Band, Chorus, etc)?"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="music_program"
items=vec!["Yes".to_string(), "No".to_string()]
required=true
/>
</ValidatedForm>
</div>
<div class="flex-1" />
}
.into_any()
})
.collect_view()
}}
</Suspense>
}
}
+54 -9
View File
@@ -4,7 +4,7 @@ use crate::pages::student::form_setup::use_student_form;
#[component] #[component]
pub fn StudentUniversityPage() -> impl IntoView { pub fn StudentUniversityPage() -> impl IntoView {
let controller = use_student_form("university"); let controller = use_student_form("university", true);
// college name, state, city, zip // college name, state, city, zip
// major, study field, intended career, college_acceptance // major, study field, intended career, college_acceptance
@@ -13,10 +13,16 @@ pub fn StudentUniversityPage() -> impl IntoView {
<Show when=move || controller.data_resource.get().is_some() fallback=Loading> <Show when=move || controller.data_resource.get().is_some() fallback=Loading>
<div class="flex flex-1" /> <div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6"> <div class="flex flex-col flex-2 mt-6">
<ValidatedForm title="University Information" on_submit=controller.submit_action> <ValidatedForm
title="University Information"
description="Here you may fill out general information about the university you plan to attend."
on_submit=controller.submit_action
disabled=controller.submit_pending
>
// Inputs here. // Inputs here.
<OutlinedTextField <OutlinedTextField
label="University Name:" label="University Name:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="college_name" data_member="college_name"
placeholder="University of Example" placeholder="University of Example"
@@ -25,23 +31,25 @@ pub fn StudentUniversityPage() -> impl IntoView {
/> />
<OutlinedTextField <OutlinedTextField
label="University Street Address:" label="University Street Address:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="college_city" data_member="college_city"
placeholder="123 Example Rd" placeholder="123 Example Rd"
input_type=TextFieldType::Text input_type=TextFieldType::Text
required=true required=true
/> />
// TODO Make this a select dropdown.
<OutlinedTextField <OutlinedTextField
label="University State:" label="University State:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="college_state" data_member="college_state"
placeholder="The State of Panic" placeholder="CT, AZ, etc."
input_type=TextFieldType::Text input_type=TextFieldType::Text
required=true required=true
/> />
<OutlinedTextField <OutlinedTextField
label="University ZIP:" label="University ZIP:"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="college_zip" data_member="college_zip"
placeholder="12345" placeholder="12345"
@@ -50,19 +58,56 @@ pub fn StudentUniversityPage() -> impl IntoView {
/> />
<Select <Select
label="Have you been sent an acceptance to the university?" label="Have you been sent an acceptance to the university?"
disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="college_acceptance" data_member="college_acceptance"
required=true required=true
value_list=vec!["Yes".to_string(), "No".to_string()] value_list=vec!["Yes".to_string(), "No".to_string()]
/> />
// TODO Make this a select dropdown. <Select
<OutlinedTextField label="Intended Major/Field of Study:"
label="Chosen Major:" disabled=controller.submit_pending
data_map=controller.data_map data_map=controller.data_map
data_member="major" data_member="major"
placeholder="Computer Science"
input_type=TextFieldType::Text
required=true required=true
value_list=vec![
"Music".to_string(),
"Education".to_string(),
"Special Education".to_string(),
"Speech Pathology".to_string(),
"School Psychology".to_string(),
"School Counseling".to_string(),
"Occupational Therapy".to_string(),
"Physical Therapy".to_string(),
"Nursing".to_string(),
"Allied Health".to_string(),
"Fine/Performing Arts".to_string(),
"Writing/Communication".to_string(),
"History".to_string(),
"Government".to_string(),
"Political Science".to_string(),
"Social Work".to_string(),
"Sports Medicine".to_string(),
"Athletic Training".to_string(),
"Horticulture".to_string(),
"Conservation Studies".to_string(),
"Ecology".to_string(),
"Environmental Studies".to_string(),
"Urban Planning".to_string(),
"Landscaping".to_string(),
"Legal Studies".to_string(),
"Criminal Justice".to_string(),
"None of the above".to_string(),
]
/>
<OutlinedTextField
label="Intended Career:"
disabled=controller.submit_pending
data_map=controller.data_map
data_member="intended_career"
placeholder="Engineer, Artist, etc."
input_type=TextFieldType::Text
required=false
/> />
</ValidatedForm> </ValidatedForm>
</div> </div>
+19 -8
View File
@@ -1,17 +1,22 @@
use leptos::prelude::*; use crate::components::{Loading, MultiEntry, ValidatedForm};
use crate::components::{Loading, MultiEntry, ValidatedForm};
use crate::input; use crate::input;
use crate::pages::student::form_setup::use_student_form; use crate::pages::student::form_setup::use_student_form;
use leptos::prelude::*;
#[component] #[component]
pub fn StudentWorkExperiencePage() -> impl IntoView { pub fn StudentWorkExperiencePage() -> impl IntoView {
let controller = use_student_form("workexp"); let controller = use_student_form("workexp", true);
view! { view! {
<Show when=move || controller.data_resource.get().is_some() fallback=Loading> <Show when=move || controller.data_resource.get().is_some() fallback=Loading>
<div class="flex flex-1" /> <div class="flex flex-1" />
<div class="flex flex-col flex-2 mt-6"> <div class="flex flex-col flex-2 mt-6">
<ValidatedForm title="Student Work Experience" on_submit=controller.submit_action> <ValidatedForm
title="Student Work Experience"
description="Here you may add information about your past/current workplace."
on_submit=controller.submit_action
disabled=controller.submit_pending
>
<MultiEntry <MultiEntry
label="Work Experience:" label="Work Experience:"
description="Leave blank if not applicable." description="Leave blank if not applicable."
@@ -22,9 +27,15 @@ pub fn StudentWorkExperiencePage() -> impl IntoView {
Text, "job_title", "Job Title:", true, "Waiter, Cashier, etc..." Text, "job_title", "Job Title:", true, "Waiter, Cashier, etc..."
), ),
input!(Text, "employer", "Employer:", true, "Your employer's name..."), input!(Text, "employer", "Employer:", true, "Your employer's name..."),
input!(Number, "start_date", "Start date:", true, "40"), input!(
input!(Number, "end_date", "End date:", true, "3"), Text, "start_date", "Approximate start date:", true, "01/01/2000"
input!(Number, "num_hours", "Number of hours completed:", true, "120"), ),
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> </ValidatedForm>
@@ -32,4 +43,4 @@ pub fn StudentWorkExperiencePage() -> impl IntoView {
<div class="flex flex-1" /> <div class="flex flex-1" />
</Show> </Show>
} }
} }