Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fff1854067 | ||
|
|
6d6862a680 | ||
|
|
61b0c8b83b | ||
|
|
e9a42aa0fa | ||
|
|
5b44bfb914 | ||
|
|
8282ba4302 | ||
|
|
3cc67cb9e6 | ||
|
|
26b984904b | ||
|
|
4f1d1c5b1c | ||
|
|
39178d05a5 | ||
|
|
4b398c8445 | ||
|
|
6294b114df | ||
|
|
48f711a84d | ||
|
|
21c0df0ded | ||
|
|
eae8d2a26a | ||
|
|
07c1679e1a | ||
|
|
2b255ce968 | ||
|
|
5cda2f8070 | ||
|
|
f8d399f9ae | ||
|
|
c5b11481b3 | ||
|
|
3cf5e6342b | ||
|
|
93a4b54d42 | ||
|
|
c6386e54fc | ||
|
|
469f192feb | ||
|
|
3baee8cac7 | ||
|
|
17e339ea00 | ||
|
|
152c01124b | ||
|
|
f8502ba781 | ||
|
|
23c0029317 | ||
|
|
8a44513a04 | ||
|
|
2db09af903 | ||
|
|
467f8c9020 | ||
|
|
909f33b044 | ||
|
|
44450f407c | ||
|
|
70a86f4545 | ||
|
|
e6409cc423 | ||
|
|
4e3161a05f | ||
|
|
51dfa013df | ||
|
|
1d41b6022e | ||
|
|
f9d5b25436 | ||
|
|
891e324186 | ||
|
|
b502c2f96d | ||
|
|
681da8c326 | ||
|
|
05b865c608 | ||
|
|
628b607074 | ||
|
|
97be249826 | ||
|
|
e1bd472a36 | ||
|
|
2e134fb693 | ||
|
|
db99f39716 | ||
|
|
dd19631e33 | ||
|
|
a0b206a554 | ||
|
|
e9b0452d38 | ||
|
|
448e8bbb38 | ||
|
|
ae7a17ad92 | ||
|
|
750059e9ad | ||
|
|
5a371ae574 | ||
|
|
d7f44463c6 | ||
|
|
5872002538 | ||
|
|
957fc0c5eb | ||
|
|
8a2d5b0494 | ||
|
|
07fae5878f | ||
|
|
def5c8696b | ||
|
|
7c1e281749 | ||
|
|
49bed2f1e1 | ||
|
|
5929a450ae | ||
|
|
05ef850f82 | ||
|
|
1427c51bd3 | ||
|
|
c6867baad4 | ||
|
|
111a7aea86 | ||
|
|
371db711b2 | ||
|
|
c33a587d96 | ||
|
|
80340d99b9 | ||
|
|
142a60bc7c | ||
|
|
97707a9b80 | ||
|
|
4091bd0162 | ||
|
|
323116663a | ||
|
|
9a6cc934de | ||
|
|
6f600a4818 | ||
|
|
311d5647e8 | ||
|
|
3e4c287aa4 | ||
|
|
9d124c4098 | ||
|
|
840be3f0e5 | ||
|
|
53bdc96d01 | ||
|
|
b422e43288 | ||
|
|
838ae8ba0f | ||
|
|
fb90a9d8ac | ||
|
|
9d156c17e9 | ||
|
|
cc09769f28 | ||
|
|
e8b0ceeb7b | ||
|
|
c035115126 | ||
|
|
6f16bbe311 | ||
|
|
1bccecdc72 | ||
|
|
5cafc7e757 | ||
|
|
3f53e8fda1 | ||
|
|
b932388092 | ||
|
|
40d1ef2a5f | ||
|
|
64554d81bc | ||
|
|
c98621aefa | ||
|
|
be54aa8fd2 | ||
|
|
1afacf6008 | ||
|
|
c850ac208f | ||
|
|
0816f2da8b | ||
|
|
04ba0ff1ea | ||
|
|
f10f01534c | ||
|
|
dddc9e4192 | ||
|
|
ebb6fe500c | ||
|
|
e1de6064d9 | ||
|
|
f0db43817c | ||
|
|
12dfe502f4 | ||
|
|
1abedae760 | ||
|
|
1f51357ca5 | ||
|
|
756b23c673 | ||
|
|
17ee7bdc4b | ||
|
|
637f143ac1 | ||
|
|
7d02c6505e | ||
|
|
f7d66ccd8e | ||
|
|
bbc19b88be | ||
|
|
d32261fe09 | ||
|
|
ffccaa6b04 | ||
|
|
97b8305068 | ||
|
|
4f61222f2d | ||
|
|
494f93084f | ||
|
|
da2cca3359 | ||
|
|
6a24c18d6f | ||
|
|
caec4fb017 | ||
|
|
1b8406e09c | ||
|
|
838a412376 | ||
|
|
fdc5374f17 | ||
|
|
fe5a107c7c | ||
|
|
8417713aa1 | ||
|
|
bb743cf114 | ||
|
|
38994088df | ||
|
|
9b3f94f3ad | ||
|
|
b11ad5ffff | ||
|
|
956c653e28 | ||
|
|
4bf0cdcb39 | ||
|
|
15b675a595 | ||
|
|
5a064bdd69 | ||
|
|
d7c4c6b707 | ||
|
|
54a0753599 | ||
|
|
06364236c5 | ||
|
|
0ca00664b0 | ||
|
|
7ba22e5d24 | ||
|
|
f0f22bb68b | ||
|
|
bb5dfa2563 | ||
|
|
f9d9e400d3 | ||
|
|
8df0d10166 | ||
|
|
75f77d5ddf | ||
|
|
f51e88396b | ||
|
|
c97074f163 | ||
|
|
b4f7ff6382 | ||
|
|
b21887f82b | ||
|
|
dfd1594395 | ||
|
|
3f1745a589 | ||
|
|
7ca78b607b | ||
|
|
51f1acc00b | ||
|
|
765322bcca | ||
|
|
3b9e2598b4 | ||
|
|
a95e9a1034 | ||
|
|
12a980f327 | ||
|
|
aa912673c0 | ||
|
|
2069beba8a | ||
|
|
9427810026 | ||
|
|
4a2a5ca9ff | ||
|
|
9e418dddf5 | ||
|
|
5e45cc89fc | ||
|
|
032e978a34 | ||
|
|
11012bb567 | ||
|
|
354875186f | ||
|
|
c0d3288e28 | ||
|
|
e508e0a916 | ||
|
|
444e5ffb72 | ||
|
|
67586d0424 | ||
|
|
cc83d3f015 | ||
|
|
bab6cdae28 | ||
|
|
116d02029c | ||
|
|
b6bc412f56 | ||
|
|
84f27e3c9e | ||
|
|
0b2dc1b0c1 | ||
|
|
081d4ca5ca | ||
|
|
b17c462abb | ||
|
|
0c00c59162 | ||
|
|
ce05be8c02 | ||
|
|
95b69a36a8 | ||
|
|
e24dcd0a90 | ||
|
|
9ebab0689a | ||
|
|
84212d2360 | ||
|
|
0b4aae4db3 | ||
|
|
c3dbe3e2ac | ||
|
|
87aa104055 | ||
|
|
6105fa3bd5 | ||
|
|
bde15de634 | ||
|
|
7d7c96e642 | ||
|
|
f15d151694 | ||
|
|
5ea7461a4d | ||
|
|
67d0f766f8 | ||
|
|
09de9fcf91 | ||
|
|
d42573bf65 | ||
|
|
4e8f4a3c5e | ||
|
|
9eacc9a618 | ||
|
|
6fdf7f0113 | ||
|
|
e143149dac | ||
|
|
26ee1cf7b4 | ||
|
|
73392d0f42 | ||
|
|
9f208fb297 | ||
|
|
ed0a9a0fff | ||
|
|
c4d5c061a7 | ||
|
|
f3e2a0cb83 | ||
|
|
7cf2d132ae | ||
|
|
6a9ce30834 | ||
|
|
b19b696490 | ||
|
|
1d58e99878 | ||
|
|
8af64cbf35 | ||
|
|
5f6e0edab2 | ||
|
|
22d2c96520 | ||
|
|
c1f0c0cb81 | ||
|
|
df4e55369b | ||
|
|
c693ed9190 | ||
|
|
8147ec3e00 | ||
|
|
14d89fc696 | ||
|
|
5d72a8f949 | ||
|
|
de7b8633ff | ||
|
|
865410b6f2 | ||
|
|
0742e833da | ||
|
|
a31c44b794 | ||
|
|
f479fb8030 | ||
|
|
0401027e7d | ||
|
|
97ad9ceee7 | ||
|
|
247d0dfa73 | ||
|
|
af1250350a | ||
|
|
8d9faf126d | ||
|
|
105e6b3e55 | ||
|
|
b2a361a02f | ||
|
|
6891a192c1 | ||
|
|
a2f69a83c2 | ||
|
|
9c7b7f1d4f | ||
|
|
cb888d2bd6 | ||
|
|
e0b54b96f9 | ||
|
|
f08fee1246 | ||
|
|
d9951000e2 | ||
|
|
bad5e994b6 | ||
|
|
b0b6f97b11 | ||
|
|
10d001fd6b | ||
|
|
f401e994a4 | ||
|
|
bb9eb465f4 | ||
|
|
f863b4db18 | ||
|
|
94c2930d2a | ||
|
|
073819c6c4 | ||
|
|
85ec857e38 | ||
|
|
74e7111820 | ||
|
|
139ffdbe55 | ||
|
|
53fd3a56c4 | ||
|
|
24c20f4199 | ||
|
|
7cf2facb18 | ||
|
|
03f7adb74d | ||
|
|
8d90145361 | ||
|
|
9e7fa3afcc | ||
|
|
1d41d1cbf5 | ||
|
|
2324c493d4 | ||
|
|
c2fdd30eea | ||
|
|
05d8e1527e | ||
|
|
f74e5ca093 | ||
|
|
ddfac3dc2e | ||
|
|
bab47ac4af | ||
|
|
b687125d9b | ||
|
|
5e9e9bcf58 | ||
|
|
fab172c4d1 | ||
|
|
38a0482b8c | ||
|
|
a0049d4797 | ||
|
|
ae320f9763 | ||
|
|
d43a6ed153 | ||
|
|
e3ea13d07d | ||
|
|
7abb1c369f | ||
|
|
5ad00e69af | ||
|
|
bbeb713fd6 | ||
|
|
4115d24bd8 | ||
|
|
3b1bf88567 | ||
|
|
d5659f6da7 | ||
|
|
7645ecc240 |
No files matched your search
+10
-1
@@ -1,2 +1,11 @@
|
||||
[target.'cfg(target_os = "linux")']
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
rustflags = [
|
||||
"--cfg=web_sys_unstable_apis",
|
||||
"-C", "link-arg=-fuse-ld=mold"
|
||||
]
|
||||
|
||||
[target.wasm32-unknown-unknown]
|
||||
rustflags = ["--cfg=web_sys_unstable_apis"]
|
||||
|
||||
[target.'cfg(target_os = "windows")']
|
||||
rustflags = ["--cfg=web_sys_unstable_apis"]
|
||||
Generated
+2015
-811
File diff suppressed because it is too large.
Load diff
+26
-10
@@ -7,27 +7,38 @@ edition = "2024"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
leptos = { version = "0.8" }
|
||||
leptos = { version = "0.8", features = ["multipart"] }
|
||||
leptos_router = { version = "0.8" }
|
||||
axum = { version = "0.8", optional = true }
|
||||
console_error_panic_hook = { version = "0.1", optional = true}
|
||||
leptos_axum = { version = "0.8", optional = true }
|
||||
leptos_meta = { version = "0.8" }
|
||||
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
|
||||
wasm-bindgen = { version = "=0.2.100", optional = true }
|
||||
aws-sdk-dynamodb = { version = "1", optional = true }
|
||||
aws-config = { version = "1.6.2", optional = true }
|
||||
serde = "1.0.219"
|
||||
serde_dynamo = { version = "4.2.14", features = ["aws-sdk-dynamodb+1"], optional = true }
|
||||
uuid = { version = "1.17.0", features = ["serde", "v4", "js"] }
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
aws-sdk-dynamodb = { version = "^1", optional = true }
|
||||
aws-sdk-s3 = { version = "1.124.0", optional = true }
|
||||
aws-config = { version = "^1.8", optional = true }
|
||||
serde = "^1.0"
|
||||
serde_dynamo = { version = "^4", features = ["aws-sdk-dynamodb+1"], optional = true }
|
||||
uuid = { version = "^1", features = ["serde", "v4", "js"] }
|
||||
leptos_oidc = "0.9.0"
|
||||
base64 = "0.22.1"
|
||||
chrono = "0.4.42"
|
||||
|
||||
base64 = "0.22"
|
||||
chrono = "0.4"
|
||||
leptos_animate = "0.1"
|
||||
macros = { path = "./macros" }
|
||||
traits = { path = "./traits" }
|
||||
icondata = { version = "0.7.0", features = ["font-awesome"] }
|
||||
leptos_icons = "0.7.1"
|
||||
indexmap = { version = "2.13.0", features = ["serde"] }
|
||||
csv = "1.4.0"
|
||||
leptos-use = { version = "0.18.3", features = ["use_drop_zone"] }
|
||||
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"], optional = true }
|
||||
reqwest = { version = "0.13.2", features = ["json"] }
|
||||
zip = { version = "8.5.1", optional = true }
|
||||
futures = { version = "0.3.32", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["ssr"]
|
||||
hydrate = [
|
||||
"leptos/hydrate",
|
||||
"dep:console_error_panic_hook",
|
||||
@@ -38,11 +49,16 @@ ssr = [
|
||||
"dep:tokio",
|
||||
"dep:leptos_axum",
|
||||
"dep:aws-sdk-dynamodb",
|
||||
"dep:aws-sdk-s3",
|
||||
"dep:aws-config",
|
||||
"dep:serde_dynamo",
|
||||
"dep:jsonwebtoken",
|
||||
"dep:zip",
|
||||
"dep:futures",
|
||||
"leptos/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
"leptos-use/ssr"
|
||||
]
|
||||
|
||||
# Defines a size-optimized profile for the WASM bundle in release mode
|
||||
|
||||
+7
-3
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.90 AS builder
|
||||
FROM rust:1.93 AS builder
|
||||
|
||||
# Install node for tailwind capability
|
||||
RUN apt-get update -y && apt-get install -y --no-install-recommends clang libc6 mold
|
||||
@@ -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
|
||||
# Debian image in the /app directory.
|
||||
FROM debian:trixie-slim AS runtime
|
||||
FROM rust:1.95-slim-trixie AS runtime
|
||||
WORKDIR /app
|
||||
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 clean -y \
|
||||
&& 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
|
||||
COPY --from=builder /app/target/release/scholarships-rs /app/
|
||||
|
||||
|
||||
@@ -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")
|
||||
+76
-126
@@ -1,136 +1,86 @@
|
||||
Forms should be built for this application with a series of JSON-type files.
|
||||
Each form will have a series of sections, and each section will have a series of
|
||||
questions. Each question has a type and a label - question styles may come later.
|
||||
It may be possible for us to call out specific question types without needing
|
||||
to parse the types separately, but that feels not-really-worth-it.
|
||||
To track errors and force form input validation, we're going to create a custom `ValidatedForm` component.
|
||||
|
||||
Example:
|
||||
This component will do two things:
|
||||
- Provide a submit button (that is disabled if the form is invalid, still thinking about that)
|
||||
- Define a validation method that will be called on submit that's updated on a per-input basis
|
||||
(this likely looks like a `Vec<RwSignal<ValidationState>>`)
|
||||
|
||||
```
|
||||
{
|
||||
"SectionDefinitions": [
|
||||
"SectionTitle": [
|
||||
"text": {
|
||||
"label": "First name question!",
|
||||
"placeholder": "John",
|
||||
},
|
||||
"text": {
|
||||
"label": "Last name question!",
|
||||
"placeholder": "Smith",
|
||||
}
|
||||
],
|
||||
"Section Two": [
|
||||
"text": {
|
||||
"label": "Section two testing question",
|
||||
"placeholder": "Some example here",
|
||||
},
|
||||
"select": {
|
||||
"label": "This is a dropdown element!",
|
||||
"placeholder": "option1",
|
||||
"possibleValues": [
|
||||
"option1": "something",
|
||||
"option2": "another something",
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
```
|
||||
In order for this to work, the form will `provide_context` to the `Vec`, and then it will check this
|
||||
vector on submit. It doesn't worry about the states inbetween, only during the submission logic.
|
||||
|
||||
...and so on. Here's a likely set of structures to use:
|
||||
The inputs start off with a default state, which will be defined by that input's validation method.
|
||||
This means that every input will run the validation method on mount and on each change. The first time the
|
||||
function is called should be during mount, which will set the default state. This does mean that any inputs
|
||||
that are marked as required will always be invalid on mount, and so we need to determine when these
|
||||
error messages should be shown and updated.
|
||||
|
||||
```
|
||||
struct FormDefinitionDoc {
|
||||
Vec<Section> section_defs;
|
||||
// and any extra values that should be applied
|
||||
// across the entire form, like settings.
|
||||
I think they should be shown in these situations:
|
||||
- Text inputs: on blur - we don't want to scream at users as they type.
|
||||
- Selectable inputs: on change and form submit - only show the message if the value is invalid, and not at mount.
|
||||
|
||||
To check values for the selectable inputs at submit time, we'll probably need to provide a refresh method
|
||||
that can be called on submit. This method must somehow be callable from the outside of the component and
|
||||
will show the error messages. We'd likely store all this in a struct of some kind, which could contain an
|
||||
`error` and a `show_message` signal.
|
||||
|
||||
We already know that we can send any `Signal` to another component, so we can just send the `show_message`
|
||||
signal to the parent component without any issues. We'll just use that signal to show the error messages
|
||||
in the component as well. Usually we don't want to edit too many signals outside their local context,
|
||||
but this is a special case – documentation is therefore incredibly important! Remember that we may also
|
||||
edit the `show_message` signal within its local context as well, so we need to make sure there are no cyclical
|
||||
dependencies. Alternatively, we could not edit the `show_message` signal at all locally and just use it
|
||||
to trigger side effects that show messages. Have to think about this more before implementation.
|
||||
|
||||
Here's a rough sketch of it all put together:
|
||||
```rust
|
||||
enum ValidationState {
|
||||
/// A valid input!
|
||||
Valid,
|
||||
/// An invalid input with an error message.
|
||||
Invalid(String)
|
||||
}
|
||||
|
||||
struct Section {
|
||||
Vec<QuestionComponent> questions;
|
||||
// each section may have a series of settings including how to submit,
|
||||
// when to submit, and where to submit to at the very least.
|
||||
// We'll store this in a Vec<RwSignal<InputState>>
|
||||
struct InputState {
|
||||
/// Determines if there is an error and provides an error message.
|
||||
error: Signal<ValidationState>,
|
||||
/// Determines if the form has requested to show error messages.
|
||||
show_message: RwSignal<bool>
|
||||
}
|
||||
|
||||
struct QuestionComponent {
|
||||
label: String,
|
||||
placeholder: String,
|
||||
possible_values: HashMap<str, str>,
|
||||
// there are definitely values that i'll find while creating this structure.
|
||||
// Define a new type for ease of use.
|
||||
type ValidationVec = Vec<RwSignal<InputState>>;
|
||||
|
||||
// Parent form component
|
||||
#[component]
|
||||
fn Form() -> impl IntoView {
|
||||
let validation_states: ValidationVec = Vec::new();
|
||||
|
||||
provide_context(validation_states);
|
||||
|
||||
/* All other form logic */
|
||||
}
|
||||
|
||||
fn validation_function(input: String) -> ValidationState {
|
||||
// Validation logic is specific to each input type.
|
||||
}
|
||||
|
||||
// Child component
|
||||
#[component]
|
||||
fn AnyInput() -> impl IntoView {
|
||||
let validation_states = use_context::<ValidationVec>().expect("Couldn't find form validation context");
|
||||
let current_input = RwSignal::new(String::new()); // Obviously, this would have a default value.
|
||||
let error_signal = RwSignal::new(validation_function(current_input.get()));
|
||||
let show_errors = RwSignal::new(false); // Don't show them on mount!
|
||||
|
||||
// See below for InputState definition
|
||||
let error_state = InputState {
|
||||
error: error_signal.clone().into(),
|
||||
show_message: show_errors.clone()
|
||||
};
|
||||
|
||||
validation_states.push(RwSignal::new())
|
||||
|
||||
/* All other input logic */
|
||||
}
|
||||
```
|
||||
|
||||
Types of questions that are required, starting with the providers.
|
||||
|
||||
Providers must be able to:
|
||||
|
||||
- Log into the website using a username and password (handled by AWS)
|
||||
- Create and manage multiple scholarships.
|
||||
- Input scholarship information for each scholarship.
|
||||
- View students that have applied.
|
||||
- View student application information (should just be labels??)
|
||||
- Buttons are a given. Duh.
|
||||
|
||||
Students must be able to:
|
||||
|
||||
- Log in using their Region 15 email (handled by AWS)
|
||||
- Create and manage their general application.
|
||||
- Create and manage applications specific to a single scholarship.
|
||||
- View their acceptance status (need more confirmation)
|
||||
|
||||
Administrators must be able to:
|
||||
|
||||
- Log in using their Region 15 email (handled by AWS)
|
||||
- Manage student scholarship applications.
|
||||
- Manage scholarship provider account information.
|
||||
- Create/manage scholarship information.
|
||||
- Download student applications if needed.
|
||||
- Statistics would be nice, but are likely a future thing.
|
||||
|
||||
Right off the bad, I think I can see a few types of buttons and text inputs,
|
||||
along with dropdowns, checkboxes, our custom `MultiEntry` element from last
|
||||
time. That means no new options, but it does mean that the `FormDefinition`
|
||||
needs to be able to keep track of all this information.
|
||||
|
||||
These are some example `QuestionComponent` objects:
|
||||
|
||||
Text fields:
|
||||
|
||||
```
|
||||
{
|
||||
question_type: "TEXT" | "EMAIL" | "NUMBER",
|
||||
label: "The question label",
|
||||
name: "input_name[jsdoc]",
|
||||
placeholder: "An example input"
|
||||
}
|
||||
```
|
||||
|
||||
Dropdowns/checkboxes/radios:
|
||||
|
||||
```
|
||||
{
|
||||
question_type: "DROPDOWN" | "CHECKBOX" | "RADIO",
|
||||
label: "The question label",
|
||||
name: "input_name[jsdoc]",
|
||||
placeholder: "The pre-selected option value",
|
||||
options: [
|
||||
"optionValue": "Option Text"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`MultiEntry`:
|
||||
|
||||
```
|
||||
{
|
||||
question_type: "MULTI",
|
||||
label: "The question label",
|
||||
name: "input_name[jsdoc]"
|
||||
}
|
||||
```
|
||||
|
||||
The real question here: how can we store this information in a database? And
|
||||
this means specifically within DynamoDB, because this is not an easy structure
|
||||
to store in there.
|
||||
That's going to be the hardest apart and I'll likely involve the AI in that
|
||||
planning.
|
||||
@@ -0,0 +1 @@
|
||||
target
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "signup-lambda"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
lambda_runtime = "1.0.1"
|
||||
aws-sdk-cognitoidentityprovider = "1.105.0"
|
||||
serde = "1"
|
||||
tokio = { version = "1", features = ["macros"] }
|
||||
aws-config = "1.8.12"
|
||||
@@ -0,0 +1,52 @@
|
||||
# Introduction
|
||||
|
||||
signup-lambda is a Rust project that implements an AWS Lambda function in Rust.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Rust](https://www.rust-lang.org/tools/install)
|
||||
- [Cargo Lambda](https://www.cargo-lambda.info/guide/installation.html)
|
||||
|
||||
## Building
|
||||
|
||||
To build the project for production, run `cargo lambda build --release`. Remove the `--release` flag to build for development.
|
||||
|
||||
Read more about building your lambda function in [the Cargo Lambda documentation](https://www.cargo-lambda.info/commands/build.html).
|
||||
|
||||
## Testing
|
||||
|
||||
You can run regular Rust unit tests with `cargo test`.
|
||||
|
||||
If you want to run integration tests locally, you can use the `cargo lambda watch` and `cargo lambda invoke` commands to do it.
|
||||
|
||||
First, run `cargo lambda watch` to start a local server. When you make changes to the code, the server will automatically restart.
|
||||
|
||||
Second, you'll need a way to pass the event data to the lambda function.
|
||||
|
||||
You can use the existent [event payloads](https://github.com/awslabs/aws-lambda-rust-runtime/tree/main/lambda-events/src/fixtures) in the Rust Runtime repository if your lambda function is using one of the supported event types.
|
||||
|
||||
You can use those examples directly with the `--data-example` flag, where the value is the name of the file in the [lambda-events](https://github.com/awslabs/aws-lambda-rust-runtime/tree/main/lambda-events/src/fixtures) repository without the `example_` prefix and the `.json` extension.
|
||||
|
||||
```bash
|
||||
cargo lambda invoke --data-example apigw-request
|
||||
```
|
||||
|
||||
For generic events, where you define the event data structure, you can create a JSON file with the data you want to test with. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "test"
|
||||
}
|
||||
```
|
||||
|
||||
Then, run `cargo lambda invoke --data-file ./data.json` to invoke the function with the data in `data.json`.
|
||||
|
||||
|
||||
Read more about running the local server in [the Cargo Lambda documentation for the `watch` command](https://www.cargo-lambda.info/commands/watch.html).
|
||||
Read more about invoking the function in [the Cargo Lambda documentation for the `invoke` command](https://www.cargo-lambda.info/commands/invoke.html).
|
||||
|
||||
## Deploying
|
||||
|
||||
To deploy the project, run `cargo lambda deploy`. This will create an IAM role and a Lambda function in your AWS account.
|
||||
|
||||
Read more about deploying your lambda function in [the Cargo Lambda documentation](https://www.cargo-lambda.info/commands/deploy.html).
|
||||
@@ -0,0 +1,76 @@
|
||||
use lambda_runtime::LambdaEvent;
|
||||
use aws_sdk_cognitoidentityprovider::Client;
|
||||
use aws_config::BehaviorVersion;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use std::collections::HashMap;
|
||||
use aws_sdk_cognitoidentityprovider::error::ProvideErrorMetadata;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PostConfirmationEvent {
|
||||
version: String,
|
||||
trigger_source: String,
|
||||
region: String,
|
||||
user_pool_id: String,
|
||||
user_name: String,
|
||||
caller_context: CallerContext,
|
||||
request: Request,
|
||||
response: Response
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CallerContext {
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Request {
|
||||
user_attributes: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Response {}
|
||||
|
||||
fn get_error_message(err: impl ProvideErrorMetadata) -> String {
|
||||
err.message().unwrap_or("An unknown error occurred").to_string()
|
||||
}
|
||||
|
||||
async fn add_user_to_group(provider: Client, user_id: String, group_name: impl Into<String>) -> Result<(), String> {
|
||||
let group_name = group_name.into();
|
||||
println!("Adding user {} to group {}", user_id, group_name);
|
||||
match provider
|
||||
.admin_add_user_to_group()
|
||||
.user_pool_id("us-east-1_Lfjuy5zaM")
|
||||
.username(user_id)
|
||||
.group_name(group_name)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(get_error_message(err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn handler(event: LambdaEvent<PostConfirmationEvent>) -> Result<PostConfirmationEvent, String> {
|
||||
println!("Found event: {:?}", event.payload);
|
||||
|
||||
let user_id = event.payload.request.user_attributes.get("sub").unwrap().to_string();
|
||||
let config = aws_config::load_defaults(BehaviorVersion::latest()).await;
|
||||
let client = Client::new(&config);
|
||||
|
||||
// Check the client ID of the request to assign the user to the correct location.
|
||||
let res = match event.payload.caller_context.client_id.as_str() {
|
||||
"10jr2h3vtpu9n7gj46pvg5qo2q" => add_user_to_group(client, user_id, "ScholarshipStudents").await,
|
||||
"56c2bqvl021rv8d5mq36blt7jv" => add_user_to_group(client, user_id, "ScholarshipProviders").await,
|
||||
_ => Err("Invalid client ID, denying all user access.".to_string()),
|
||||
};
|
||||
|
||||
if res.is_ok() {
|
||||
Ok(event.payload)
|
||||
} else {
|
||||
Err(res.unwrap_err())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use lambda_runtime::{run, service_fn, tracing, Error};
|
||||
mod handler;
|
||||
|
||||
use handler::handler as function_handler;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Error> {
|
||||
tracing::init_default_subscriber();
|
||||
|
||||
run(service_fn(function_handler)).await
|
||||
}
|
||||
+97
-60
@@ -1,10 +1,14 @@
|
||||
use crate::pages::{AboutPage, ComparisonTestPage, HomePage, LoanerPage, ProviderPortal, TestPage};
|
||||
use leptos::leptos_dom::log;
|
||||
use crate::components::ToastList;
|
||||
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_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
|
||||
use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge};
|
||||
use leptos_oidc::AuthSignal;
|
||||
use leptos_router::any_nested_route::IntoAnyNestedRoute;
|
||||
use leptos_router::{
|
||||
components::{Route, Router, Routes},
|
||||
MatchNestedRoutes,
|
||||
components::{ParentRoute, Route, Router, Routes},
|
||||
path,
|
||||
};
|
||||
|
||||
@@ -34,10 +38,10 @@ pub fn App() -> impl IntoView {
|
||||
view! {
|
||||
// injects a stylesheet into the document <head>
|
||||
// id=leptos means cargo-leptos will hot-reload this stylesheet
|
||||
<Stylesheet id="leptos" href="/pkg/scholarships-rs-wasm.css"/>
|
||||
<Stylesheet id="leptos" href="/pkg/scholarships-rs-wasm.css" />
|
||||
|
||||
// sets the document title
|
||||
<Title text="R15 Scholarship App DEV"/>
|
||||
<Title text="R15 Scholarship App DEV" />
|
||||
|
||||
// content for this welcome page
|
||||
<Router>
|
||||
@@ -46,77 +50,110 @@ pub fn App() -> impl IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
fn use_origin() -> String {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
use leptos::web_sys;
|
||||
// Just get the current URL origin.
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.location()
|
||||
.origin()
|
||||
.unwrap_or("http://localhost:3000/".to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// Read the expected origin out of an environment variable.
|
||||
std::env::var("LP_SITE_ORIGIN").unwrap_or("http://localhost:3000/".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AppWithRoutes() -> impl IntoView {
|
||||
provide_meta_context();
|
||||
|
||||
let current_origin = use_origin();
|
||||
log!("Current origin: {}", current_origin);
|
||||
|
||||
// Authentication setup
|
||||
let parameters = AuthParameters {
|
||||
issuer: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Lfjuy5zaM".into(),
|
||||
client_id: "10jr2h3vtpu9n7gj46pvg5qo2q".into(),
|
||||
redirect_uri: format!("{}/", use_origin()),
|
||||
// redirect_uri: Url::parse(format!("{}", current_origin).as_str()).unwrap().to_string(),
|
||||
// redirect_uri: Url::parse("http://localhost:3000").unwrap().to_string(),
|
||||
post_logout_redirect_uri: current_origin,
|
||||
scope: Some("openid%20profile%20email".into()),
|
||||
audience: None,
|
||||
challenge: Challenge::None,
|
||||
};
|
||||
|
||||
let auth = Auth::signal();
|
||||
provide_context(auth); // allows use of this signal in lower areas of the tree without
|
||||
// explicitly passing it through the html tree
|
||||
|
||||
let _ = Auth::init(parameters);
|
||||
|
||||
view! {
|
||||
// injects a stylesheet into the document <head>
|
||||
// id=leptos means cargo-leptos will hot-reload this stylesheet
|
||||
<Stylesheet id="leptos" href="/pkg/scholarships-rs-wasm.css" />
|
||||
|
||||
// <AuthLoading><p>"Authentication is loading"</p></AuthLoading>
|
||||
// <AuthErrorContext><AuthErrorView/></AuthErrorContext>
|
||||
|
||||
// sets the document title
|
||||
<Title text="R15 Scholarship App DEV" />
|
||||
|
||||
<main>
|
||||
// TODO Create a 404 page
|
||||
<Routes fallback=|| "Page not found.".into_view()>
|
||||
<Route path=path!("") view=HomePage/>
|
||||
<Route path=path!("about") view=AboutPage/>
|
||||
<Route path=path!("test_page") view=TestPage/>
|
||||
<Route path=path!("comparison") view=ComparisonTestPage />
|
||||
<Route path=path!("providers") view=ProviderPortal />
|
||||
<Route path=path!("loaners") view=LoanerPage />
|
||||
<Route path=path!("loaners/:form_name") view=LoanerPage />
|
||||
</Routes>
|
||||
<ToastList>
|
||||
// TODO Create a 404 page
|
||||
<Routes fallback=|| "Page not found.".into_view()>
|
||||
// <Route path=path!("/testing") view=TestPage />
|
||||
<ProviderRoutes />
|
||||
<LoanerRoutes />
|
||||
<StudentRoutes />
|
||||
<AdminRoutes />
|
||||
</Routes>
|
||||
</ToastList>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
#[component(transparent)]
|
||||
fn ProviderRoutes() -> impl MatchNestedRoutes + Clone {
|
||||
view! {
|
||||
<ParentRoute path=path!("/providers") view=ProviderLoginContext>
|
||||
<Route path=path!("") view=ProviderPortal />
|
||||
<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!("/profile") view=ProviderContactPage />
|
||||
// <Route path=path!("/scholarships") view=FormClosedPage />
|
||||
<ParentRoute path=path!("/scholarships") view=ScholarshipInfoPage>
|
||||
<Route path=path!(":id") view=ScholarshipInfoPage />
|
||||
<Route path=path!("") view=ScholarshipInfoPage />
|
||||
</ParentRoute>
|
||||
</ParentRoute>
|
||||
}
|
||||
.into_inner()
|
||||
.into_any_nested_route()
|
||||
}
|
||||
|
||||
#[component(transparent)]
|
||||
fn LoanerRoutes() -> impl MatchNestedRoutes + Clone {
|
||||
view! {
|
||||
<ParentRoute path=path!("loaners") view=LoanerShell>
|
||||
<Route path=path!("") view=LoanerFallback />
|
||||
<Route path=path!("borrowing") view=LoanerBorrowForm />
|
||||
<Route path=path!("returning") view=LoanerReturnForm />
|
||||
</ParentRoute>
|
||||
}
|
||||
.into_inner()
|
||||
.into_any_nested_route()
|
||||
}
|
||||
|
||||
#[component(transparent)]
|
||||
fn AdminRoutes() -> impl MatchNestedRoutes + Clone {
|
||||
view! {
|
||||
<ParentRoute path=path!("admin") view=AdminShell>
|
||||
<Route path=path!("callback") view=AuthCallbackPage />
|
||||
<Route path=path!("home") view=AdminHomePage />
|
||||
<Route path=path!("providers") view=AdminProviderPage />
|
||||
<Route path=path!("scholarships") view=AdminScholarshipPage />
|
||||
<Route path=path!("utilities") view=AdminUtilsPage />
|
||||
<ParentRoute path=path!("applicants") view=AdminApplicantsPageShell>
|
||||
<Route path=path!("") view=AdminApplicantsPageFallback />
|
||||
<Route path=path!("/:scholarship_id") view=AdminApplicantsStudentList />
|
||||
</ParentRoute>
|
||||
</ParentRoute>
|
||||
}
|
||||
.into_inner()
|
||||
.into_any_nested_route()
|
||||
}
|
||||
|
||||
#[component(transparent)]
|
||||
fn StudentRoutes() -> impl MatchNestedRoutes + Clone {
|
||||
view! {
|
||||
<ParentRoute path=path!("students") view=student::StudentShell>
|
||||
<Route path=path!("callback") view=AuthCallbackPage />
|
||||
<Route path=path!("home") view=student::StudentHomePage />
|
||||
<Route path=path!("demographics") view=student::ClosedForm />
|
||||
<Route path=path!("eligibility") view=student::ClosedForm />
|
||||
<Route path=path!("additional") view=student::ClosedForm />
|
||||
<Route path=path!("additional/academics") view=student::ClosedForm />
|
||||
<Route path=path!("additional/athletics") view=student::ClosedForm />
|
||||
<Route path=path!("additional/extracurriculars") view=student::ClosedForm />
|
||||
<Route path=path!("additional/work-experience") view=student::ClosedForm />
|
||||
<Route path=path!("additional/university") view=student::ClosedForm />
|
||||
<Route path=path!("additional/family-info") view=student::ClosedForm />
|
||||
<Route path=path!("additional/financials") view=student::ClosedForm />
|
||||
<Route path=path!("additional/specifics") view=student::ClosedForm />
|
||||
</ParentRoute>
|
||||
}
|
||||
.into_inner()
|
||||
.into_any_nested_route()
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AuthErrorView() -> impl IntoView {
|
||||
let auth = use_context::<AuthSignal>().expect("AuthError: RwSignal<AuthStore> was not found");
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
use leptos_animate::animations::classes::{In, Out};
|
||||
|
||||
pub fn pop_in_out() -> (In, Out) {
|
||||
(
|
||||
In::default()
|
||||
.source("opacity-0 -translate-y-2 scale-90")
|
||||
.active("transition-all duration-150 transform-gpu")
|
||||
.target("opacity-100 translate-y-0 scale-100"),
|
||||
Out::default()
|
||||
.source("opacity-100 translate-y-0 scale-100")
|
||||
.active("transition-all duration-150 transform-gpu")
|
||||
.target("opacity-0 -translate-y-2 scale-90"),
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,8 @@ use super::{
|
||||
Comparison, MapListComparison, NestedListComparison, NumberComparison, NumberListComparison,
|
||||
TextComparison, TextListComparison,
|
||||
};
|
||||
use crate::common::{ExpandableInfo, ValueType};
|
||||
use crate::common::ValueType;
|
||||
use std::collections::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
@@ -23,6 +24,7 @@ pub struct ComparisonData {
|
||||
}
|
||||
|
||||
impl ComparisonData {
|
||||
#[allow(unused)]
|
||||
pub fn new(
|
||||
id: impl Into<String>,
|
||||
member: impl Into<String>,
|
||||
@@ -41,15 +43,13 @@ impl ComparisonData {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compare(&self, student_data: &ExpandableInfo) -> Result<bool, String> {
|
||||
let value = student_data.data.get(&self.member).expect(
|
||||
format!(
|
||||
"Couldn't find member {:?}, current student data: {:?}",
|
||||
self.member, student_data
|
||||
pub fn compare(&self, student_data: &HashMap<String, ValueType>) -> Result<bool, String> {
|
||||
student_data.get(&self.member)
|
||||
.ok_or_else(||
|
||||
format!("Couldn't find member {:?}, current student data: {:?}", self.member, student_data)
|
||||
)
|
||||
.as_str(),
|
||||
);
|
||||
self.comparison.evaluate(value, &self.target_value)
|
||||
.and_then(|value| self.comparison.evaluate(value, &self.target_value))
|
||||
.or_else(|err| Err(err.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,11 +105,11 @@ mod tests {
|
||||
display_text: "First Name is Jane".to_string(),
|
||||
};
|
||||
|
||||
let mut result = text_comp_success.compare(&student_data);
|
||||
let mut result = text_comp_success.compare(&student_data.data);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), true);
|
||||
|
||||
result = text_comp_fail.compare(&student_data);
|
||||
result = text_comp_fail.compare(&student_data.data);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ mod lists;
|
||||
mod number;
|
||||
mod text;
|
||||
mod traits;
|
||||
mod utils;
|
||||
|
||||
pub use self::{base::*, lists::*, number::*, text::*, traits::*};
|
||||
|
||||
|
||||
@@ -17,14 +17,16 @@ impl Comparison for NumberComparison {
|
||||
// The prerequisites for all number comparisons is that the target value is Some(number) and the
|
||||
// comparison member points to Some(number) in the student_info.
|
||||
let student_value = match comp_value {
|
||||
ValueType::Number(Some(num)) => num.parse::<i32>().unwrap(),
|
||||
ValueType::Number(Some(num)) => num.parse::<f32>().unwrap(),
|
||||
_ => {
|
||||
return Err("Student value is not a number".into());
|
||||
}
|
||||
};
|
||||
|
||||
leptos::logging::log!("Parsing value: {:?}", target_value);
|
||||
|
||||
let target = match target_value {
|
||||
ValueType::Number(Some(num)) => num.parse::<i32>().unwrap(),
|
||||
ValueType::Number(Some(num)) => num.parse::<f32>().unwrap(),
|
||||
_ => {
|
||||
return Err("Target value is not a number".into());
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
use crate::common::ValueType;
|
||||
|
||||
/// This function is a work in progress. It's designed to flatten a list of maps by using the
|
||||
/// given key and verifier function, however it currently doesn't work and just needs to be
|
||||
/// planned out a little more.
|
||||
fn flatten_map_helper<F>(
|
||||
list: &Vec<ValueType>,
|
||||
key: &String,
|
||||
verifier: F,
|
||||
) -> Result<Vec<ValueType>, String>
|
||||
where
|
||||
F: Fn(&ValueType) -> Option<&ValueType>,
|
||||
{
|
||||
let mut out = Vec::new();
|
||||
for item in list {
|
||||
let map = match item {
|
||||
ValueType::Map(Some(map)) => map,
|
||||
_ => return Err("Expected a map".to_owned()),
|
||||
};
|
||||
|
||||
// Get the key, and verify that the value is the correct type. If so, push it.
|
||||
if let Some(value) = map.get(key).and_then(|v| verifier(v)) {
|
||||
out.push(value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use chrono::{DateTime, FixedOffset, Local};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DateInfo {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub date: DateRange,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl DateInfo {
|
||||
pub fn get_start_date(&self) -> DateTime<FixedOffset> {
|
||||
match self.date {
|
||||
DateRange::Single(date) | DateRange::Range(date, _) => date,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum DateRange {
|
||||
Single(DateTime<FixedOffset>),
|
||||
Range(DateTime<FixedOffset>, DateTime<FixedOffset>),
|
||||
}
|
||||
|
||||
impl DateRange {
|
||||
pub fn get_status(&self) -> DateStatus {
|
||||
match self {
|
||||
DateRange::Single(_) => DateStatus::Blank,
|
||||
DateRange::Range(start_date, end_date) => {
|
||||
let local = Local::now();
|
||||
// Check the amount of time from now to the start date. Values that are negative
|
||||
// indicate that the start date has already passed.
|
||||
if dbg!(start_date.signed_duration_since(local).num_days()) > 0 {
|
||||
DateStatus::Upcoming
|
||||
} else {
|
||||
// Check the amount of time from now to the end date. The value will be positive if
|
||||
// the end date is in the future. We will be specific to the minute.
|
||||
let duration_until = dbg!(end_date).signed_duration_since(local);
|
||||
let num_days = dbg!(duration_until.num_days());
|
||||
let num_mins = dbg!(duration_until.num_minutes());
|
||||
|
||||
if num_mins <= 0 {
|
||||
DateStatus::Closed
|
||||
} else if num_days <= 5 {
|
||||
DateStatus::Deadline
|
||||
} else {
|
||||
DateStatus::Open
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd, Eq, Ord)]
|
||||
pub enum DateStatus {
|
||||
Closed,
|
||||
Deadline,
|
||||
Open,
|
||||
Upcoming,
|
||||
Blank,
|
||||
}
|
||||
@@ -15,7 +15,7 @@ use std::collections::HashMap;
|
||||
/// now, the table will simply create a single massive `Map` field that contains all the stored
|
||||
/// information in the `HashMap`. We'd prefer to be able to get single values if possible,
|
||||
/// especially for interoperability with other applications, should they be created.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Reactive)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Reactive)]
|
||||
pub struct ExpandableInfo {
|
||||
/// The main key member. Required and used to get/put the information in a database.
|
||||
pub subject: String,
|
||||
|
||||
+73
-58
@@ -1,8 +1,6 @@
|
||||
use crate::common::ValueType;
|
||||
use crate::components::{CheckboxList, OutlinedTextField, RadioList, Row, Select};
|
||||
use leptos::either::EitherOf5;
|
||||
use leptos::prelude::RwSignal;
|
||||
use leptos::{IntoView, view};
|
||||
use crate::components::{CheckboxList, OutlinedTextField, RadioList, Row, Select, TextFieldType};
|
||||
use leptos::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -11,85 +9,100 @@ use std::collections::HashMap;
|
||||
pub enum InputType {
|
||||
/// Represents a text input. Requires a `String` key, `String` label, and `String` placeholder.
|
||||
/// The entered value will be returned as a single `String` value.
|
||||
Text(String, String, String),
|
||||
Text(String, String, String, bool),
|
||||
/// Represents a number input. Requires a `String` key, `String` label, and a `String` placeholder.
|
||||
/// The entered value will be returned as a `String`, and will be parseable into any number type
|
||||
/// (i.e. an `i32`, `i64`, etc)
|
||||
Number(String, String, String),
|
||||
Number(String, String, String, bool),
|
||||
/// Represents a radio input. Requires a `String` key, `String` label, and a series of `String`
|
||||
/// options. The selected value will be returned as a `String`.
|
||||
Radio(String, String, Vec<String>),
|
||||
Radio(String, String, Vec<String>, bool),
|
||||
/// Represents a checkbox input. Requires a `String` key, `String` label, and a series of `String`
|
||||
/// options. The selected values will be returned as a `Vec<String>`.
|
||||
Checkbox(String, String, Vec<String>),
|
||||
Checkbox(String, String, Vec<String>, bool),
|
||||
/// Represents a dropdown input. Requires a `String` key, `String` label, and a series
|
||||
/// of `String` options. The selected value will be returned as a `String`.
|
||||
Select(String, String, Vec<String>),
|
||||
Select(String, String, Vec<String>, bool),
|
||||
}
|
||||
|
||||
impl InputType {
|
||||
pub fn into_view(self, data_map: RwSignal<HashMap<String, ValueType>>) -> impl IntoView {
|
||||
/// Converts an `InputType` into an `impl IntoView`. A name must be specified in the case that
|
||||
/// the component is a checkbox/radio list, which requires a unique name to function properly.
|
||||
/// Many times, the name will be the same as the data member.
|
||||
pub fn into_view(
|
||||
self,
|
||||
data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
name: String,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
{match self {
|
||||
InputType::Text(member, label, placeholder) =>
|
||||
EitherOf5::A(view! {
|
||||
InputType::Text(member, label, placeholder, required) => {
|
||||
view! {
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label = label.clone()
|
||||
placeholder = placeholder.clone()
|
||||
data_member = member.clone()
|
||||
data_map = data_map
|
||||
label=label.clone()
|
||||
placeholder=placeholder.clone()
|
||||
data_member=member.clone()
|
||||
data_map=data_map
|
||||
required=required
|
||||
/>
|
||||
</Row>
|
||||
}),
|
||||
InputType::Number(member, label, placeholder) =>
|
||||
EitherOf5::B(view! {
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
InputType::Number(member, label, placeholder, required) => {
|
||||
view! {
|
||||
<OutlinedTextField
|
||||
label = label.clone()
|
||||
placeholder = placeholder.clone()
|
||||
data_member = member.clone()
|
||||
data_map = data_map
|
||||
input_type = "number"
|
||||
label=label.clone()
|
||||
placeholder=placeholder.clone()
|
||||
data_member=member.clone()
|
||||
data_map=data_map
|
||||
input_type=TextFieldType::Number
|
||||
required=required
|
||||
/>
|
||||
}),
|
||||
InputType::Checkbox(member, label, options) =>
|
||||
EitherOf5::C(view! {
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
InputType::Checkbox(member, label, options, required) => {
|
||||
view! {
|
||||
<CheckboxList
|
||||
label = label.clone()
|
||||
items = options.clone()
|
||||
data_member = member.clone()
|
||||
data_map = data_map
|
||||
label=label.clone()
|
||||
name=name.clone()
|
||||
items=options.clone()
|
||||
data_member=member.clone()
|
||||
data_map=data_map
|
||||
required=required
|
||||
/>
|
||||
}),
|
||||
InputType::Radio(member, label, options) =>
|
||||
EitherOf5::D(view! {
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
InputType::Radio(member, label, options, required) => {
|
||||
view! {
|
||||
<RadioList
|
||||
label = label.clone()
|
||||
data_member = member.clone()
|
||||
data_map = data_map
|
||||
items = options.clone()
|
||||
label=label.clone()
|
||||
data_member=member.clone()
|
||||
data_map=data_map
|
||||
items=options.clone()
|
||||
required=required
|
||||
/>
|
||||
}),
|
||||
InputType::Select(member, label, options) =>
|
||||
EitherOf5::E(view! {
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
InputType::Select(member, label, options, required) => {
|
||||
view! {
|
||||
<Select
|
||||
label = label.clone()
|
||||
value_list = options.clone()
|
||||
data_member = member.clone()
|
||||
data_map = data_map
|
||||
label=label.clone()
|
||||
value_list=options.clone()
|
||||
data_member=member.clone()
|
||||
data_map=data_map
|
||||
required=required
|
||||
/>
|
||||
})
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_view_rows(self, data_map: RwSignal<HashMap<String, ValueType>>) -> impl IntoView {
|
||||
view! {
|
||||
<Row>
|
||||
{self.into_view(data_map)}
|
||||
</Row>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shorthand macro for creating `InputType` enums.
|
||||
@@ -104,19 +117,21 @@ impl InputType {
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! input {
|
||||
($variant:ident, $key:expr, $label:expr, [$($option:expr),* $(,)?]) => {
|
||||
($variant:ident, $key:expr, $label:expr, $required:expr, [$($option:expr),* $(,)?]) => {
|
||||
$crate::common::InputType::$variant(
|
||||
$key.to_string(),
|
||||
$label.to_string(),
|
||||
vec![$($option.to_string()), *]
|
||||
vec![$($option.to_string()), *],
|
||||
$required
|
||||
)
|
||||
};
|
||||
|
||||
($variant:ident, $key:expr, $label:expr, $placeholder:expr) => {
|
||||
($variant:ident, $key:expr, $label:expr, $required:expr, $placeholder:expr) => {
|
||||
$crate::common::InputType::$variant(
|
||||
$key.to_string(),
|
||||
$label.to_string(),
|
||||
$placeholder.to_string()
|
||||
$placeholder.to_string(),
|
||||
$required
|
||||
)
|
||||
};
|
||||
}
|
||||
+7
-1
@@ -1,10 +1,16 @@
|
||||
mod comparison;
|
||||
mod date_info;
|
||||
mod expandable_info;
|
||||
mod form_params;
|
||||
mod form_schema;
|
||||
mod submit_status;
|
||||
mod user;
|
||||
mod value_type;
|
||||
pub mod animations;
|
||||
mod tab_info;
|
||||
mod schema_node;
|
||||
|
||||
pub use self::{
|
||||
comparison::*, expandable_info::*, form_params::*, form_schema::*, user::*, value_type::*,
|
||||
comparison::*, date_info::*, expandable_info::*, form_params::*, form_schema::*,
|
||||
submit_status::*, user::*, value_type::*, tab_info::*, schema_node::*,
|
||||
};
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SubmitStatus {
|
||||
Idle,
|
||||
Sending,
|
||||
Success,
|
||||
Error(()),
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TabInfo {
|
||||
pub text: String,
|
||||
pub path: String,
|
||||
pub sub_paths: Vec<SubTabInfo>,
|
||||
}
|
||||
|
||||
impl TabInfo {
|
||||
pub fn new(
|
||||
text: impl Into<String>,
|
||||
path: impl Into<String>,
|
||||
sub_paths: Option<Vec<SubTabInfo>>
|
||||
) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
path: path.into(),
|
||||
sub_paths: sub_paths.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubTabInfo {
|
||||
pub text: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
impl SubTabInfo {
|
||||
pub fn new(text: impl Into<String>, path: impl Into<String>) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
path: path.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,4 +22,9 @@ pub struct UserClaims {
|
||||
/// The username of the user. This should be their email address.
|
||||
#[serde(rename = "username")]
|
||||
pub username: String,
|
||||
/// The intended use for the token.
|
||||
pub token_use: String,
|
||||
/// 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>,
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use std::fmt::Display;
|
||||
pub enum ValueType {
|
||||
/// Indicates a `String` value.
|
||||
String(Option<String>),
|
||||
/// Indicates an `i32` value.
|
||||
/// Indicates an `i32` value. /// Indicates an `f32` value.
|
||||
Number(Option<String>),
|
||||
/// Indicates a `Vec<T>`, where `T` can be any other `ValueType`.
|
||||
List(Option<Vec<ValueType>>),
|
||||
@@ -105,11 +105,51 @@ impl Default for ValueType {
|
||||
impl Display for ValueType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let str = match self {
|
||||
ValueType::String(s) => s.clone().unwrap_or("".to_owned()),
|
||||
ValueType::Number(n) => n.clone().unwrap_or("".to_owned()),
|
||||
ValueType::String(s) => s.clone().unwrap_or_default(),
|
||||
ValueType::Number(n) => n.clone().unwrap_or_default(),
|
||||
ValueType::List(l) => format!("{l:?}"),
|
||||
ValueType::Map(m) => format!("{m:?}"),
|
||||
};
|
||||
write!(f, "{}", str)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use aws_sdk_dynamodb::types::AttributeValue;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl From<&AttributeValue> for ValueType {
|
||||
fn from(attr: &AttributeValue) -> Self {
|
||||
match attr {
|
||||
AttributeValue::S(s) => ValueType::String(Some(s.clone())),
|
||||
AttributeValue::N(n) => ValueType::Number(Some(n.clone())),
|
||||
AttributeValue::L(l) => ValueType::List(Some(l.iter().map(|v| v.into()).collect())),
|
||||
AttributeValue::M(m) => {
|
||||
ValueType::Map(Some(m.iter().map(|(k, v)| (k.clone(), v.into())).collect()))
|
||||
}
|
||||
_ => ValueType::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl From<ValueType> for AttributeValue {
|
||||
fn from(value: ValueType) -> Self {
|
||||
match value {
|
||||
ValueType::String(s) => AttributeValue::S(s.unwrap_or_default()),
|
||||
ValueType::Number(n) => AttributeValue::N(n.unwrap_or_default()),
|
||||
ValueType::List(l) => AttributeValue::L(
|
||||
l.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|v| v.into())
|
||||
.collect(),
|
||||
),
|
||||
ValueType::Map(m) => AttributeValue::M(
|
||||
m.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, v.into()))
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,19 @@ use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn ActionButton(
|
||||
#[prop(optional, default = String::from("button"))] button_type: String,
|
||||
#[prop(optional, default = RwSignal::new(false))] disabled: RwSignal<bool>,
|
||||
#[prop(default = String::from("button"), into)] button_type: String,
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<button
|
||||
class="m-1.5 p-2.5 rounded-lg font-semibold text-white transition-all duration-400"
|
||||
class=(["bg-red-700", "hover:bg-red-800"], move || !disabled.get())
|
||||
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-gray-600", "pointer-events-none"], move || disabled.get())
|
||||
r#type=button_type
|
||||
disabled=move || disabled.get()
|
||||
>
|
||||
{ children() }
|
||||
{children()}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
+31
-15
@@ -1,5 +1,6 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_navigate;
|
||||
use leptos_icons::Icon;
|
||||
|
||||
/// # Banner Component
|
||||
///
|
||||
@@ -21,28 +22,43 @@ pub fn Banner(
|
||||
title: String,
|
||||
/// The file path or URL of a logo displayed at the top left of the banner.
|
||||
#[prop(into)]
|
||||
logo: String,
|
||||
logo: Signal<String>,
|
||||
/// The route that the navigates user to a page (home page).
|
||||
#[prop(into, default = String::from("/"))]
|
||||
path: String,
|
||||
#[prop(into, optional)]
|
||||
path: Signal<String>,
|
||||
) -> impl IntoView {
|
||||
let navigate = use_navigate();
|
||||
|
||||
let on_click = move |_| navigate(&path, Default::default());
|
||||
let show_back = Signal::derive(move || !path.get().is_empty());
|
||||
|
||||
let on_click = move |_| {
|
||||
let path = path.get();
|
||||
if !path.is_empty() {
|
||||
navigate(&path, Default::default());
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="mx-auto">
|
||||
<div class="bg-red-900 shadow-lg shadow-[inset_0_2px_4px_0_rgba(0,0,0,0.3),0_4px_6px_-1px_rgba(127,29,29,0.3),0_2px_4px_-1px_rgba(127,29,29,0.2)]">
|
||||
<div class="flex items-center gap-4 p-6">
|
||||
<div class="flex-shrink-0">
|
||||
<img src={logo.clone()} class="h-10 w-10 cursor-pointer" alt="icon" on:click=on_click/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-white text-lg font-bold">{title.clone()}</h2>
|
||||
</div>
|
||||
<div class="w-screen bg-red-900">
|
||||
<div class="flex flex-row items-center gap-4 p-6 pb-2">
|
||||
<div class="flex-shrink-0">
|
||||
<img src=logo class="h-10 w-10 cursor-pointer" alt="icon" on:click=on_click />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-white text-2xl font-bold">{title.clone()}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-4" class=("p-2", move || !show_back.get())>
|
||||
<Show when=move || show_back.get()>
|
||||
<a
|
||||
href=path
|
||||
class="flex flex-row items-center gap-2 m-2 ml-14 text-gray-200 hover:underline"
|
||||
>
|
||||
<Icon icon=icondata::FaArrowLeftSolid />
|
||||
<span>"Back to Dashboard"</span>
|
||||
</a>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
use crate::common::ValueType;
|
||||
use crate::components::utils::create_unique_id;
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[component]
|
||||
pub fn Checkbox(
|
||||
#[prop()] checked: Signal<bool>,
|
||||
#[prop(into)] on_change: Callback<(), ()>,
|
||||
#[prop(into)] value: String,
|
||||
#[prop(into)] name: String,
|
||||
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
let id = create_unique_id(&name, &value);
|
||||
|
||||
view! {
|
||||
<label for=id class="flex items-center">
|
||||
<input
|
||||
// Using the "peer" class links this input with the div "peer" at the same level.
|
||||
// By using the "peer-checked" selector this div can toggle states based on this
|
||||
// input's state (checked/unchecked)
|
||||
class="
|
||||
relative peer shrink-0
|
||||
hidden"
|
||||
type="checkbox"
|
||||
id=id.clone()
|
||||
name=name.clone()
|
||||
on:change=move |_| on_change.run(())
|
||||
prop:checked=checked
|
||||
disabled=disabled
|
||||
/>
|
||||
// This div displays the box for the checkbox, and applies some styling to the child
|
||||
// span element.
|
||||
<div class="relative inline-block h-5 w-5 m-1 mr-2 rounded-sm border border-gray-300 bg-white
|
||||
transition-bg duration-150 peer-checked:bg-red-700 peer-checked:border-red-700
|
||||
[&>span]:opacity-0 peer-checked:[&>span]:opacity-100
|
||||
peer-disabled:bg-gray-600/33 peer-disabled:border-gray-600
|
||||
peer-disabled:peer-checked:bg-gray-600">
|
||||
// This span is the check icon. It is centered within the div and has a border on the bottom
|
||||
// and right sides, which is then rotated to appear like a checkmark.
|
||||
<span class="absolute left-1/2 top-1/2 block h-3 w-1.5 -translate-x-1/2 -translate-y-1/2
|
||||
rotate-45 transform border-b-3 border-r-3
|
||||
border-white transition-opacity duration-150"></span>
|
||||
</div>
|
||||
<span>{value.clone()}</span>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn CheckboxList(
|
||||
#[prop(optional)] selected: RwSignal<Vec<String>>,
|
||||
#[prop(into)] data_member: String,
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
#[prop()] items: Vec<String>,
|
||||
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
|
||||
#[prop(optional, into)] label: String,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="m-1.5 mt-0 mb-0">
|
||||
<span class="font-bold">{label}</span>
|
||||
{items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let checked_signal = Signal::derive({
|
||||
let item_name = item.clone();
|
||||
let data_member = data_member.clone();
|
||||
move || {
|
||||
data_map.get() // HashMap
|
||||
.get(&data_member) // Option<ValueType>
|
||||
.unwrap_or(&ValueType::List(None)) // ValueType
|
||||
.as_list() // Result<Option<Vec<ValueType>>, ValueType>
|
||||
.unwrap_or(Some(vec!())) // Option<Vec<ValueType>>
|
||||
.unwrap_or(vec!()) // Vec<ValueType>
|
||||
.contains(&ValueType::String(Some(item_name.clone()))) // bool
|
||||
}
|
||||
});
|
||||
|
||||
let on_change = {
|
||||
let item_name = item.clone();
|
||||
let data_member = data_member.clone();
|
||||
move || {
|
||||
let result = ValueType::String(Some(item_name.clone()));
|
||||
data_map.update(|map| {
|
||||
// Attempt to get the value type from the hash map.
|
||||
match map.get_mut(&data_member) {
|
||||
Some(value_type) => {
|
||||
// Attempt to get the value as a list.
|
||||
match value_type.as_list().unwrap_or(None) {
|
||||
Some(mut list) => {
|
||||
// Attempt to find the selected value in the selected list.
|
||||
// If it's present, remove it, or vice versa.
|
||||
match list.iter().position(|val| *val == result) {
|
||||
Some(index) => { list.remove(index); },
|
||||
None => { list.push(result); }
|
||||
};
|
||||
// Update the existing value_type entry in the hash map.
|
||||
*value_type = ValueType::List(Some(list));
|
||||
},
|
||||
// Insert a new value_type entry.
|
||||
None => { *value_type = ValueType::List(Some(vec![result])); }
|
||||
};
|
||||
},
|
||||
// Insert a new value_type entry.
|
||||
None => { map.insert(data_member.clone(), ValueType::List(Some(vec![result]))); }
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<Checkbox
|
||||
checked=checked_signal
|
||||
on_change=on_change
|
||||
// The actual selected values are tracked by this element, not by the checkboxes themselves.
|
||||
value=item.clone()
|
||||
name=data_member.clone()
|
||||
disabled=disabled
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
use crate::common::ValueType;
|
||||
use crate::components::utils::create_unique_id;
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// # Chip Component
|
||||
///
|
||||
/// This component defines a single chip for use in the `ChipsList` component. It functions almost
|
||||
/// identically to the `Checkbox` component. It should only be created from within its parent
|
||||
/// `CheckboxList` component.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```
|
||||
/// view! {
|
||||
/// <Chip
|
||||
/// value="Some Value"
|
||||
/// name="list_name"
|
||||
/// on_change=move || { println!("Some work here.") }
|
||||
/// checked=RwSignal::new(false)
|
||||
/// disabled=RwSignal::new(false)
|
||||
/// />
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn Chip(
|
||||
#[prop(into)] display_text: String,
|
||||
#[prop(into)] value: String,
|
||||
#[prop(into)] name: String,
|
||||
#[prop(into)] on_change: Callback<(), ()>,
|
||||
#[prop()] checked: Signal<bool>,
|
||||
#[prop(optional)] disabled: RwSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
// Generate a unique id - ensure that the value doesn't contain spaces.
|
||||
// Without this id, checkbox/radio inputs can interfere with each other.
|
||||
let id = create_unique_id(&name, &value);
|
||||
|
||||
view! {
|
||||
<label for=id>
|
||||
<input
|
||||
r#type="checkbox"
|
||||
class="relative peer shrink-0 hidden"
|
||||
name=name.clone()
|
||||
id=id.clone()
|
||||
prop:checked=checked
|
||||
on:change=move |_| on_change.run(())
|
||||
disabled=disabled />
|
||||
<div class="rounded-full p-2.5
|
||||
cursor-pointer text-base
|
||||
bg-transparent border-1 border-gray-300
|
||||
shadow-md/50 hover:shadow-md/70
|
||||
peer-checked:bg-red-700 peer-checked:border-red-700 peer-checked:text-white
|
||||
peer-disabled:peer-checked:bg-gray-700
|
||||
peer-disabled:peer-checked:border-gray-700
|
||||
peer-disabled:cursor-default
|
||||
transition-all duration-150">
|
||||
<span>{display_text.clone()}</span>
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
/// # Chips List Component
|
||||
///
|
||||
/// This defines a list of `Chip` components. The list of values is created based on the data from the
|
||||
/// `data_map` prop, which is found using the given `data_member`.
|
||||
///
|
||||
/// Any values selected from the list are stored within the `data_map` immediately as part of a callback
|
||||
/// passed to the `on_change` prop for each individual `Chip` component.
|
||||
#[component]
|
||||
pub fn ChipsList(
|
||||
#[prop(into)] data_member: String,
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
#[prop()] displayed_text: Vec<String>,
|
||||
#[prop()] values: Vec<String>,
|
||||
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
|
||||
#[prop(optional, into)] label: String,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="m-1.5 mt-0 mb-0">
|
||||
<span class="font-bold">{label}</span>
|
||||
<div class="flex flex-row flex-wrap gap-2 mt-1">
|
||||
{displayed_text.into_iter().zip(values)
|
||||
.into_iter()
|
||||
.map(|(text, value)| {
|
||||
let checked_signal = Signal::derive({
|
||||
let item_name = value.clone();
|
||||
let data_member = data_member.clone();
|
||||
move || {
|
||||
data_map.get()
|
||||
.get(&data_member)
|
||||
.unwrap_or(&ValueType::List(None))
|
||||
.as_list()
|
||||
.unwrap_or(Some(vec!()))
|
||||
.unwrap_or(vec!())
|
||||
.contains(&ValueType::String(Some(item_name.clone())))
|
||||
}
|
||||
});
|
||||
|
||||
let on_change = {
|
||||
let item_name = value.clone();
|
||||
let data_member = data_member.clone();
|
||||
move || {
|
||||
let result = ValueType::String(Some(item_name.clone()));
|
||||
data_map.update(|map| {
|
||||
// Attempt to get the value type from the hash map.
|
||||
match map.get_mut(&data_member) {
|
||||
Some(value_type) => {
|
||||
// Attempt to get the value as a list.
|
||||
match value_type.as_list().unwrap_or(None) {
|
||||
Some(mut list) => {
|
||||
// Attempt to find the selected value in the selected list.
|
||||
// If it's present, remove it, or vice versa.
|
||||
match list.iter().position(|val| *val == result) {
|
||||
Some(index) => { list.remove(index); },
|
||||
None => { list.push(result); }
|
||||
};
|
||||
// Update the existing value_type entry in the hash map.
|
||||
*value_type = ValueType::List(Some(list));
|
||||
},
|
||||
// Insert a new value_type entry.
|
||||
None => { *value_type = ValueType::List(Some(vec![result])); }
|
||||
};
|
||||
},
|
||||
// Insert a new value_type entry.
|
||||
None => { map.insert(data_member.clone(), ValueType::List(Some(vec![result]))); }
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<Chip
|
||||
checked=checked_signal
|
||||
name=data_member.clone()
|
||||
on_change=on_change
|
||||
value=value.clone()
|
||||
display_text=text.clone()
|
||||
disabled=disabled
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ pub fn DashboardButton(
|
||||
#[prop(into)]
|
||||
description: String,
|
||||
/// The file path or URL of the icon displayed at the top of the button.
|
||||
#[prop(into)]
|
||||
#[prop(optional, into)]
|
||||
icon: String,
|
||||
/// The route that the user is navigated to when clicking the button.
|
||||
#[prop(into)]
|
||||
@@ -37,25 +37,26 @@ pub fn DashboardButton(
|
||||
|
||||
let on_click = move |_| navigate(&path, Default::default());
|
||||
|
||||
let icon = StoredValue::new(icon);
|
||||
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class="dashboard-button flex items-start gap-3 rounded-lg bg-white border-grey-300 p-6
|
||||
hover:bg-gray-100 transition cursor-pointer w-full text-left
|
||||
shadow-[inset_0_0_6px_rgba(0,0,0,0.12)]"
|
||||
on:click=on_click>
|
||||
|
||||
hover:bg-gray-100 transition cursor-pointer w-full text-left
|
||||
shadow-[inset_0_0_6px_rgba(0,0,0,0.12)]"
|
||||
on:click=on_click
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<img src={icon.clone()} class="h-8 w-8" alt="icon"/>
|
||||
<h3 class="font-semibold text-base">
|
||||
{title.clone()}
|
||||
</h3>
|
||||
<Show when=move || !icon.get_value().is_empty()>
|
||||
<img src=icon.get_value() class="h-8 w-8" alt="icon" />
|
||||
</Show>
|
||||
|
||||
<p class="text-sm text-gray-600 pt-6">
|
||||
{description.clone()}
|
||||
</p>
|
||||
<h3 class="font-semibold text-base">{title.clone()}</h3>
|
||||
<p class="text-sm text-gray-600 pt-6">{description.clone()}</p>
|
||||
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use crate::common::{DateInfo, DateRange, DateStatus};
|
||||
use leptos::prelude::*;
|
||||
|
||||
fn render_dates(dates: DateRange) -> impl IntoView {
|
||||
let date_text = match dates {
|
||||
DateRange::Single(date) => format!("{}", date.format("%A %B %d, %I:%M%p")),
|
||||
DateRange::Range(start_date, end_date) =>
|
||||
format!("{} - {}",
|
||||
start_date.format("%D %I:%M%p"),
|
||||
end_date.format("%D %I:%M%p")
|
||||
),
|
||||
};
|
||||
|
||||
view! { <span>{move || date_text.clone()}</span> }
|
||||
}
|
||||
|
||||
fn render_status(date: DateRange) -> impl IntoView {
|
||||
let status = date.get_status();
|
||||
|
||||
let status_text = match dbg!(status.clone()) {
|
||||
DateStatus::Blank => "",
|
||||
DateStatus::Upcoming => "Upcoming",
|
||||
DateStatus::Open => "Open",
|
||||
DateStatus::Deadline => "Deadline",
|
||||
DateStatus::Closed => "Closed",
|
||||
};
|
||||
|
||||
let background_class = match status {
|
||||
DateStatus::Blank => "hidden border-none",
|
||||
DateStatus::Upcoming => "bg-yellow-100 text-yellow-800 border-yellow-100",
|
||||
DateStatus::Open => "bg-green-100 text-green-800 border-green-100",
|
||||
DateStatus::Deadline => "bg-red-100 text-red-800 border-red-100",
|
||||
DateStatus::Closed => "bg-red-700 text-white border-red-700"
|
||||
};
|
||||
|
||||
let classes =
|
||||
format!("inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs
|
||||
font-medium w-fit whitespace-nowrap shrink-0 {}",
|
||||
background_class
|
||||
);
|
||||
|
||||
view! { <span class=classes>{move || status_text}</span> }
|
||||
}
|
||||
|
||||
/// # Date Component
|
||||
/// A single date, for use in a [`DateList`] component.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```
|
||||
/// let date_info = DateInfo {
|
||||
/// title: "Example Title".to_string(),
|
||||
/// date: DateRange::Single("January 1st, 1990".to_string()),
|
||||
/// description: "A description of the date.".to_string(),
|
||||
/// status: DateStatus::Upcoming
|
||||
/// }
|
||||
///
|
||||
/// view! {
|
||||
/// <Date
|
||||
/// info=date_info
|
||||
/// />
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
fn Date(
|
||||
/// Struct of title, date, and description
|
||||
#[prop(into)] info: Signal<DateInfo>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="flex items-start gap-3 rounded-lg border-grey-300 p-3
|
||||
w-full text-left shadow-lg">
|
||||
// <div class="flex-shrink-0 mt-0.5">
|
||||
// <img src={icon} class="h-4 w-4" alt="icon"/>
|
||||
// </div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="font-bold text-md text-base">{info.get().title}</h3>
|
||||
<h3 class="text-md text-base mt-1">
|
||||
{move || render_dates(info.get().date)}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">{info.get().description}</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0 ml-2">{move || render_status(info.get().date)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
///# Dates List Component
|
||||
/// Displays a list of important dates in a column, with badges that draw attention to dates that
|
||||
/// are more important.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```
|
||||
/// let dates = vec![
|
||||
/// DateInfo {
|
||||
/// title: "Example Title".to_string(),
|
||||
/// date: DateRange::Single("January 1st, 1990".to_string()),
|
||||
/// description: "A description of the date.".to_string(),
|
||||
/// status: DateStatus::Upcoming
|
||||
/// }
|
||||
/// ]
|
||||
///
|
||||
/// view! {
|
||||
/// <DateList dates=dates />
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn DateList(
|
||||
#[prop(into)] dates: Signal<Vec<DateInfo>>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
{move || {
|
||||
dates
|
||||
.get()
|
||||
.iter()
|
||||
.map(|info| {
|
||||
view! { <Date info=info.clone() /> }
|
||||
})
|
||||
.collect_view()
|
||||
}}
|
||||
}.into_any()
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
/>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// # Header Component
|
||||
///
|
||||
/// A simple header that may also contain a description. Should be used to annotate sections and
|
||||
/// forms, with extra information about how that section/form will work.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```
|
||||
/// view! {
|
||||
/// <Header
|
||||
/// title: "Example header"
|
||||
/// description: "Example description"
|
||||
/// />
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn Header(
|
||||
/// The title of the header. Must always be present.
|
||||
#[prop(into)] title: Signal<String>,
|
||||
/// The description of the header. Hidden when nothing i s
|
||||
#[prop(optional, into)] description: Signal<String>,
|
||||
) -> impl IntoView {
|
||||
let show_description = Signal::derive(move || {
|
||||
!description.get().is_empty()
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="flex flex-col gap-2 m-1.5 mt-3 mb-5">
|
||||
<div class="text-3xl font-bold">
|
||||
{title}
|
||||
</div>
|
||||
<Show when=move || show_description.get()>
|
||||
<div>
|
||||
{description}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod multi;
|
||||
mod single;
|
||||
|
||||
pub use {
|
||||
multi::*, single::*
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
use crate::common::ValueType;
|
||||
use crate::components::utils::create_unique_id;
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use super::utils::use_selectable_list;
|
||||
use crate::components::ValidationState;
|
||||
|
||||
/// # Checkbox Component
|
||||
/// This component should only be used from within a [`CheckboxList`] component.
|
||||
///
|
||||
/// It creates a single checkbox with a specific value. Indicating if it is checked is done
|
||||
/// _externally_ from the component, and requires some sort of signal. The component itself
|
||||
/// does not track whether it is checked, it only runs the `on_change` callback.
|
||||
///
|
||||
/// Example usage (from within a [`CheckboxList`]:
|
||||
/// ```
|
||||
/// let disabled_signal = RwSignal::new(false);
|
||||
/// let checked_signal = Signal::derive(move || {
|
||||
/// // It's recommended to use a derived signal of some sort, but if you want to create
|
||||
/// // a manually controlled RwSignal, that's fine too.
|
||||
/// true
|
||||
/// });
|
||||
///
|
||||
/// view! {
|
||||
/// <Checkbox
|
||||
/// checked=checked_signal
|
||||
/// on_change=move || log!("A callback that's run on change.")
|
||||
/// name="some_name"
|
||||
/// disabled=disabled_signal
|
||||
/// value="Some Value"
|
||||
/// />
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn Checkbox(
|
||||
/// A [`Signal`] indicating whether the checkbox is checked.
|
||||
#[prop()] checked: Signal<bool>,
|
||||
/// A callback that is run when the checkbox is checked and unchecked.
|
||||
#[prop(into)] on_change: Callback<(), ()>,
|
||||
/// The value of the checkbox.
|
||||
#[prop(into)] value: Signal<String>,
|
||||
/// The name of the checkbox. This is used to group checkboxes together, so every checkbox
|
||||
/// in a group should have the same name.
|
||||
#[prop(into)] name: Signal<String>,
|
||||
/// A [`Signal`] indicating whether the checkbox is disabled.
|
||||
#[prop(into)] disabled: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
let id = create_unique_id(&name.get(), &value.get_untracked());
|
||||
|
||||
view! {
|
||||
<label for=id class="flex items-center">
|
||||
<input
|
||||
// Using the "peer" class links this input with the div "peer" at the same level.
|
||||
// By using the "peer-checked" selector this div can toggle states based on this
|
||||
// input's state (checked/unchecked)
|
||||
class="
|
||||
relative peer shrink-0
|
||||
hidden"
|
||||
type="checkbox"
|
||||
id=id.clone()
|
||||
name=name.clone()
|
||||
on:change=move |_| on_change.run(())
|
||||
prop:checked=checked
|
||||
disabled=disabled
|
||||
/>
|
||||
// This div displays the box for the checkbox, and applies some styling to the child
|
||||
// span element.
|
||||
<div class="relative inline-block h-5 w-5 m-1 mr-2 rounded-sm border border-gray-300 bg-white
|
||||
transition-bg duration-150 peer-checked:bg-red-700 peer-checked:border-red-700
|
||||
[&>span]:opacity-0 peer-checked:[&>span]:opacity-100
|
||||
peer-disabled:bg-gray-600/33 peer-disabled:border-gray-600
|
||||
peer-disabled:peer-checked:bg-gray-600">
|
||||
// This span is the check icon. It is centered within the div and has a border on the bottom
|
||||
// and right sides, which is then rotated to appear like a checkmark.
|
||||
<span class="absolute left-1/2 top-1/2 block h-3 w-1.5 -translate-x-1/2 -translate-y-1/2
|
||||
rotate-45 transform border-b-3 border-r-3
|
||||
border-white transition-opacity duration-150"></span>
|
||||
</div>
|
||||
<span>{value}</span>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
/// # Checkbox List Component
|
||||
/// This component creates a list of checkboxes, each with a specific value. The values are passed
|
||||
/// through a prop rather than manually creating children.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```
|
||||
/// let items = vec! [
|
||||
/// "Item 1",
|
||||
/// "Item 2",
|
||||
/// "Item 3"
|
||||
/// ];
|
||||
///
|
||||
/// view! {
|
||||
/// <CheckboxList
|
||||
/// data_member="data_map_member"
|
||||
/// data_map=RwSignal::new(HashMap::new())
|
||||
/// items=items
|
||||
/// />
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn CheckboxList(
|
||||
/// The name that this list should use. This doesn't need to be specified all the time, but is
|
||||
/// for use in elements that may treat this list as a sub-input (components like the [`MultiEntry`]
|
||||
/// do this)
|
||||
#[prop(optional, into)] name: Signal<String>,
|
||||
/// The data member that this list should edit/display from the `data_map`.
|
||||
#[prop(into)] data_member: Signal<String>,
|
||||
/// A [`Signal`] allowing access to a data store.
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
/// The available choices for this list.
|
||||
#[prop()] items: Vec<String>,
|
||||
/// A [`Signal`] indicating whether the checkbox list is disabled.
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
/// A label for the list. Shown like a field name in a form.
|
||||
#[prop(optional, into)] label: String,
|
||||
/// A [`Signal`] indicating whether the list is required. Used for validation.
|
||||
#[prop(optional, into)] required: Signal<bool>
|
||||
) -> impl IntoView {
|
||||
let controller = use_selectable_list(
|
||||
data_member.clone(),
|
||||
data_map.clone(),
|
||||
required.clone()
|
||||
);
|
||||
|
||||
//#region Render Logic
|
||||
view! {
|
||||
<div class="flex flex-col">
|
||||
<div class="m-1.5 mt-0 mb-0">
|
||||
<span class="font-bold">{label}</span>
|
||||
{items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let item = Signal::derive({
|
||||
let cloned = item.clone();
|
||||
move || cloned.clone()
|
||||
});
|
||||
let checked = Signal::derive(move || {
|
||||
controller.selected_list.get().contains(&item.get())
|
||||
});
|
||||
let on_change = move || {
|
||||
controller
|
||||
.selected_list
|
||||
.update(|list| {
|
||||
let item = item.get();
|
||||
if list.contains(&item) {
|
||||
list.retain(|val| *val != item);
|
||||
} else {
|
||||
list.push(item);
|
||||
}
|
||||
});
|
||||
controller.dirty.set(true);
|
||||
};
|
||||
|
||||
// Update the data list by checking if the value is present in it. If so,
|
||||
// remove it. If not, add it.
|
||||
|
||||
view! {
|
||||
<Checkbox
|
||||
checked=checked
|
||||
on_change=on_change
|
||||
// The actual selected values are tracked by this element, not by the checkboxes themselves.
|
||||
value=item
|
||||
name=move || format!("{}_{}", name.get(), data_member.get())
|
||||
disabled=disabled
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect_view()}
|
||||
</div>
|
||||
<Show when=move || controller.show_errors.get()>
|
||||
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
|
||||
{move || {
|
||||
match controller.error.get() {
|
||||
ValidationState::Invalid(msg) => msg,
|
||||
_ => "There is no error - should not see this message.".to_string(),
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use crate::common::ValueType;
|
||||
use crate::components::utils::create_unique_id;
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use crate::components::ValidationState;
|
||||
use super::utils::use_selectable_list;
|
||||
|
||||
/// # Chip Component
|
||||
///
|
||||
/// This component defines a single chip for use in the `ChipsList` component. It functions almost
|
||||
/// identically to the `Checkbox` component. It should only be created from within its parent
|
||||
/// `CheckboxList` component.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```
|
||||
/// view! {
|
||||
/// <Chip
|
||||
/// value="Some Value"
|
||||
/// name="list_name"
|
||||
/// on_change=move || { println!("Some work here.") }
|
||||
/// checked=RwSignal::new(false)
|
||||
/// disabled=RwSignal::new(false)
|
||||
/// />
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn Chip(
|
||||
#[prop(into)] display_text: Signal<String>,
|
||||
#[prop(into)] value: Signal<String>,
|
||||
#[prop(into)] name: Signal<String>,
|
||||
#[prop(into)] on_change: Callback<(), ()>,
|
||||
#[prop()] checked: Signal<bool>,
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
// Generate a unique id - ensure that the value doesn't contain spaces.
|
||||
// Without this id, checkbox/radio inputs can interfere with each other.
|
||||
let id = create_unique_id(&name.get(), &value.get_untracked());
|
||||
|
||||
view! {
|
||||
<label for=id>
|
||||
<input
|
||||
r#type="checkbox"
|
||||
class="relative peer shrink-0 hidden"
|
||||
name=name.clone()
|
||||
id=id.clone()
|
||||
prop:checked=checked
|
||||
on:change=move |_| on_change.run(())
|
||||
disabled=disabled
|
||||
/>
|
||||
<div class="rounded-full p-2.5
|
||||
cursor-pointer text-base
|
||||
bg-transparent border-1 border-gray-300
|
||||
shadow-md/50 hover:shadow-md/70
|
||||
peer-checked:bg-red-700 peer-checked:border-red-700 peer-checked:text-white
|
||||
peer-disabled:peer-checked:bg-gray-700
|
||||
peer-disabled:peer-checked:border-gray-700
|
||||
peer-disabled:cursor-default
|
||||
transition-all duration-150">
|
||||
<span>{display_text.clone()}</span>
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
/// # Chips List Component
|
||||
///
|
||||
/// This defines a list of `Chip` components. The list of values is created based on the data from the
|
||||
/// `data_map` prop, which is found using the given `data_member`.
|
||||
///
|
||||
/// Any values selected from the list are stored within the `data_map` immediately as part of a callback
|
||||
/// passed to the `on_change` prop for each individual `Chip` component.
|
||||
#[component]
|
||||
pub fn ChipsList(
|
||||
/// The member in the data map that this input affects.
|
||||
#[prop(into)] data_member: Signal<String>,
|
||||
/// The data map that contains the list of values. This will be edited as the input changes.
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
/// The list of strings that should be displayed in the list.
|
||||
#[prop(into)] displayed_text: Signal<Vec<String>>,
|
||||
/// The list of values that should be displayed in the list.
|
||||
#[prop(into)] values: Signal<Vec<String>>,
|
||||
/// Indicates if the input should be disabled.
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
/// The label for the header of the input.
|
||||
#[prop(optional, into)] label: String,
|
||||
/// Indicates if this input is required.
|
||||
#[prop(optional, into)] required: Signal<bool>,
|
||||
/// Determines if this list behaves like a checkbox or radio list.
|
||||
#[prop(default = true.into(), into)] allows_multiple: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
let controller = use_selectable_list(
|
||||
data_member.clone(),
|
||||
data_map.clone(),
|
||||
required.clone()
|
||||
);
|
||||
|
||||
view! {
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col gap-2 m-1.5 mt-0 mb-0">
|
||||
<span class="font-bold">{label}</span>
|
||||
<div class="flex flex-row flex-wrap gap-2 mb-1">
|
||||
{move || {
|
||||
displayed_text
|
||||
.get()
|
||||
.into_iter()
|
||||
.zip(values.get())
|
||||
.into_iter()
|
||||
.map(|(text, value)| {
|
||||
let value = RwSignal::new(value);
|
||||
let checked = Signal::derive(move || {
|
||||
controller.selected_list.get().contains(&value.get())
|
||||
});
|
||||
let on_change = move || {
|
||||
let value = value.get();
|
||||
let list = controller.selected_list.get();
|
||||
if list.contains(&value) {
|
||||
allows_multiple
|
||||
.get()
|
||||
.then(|| {
|
||||
controller
|
||||
.selected_list
|
||||
.update(|list| list.retain(|v| v != &value))
|
||||
})
|
||||
.or_else(|| Some(controller.selected_list.set(Vec::new())));
|
||||
} else {
|
||||
allows_multiple
|
||||
.get()
|
||||
.then(|| {
|
||||
controller
|
||||
.selected_list
|
||||
.update(|list| list.push(value.clone()))
|
||||
})
|
||||
.or_else(|| Some(
|
||||
controller.selected_list.set(vec![value.clone()]),
|
||||
));
|
||||
}
|
||||
controller.dirty.set(true);
|
||||
};
|
||||
|
||||
// Update the data list by checking if the value is present in it. If so,
|
||||
// remove it. If not, add it.
|
||||
|
||||
view! {
|
||||
<Chip
|
||||
checked=checked
|
||||
on_change=on_change
|
||||
value=value
|
||||
display_text=text
|
||||
name=data_member
|
||||
disabled=disabled
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<Show when=move || controller.show_errors.get()>
|
||||
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
|
||||
{move || {
|
||||
match controller.error.get() {
|
||||
ValidationState::Invalid(msg) => msg,
|
||||
_ => "There is no error - should not see this message.".to_string(),
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
mod utils;
|
||||
mod checkbox;
|
||||
mod chips;
|
||||
mod select_nav_list;
|
||||
|
||||
pub use checkbox::*;
|
||||
pub use chips::*;
|
||||
pub use select_nav_list::*;
|
||||
@@ -0,0 +1,179 @@
|
||||
use leptos::logging::log;
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::components::A;
|
||||
use crate::components::utils;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NavListInfo {
|
||||
pub text: Vec<String>,
|
||||
pub id: String,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn NavListItem(
|
||||
#[prop(into)] name: Signal<String>,
|
||||
#[prop(into)] value: Signal<String>,
|
||||
#[prop(into)] text: Signal<Vec<String>>,
|
||||
#[prop(into)] slug: Signal<String>,
|
||||
#[prop(into)] selected: Signal<bool>,
|
||||
#[prop(into)] on_change: Callback<()>,
|
||||
) -> impl IntoView {
|
||||
let linked_info = Signal::derive(move ||
|
||||
text.get()
|
||||
.get(0)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
);
|
||||
let other_info = Signal::derive(move ||
|
||||
text.get()
|
||||
.iter()
|
||||
.cloned()
|
||||
.skip(1)
|
||||
.collect::<Vec<String>>()
|
||||
);
|
||||
|
||||
view! {
|
||||
<div
|
||||
class="flex flex-row gap-2 p-1 rounded-lg"
|
||||
class=(["bg-yellow-100"], move || selected.get())
|
||||
>
|
||||
<NavListCheckbox
|
||||
name=name
|
||||
value=value
|
||||
on_change=move || on_change.run(())
|
||||
selected=selected
|
||||
/>
|
||||
<A
|
||||
href=move || slug.get()
|
||||
{..}
|
||||
class="flex flex-1 items-center underline hover:text-blue-400"
|
||||
>
|
||||
{linked_info}
|
||||
</A>
|
||||
<For each=move || other_info.get() key=|info| info.clone() let(info)>
|
||||
<p class="flex flex-1 items-center">{info}</p>
|
||||
</For>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn NavListCheckbox(
|
||||
#[prop(into)] selected: Signal<bool>,
|
||||
#[prop(into)] on_change: Callback<()>,
|
||||
#[prop(into)] name: Signal<String>,
|
||||
#[prop(into)] value: Signal<String>,
|
||||
) -> impl IntoView {
|
||||
let id = Memo::new(move |_| utils::create_unique_id(&name.get(), &value.get()));
|
||||
|
||||
view! {
|
||||
<label for=id class="flex items-center">
|
||||
<input
|
||||
class="hidden relative peer shrink-0"
|
||||
type="checkbox"
|
||||
name=name
|
||||
id=id
|
||||
prop:checked=selected
|
||||
on:change=move |_| on_change.run(())
|
||||
/>
|
||||
<div class="relative inline-block h-5 w-5 m-2 rounded-sm border border-gray-300 bg-white
|
||||
transition-bg duration-150 peer-checked:bg-red-700 peer-checked:border-red-700
|
||||
[&>span]:opacity-0 peer-checked:[&>span]:opacity-100
|
||||
peer-disabled:bg-gray-600/33 peer-disabled:border-gray-600
|
||||
peer-disabled:peer-checked:bg-gray-600">
|
||||
// This span is the check icon. It is centered within the div and has a border on the bottom
|
||||
// and right sides, which is then rotated to appear like a checkmark.
|
||||
<span class="absolute left-1/2 top-1/2 block h-3 w-1.5 -translate-x-1/2 -translate-y-1/2
|
||||
rotate-45 transform border-b-3 border-r-3
|
||||
border-white transition-opacity duration-150"></span>
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
/// # Selectable Nav-List Component
|
||||
///
|
||||
/// This component takes a `Vec<NavListInfo>` to show a list. The list contains links that append
|
||||
/// the `NavListItem::slug` to the current path, and each item is selectable. The list of selected
|
||||
/// items is usable in this component's parent.
|
||||
#[component]
|
||||
pub fn SelectableNavList(
|
||||
#[prop(into)] selected: RwSignal<Vec<String>>,
|
||||
#[prop(into)] items: Signal<Vec<NavListInfo>>,
|
||||
#[prop(into)] headers: Signal<Vec<String>>,
|
||||
#[prop(into)] name: Signal<String>,
|
||||
) -> impl IntoView {
|
||||
let all_selected = RwSignal::new(false);
|
||||
let all_selected_change = move || {
|
||||
if all_selected.get() {
|
||||
log!("Unchecking all values.");
|
||||
all_selected.set(false);
|
||||
selected.set(Vec::new());
|
||||
} else {
|
||||
log!("Checking all values.");
|
||||
all_selected.set(true);
|
||||
selected.set(
|
||||
items
|
||||
.get()
|
||||
.iter()
|
||||
.map(|info| info.id.clone())
|
||||
.collect::<Vec<String>>()
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="flex flex-1 flex-col m-3">
|
||||
// Header block - contains evenly spaced headers and the "select all" checkbox.
|
||||
// Include a checkbox here that's got the same style as the items. This maintains spacing.
|
||||
// All headers *must* be evenly spaced, otherwise the list starts getting confusing.
|
||||
<div class="flex flex-row bg-red-800 rounded-lg p-1 gap-2">
|
||||
<NavListCheckbox
|
||||
selected=all_selected
|
||||
name=name
|
||||
value="select-all"
|
||||
on_change=all_selected_change
|
||||
/>
|
||||
<For each=move || headers.get() key=|header| header.clone() let(header_text)>
|
||||
<div class="flex flex-1 text-white font-bold items-center">{header_text}</div>
|
||||
</For>
|
||||
</div>
|
||||
// Items block - contains constructed items. These are selectable.
|
||||
<For
|
||||
each=move || items.get()
|
||||
key=|item| item.id.clone()
|
||||
children=move |entry| {
|
||||
let id = Signal::derive(move || entry.id.clone());
|
||||
let text = Signal::derive(move || entry.text.clone());
|
||||
let selected_sig = Signal::derive(move || selected.get().contains(&id.get()));
|
||||
let on_change = move || {
|
||||
selected
|
||||
.update(|list| {
|
||||
let id = id.get();
|
||||
if list.contains(&id) {
|
||||
log!("Found value {}, removing.", id);
|
||||
list.retain(|s| s != &id);
|
||||
} else {
|
||||
log!("Adding value {} to the list.", id);
|
||||
list.push(id);
|
||||
}
|
||||
});
|
||||
all_selected.set(false);
|
||||
};
|
||||
|
||||
view! {
|
||||
<NavListItem
|
||||
name=name
|
||||
text=text
|
||||
value=id.clone()
|
||||
slug=entry.slug
|
||||
selected=selected_sig
|
||||
on_change=on_change
|
||||
/>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use crate::common::ValueType;
|
||||
use crate::components::{use_validation_context, InputState, ValidationState};
|
||||
|
||||
pub struct ListController {
|
||||
pub error: Signal<ValidationState>,
|
||||
pub dirty: RwSignal<bool>,
|
||||
pub show_errors: Signal<bool>,
|
||||
pub selected_list: RwSignal<Vec<String>>,
|
||||
}
|
||||
|
||||
fn value_type_to_vec(value_type: &ValueType) -> Vec<String> {
|
||||
let ValueType::List(Some(list)) = value_type else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
list.iter().map(|val| {
|
||||
val.as_string()
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default()
|
||||
}).collect::<Vec<String>>()
|
||||
}
|
||||
|
||||
pub fn use_selectable_list(
|
||||
data_member: Signal<String>,
|
||||
data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
required: Signal<bool>
|
||||
) -> ListController {
|
||||
//#region Central List Logic
|
||||
|
||||
// "Raw" selected values - just strings, no ValueTyping
|
||||
let selected_list = RwSignal::new(Vec::new());
|
||||
let hydrated = RwSignal::new(false);
|
||||
let dirty = RwSignal::new(false);
|
||||
|
||||
// Hydration effect - runs only once, to hydrate data.
|
||||
Effect::new(move || {
|
||||
if hydrated.get() {
|
||||
return;
|
||||
}
|
||||
|
||||
// React to data_map changes
|
||||
let map = data_map.get();
|
||||
let key = data_member.get();
|
||||
|
||||
let list = map
|
||||
.get(&key)
|
||||
.unwrap_or(&ValueType::List(None));
|
||||
|
||||
selected_list.set(value_type_to_vec(list));
|
||||
|
||||
hydrated.set(true);
|
||||
});
|
||||
|
||||
Effect::new({
|
||||
move || {
|
||||
// Convert selected strings into ValueTypes
|
||||
let typed_list = selected_list.with(|list|
|
||||
list.iter().cloned().map(|val|
|
||||
ValueType::String(Some(val))
|
||||
).collect::<Vec<ValueType>>()
|
||||
);
|
||||
|
||||
// Update the map
|
||||
data_map.update(|map| {
|
||||
map.insert(data_member.get(), ValueType::List(Some(typed_list)));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region Form Validation
|
||||
|
||||
let validation_context = use_validation_context()
|
||||
.expect("FormValidationRegistry was not found");
|
||||
|
||||
let error = Signal::derive(move || {
|
||||
if required.get() && selected_list.get().len() <= 0 {
|
||||
ValidationState::Invalid("This field is required.".to_string())
|
||||
} else {
|
||||
ValidationState::Valid
|
||||
}
|
||||
});
|
||||
|
||||
let show_errors = Signal::derive(move || dirty.get() && matches!(error.get(), ValidationState::Invalid(_)));
|
||||
|
||||
let validator = RwSignal::new(InputState::new(
|
||||
data_member.get(),
|
||||
error.clone(),
|
||||
dirty.clone()
|
||||
));
|
||||
|
||||
validation_context.validators.update(|list| list.push(validator.clone()));
|
||||
|
||||
on_cleanup(move || {
|
||||
validation_context.validators.update(|list| {
|
||||
list.retain(|v| *v.get_untracked().input_name != *validator.get_untracked().input_name)
|
||||
});
|
||||
});
|
||||
|
||||
//#endregion
|
||||
|
||||
ListController {
|
||||
error,
|
||||
dirty,
|
||||
show_errors,
|
||||
selected_list,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod radio;
|
||||
pub mod select;
|
||||
|
||||
pub use radio::*;
|
||||
pub use select::*;
|
||||
@@ -0,0 +1,299 @@
|
||||
use crate::common::ValueType;
|
||||
use crate::components::utils::create_unique_id;
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use leptos::html::Input;
|
||||
use crate::components::{use_validation_context, InputState, ValidationState};
|
||||
|
||||
#[component]
|
||||
pub fn Radio(
|
||||
#[prop()] checked: Signal<bool>,
|
||||
#[prop(into)] on_change: Callback<(), ()>,
|
||||
#[prop(into)] value: Signal<String>,
|
||||
#[prop(into)] name: Signal<String>,
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
let id = create_unique_id(&name.get_untracked(), &value.get_untracked());
|
||||
|
||||
view! {
|
||||
<label for=id class="flex items-center">
|
||||
<input
|
||||
// Using the "peer" class links this input with the div "peer" at the same level.
|
||||
// By using the "peer-checked" selector this div can toggle states based on this
|
||||
// input's state (checked/unchecked)
|
||||
class="relative peer shrink-0 hidden"
|
||||
type="radio"
|
||||
id=id.clone()
|
||||
name=name
|
||||
on:change=move |_| on_change.run(())
|
||||
prop:checked=checked
|
||||
disabled=disabled
|
||||
/>
|
||||
// This div displays the box for the checkbox, and applies some styling to the child
|
||||
// span element.
|
||||
<div class="relative inline-block h-5 w-5 m-1 mr-2 rounded-full border border-gray-400 bg-white
|
||||
transition-bg duration-200 peer-checked:bg-red-700 peer-checked:border-red-700
|
||||
[&>span]:opacity-0 peer-checked:[&>span]:opacity-100
|
||||
peer-disabled:bg-gray-600/33 peer-disabled:border-gray-600
|
||||
peer-disabled:peer-checked:bg-gray-600">
|
||||
// This span is the check icon. It is centered within the div and has a border on the bottom
|
||||
// and right sides, which is then rotated to appear like a checkmark.
|
||||
<span class="absolute left-1/2 top-1/2 block h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2
|
||||
rotate-45 transform border-3 border-3 rounded-full bg-white
|
||||
border-white transition-opacity duration-150"></span>
|
||||
</div>
|
||||
<span class="px-0.5 py-0.5 flex-1">{value.clone()}</span>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
/// # Ratio Other Component
|
||||
/// This is a simple "other" field that contains a text input component. It can be selected using the
|
||||
/// same logic as the [`Radio`] component, and also has a secondary `RwSignal` that contains the
|
||||
/// current value of the embedded text input.
|
||||
///
|
||||
/// Example usage: <still working on this>
|
||||
#[component]
|
||||
fn RadioOther(
|
||||
#[prop(into)] name: Signal<String>,
|
||||
#[prop(into)] checked: Signal<bool>,
|
||||
#[prop(into)] on_change: Callback<()>,
|
||||
#[prop(into)] disabled: Signal<bool>,
|
||||
#[prop(optional, into)] value: RwSignal<String>,
|
||||
#[prop(into)] dirty: RwSignal<bool>,
|
||||
#[prop(into)] placeholder: Signal<String>,
|
||||
) -> impl IntoView {
|
||||
let id = create_unique_id(&name.get_untracked(), &"other".to_string());
|
||||
let input_ref = NodeRef::<Input>::new();
|
||||
|
||||
let input_disabled = Signal::derive(move || {
|
||||
!checked.get() || disabled.get()
|
||||
});
|
||||
|
||||
Effect::new(move || {
|
||||
checked.get()
|
||||
.then(|| {
|
||||
if let Some(node) = input_ref.get() {
|
||||
node.focus().expect("Couldn't focus input element");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let on_change_text = move |ev| {
|
||||
let val = event_target_value(&ev);
|
||||
|
||||
value.set(val);
|
||||
|
||||
on_change.run(());
|
||||
};
|
||||
|
||||
let on_blur_text = move |_| {
|
||||
dirty.set(true);
|
||||
};
|
||||
|
||||
view! {
|
||||
<label for=id class="flex items_center">
|
||||
<input
|
||||
// Using the "peer" class links this input with the div "peer" at the same level.
|
||||
// By using the "peer-checked" selector this div can toggle states based on this
|
||||
// input's state (checked/unchecked)
|
||||
class="relative peer shrink-0 hidden"
|
||||
type="radio"
|
||||
id=id.clone()
|
||||
name=name
|
||||
on:change=move |_| on_change.run(())
|
||||
prop:checked=checked
|
||||
disabled=disabled
|
||||
/>
|
||||
// This div displays the box for the checkbox, and applies some styling to the child
|
||||
// span element.
|
||||
<div class="relative inline-block min-h-5 min-w-5 h-5 w-5 m-1 mr-2 rounded-full border border-gray-400 bg-white
|
||||
transition-bg duration-200 peer-checked:bg-red-700 peer-checked:border-red-700
|
||||
[&>span]:opacity-0 peer-checked:[&>span]:opacity-100
|
||||
peer-disabled:bg-gray-600/33 peer-disabled:border-gray-600
|
||||
peer-disabled:peer-checked:bg-gray-600">
|
||||
|
||||
// This span is the check icon. It is centered within the div and has a border on the bottom
|
||||
// and right sides, which is then rotated to appear like a checkmark.
|
||||
<span class="absolute left-1/2 top-1/2 block h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2
|
||||
rotate-45 transform border-3 border-3 rounded-full bg-white
|
||||
border-white transition-opacity duration-150"></span>
|
||||
</div>
|
||||
<input
|
||||
node_ref=input_ref
|
||||
class="border-b-2 mb-1.5 px-1.5 py-0.5 rounded-t-xs bg-transparent relative flex-1 transition-all duration-150
|
||||
border-red-700 disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
|
||||
r#type="text"
|
||||
disabled=input_disabled
|
||||
placeholder=placeholder
|
||||
prop:value=value
|
||||
on:input=on_change_text
|
||||
on:blur=on_blur_text
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(value: String, required: bool) -> ValidationState {
|
||||
if required && value.is_empty() {
|
||||
ValidationState::Invalid("This field is required".into())
|
||||
} else {
|
||||
ValidationState::Valid
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn RadioList(
|
||||
#[prop(into)] data_member: Signal<String>,
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
#[prop()] items: Vec<String>,
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
#[prop(optional, into)] label: String,
|
||||
#[prop(optional, into)] required: Signal<bool>,
|
||||
#[prop(optional, into)] other_prompt: Signal<String>,
|
||||
#[prop(optional, into)] name: Signal<String>
|
||||
) -> impl IntoView {
|
||||
//#region Value Logic
|
||||
|
||||
// Store a single signal that is the currently selected value. We can only have one selected
|
||||
// value, so there's no list needed. The default selected value is whatever the map has selected
|
||||
// already, or nothing.
|
||||
|
||||
let hydrated = RwSignal::new(false);
|
||||
let selected_value = RwSignal::new(String::new());
|
||||
let other_checked = RwSignal::new(false);
|
||||
let other_value = RwSignal::new(String::new());
|
||||
|
||||
// Hydration effect that resolves data. Sets up all data properly.
|
||||
Effect::new({
|
||||
let items = items.clone();
|
||||
move || {
|
||||
if hydrated.get() {
|
||||
return;
|
||||
}
|
||||
|
||||
let default_value = match data_map.get_untracked().get(&data_member.get_untracked()) {
|
||||
Some(ValueType::String(Some(value))) => value.clone(),
|
||||
_ => String::new()
|
||||
};
|
||||
|
||||
if !items.contains(&default_value) && !default_value.is_empty() {
|
||||
other_checked.set(true);
|
||||
other_value.set(default_value.clone());
|
||||
}
|
||||
|
||||
selected_value.set(default_value);
|
||||
|
||||
hydrated.set(true);
|
||||
}
|
||||
});
|
||||
|
||||
// When the other option is selected and the other value changes, update the selected value.
|
||||
Effect::new(move || {
|
||||
other_checked.get()
|
||||
.then(|| selected_value.set(other_value.get()));
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region Validation Logic
|
||||
|
||||
let validation_context = use_validation_context()
|
||||
.expect("Could not find FormValidationRegistry context");
|
||||
|
||||
let error = RwSignal::new(validate(selected_value.get_untracked(), required.get_untracked()));
|
||||
let dirty = RwSignal::new(false);
|
||||
let show_errors = Signal::derive(move || {
|
||||
dirty.get() && matches!(error.get(), ValidationState::Invalid(_))
|
||||
});
|
||||
|
||||
let input_state = RwSignal::new(InputState::new(
|
||||
data_member.get_untracked(),
|
||||
error.clone(),
|
||||
dirty.clone()
|
||||
));
|
||||
|
||||
validation_context.validators.update(|list| list.push(input_state));
|
||||
|
||||
on_cleanup(move || {
|
||||
validation_context.validators.update(|list| {
|
||||
list.retain(|v| *v.get_untracked().input_name != *input_state.get_untracked().input_name)
|
||||
});
|
||||
});
|
||||
|
||||
// When the selected value changes, update the data map at the correct location and validate.
|
||||
Effect::new(move || {
|
||||
error.set(validate(selected_value.get(), required.get()));
|
||||
data_map.update(|map| {
|
||||
map.insert(data_member.get(), ValueType::String(Some(selected_value.get())));
|
||||
});
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region Render Logic
|
||||
|
||||
view! {
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="m-1.5 mt-0 mb-0">
|
||||
<span class="font-bold">{label}</span>
|
||||
{items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let item = RwSignal::new(item);
|
||||
let checked_signal = Signal::derive(move || {
|
||||
selected_value.get() == item.get() && !other_checked.get()
|
||||
});
|
||||
let on_change = move || {
|
||||
other_checked.set(false);
|
||||
selected_value.set(item.get());
|
||||
error
|
||||
.set(
|
||||
validate(
|
||||
selected_value.get_untracked(),
|
||||
required.get_untracked(),
|
||||
),
|
||||
);
|
||||
dirty.set(true);
|
||||
};
|
||||
// on_change only runs when the radio button is checked, not when unchecked.
|
||||
// Change the selected value to the new one.
|
||||
|
||||
view! {
|
||||
<Radio
|
||||
checked=checked_signal
|
||||
on_change=on_change
|
||||
// The actual selected values are tracked by this element, not by the checkboxes themselves.
|
||||
value=item
|
||||
name=move || format!("{}_{}", name.get(), data_member.get())
|
||||
disabled=disabled
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()}
|
||||
|
||||
<Show when=move || !other_prompt.get().is_empty()>
|
||||
<RadioOther
|
||||
checked=other_checked
|
||||
name=data_member
|
||||
disabled=disabled
|
||||
value=other_value
|
||||
on_change=move |_| other_checked.set(true)
|
||||
placeholder=other_prompt
|
||||
dirty=dirty
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when=move || show_errors.get()>
|
||||
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
|
||||
{move || {
|
||||
match error.get() {
|
||||
ValidationState::Invalid(msg) => msg,
|
||||
_ => "There is no error - should not see this message.".to_string(),
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use crate::common::ValueType;
|
||||
use leptos::ev::{Event, Targeted};
|
||||
use leptos::prelude::*;
|
||||
use leptos::web_sys::HtmlSelectElement;
|
||||
use std::collections::HashMap;
|
||||
use crate::components::{use_validation_context, InputState, ValidationState};
|
||||
|
||||
fn validate(required: bool, value: String) -> ValidationState {
|
||||
if required && (value.is_empty()) {
|
||||
ValidationState::Invalid("This input is required.".to_string())
|
||||
} else {
|
||||
ValidationState::Valid
|
||||
}
|
||||
}
|
||||
|
||||
/// A custom-styled select input.
|
||||
///
|
||||
/// Takes a `Vec<String>` as the set of inputs. Only one value may be selected, which is indicated
|
||||
/// by the `value` prop of the component.
|
||||
#[component]
|
||||
pub fn Select(
|
||||
#[prop(into)] value_list: Signal<Vec<String>>,
|
||||
#[prop(into)] data_member: Signal<String>,
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
#[prop(optional, into)] label: String,
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
#[prop(optional, into)] required: Signal<bool>
|
||||
) -> impl IntoView {
|
||||
//#region Value Logic
|
||||
|
||||
let value = Memo::new(move |_| {
|
||||
data_map.with(|map| {
|
||||
map.get(&data_member.get_untracked())
|
||||
.and_then(|v| v.as_string().ok().flatten())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region Validation Logic
|
||||
|
||||
let error = Memo::new(move |_| validate(required.get(), value.get()));
|
||||
let dirty = RwSignal::new(false);
|
||||
let show_errors = Signal::derive(move || dirty.get() && matches!(error.get(), ValidationState::Invalid(_)));
|
||||
|
||||
let input_state = RwSignal::new(InputState::new(data_member.get(), error.clone(), dirty.clone()));
|
||||
|
||||
let validation_context = use_validation_context()
|
||||
.expect("Could not find FormValidationRegistry context");
|
||||
|
||||
validation_context.validators.update(|list| list.push(input_state));
|
||||
|
||||
on_cleanup(move || {
|
||||
validation_context.validators.update(|list| {
|
||||
list.retain(|v| *v.get_untracked().input_name != *input_state.get_untracked().input_name)
|
||||
});
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region Event Logic
|
||||
|
||||
let on_change = move |e: Targeted<Event, HtmlSelectElement>| {
|
||||
let value = e.target().value();
|
||||
dirty.set(true);
|
||||
data_map.update(|map| {
|
||||
map.insert(data_member.get(), ValueType::String(Some(value)));
|
||||
});
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
view! {
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex flex-1">
|
||||
<label class="flex flex-col flex-1">
|
||||
<span class="ml-1.5 mb-0 font-bold">{label}</span>
|
||||
<select
|
||||
class="relative flex-1 border-2 m-1.5 mt-0 p-1.5 rounded-md
|
||||
transition-border duration-150
|
||||
border-red-700 bg-transparent
|
||||
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
|
||||
prop:value=move || value.get()
|
||||
on:change:target=on_change
|
||||
disabled=disabled
|
||||
>
|
||||
// This closure handles the display of the options.
|
||||
<option value="" disabled hidden selected=move || value.get().is_empty()>
|
||||
"Select one..."
|
||||
</option>
|
||||
|
||||
// We can reasonably assume that the options in the list will be distinct.
|
||||
<For
|
||||
each=move || value_list.get()
|
||||
key=|value| value.clone()
|
||||
children=move |val| view! { <option value=val>{val.clone()}</option> }
|
||||
/>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<Show when=move || show_errors.get()>
|
||||
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
|
||||
{move || {
|
||||
match error.get() {
|
||||
ValidationState::Invalid(msg) => msg,
|
||||
_ => "There is no error - should not see this message.".to_string(),
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ use leptos::prelude::*;
|
||||
#[component]
|
||||
pub fn Loading() -> impl IntoView {
|
||||
view! {
|
||||
<div>
|
||||
<img src="loading.gif" />
|
||||
<div class="size-20 mx-auto mt-20">
|
||||
<img src="/loading.gif" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use crate::components::login::setup::provide_auth_context;
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn AdminLoginContext(children: Children) -> impl IntoView {
|
||||
provide_auth_context(
|
||||
crate::utils::use_origin(),
|
||||
"https://cognito-idp.us-east-1.amazonaws.com/us-east-1_rvCU4Xy4j",
|
||||
"1vh1q994rid6cgi6tjf58r9jp4",
|
||||
"/admin/callback",
|
||||
);
|
||||
|
||||
view! { {children()} }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub(crate) mod setup;
|
||||
mod provider;
|
||||
pub use provider::*;
|
||||
mod student;
|
||||
pub use student::*;
|
||||
mod admin;
|
||||
pub use admin::*;
|
||||
@@ -0,0 +1,28 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::components::Outlet;
|
||||
use super::setup::provide_auth_context;
|
||||
|
||||
///# Provider Login Context Component
|
||||
///
|
||||
/// This component handles providing context to the provider login information.
|
||||
/// It creates the corresponding [`AuthSignal`], specific to the student login page and redirects.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```
|
||||
/// view! {
|
||||
/// <ProviderLoginContext>
|
||||
/// // The rest of the student application. Don't forget AuthLoaded and
|
||||
/// // Authenticated components!
|
||||
/// </ProviderLoginContext>
|
||||
/// }
|
||||
#[component]
|
||||
pub fn ProviderLoginContext() -> impl IntoView {
|
||||
provide_auth_context(
|
||||
crate::utils::use_origin(),
|
||||
"https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Lfjuy5zaM",
|
||||
"56c2bqvl021rv8d5mq36blt7jv",
|
||||
"/providers/callback"
|
||||
);
|
||||
|
||||
view! { <Outlet /> }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos_oidc::{Auth, AuthParameters, Challenge};
|
||||
|
||||
pub fn provide_auth_context(
|
||||
origin: String,
|
||||
issuer: impl Into<String>,
|
||||
client_id: impl Into<String>,
|
||||
redirect_path: impl Into<String>,
|
||||
) {
|
||||
let params = AuthParameters {
|
||||
issuer: issuer.into(),
|
||||
client_id: client_id.into(),
|
||||
redirect_uri: format!("{}{}", origin, redirect_path.into()),
|
||||
post_logout_redirect_uri: origin,
|
||||
scope: Some("openid%20profile%20email".into()),
|
||||
audience: None,
|
||||
challenge: Challenge::None,
|
||||
};
|
||||
|
||||
let auth = Auth::signal();
|
||||
provide_context(auth);
|
||||
|
||||
let _ = Auth::init(params);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use leptos::prelude::*;
|
||||
use super::setup::provide_auth_context;
|
||||
|
||||
///# Student Login Context Component
|
||||
///
|
||||
/// This component handles providing context to the student login information.
|
||||
/// It creates the corresponding [`AuthSignal`], specific to the student login page and redirects.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```
|
||||
/// view! {
|
||||
/// <StudentLoginContext>
|
||||
/// // The rest of the student application. Don't forget AuthLoaded and
|
||||
/// // Authenticated components!
|
||||
/// </StudentLoginContext>
|
||||
/// }
|
||||
#[component]
|
||||
pub fn StudentLoginContext(
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
provide_auth_context(
|
||||
crate::utils::use_origin(),
|
||||
"https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Lfjuy5zaM",
|
||||
"10jr2h3vtpu9n7gj46pvg5qo2q",
|
||||
"/students/callback"
|
||||
);
|
||||
|
||||
view! { {children()} }
|
||||
}
|
||||
+12
-6
@@ -1,18 +1,24 @@
|
||||
mod action_button;
|
||||
mod banner;
|
||||
mod checkbox;
|
||||
mod chips;
|
||||
mod dashboard_button;
|
||||
mod data_display;
|
||||
mod date;
|
||||
mod file_drop;
|
||||
mod header;
|
||||
mod lists;
|
||||
mod loading;
|
||||
pub mod login;
|
||||
mod multi_entry;
|
||||
mod panel;
|
||||
mod radio;
|
||||
mod row;
|
||||
mod select;
|
||||
mod tabs;
|
||||
mod text_field;
|
||||
mod toasts;
|
||||
mod utils;
|
||||
mod validated_form;
|
||||
|
||||
pub use self::{
|
||||
action_button::*, banner::*, checkbox::*, chips::*, dashboard_button::*, loading::*,
|
||||
multi_entry::*, panel::*, radio::*, row::*, select::*, text_field::*,
|
||||
action_button::*, banner::*, dashboard_button::*, data_display::*, date::*, file_drop::*,
|
||||
header::*, lists::*, loading::*, multi_entry::*, panel::*, row::*, tabs::*, text_field::*,
|
||||
toasts::*, validated_form::*,
|
||||
};
|
||||
+112
-78
@@ -1,8 +1,9 @@
|
||||
use crate::common::{InputType, ValueType};
|
||||
use crate::components::ActionButton;
|
||||
use leptos::leptos_dom::logging::console_log;
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use leptos::logging::log;
|
||||
use leptos_icons::Icon;
|
||||
use uuid::Uuid;
|
||||
|
||||
// region Helper Functions
|
||||
@@ -12,6 +13,14 @@ fn get_uuid_from_map(map: &HashMap<String, ValueType>) -> Option<String> {
|
||||
map.get("uuid")?.as_string().unwrap_or(None)
|
||||
}
|
||||
|
||||
/// Does the same as [`get_uuid_from_map`], but starts from a [`ValueType`] enum.
|
||||
fn get_uuid(vt: ValueType) -> Option<String> {
|
||||
vt.as_map().ok().flatten().unwrap_or_default()
|
||||
.get("uuid")?
|
||||
.as_string()
|
||||
.ok().flatten()
|
||||
}
|
||||
|
||||
/// Updates an Entry within the given `Vec`.
|
||||
fn update_entry_list(list: &mut Vec<ValueType>, data: &HashMap<String, ValueType>) {
|
||||
if let Some(uuid) = get_uuid_from_map(&data) {
|
||||
@@ -66,85 +75,103 @@ fn new_entry() -> ValueType {
|
||||
/// `first_name` and `last_name` keys within that `Entry`.
|
||||
#[component]
|
||||
pub fn MultiEntry(
|
||||
#[prop(into)] label: Signal<String>,
|
||||
#[prop(optional, into)] description: Signal<String>,
|
||||
/// The data member that contains the list of entries within the `data_map`.
|
||||
#[prop(into)]
|
||||
data_member: String,
|
||||
data_member: Signal<String>,
|
||||
/// The data map that contains all the information. Data is found within this map using the
|
||||
/// component's `data_member` input.
|
||||
#[prop()]
|
||||
data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
/// Currently unused.
|
||||
#[prop(optional, into)]
|
||||
name_member: String,
|
||||
/// The type and order of the inputs for each `Entry` component within this `MultiEntry` component.
|
||||
#[prop(optional)]
|
||||
schema: Vec<InputType>,
|
||||
#[prop(optional, into)]
|
||||
schema: Signal<Vec<InputType>>,
|
||||
) -> impl IntoView {
|
||||
// Make the values within the list reactive. Each of these values should be a ValueType::Map,
|
||||
// so we can pass these directly to the application.
|
||||
let data_reactive = RwSignal::new(
|
||||
data_map
|
||||
.get_untracked()
|
||||
.get(&data_member)
|
||||
.unwrap_or(&ValueType::List(None))
|
||||
.as_list()
|
||||
.unwrap_or(Some(Vec::<ValueType>::new()))
|
||||
.unwrap_or(Vec::new()),
|
||||
);
|
||||
|
||||
// This effect updates the data_map with any new values from the data_reactive list, every time
|
||||
// the data_reactive list is changed. This allows changes from within the Entry component to
|
||||
// bubble up to the data_map effectively.
|
||||
Effect::new(move |_| {
|
||||
let entry_list = data_reactive.get();
|
||||
data_map.update(|map| {
|
||||
map.insert(data_member.clone(), ValueType::List(Some(entry_list)));
|
||||
});
|
||||
let data_list = Memo::new(move |_| {
|
||||
data_map.with(|map| {
|
||||
map.get(&data_member.get())
|
||||
.and_then(|v| v.as_list().ok().flatten())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
});
|
||||
|
||||
// This effect updates the data_map with any new values from the data_list, every time
|
||||
// the data_list is changed. This allows changes from within the Entry component to
|
||||
// bubble up to the data_map effectively.
|
||||
let update_parent_list = move |list| {
|
||||
log!("Updating parent list using child list {:?}", list);
|
||||
data_map.update(|map| {
|
||||
map.insert(data_member.get(), ValueType::List(Some(list)));
|
||||
});
|
||||
};
|
||||
|
||||
let add_entry = move |_| data_reactive.update(|list| list.push(new_entry()));
|
||||
let add_entry = move |_| data_map.update(|map| {
|
||||
log!("Adding new entry.");
|
||||
let mut list = data_list.get_untracked();
|
||||
|
||||
list.push(new_entry());
|
||||
map.insert(data_member.get(), ValueType::List(Some(list)));
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex flex-col gap-2 p-2">
|
||||
<span class="font-bold">{label}</span>
|
||||
<Show when=move || !description.get().is_empty()>
|
||||
<span>{description}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex flex-col gap-6 p-2">
|
||||
// Render the entries
|
||||
<For
|
||||
each=move || data_reactive.get()
|
||||
key=|entry| get_uuid_from_map(&entry.as_map().unwrap().unwrap()) // Entries MUST be given a unique ID. This is absolutely ridiculous.
|
||||
children=move |mut entry_map| {
|
||||
// The map_signal is the map used for the internal element. This signal will
|
||||
// keep track of the individual entry's data, and whenever it is changed,
|
||||
// the parent data_map will also be updated to reflect these changes.
|
||||
let map_signal = RwSignal::new(entry_map
|
||||
.as_map()
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default());
|
||||
|
||||
// This effect updates the data_reactive list with the map_signal values, every
|
||||
// time the map_signal is changed.
|
||||
Effect::new(move |_| {
|
||||
let child_map = map_signal.get(); // HashMap
|
||||
data_reactive.update(|mut list| {
|
||||
update_entry_list(&mut list, &child_map);
|
||||
});
|
||||
});
|
||||
|
||||
view! {
|
||||
<Entry
|
||||
data_map = map_signal
|
||||
name_member = name_member.clone()
|
||||
schema = schema.clone()
|
||||
/>
|
||||
}
|
||||
<Show
|
||||
when=move || !data_list.get().is_empty()
|
||||
fallback=|| {
|
||||
view! { <div class="mx-auto">"You haven't added any entries yet."</div> }
|
||||
}
|
||||
/>
|
||||
>
|
||||
<For
|
||||
each=move || data_list.get()
|
||||
// Entries MUST be given a unique ID.
|
||||
key=|entry| get_uuid(entry.clone())
|
||||
children=move |mut entry_map| {
|
||||
let map_signal = RwSignal::new(
|
||||
entry_map.as_map().ok().flatten().unwrap_or_default(),
|
||||
);
|
||||
let unique_id = Memo::new(move |_| {
|
||||
get_uuid_from_map(&map_signal.get()).unwrap_or_default()
|
||||
});
|
||||
let on_delete = move || {
|
||||
let mut list = data_list.get_untracked();
|
||||
let uuid = unique_id.get_untracked();
|
||||
list.retain(|map| {
|
||||
get_uuid(map.clone()).unwrap_or_default() != uuid
|
||||
});
|
||||
update_parent_list(list);
|
||||
};
|
||||
Effect::new(move |_| {
|
||||
let child_map = map_signal.get();
|
||||
let mut list = data_list.get_untracked();
|
||||
update_entry_list(&mut list, &child_map);
|
||||
update_parent_list(list);
|
||||
});
|
||||
|
||||
view! {
|
||||
<Entry
|
||||
data_map=map_signal
|
||||
schema=schema
|
||||
id=unique_id
|
||||
on_delete=on_delete
|
||||
/>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<ActionButton on:click = add_entry>"Add entry"</ActionButton>
|
||||
<ActionButton on:click=move |_| {
|
||||
console_log(format!("Entry list: {:?}", data_reactive.get()).as_str());
|
||||
}
|
||||
>"Print Info"</ActionButton>
|
||||
<ActionButton on:click=add_entry>"Add entry"</ActionButton>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -180,28 +207,35 @@ fn Entry(
|
||||
/// The data map that contains all information. Data is found within this map using the `member`
|
||||
/// props. This map should be unique to this entry; in other words, it should be contained and
|
||||
/// passed down from within the entry list in the parent `MultiEntry` component.
|
||||
#[prop()]
|
||||
data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
/// The schema of this entry. Should contain all information about what inputs to use and what
|
||||
/// data members those inputs modify.
|
||||
#[prop()]
|
||||
schema: Vec<InputType>,
|
||||
/// The display name member of this entry. Determines where the component should search for the
|
||||
/// name of the entry within the `data_map`.
|
||||
#[prop(into)]
|
||||
name_member: String,
|
||||
/// The info member of this entry. Determines where the component should search for the extra
|
||||
/// information of this entry within the `data_map`.
|
||||
#[prop(optional, into)]
|
||||
info_member: String,
|
||||
#[prop(into)] schema: Signal<Vec<InputType>>,
|
||||
/// The unique ID for this entry.
|
||||
#[prop(into)] id: Signal<String>,
|
||||
/// The function to run when the delete button is clicked.
|
||||
#[prop(into)] on_delete: Callback<()>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="flex flex-col flex-1 p-2 rounded-sm transition-shadow shadow-sm hover:shadow-lg/30">
|
||||
{schema.iter()
|
||||
.map(|input_type| {
|
||||
input_type.clone().into_view(data_map.clone())
|
||||
}).collect_view()
|
||||
}
|
||||
<div class="relative rounded-sm transition-shadow shadow-sm hover:shadow-lg/30">
|
||||
// Delete button
|
||||
<div
|
||||
class="absolute rounded-full w-8 h-8 -top-4 -right-4 flex items-center justify-center bg-gray-100 text-gray-700 hover:text-red-600 transition-all cursor-pointer"
|
||||
on:click=move |_| on_delete.run(())
|
||||
>
|
||||
<Icon icon=icondata::FaTrashCanRegular />
|
||||
</div>
|
||||
|
||||
// Input section
|
||||
<div class="flex flex-col flex-1 p-2">
|
||||
{move || {
|
||||
schema
|
||||
.get()
|
||||
.iter()
|
||||
.map(|input_type| input_type.clone().into_view(data_map.clone(), id.get()))
|
||||
.collect_view()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
use crate::common::ValueType;
|
||||
use crate::components::utils::create_unique_id;
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[component]
|
||||
pub fn Radio(
|
||||
#[prop()] checked: Signal<bool>,
|
||||
#[prop(into)] on_change: Callback<(), ()>,
|
||||
#[prop(into)] value: String,
|
||||
#[prop(into)] name: String,
|
||||
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
let id = create_unique_id(&name, &value);
|
||||
|
||||
view! {
|
||||
<label for=id class="flex items-center">
|
||||
<input
|
||||
// Using the "peer" class links this input with the div "peer" at the same level.
|
||||
// By using the "peer-checked" selector this div can toggle states based on this
|
||||
// input's state (checked/unchecked)
|
||||
class="
|
||||
relative peer shrink-0
|
||||
hidden"
|
||||
type="radio"
|
||||
id=id.clone()
|
||||
name=name.clone()
|
||||
on:change=move |_| on_change.run(())
|
||||
prop:checked=checked
|
||||
disabled=disabled
|
||||
/>
|
||||
// This div displays the box for the checkbox, and applies some styling to the child
|
||||
// span element.
|
||||
<div class="relative inline-block h-5 w-5 m-1 mr-2 rounded-full border border-gray-300 bg-white
|
||||
transition-bg duration-200 peer-checked:bg-red-700 peer-checked:border-red-700
|
||||
[&>span]:opacity-0 peer-checked:[&>span]:opacity-100
|
||||
peer-disabled:bg-gray-600/33 peer-disabled:border-gray-600
|
||||
peer-disabled:peer-checked:bg-gray-600">
|
||||
// This span is the check icon. It is centered within the div and has a border on the bottom
|
||||
// and right sides, which is then rotated to appear like a checkmark.
|
||||
<span class="absolute left-1/2 top-1/2 block h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2
|
||||
rotate-45 transform border-3 border-3 rounded-full bg-white
|
||||
border-white transition-opacity duration-150"></span>
|
||||
</div>
|
||||
<span>{value.clone()}</span>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn RadioList(
|
||||
// #[prop(default = RwSignal::new(String::new()))] selected: RwSignal<ValueType>,
|
||||
#[prop(into)] data_member: String,
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
#[prop()] items: Vec<String>,
|
||||
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
|
||||
#[prop(optional, into)] label: String,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="m-1.5 mt-0 mb-0">
|
||||
<span class="font-bold">{label}</span>
|
||||
{items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let checked_signal = Signal::derive({
|
||||
let item_name = item.clone();
|
||||
let data_member = data_member.clone();
|
||||
move || {
|
||||
// Check if the value is contained in the data map's fields
|
||||
let map = data_map.get();
|
||||
let value = map.get(&data_member);
|
||||
let mut result = false;
|
||||
if let Some(val) = value {
|
||||
if val.is_string() {
|
||||
result = val.as_string().unwrap().unwrap() == item_name
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
});
|
||||
|
||||
let on_change = {
|
||||
let item_name = item.clone();
|
||||
let data_member = data_member.clone();
|
||||
move || {
|
||||
// Update the hash map
|
||||
data_map.update(|map| {
|
||||
if let Some(val) = map.get_mut(&data_member) {
|
||||
*val = ValueType::String(Some(item_name.clone()));
|
||||
} else {
|
||||
map.insert(data_member.clone(), ValueType::String(Some(item_name.clone())));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<Radio
|
||||
checked=checked_signal
|
||||
on_change=on_change
|
||||
// The actual selected values are tracked by this element, not by the checkboxes themselves.
|
||||
value=item.clone()
|
||||
name=data_member.clone()
|
||||
disabled=disabled
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
use crate::common::ValueType;
|
||||
use leptos::ev::{Event, Targeted};
|
||||
use leptos::prelude::*;
|
||||
use leptos::web_sys::HtmlSelectElement;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A custom-styled select input.
|
||||
///
|
||||
/// Takes a `Vec<String>` as the set of inputs. Only one value may be selected, which is indicated
|
||||
/// by the `value` prop of the component.
|
||||
#[component]
|
||||
pub fn Select(
|
||||
#[prop()] value_list: Vec<String>,
|
||||
#[prop(optional)] value: RwSignal<String>,
|
||||
#[prop(into)] data_member: String,
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
#[prop(optional, into)] label: String,
|
||||
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
let on_change = {
|
||||
let data_member = data_member.clone();
|
||||
move |e: Targeted<Event, HtmlSelectElement>| {
|
||||
let target_value = e.target().value();
|
||||
data_map.update(|map| {
|
||||
if let Some(val) = map.get_mut(&data_member) {
|
||||
*val = ValueType::String(Some(target_value));
|
||||
} else {
|
||||
map.insert(data_member.clone(), ValueType::String(Some(target_value)));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="flex flex-1">
|
||||
<label class="flex flex-col flex-1">
|
||||
<span class="ml-1.5 mb-0 font-bold">{label}</span>
|
||||
<select
|
||||
class="relative flex-1 border-2 m-1.5 mt-0 p-1.5 rounded-md
|
||||
transition-border duration-150
|
||||
border-red-700 bg-transparent
|
||||
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
|
||||
on:change:target=on_change
|
||||
disabled = disabled >
|
||||
// This closure handles the display of the options.
|
||||
{move || {
|
||||
value_list.iter().map(
|
||||
move |value| {
|
||||
let display = value.to_owned();
|
||||
view! { <option value={display}>{display.clone()}</option> }
|
||||
}
|
||||
).collect::<Vec<_>>()
|
||||
}}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
mod sidebar;
|
||||
pub use sidebar::*;
|
||||
@@ -0,0 +1,141 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::components::{Outlet, A};
|
||||
use leptos_router::hooks::use_location;
|
||||
use crate::common::{SubTabInfo, TabInfo};
|
||||
|
||||
/// # Tab Sidebar Component
|
||||
///
|
||||
/// This component creates a list of tabs that correspond to the given `Vec<TabInfo>`.
|
||||
/// There are a few things to note:
|
||||
///
|
||||
/// - This component defines an [`Outlet`], meaning that it is designed to display the route
|
||||
/// information on its own. If you intend to use this on a page, the page should not also contain
|
||||
/// an `Outlet` component unless it needs to render its own nested children.
|
||||
/// - The "selected" appearance for the individual tabs work best when their paths are at the same
|
||||
/// depth. For example:
|
||||
/// ```
|
||||
/// let good_list = vec! [
|
||||
/// TabInfo {
|
||||
/// ...,
|
||||
/// path: "/default/path"
|
||||
/// },
|
||||
/// TabInfo {
|
||||
/// ...,
|
||||
/// path: "/good/path"
|
||||
/// }
|
||||
/// ];
|
||||
///
|
||||
/// let bad_list = vec! [
|
||||
/// TabInfo {
|
||||
/// ...,
|
||||
/// path: "/default/path"
|
||||
/// },
|
||||
/// TabInfo {
|
||||
/// ...,
|
||||
/// path: "/bad/path/here"
|
||||
/// }
|
||||
/// ]
|
||||
/// ```
|
||||
///
|
||||
/// Example usage in a view (assuming the good list from above):
|
||||
/// ```
|
||||
/// view! {
|
||||
/// <TabSidebarList
|
||||
/// tabs=good_list
|
||||
/// />
|
||||
/// // Remember, no Outlets!
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn TabSidebarList(
|
||||
#[prop(into)] base_path: Signal<String>,
|
||||
#[prop(into)] tabs: Signal<Vec<TabInfo>>,
|
||||
) -> impl IntoView {
|
||||
// Create a series of tabs based on a given list of names and paths.
|
||||
|
||||
view! {
|
||||
<div class="flex flex-1 flex-row w-full">
|
||||
<nav class="flex flex-col bg-red-900 w-64 overflow-scroll">
|
||||
<For
|
||||
each=move || tabs.get()
|
||||
key=|info| info.text.clone()
|
||||
let(TabInfo { text, path, sub_paths })
|
||||
>
|
||||
<SidebarTab base_path=base_path text=text path=path sub_paths=sub_paths />
|
||||
</For>
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-1 overflow-scroll">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn SidebarTab(
|
||||
#[prop(into)] base_path: Signal<String>,
|
||||
#[prop(into)] text: Signal<String>,
|
||||
#[prop(into)] path: Signal<String>,
|
||||
#[prop(into)] sub_paths: Signal<Vec<SubTabInfo>>,
|
||||
) -> impl IntoView {
|
||||
let location = use_location();
|
||||
|
||||
let full_path = Memo::new(move |_| {
|
||||
format!("/{}/{}", base_path.get(), path.get())
|
||||
});
|
||||
|
||||
let selected = Memo::new(move |_| {
|
||||
let current_path = location.pathname.get();
|
||||
|
||||
// There's extra logic here as well, but for now this works.
|
||||
current_path.starts_with(&full_path.get())
|
||||
});
|
||||
|
||||
view! {
|
||||
<div
|
||||
class="transition-all duration-300"
|
||||
class=(["bg-transparent", "text-white"], move || !selected.get())
|
||||
class=(["bg-white", "text-black"], move || selected.get())
|
||||
>
|
||||
<A href=move || path.get()>
|
||||
<div class="p-3 transition-bg font-bold">{text}</div>
|
||||
</A>
|
||||
<div
|
||||
class="grid transition-all ease-in-out"
|
||||
style:grid-template-rows=move || if selected.get() { "1fr" } else { "0fr" }
|
||||
>
|
||||
<div class="overflow-hidden min-h-0">
|
||||
<For
|
||||
each=move || sub_paths.get()
|
||||
key=|info| info.text.clone()
|
||||
let(SubTabInfo { text, path })
|
||||
>
|
||||
<SidebarSubTab parent_path=full_path text=text path=path />
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn SidebarSubTab(
|
||||
#[prop(into)] parent_path: Signal<String>,
|
||||
#[prop(into)] text: Signal<String>,
|
||||
#[prop(into)] path: Signal<String>,
|
||||
) -> impl IntoView {
|
||||
let full_path = Memo::new(move |_| {
|
||||
format!("{}/{}", parent_path.get(), path.get())
|
||||
});
|
||||
|
||||
view! {
|
||||
<A
|
||||
href=move || full_path.get()
|
||||
{..}
|
||||
class="block p-3 pl-10 transition-all aria-[current]:font-bold"
|
||||
>
|
||||
{text}
|
||||
</A>
|
||||
}
|
||||
}
|
||||
+151
-70
@@ -1,70 +1,148 @@
|
||||
use crate::common::ValueType;
|
||||
use leptos::leptos_dom::logging::console_log;
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use leptos::html::Input;
|
||||
use leptos::logging::debug_log;
|
||||
use crate::components::{use_validation_context, InputState};
|
||||
use crate::components::validated_form::ValidationState;
|
||||
|
||||
/// Determines the available types of text fields.
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub enum TextFieldType {
|
||||
/// A text field that accepts any string.
|
||||
#[default]
|
||||
Text,
|
||||
/// A text field that accepts only numbers.
|
||||
Number,
|
||||
/// A text field that accepts the specified email domains.
|
||||
/// Values should be given as the string following the '@' in an address, or '*' for any.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```
|
||||
/// TextFieldType::Email(vec!["gmail.com".to_string(), "customdomain.org".to_string()]);
|
||||
/// TextFieldType::Email(vec!["*".to_string()]);
|
||||
/// ```
|
||||
Email(Vec<String>)
|
||||
}
|
||||
|
||||
/// Entry point for validating a text field.
|
||||
fn validate(required: bool, value: &str, input_type: &TextFieldType) -> ValidationState {
|
||||
if required && value.is_empty() {
|
||||
ValidationState::Invalid("This field is required.".to_string())
|
||||
} else if !required && value.is_empty() {
|
||||
ValidationState::Valid
|
||||
} else {
|
||||
match input_type {
|
||||
TextFieldType::Email(domains) => validate_email(value, domains),
|
||||
TextFieldType::Number => validate_number(value),
|
||||
TextFieldType::Text => ValidationState::Valid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates an email address.
|
||||
fn validate_email(input: &str, valid_domains: &Vec<String>) -> ValidationState {
|
||||
// Check for wildcard domain. We won't check domains if it's present.
|
||||
let wild = valid_domains.contains(&"*".to_string());
|
||||
|
||||
input.split_once('@')
|
||||
.and_then(|(_, domain)| {
|
||||
(wild || valid_domains.contains(&domain.to_string()))
|
||||
.then(|| ValidationState::Valid)
|
||||
.or_else(|| Some(ValidationState::Invalid(
|
||||
format!("Email address must match one of the following: {}", valid_domains.join(", "))
|
||||
)))
|
||||
}).unwrap_or(ValidationState::Invalid("Invalid email address.".to_string()))
|
||||
}
|
||||
|
||||
/// Validates a number.
|
||||
fn validate_number(input: &str) -> ValidationState {
|
||||
if input.parse::<f32>().is_ok() {
|
||||
ValidationState::Valid
|
||||
} else {
|
||||
ValidationState::Invalid("Value is not a valid number.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn OutlinedTextField(
|
||||
#[prop(optional, into)] placeholder: String,
|
||||
// #[prop()] value: Subfield<TStore, TInner, ValueType>,
|
||||
#[prop(into)] data_member: String, // should this be an RwSignal??
|
||||
#[prop(into)] data_member: String,
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
#[prop(optional)] value: RwSignal<ValueType>,
|
||||
#[prop(default = "text".to_owned(), into)] input_type: String,
|
||||
#[prop(optional)] disabled: RwSignal<bool>,
|
||||
#[prop(optional)] error: RwSignal<bool>,
|
||||
#[prop(optional, into)] input_type: Signal<TextFieldType>,
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
#[prop(optional, into)] name: String,
|
||||
#[prop(optional, into)] label: String,
|
||||
#[prop(optional, into)] error_text: RwSignal<String>,
|
||||
#[prop(optional, into)] required: Signal<bool>
|
||||
) -> impl IntoView {
|
||||
let input_ref = NodeRef::<Input>::new();
|
||||
let data_member = StoredValue::new(data_member);
|
||||
|
||||
// Register this input's validation signal.
|
||||
let validator_context = use_validation_context()
|
||||
.expect("FormValidSignal was not found");
|
||||
|
||||
let display_value = RwSignal::new(
|
||||
data_map.get_untracked()
|
||||
.get(&data_member.get_value())
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default()
|
||||
);
|
||||
|
||||
let dirty = RwSignal::new(false);
|
||||
let error = Signal::derive(move ||
|
||||
validate(required.get(), &display_value.get(), &input_type.get())
|
||||
);
|
||||
let show_errors = Signal::derive(move || {
|
||||
dirty.get() && matches!(error.get(), ValidationState::Invalid(_))
|
||||
});
|
||||
|
||||
let validator = RwSignal::new(InputState::new(data_member.get_value(), error.clone(), dirty.clone()));
|
||||
|
||||
validator_context.validators.update(|list| list.push(validator));
|
||||
|
||||
on_cleanup(move || {
|
||||
validator_context.validators.update(|list| {
|
||||
list.retain(|v| *v.get_untracked().input_name != *validator.get_untracked().input_name)
|
||||
});
|
||||
});
|
||||
|
||||
let on_blur = move |_| {
|
||||
// Check validity of input's current value. The value will be updated by on_input,
|
||||
// and it should be a raw input (not parsed into a ValueType).
|
||||
dirty.set(true);
|
||||
};
|
||||
|
||||
// Syncs data from the map into the display_value, only when the map is actually changed.
|
||||
Effect::new(move || {
|
||||
let new_value = data_map.get()
|
||||
.get(&data_member.get_value())
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
if new_value != display_value.get_untracked() {
|
||||
debug_log!("Found value from map: {}", new_value);
|
||||
display_value.set(new_value);
|
||||
}
|
||||
});
|
||||
|
||||
// This function parses the input into the correct type. It only accepts numbers or strings as
|
||||
// valid types, and parses them accordingly.
|
||||
let on_input = {
|
||||
let input_type = input_type.clone();
|
||||
let data_member = data_member.clone();
|
||||
move |e| {
|
||||
let to_parse = event_target_value(&e);
|
||||
match input_type.as_str() {
|
||||
"text" => {
|
||||
// String parse function. It will always parse, but make sure the error
|
||||
// state is set properly anyway.
|
||||
error.set(false);
|
||||
value.set(ValueType::String(Some(to_parse.clone())));
|
||||
data_map.update(|map| {
|
||||
map.insert(data_member.clone(), ValueType::String(Some(to_parse)));
|
||||
});
|
||||
}
|
||||
"number" => {
|
||||
// Define number parse function.
|
||||
// If the number parses, Some(num), else None, but we want to keep the value that's
|
||||
// in the text field at this point, just set the error.
|
||||
console_log(format!("Parsing {to_parse} to a number").as_str());
|
||||
match to_parse
|
||||
.parse::<i32>()
|
||||
.map(|n| ValueType::Number(Some(n.to_string())))
|
||||
{
|
||||
Ok(value_type) => {
|
||||
error.set(false);
|
||||
value.set(value_type.clone());
|
||||
data_map.update(|map| {
|
||||
map.insert(data_member.clone(), value_type);
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
error.set(true);
|
||||
error_text.set("Value is not a valid number.".to_owned());
|
||||
}
|
||||
};
|
||||
}
|
||||
_ => {
|
||||
// Create some custom function that reports failure.
|
||||
let msg = format!("Input {input_type:?} is not a valid type.");
|
||||
console_log(msg.as_str());
|
||||
error.set(true);
|
||||
error_text.set(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
let on_input = move |e| {
|
||||
let to_parse = event_target_value(&e);
|
||||
debug_log!("Setting value to display_value: {}", to_parse);
|
||||
display_value.set(to_parse.clone());
|
||||
|
||||
// Push the new value to the data map. This happens regardless of whether the value parses
|
||||
// or not.
|
||||
data_map.update(|map| {
|
||||
let into_map = match input_type.get() {
|
||||
TextFieldType::Number => ValueType::Number(Some(to_parse)),
|
||||
_ => ValueType::String(Some(to_parse))
|
||||
};
|
||||
|
||||
map.insert(data_member.get_value(), into_map);
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
@@ -72,26 +150,29 @@ pub fn OutlinedTextField(
|
||||
<label class="flex flex-col flex-1">
|
||||
<span class="block ml-1.5 mb-0 font-bold">{label}</span>
|
||||
<input
|
||||
node_ref=input_ref
|
||||
class="border-2 m-1.5 p-1.5 mt-0 rounded-md bg-transparent relative flex-1
|
||||
transition-all duration-150
|
||||
border-red-700 bg-transparent
|
||||
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
|
||||
r#type={input_type}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
transition-all duration-150
|
||||
border-red-700 bg-transparent
|
||||
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
|
||||
r#type="text"
|
||||
disabled=disabled
|
||||
placeholder=placeholder
|
||||
prop:name=name
|
||||
prop:value=move || data_map
|
||||
.get() // HashMap
|
||||
.get(&data_member) // Option<ValueType>
|
||||
.unwrap_or(&ValueType::String(None)) // ValueType
|
||||
.to_string()
|
||||
prop:value=display_value
|
||||
on:input=on_input
|
||||
on:blur=on_blur
|
||||
/>
|
||||
<div
|
||||
class="text-red-600 text-sm mr-1.5 ml-1.5"
|
||||
class=(["hidden"], move || !error.get())>
|
||||
{error_text}
|
||||
</div>
|
||||
<Show when=move || show_errors.get()>
|
||||
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
|
||||
{move || {
|
||||
match error.get() {
|
||||
ValidationState::Invalid(msg) => msg,
|
||||
_ => "There is no error - should not see this message.".to_string(),
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</Show>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
mod toast_list;
|
||||
mod toast;
|
||||
|
||||
pub use toast_list::*;
|
||||
@@ -0,0 +1,37 @@
|
||||
use std::time::Duration;
|
||||
use leptos::prelude::*;
|
||||
use leptos_animate::animate;
|
||||
use leptos_animate::animations::classes::{In, Out};
|
||||
|
||||
#[component]
|
||||
pub fn Toast(
|
||||
#[prop(into)] on_close: Callback<()>,
|
||||
#[prop(into)] message: Signal<String>,
|
||||
#[prop(into)] title: Signal<String>
|
||||
) -> impl IntoView {
|
||||
set_timeout(
|
||||
move || {
|
||||
on_close.run(())
|
||||
},
|
||||
Duration::from_secs(3)
|
||||
);
|
||||
|
||||
view! {
|
||||
<div
|
||||
class="flex flex-col rounded-md shadow-lg/33 bg-white p-3 m-1.5 mx-auto min-w-lg max-w-lg"
|
||||
use:animate=(
|
||||
In::default()
|
||||
.source("opacity-0 -translate-y-2 scale-90")
|
||||
.active("transition-all duration-150 transform-gpu")
|
||||
.target("opacity-100 translate-y-0 scale-100"),
|
||||
Out::default()
|
||||
.source("opacity-100 translate-y-0 scale-100")
|
||||
.active("transition-all duration-150 transform-gpu")
|
||||
.target("opacity-0 -translate-y-2 scale-90"),
|
||||
)
|
||||
>
|
||||
<h3 class="font-bold text-lg">{title}</h3>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use leptos::prelude::*;
|
||||
use crate::components::toasts::toast::Toast;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Toast {
|
||||
id: String,
|
||||
header: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl Toast {
|
||||
pub fn new() -> Self {
|
||||
Toast {
|
||||
id: "".to_owned(),
|
||||
header: "".to_owned(),
|
||||
message: "".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn header(mut self, header: impl Into<String>) -> Self {
|
||||
self.header = header.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn msg(mut self, msg: impl Into<String>) -> Self {
|
||||
self.message = msg.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn id(mut self, id: impl Into<String>) -> Self {
|
||||
self.id = id.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ToastContext {
|
||||
messages: RwSignal<Vec<Toast>>,
|
||||
}
|
||||
|
||||
impl ToastContext {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
messages: RwSignal::new(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toast(&mut self, toast: Toast) {
|
||||
self.messages.update(|list| list.push(toast));
|
||||
}
|
||||
}
|
||||
|
||||
/// # Toast List Component
|
||||
/// Displays a list of toasts. They must be queued using the [`ToastContext`].
|
||||
#[component]
|
||||
pub fn ToastList(
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
// This context should be used wherever the application might need to use a toast.
|
||||
// That specific component should also be wrapped in a ToastList component, otherwise
|
||||
// this context won't exist.
|
||||
let context = use_context::<ToastContext>()
|
||||
.unwrap_or_else(provide_toast_context);
|
||||
|
||||
view! {
|
||||
{children()}
|
||||
<div class="fixed top-1 left-1/2 right-1/2 -translate-x-1/2 w-full">
|
||||
<For
|
||||
each=move || context.messages.get()
|
||||
key=|toast| toast.id.clone()
|
||||
let(Toast { id, header, message })
|
||||
>
|
||||
<Toast
|
||||
message=message
|
||||
title=header
|
||||
on_close=move || {
|
||||
context.messages.update(|list|
|
||||
list.retain(|v| v.id != id)
|
||||
);
|
||||
}
|
||||
/>
|
||||
</For>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn provide_toast_context() -> ToastContext {
|
||||
let context = ToastContext::new();
|
||||
provide_context(context);
|
||||
context
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
use leptos::logging::log;
|
||||
use leptos::prelude::*;
|
||||
use leptos::web_sys::SubmitEvent;
|
||||
use crate::components::{ActionButton, Header};
|
||||
|
||||
/// Indicates the current state of an input.
|
||||
#[derive(Default, Debug, Clone)]
|
||||
#[derive(PartialEq)]
|
||||
pub enum ValidationState {
|
||||
/// A valid input.
|
||||
#[default]
|
||||
Valid,
|
||||
/// An invalid input with an error message.
|
||||
Invalid(String)
|
||||
}
|
||||
|
||||
/// A struct that contains all information required for a form to make real-time validation
|
||||
/// decisions, including display of error messages.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InputState {
|
||||
/// The name of the input. This is used to identify the input in the form.
|
||||
pub input_name: String,
|
||||
/// Indicates the current state of the input.
|
||||
error: Signal<ValidationState>,
|
||||
/// Indicates whether the form has requested the error state. This means that the input
|
||||
/// should show their error appearances immediately.
|
||||
form_requested_validation: RwSignal<bool>,
|
||||
}
|
||||
|
||||
impl InputState {
|
||||
/// Creates a new [`InputState`] with the given error and form requested validation signals.
|
||||
pub fn new(
|
||||
name: impl Into<String>,
|
||||
error: impl Into<Signal<ValidationState>>,
|
||||
form_requested_errors: RwSignal<bool>
|
||||
) -> Self {
|
||||
Self {
|
||||
input_name: name.into(),
|
||||
error: error.into(),
|
||||
form_requested_validation: form_requested_errors
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(&self) -> Signal<ValidationState> {
|
||||
self.error.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of [`InputState`]s.
|
||||
pub type ValidatorList = Vec<RwSignal<InputState>>;
|
||||
|
||||
/// A struct that keeps track of all components that must be validated.
|
||||
/// All of our custom-made components must be placed within an area that contains context with
|
||||
/// this struct.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FormValidationRegistry {
|
||||
pub validators: RwSignal<ValidatorList>
|
||||
}
|
||||
|
||||
/// Gets the [`FormValidationRegistry`] context if it exists.
|
||||
pub fn use_validation_context() -> Option<FormValidationRegistry> {
|
||||
use_context::<FormValidationRegistry>()
|
||||
}
|
||||
|
||||
/// # Validated Form Component
|
||||
///
|
||||
/// A simple HTML `<form>` wrapper that executes validation logic using a [`FormValidationRegistry`].
|
||||
/// It will ensure that any invalid inputs are caught before submission. All input components that
|
||||
/// are used within this form should call `use_context::<FormValidationRegistry>()` to register
|
||||
/// themselves with the form.
|
||||
///
|
||||
/// Example usage in the view macro:
|
||||
/// ```
|
||||
/// view! {
|
||||
/// <ValidatedForm
|
||||
/// on_submit=Callback::new(move || {/* something here */})
|
||||
/// title="Example Title"
|
||||
/// description="Example description."
|
||||
/// >
|
||||
/// {/* As many input components as you want */}
|
||||
/// </ValidatedForm>
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Example input component implementation:
|
||||
/// ```
|
||||
/// fn SomeComponent() -> impl IntoView {
|
||||
/// // Get FormValidationContext
|
||||
/// let context = use_context::<FormValidationRegistry>()
|
||||
/// .expect("Couldn't find FormValidationRegsitry context");
|
||||
///
|
||||
/// // Register with the form
|
||||
/// let error = RwSignal::new(ValidationState::Valid);
|
||||
/// let form_requested_errors = RwSignal::new(false);
|
||||
/// let state = InputState::new(
|
||||
/// "the_input_name",
|
||||
/// error.clone(),
|
||||
/// form_requested_errors.clone()
|
||||
/// );
|
||||
/// context.validators.update(|list| list.push(RwSignal::new(state)));
|
||||
///
|
||||
/// /* Other input view logic */
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn ValidatedForm(
|
||||
children: Children,
|
||||
/// A callback that runs when the form successfully validates.
|
||||
#[prop(into)] on_submit: Callback<()>,
|
||||
/// The title of the included header.
|
||||
#[prop(into)] title: Signal<String>,
|
||||
/// The description for the included header.
|
||||
#[prop(optional, into)] description: Signal<String>,
|
||||
/// A `Signal` that determines when the submit button should be disabled.
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
let validators = RwSignal::new(vec![]);
|
||||
|
||||
// Provide context to all children nested within this component. They will be able to access
|
||||
// this no matter how nested they are.
|
||||
provide_context(FormValidationRegistry {validators});
|
||||
|
||||
let can_submit = Memo::new(move |_| {
|
||||
log!("Checking {} inputs...", validators.get().len());
|
||||
validators
|
||||
.with(|list| {
|
||||
list.iter()
|
||||
// TODO all() short circuits on the first false
|
||||
.fold(true, |acc, v| {
|
||||
v.update(|validator| validator.form_requested_validation.set(true));
|
||||
matches!(v.get().error().get(), ValidationState::Valid) && acc
|
||||
})
|
||||
// .all(|v| {
|
||||
// // Not sure how this will work reactively.
|
||||
// let result = matches!(v.get().error().get(), ValidationState::Valid);
|
||||
// v.update(|validator| validator.form_requested_validation.set(true));
|
||||
// result
|
||||
// })
|
||||
})
|
||||
});
|
||||
|
||||
let submit_success_event = move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
|
||||
if !can_submit.get() {
|
||||
log!("Form failed to submit, there are errors in form inputs.");
|
||||
return;
|
||||
}
|
||||
|
||||
log!("Form validation successful.");
|
||||
on_submit.run(());
|
||||
};
|
||||
|
||||
view! {
|
||||
<form class="flex flex-col gap-2 py-7" novalidate on:submit=submit_success_event>
|
||||
<Header title=title description=description />
|
||||
{children()}
|
||||
<ActionButton
|
||||
disabled=disabled
|
||||
on:click=move |_| {
|
||||
log!("{:?}", can_submit.get());
|
||||
}
|
||||
button_type="submit"
|
||||
>
|
||||
"Submit"
|
||||
</ActionButton>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@ pub mod app;
|
||||
mod common;
|
||||
mod components;
|
||||
mod pages;
|
||||
mod utils;
|
||||
pub mod utils;
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||
|
||||
+19
-4
@@ -6,7 +6,12 @@ async fn main() {
|
||||
use leptos::prelude::*;
|
||||
use leptos_axum::{LeptosRoutes, generate_route_list};
|
||||
use scholarships_rs::app::*;
|
||||
use scholarships_rs::utils::server::create_dynamo_client;
|
||||
use std::sync::Arc;
|
||||
|
||||
let dynamo_client = create_dynamo_client().await;
|
||||
let shared_client = Arc::new(dynamo_client);
|
||||
|
||||
let conf = get_configuration(None).unwrap();
|
||||
let addr = conf.leptos_options.site_addr;
|
||||
let leptos_options = conf.leptos_options;
|
||||
@@ -14,10 +19,20 @@ async fn main() {
|
||||
let routes = generate_route_list(App);
|
||||
|
||||
let app = Router::new()
|
||||
.leptos_routes(&leptos_options, routes, {
|
||||
let leptos_options = leptos_options.clone();
|
||||
move || shell(leptos_options.clone())
|
||||
})
|
||||
.leptos_routes_with_context(
|
||||
&leptos_options,
|
||||
routes,
|
||||
{
|
||||
let shared_client = Arc::clone(&shared_client);
|
||||
move || {
|
||||
provide_context(Arc::clone(&shared_client));
|
||||
}
|
||||
},
|
||||
{
|
||||
let leptos_options = leptos_options.clone();
|
||||
move || shell(leptos_options.clone())
|
||||
}
|
||||
)
|
||||
.fallback(leptos_axum::file_and_error_handler(shell))
|
||||
.with_state(leptos_options);
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn AdminHomePage() -> impl IntoView {
|
||||
view! {
|
||||
<div class="flex flex-1 flex-col gap-4 items-center m-4">
|
||||
<div class="font-bold text-3xl">"Admin Home Page"</div>
|
||||
<div class="text-lg">"This is just a placeholder for now."</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
mod shell;
|
||||
mod home_page;
|
||||
mod provider_page;
|
||||
mod scholarship_page;
|
||||
mod utils_page;
|
||||
mod applicants_list;
|
||||
|
||||
pub use shell::*;
|
||||
pub use home_page::*;
|
||||
pub use provider_page::*;
|
||||
pub use scholarship_page::*;
|
||||
pub use utils_page::*;
|
||||
pub use applicants_list::*;
|
||||
@@ -0,0 +1,83 @@
|
||||
use leptos::logging::log;
|
||||
use leptos::prelude::*;
|
||||
use crate::common::ValueType;
|
||||
use crate::components::{NavListInfo, SelectableNavList};
|
||||
use crate::pages::api::get_all_providers;
|
||||
|
||||
#[component]
|
||||
pub fn AdminProviderPage() -> impl IntoView {
|
||||
let selected_items = RwSignal::new(Vec::new());
|
||||
let provider_list = RwSignal::new(Vec::new());
|
||||
|
||||
let provider_trigger = Trigger::new();
|
||||
let provider_resource = Resource::new(
|
||||
move || provider_trigger.track(),
|
||||
async move |_| {
|
||||
get_all_providers().await
|
||||
}
|
||||
);
|
||||
|
||||
Effect::new(move || {
|
||||
if let Some(Err(err)) = provider_resource.get() {
|
||||
log!("Found error: {}", err.to_string());
|
||||
}
|
||||
let Some(Ok(list)) = provider_resource.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let members_list = vec![
|
||||
"contact_email".to_string(),
|
||||
"first_name".to_string(),
|
||||
"last_name".to_string(),
|
||||
];
|
||||
|
||||
// Convert the list into a series of NavListInfo
|
||||
let converted = list
|
||||
.into_iter()
|
||||
.map(|info| {
|
||||
let mut text_list = vec![];
|
||||
members_list.iter().for_each(|member| {
|
||||
let val = info.get(member)
|
||||
.unwrap_or(&ValueType::String(None))
|
||||
.as_string()
|
||||
.ok().flatten()
|
||||
.unwrap_or_default();
|
||||
text_list.push(val);
|
||||
});
|
||||
|
||||
let subject = info.get("subject")
|
||||
.unwrap_or(&ValueType::String(None))
|
||||
.to_string();
|
||||
|
||||
NavListInfo {
|
||||
text: text_list,
|
||||
id: subject.clone(),
|
||||
slug: subject,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
provider_list.set(converted);
|
||||
});
|
||||
|
||||
let headers = vec![
|
||||
"Email",
|
||||
"First Name",
|
||||
"Last Name"
|
||||
]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
view! {
|
||||
<div class="flex flex-1 flex-col gap-4 items-stretch mt-4">
|
||||
<div class="font-bold text-3xl self-center">"Provider View"</div>
|
||||
<SelectableNavList
|
||||
selected=selected_items
|
||||
name="providers-nav-list"
|
||||
items=provider_list
|
||||
headers=headers
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn AdminScholarshipPage() -> impl IntoView {
|
||||
view! {
|
||||
<div class="flex flex-1 flex-col gap-4">
|
||||
<div class="font-bold text-3xl">"Scholarships View"</div>
|
||||
<div>"This is a placeholder for now. It will show a list of all scholarships."</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use crate::common::TabInfo;
|
||||
use crate::components::login::AdminLoginContext;
|
||||
use crate::components::{Banner, Loading, TabSidebarList};
|
||||
use crate::pages::UnauthenticatedPage;
|
||||
use leptos::prelude::*;
|
||||
use leptos_oidc::{AuthLoaded, Authenticated};
|
||||
|
||||
/// # Admin Shell Component
|
||||
///
|
||||
/// This shell should keep track of pretty much everything that changes across the admin page. It
|
||||
/// will provide the login context, the banner, and the tabs.
|
||||
#[component]
|
||||
pub fn AdminShell() -> impl IntoView {
|
||||
// Create login context
|
||||
view! {
|
||||
<AdminLoginContext>
|
||||
<Banner title="R15 Scholarships Admin" logo="/PHS_Stacked_Acronym.png" />
|
||||
<AuthLoaded fallback=Loading>
|
||||
<Authenticated unauthenticated=UnauthenticatedPage>
|
||||
// We'll put the tabs here.
|
||||
<TabSidebarList
|
||||
base_path="admin"
|
||||
tabs=vec![
|
||||
TabInfo::new("Home", "home", None),
|
||||
TabInfo::new("Providers", "providers", None),
|
||||
TabInfo::new("Scholarships", "scholarships", None),
|
||||
TabInfo::new("Utilities", "utilities", None),
|
||||
TabInfo::new("Scholarship Applicants", "applicants", None),
|
||||
]
|
||||
/>
|
||||
</Authenticated>
|
||||
</AuthLoaded>
|
||||
</AdminLoginContext>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use crate::common::{DateInfo, DateRange};
|
||||
use crate::components::ActionButton;
|
||||
use crate::pages::api::exports::get_scholarship_csv;
|
||||
use crate::pages::api::{CreateDates, CreateTestComparisons, exports::GetScholarshipCsv};
|
||||
use chrono::DateTime;
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos::wasm_bindgen::JsCast;
|
||||
use leptos::web_sys::{Blob, HtmlAnchorElement, Url, js_sys};
|
||||
use crate::pages::api::students::{get_student_info_json, get_student_pdf};
|
||||
|
||||
fn get_important_dates() -> Vec<DateInfo> {
|
||||
vec![
|
||||
DateInfo {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
title: "Provider Forms Open".to_string(),
|
||||
date: DateRange::Range(
|
||||
DateTime::parse_from_rfc3339("2026-01-20T07:15:00-05:00").unwrap(),
|
||||
DateTime::parse_from_rfc3339("2026-03-03T23:59:00-05:00").unwrap(),
|
||||
),
|
||||
description: "".to_string(),
|
||||
},
|
||||
DateInfo {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
title: "Student Forms Open".to_string(),
|
||||
date: DateRange::Range(
|
||||
DateTime::parse_from_rfc3339("2026-03-03T07:15:00-05:00").unwrap(),
|
||||
DateTime::parse_from_rfc3339("2026-03-27T14:05:00-05:00").unwrap(),
|
||||
),
|
||||
description: "".to_string(),
|
||||
},
|
||||
DateInfo {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
title: "Scholarship Decision Window".to_string(),
|
||||
date: DateRange::Range(
|
||||
DateTime::parse_from_rfc3339("2026-04-10T07:15:00-05:00").unwrap(),
|
||||
DateTime::parse_from_rfc3339("2026-05-01T23:59:00-05:00").unwrap(),
|
||||
),
|
||||
description: "Start date may change if available sooner.".to_string(),
|
||||
},
|
||||
DateInfo {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
title: "PHS Scholarship Committee".to_string(),
|
||||
date: DateRange::Single(
|
||||
DateTime::parse_from_rfc3339("2026-05-18T00:00:00-05:00").unwrap(),
|
||||
),
|
||||
description: "Time TBD".to_string(),
|
||||
},
|
||||
DateInfo {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
title: "Scholarship and Awards Night".to_string(),
|
||||
date: DateRange::Single(
|
||||
DateTime::parse_from_rfc3339("2026-06-09T00:00:00-05:00").unwrap(),
|
||||
),
|
||||
description: "Time TBD".to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AdminUtilsPage() -> impl IntoView {
|
||||
let create_comparisons = ServerAction::<CreateTestComparisons>::new();
|
||||
let create_dates = ServerAction::<CreateDates>::new();
|
||||
let create_export = ServerAction::<GetScholarshipCsv>::new();
|
||||
|
||||
let on_click_comparisons = move |_| {
|
||||
create_comparisons.dispatch(CreateTestComparisons {});
|
||||
};
|
||||
|
||||
let on_click_dates = move |_| {
|
||||
create_dates.dispatch(CreateDates {
|
||||
dates: get_important_dates(),
|
||||
});
|
||||
};
|
||||
|
||||
let on_click_export = move |_| {
|
||||
spawn_local(async move {
|
||||
let file_bytes = get_scholarship_csv().await.unwrap_or_default();
|
||||
|
||||
let uint_arr = js_sys::Uint8Array::new_with_length(file_bytes.len() as u32);
|
||||
uint_arr.copy_from(&file_bytes);
|
||||
|
||||
let blob_parts = js_sys::Array::new();
|
||||
blob_parts.push(&uint_arr);
|
||||
|
||||
let blob = Blob::new_with_u8_array_sequence(&blob_parts).unwrap();
|
||||
|
||||
let blob_url = Url::create_object_url_with_blob(&blob).unwrap();
|
||||
|
||||
let window = window();
|
||||
let document = window.document().unwrap();
|
||||
let a: HtmlAnchorElement = document.create_element("a").unwrap().dyn_into().unwrap();
|
||||
|
||||
a.set_href(&blob_url);
|
||||
a.set_download("scholarships.csv");
|
||||
document.body().unwrap().append_child(&a).unwrap();
|
||||
a.click();
|
||||
|
||||
document.body().unwrap().remove_child(&a).unwrap();
|
||||
Url::revoke_object_url(&blob_url).unwrap();
|
||||
});
|
||||
};
|
||||
|
||||
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! {
|
||||
<div class="mx-auto px-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="self-centered text-lg mt-6">
|
||||
"This utility page provides buttons to initialize the corresponding lists of information."
|
||||
</div>
|
||||
<ActionButton on:click=on_click_comparisons>"Create Comparisons"</ActionButton>
|
||||
<ActionButton on:click=on_click_dates>"Create Dates"</ActionButton>
|
||||
<ActionButton on:click=on_click_export>"Scholarship Export"</ActionButton>
|
||||
<ActionButton on:click=on_click_json>"Get student JSON"</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use crate::common::ValueType;
|
||||
use leptos::logging::log;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
mod server_logic {
|
||||
pub use crate::utils::server::create_dynamo_client;
|
||||
pub use super::super::PROVIDER_CONTACT_TABLE;
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn get_all_providers() -> Result<Vec<HashMap<String, ValueType>>, ServerFnError> {
|
||||
use server_logic::*;
|
||||
|
||||
// We want to get all provider information from the contacts table.
|
||||
// This will be migrated to the new table structure when it happens.
|
||||
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
log!("Scanning all provider information from {}", PROVIDER_CONTACT_TABLE);
|
||||
|
||||
client
|
||||
.scan()
|
||||
.table_name(PROVIDER_CONTACT_TABLE)
|
||||
.send()
|
||||
.await
|
||||
.map(|output| {
|
||||
if let Some(items) = output.items {
|
||||
items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
item.into_iter().map(|(key, attr)| {
|
||||
let val = ValueType::from(&attr);
|
||||
(key, val)
|
||||
}).collect()
|
||||
})
|
||||
.collect::<Vec<HashMap<String, ValueType>>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
})
|
||||
.map_err(ServerFnError::from)
|
||||
}
|
||||
+209
-51
@@ -1,19 +1,26 @@
|
||||
#[cfg(feature = "ssr")]
|
||||
use crate::pages::utils::server_utils::create_dynamo_client;
|
||||
use crate::utils::server::create_dynamo_client;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use leptos::logging::log;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use super::COMPARISONS_TABLE;
|
||||
|
||||
use crate::common::{
|
||||
ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison,
|
||||
TextComparison, TextListComparison, ValueType,
|
||||
};
|
||||
use leptos::leptos_dom::log;
|
||||
use leptos::prelude::ServerFnError;
|
||||
use leptos::server;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Helper function to create all sports comparisons. Only used to create initial
|
||||
/// comparison data, and should be removed upon completion of a comparison editor page.
|
||||
#[allow(unused)]
|
||||
fn create_sports_comparisons() -> Vec<ComparisonData> {
|
||||
vec![
|
||||
ComparisonData::new(
|
||||
@@ -181,14 +188,92 @@ fn create_sports_comparisons() -> Vec<ComparisonData> {
|
||||
"Sports Participation",
|
||||
"Tennis",
|
||||
),
|
||||
ComparisonData::new(
|
||||
"sports_baseball",
|
||||
"sports_participation",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToTextList(
|
||||
"sport_name".to_string(),
|
||||
Box::new(TextListComparison::Contains),
|
||||
)),
|
||||
ValueType::String(Some("Baseball".to_string())),
|
||||
"Sports Participation",
|
||||
"Baseball",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[server(CreateTestComparisons, endpoint = "/comparisons/create-test")]
|
||||
pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
|
||||
let client = create_dynamo_client().await;
|
||||
fn create_major_comparison(
|
||||
id: impl Into<String>,
|
||||
display_text: impl Into<String>,
|
||||
) -> ComparisonData {
|
||||
let display = display_text.into();
|
||||
ComparisonData::new(
|
||||
id,
|
||||
"major",
|
||||
ComparisonType::Text(TextComparison::Contains),
|
||||
ValueType::String(Some(display.clone())),
|
||||
"Majors",
|
||||
display,
|
||||
)
|
||||
}
|
||||
|
||||
log!("Creating test comparisons");
|
||||
fn create_major_comparisons() -> Vec<ComparisonData> {
|
||||
let display_texts = vec![
|
||||
"Music",
|
||||
"Education",
|
||||
"Special Education",
|
||||
"Speech Pathology",
|
||||
"School Psychology",
|
||||
"School Counseling",
|
||||
"Occupational Therapy",
|
||||
"Physical Therapy",
|
||||
"Nursing",
|
||||
"Allied Health",
|
||||
"Fine/Performing Arts",
|
||||
"Writing/Communication",
|
||||
"History",
|
||||
"Government",
|
||||
"Political Science",
|
||||
"Social Work",
|
||||
"Sports Medicine",
|
||||
"Athletic Training",
|
||||
"Horticulture",
|
||||
"Conservation Studies",
|
||||
"Ecology",
|
||||
"Environmental Studies",
|
||||
"Urban Planning",
|
||||
"Landscaping",
|
||||
"Legal Studies",
|
||||
"Criminal Justice",
|
||||
];
|
||||
|
||||
let ids = display_texts
|
||||
.iter()
|
||||
.map(|text| {
|
||||
format!(
|
||||
"{}_{}",
|
||||
"major",
|
||||
text.to_lowercase().replace(" ", "_").replace("/", "_")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
ids.iter()
|
||||
.zip(display_texts)
|
||||
.map(|(id, display_text)| create_major_comparison(id, display_text))
|
||||
.collect::<Vec<ComparisonData>>()
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
fn make_comp_list() -> Vec<ComparisonData> {
|
||||
let gpa_3 = ComparisonData::new(
|
||||
"gpa_top_third",
|
||||
"weighted_gpa",
|
||||
ComparisonType::Number(NumberComparison::GreaterThanOrEqual),
|
||||
ValueType::Number(Some(6.264.to_string())),
|
||||
"GPA Limits",
|
||||
"Top Third GPA",
|
||||
);
|
||||
|
||||
let test_comp = ComparisonData::new(
|
||||
"math_sat_comp",
|
||||
@@ -199,46 +284,31 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
|
||||
"Math SAT Score > 650",
|
||||
);
|
||||
|
||||
let service_hours_25 = ComparisonData::new(
|
||||
"map_sum_comp",
|
||||
"community_involvement",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToNumberList(
|
||||
"service_hours".to_string(),
|
||||
Box::new(NumberListComparison::Sum(Box::new(
|
||||
NumberComparison::GreaterThanOrEqual,
|
||||
))),
|
||||
)),
|
||||
ValueType::Number(Some(25.to_string())),
|
||||
"Community Involvement",
|
||||
"25 service hours",
|
||||
);
|
||||
|
||||
let service_hours_20 = ComparisonData::new(
|
||||
"service_hours_20",
|
||||
"community_involvement",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToNumberList(
|
||||
"service_hours".to_string(),
|
||||
Box::new(NumberListComparison::Sum(Box::new(
|
||||
NumberComparison::GreaterThanOrEqual,
|
||||
))),
|
||||
)),
|
||||
"service_hours",
|
||||
ComparisonType::Number(NumberComparison::GreaterThanOrEqual),
|
||||
ValueType::Number(Some(20.to_string())),
|
||||
"Community Involvement",
|
||||
"20 service hours",
|
||||
"Community Service",
|
||||
"20+ service hours",
|
||||
);
|
||||
|
||||
let service_hours_25 = ComparisonData::new(
|
||||
"service_hours_25",
|
||||
"service_hours",
|
||||
ComparisonType::Number(NumberComparison::GreaterThanOrEqual),
|
||||
ValueType::Number(Some(25.to_string())),
|
||||
"Community Service",
|
||||
"25+ service hours",
|
||||
);
|
||||
|
||||
let service_hours_30 = ComparisonData::new(
|
||||
"service_hours_30",
|
||||
"community_involvement",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToNumberList(
|
||||
"service_hours".to_string(),
|
||||
Box::new(NumberListComparison::Sum(Box::new(
|
||||
NumberComparison::GreaterThanOrEqual,
|
||||
))),
|
||||
)),
|
||||
"service_hours",
|
||||
ComparisonType::Number(NumberComparison::GreaterThanOrEqual),
|
||||
ValueType::Number(Some(30.to_string())),
|
||||
"Community Involvement",
|
||||
"30 service hours",
|
||||
"Community Service",
|
||||
"30+ service hours",
|
||||
);
|
||||
|
||||
let residency_southbury = ComparisonData::new(
|
||||
@@ -261,10 +331,10 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
|
||||
|
||||
let attended_bas = ComparisonData::new(
|
||||
"attended_bas",
|
||||
"attended_bas",
|
||||
"attend_bas",
|
||||
ComparisonType::Text(TextComparison::Matches),
|
||||
ValueType::String(Some("Yes".to_string())),
|
||||
"Miscellaneous",
|
||||
"Additional Eligibility Factors",
|
||||
"Attended BAS",
|
||||
);
|
||||
|
||||
@@ -273,7 +343,7 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
|
||||
"middsouth_church",
|
||||
ComparisonType::Text(TextComparison::Matches),
|
||||
ValueType::String(Some("Yes".to_string())),
|
||||
"Miscellaneous",
|
||||
"Additional Eligibility Factors",
|
||||
"Member of Midd-South Church",
|
||||
);
|
||||
|
||||
@@ -282,14 +352,51 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
|
||||
"family_military_service",
|
||||
ComparisonType::Text(TextComparison::Matches),
|
||||
ValueType::String(Some("Yes".to_string())),
|
||||
"Miscellaneous",
|
||||
"Additional Eligibility Factors",
|
||||
"Family with Military Service",
|
||||
);
|
||||
|
||||
let youth_baseball = ComparisonData::new(
|
||||
"youth_baseball",
|
||||
"youth_baseball",
|
||||
ComparisonType::Text(TextComparison::Matches),
|
||||
ValueType::String(Some("Yes".to_string())),
|
||||
"Additional Eligibility Factors",
|
||||
"Participated in Pomperaug Youth Baseball",
|
||||
);
|
||||
|
||||
let aquatic_club = ComparisonData::new(
|
||||
"aquatic_club",
|
||||
"aquatic_club",
|
||||
ComparisonType::Text(TextComparison::Matches),
|
||||
ValueType::String(Some("Yes".to_string())),
|
||||
"Additional Eligibility Factors",
|
||||
"Participated in Panthers Aquatic Club",
|
||||
);
|
||||
|
||||
let pea_member = ComparisonData::new(
|
||||
"pea_member",
|
||||
"pea_member",
|
||||
ComparisonType::Text(TextComparison::Matches),
|
||||
ValueType::String(Some("Yes".to_string())),
|
||||
"Additional Eligibility Factors",
|
||||
"Has a Parent in the PEA",
|
||||
);
|
||||
|
||||
let music_program = ComparisonData::new(
|
||||
"music_program",
|
||||
"music_program",
|
||||
ComparisonType::Text(TextComparison::Matches),
|
||||
ValueType::String(Some("Yes".to_string())),
|
||||
"Sports Participation",
|
||||
"PHS Music Program",
|
||||
);
|
||||
|
||||
let mut sports_comps = create_sports_comparisons();
|
||||
let mut major_comps = Vec::<ComparisonData>::new();
|
||||
let mut major_comps = create_major_comparisons();
|
||||
|
||||
let mut comp_list = vec![
|
||||
gpa_3,
|
||||
test_comp,
|
||||
service_hours_20,
|
||||
service_hours_25,
|
||||
@@ -299,20 +406,36 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
|
||||
attended_bas,
|
||||
midd_south,
|
||||
family_military,
|
||||
youth_baseball,
|
||||
aquatic_club,
|
||||
pea_member,
|
||||
music_program,
|
||||
];
|
||||
|
||||
comp_list.append(&mut sports_comps);
|
||||
comp_list.append(&mut major_comps);
|
||||
|
||||
comp_list
|
||||
}
|
||||
|
||||
#[server(CreateTestComparisons, endpoint = "/comparisons/create-test")]
|
||||
pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
log!("Creating test comparisons");
|
||||
|
||||
let comp_list = make_comp_list();
|
||||
|
||||
for comparison in comp_list {
|
||||
if let Err(err) = client
|
||||
.put_item()
|
||||
.table_name("leptos-comparison-test")
|
||||
.table_name(COMPARISONS_TABLE)
|
||||
.set_item(Some(serde_dynamo::to_item(&comparison)?))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let msg = err.message().unwrap_or("An unknown error occurred");
|
||||
leptos::logging::error!("{}", msg);
|
||||
return Err(ServerFnError::new(msg));
|
||||
}
|
||||
}
|
||||
@@ -328,15 +451,9 @@ pub async fn get_comparison_info() -> Result<Vec<ComparisonData>, ServerFnError>
|
||||
|
||||
// Query the database for all comparisons. The client is only going to use the
|
||||
// id and display text, but we'll return the whole thing.
|
||||
match client
|
||||
.scan()
|
||||
.table_name("leptos-comparison-test")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
match client.scan().table_name(COMPARISONS_TABLE).send().await {
|
||||
Ok(output) => {
|
||||
if let Some(items) = output.items {
|
||||
log!("Found comparisons from API: {:?}", items);
|
||||
Ok(serde_dynamo::from_items(items)?)
|
||||
} else {
|
||||
Ok(vec![])
|
||||
@@ -344,6 +461,47 @@ pub async fn get_comparison_info() -> Result<Vec<ComparisonData>, ServerFnError>
|
||||
}
|
||||
Err(err) => {
|
||||
let msg = err.message().unwrap_or("An unknown error occurred");
|
||||
leptos::logging::error!("{}", msg);
|
||||
Err(ServerFnError::new(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn get_comparisons_categorized()
|
||||
-> Result<HashMap<String, Vec<ComparisonData>>, ServerFnError> {
|
||||
let client = create_dynamo_client().await;
|
||||
log!("Getting all comparisons from the database, by category.");
|
||||
|
||||
match client.scan().table_name(COMPARISONS_TABLE).send().await {
|
||||
Ok(output) => {
|
||||
if let Some(items) = output.items {
|
||||
log!("Found comparisons, categorizing.");
|
||||
|
||||
let items: Vec<ComparisonData> = serde_dynamo::from_items(items)?;
|
||||
|
||||
let categorized = items.iter().fold(
|
||||
HashMap::<String, Vec<ComparisonData>>::new(),
|
||||
|mut map, comp| {
|
||||
// Load each comparison into a map, then return that map.
|
||||
map.entry(comp.category.clone())
|
||||
.and_modify(|vec| vec.push(comp.clone()))
|
||||
.or_insert(vec![comp.clone()]);
|
||||
|
||||
map
|
||||
},
|
||||
);
|
||||
|
||||
Ok(categorized)
|
||||
} else {
|
||||
let msg = "Couldn't find any comparisons.";
|
||||
leptos::logging::error!("{}", msg);
|
||||
Err(ServerFnError::new(msg))
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let msg = err.message().unwrap_or("An unknown error occurred");
|
||||
leptos::logging::error!("{}", msg);
|
||||
Err(ServerFnError::new(msg))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
use crate::common::DateInfo;
|
||||
use leptos::logging::{error, log};
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use super::DATES_TABLE;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use crate::utils::server::create_dynamo_client;
|
||||
#[cfg(feature = "ssr")]
|
||||
use aws_sdk_dynamodb::{
|
||||
Client as DynamoClient,
|
||||
error::ProvideErrorMetadata,
|
||||
types::{PutRequest, WriteRequest},
|
||||
};
|
||||
|
||||
#[server]
|
||||
pub async fn create_dates(dates: Vec<DateInfo>) -> Result<(), ServerFnError> {
|
||||
// Serialize and put dates into the database.
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
log!("Creating dates: {:?}", dates);
|
||||
|
||||
let requests = dates
|
||||
.into_iter()
|
||||
.filter_map(|date| {
|
||||
let item = serde_dynamo::to_item(&date).unwrap_or_default();
|
||||
|
||||
let put_request = PutRequest::builder().set_item(Some(item)).build().ok()?;
|
||||
|
||||
Some(WriteRequest::builder().put_request(put_request).build())
|
||||
})
|
||||
.collect::<Vec<WriteRequest>>();
|
||||
|
||||
let mut request_map = HashMap::new();
|
||||
request_map.insert(DATES_TABLE.to_string(), requests);
|
||||
|
||||
client
|
||||
.batch_write_item()
|
||||
.set_request_items(Some(request_map))
|
||||
.send()
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|err| {
|
||||
let msg = err.message().unwrap_or("Unknown error occurred");
|
||||
error!("{}", msg);
|
||||
ServerFnError::new(msg)
|
||||
})
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn get_important_dates() -> Result<Vec<DateInfo>, ServerFnError> {
|
||||
log!("Getting important dates list");
|
||||
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
client
|
||||
.scan()
|
||||
.table_name(DATES_TABLE)
|
||||
.send()
|
||||
.await
|
||||
.map(|output| {
|
||||
if let Some(items) = output.items {
|
||||
items
|
||||
.into_iter()
|
||||
.filter_map(|item| serde_dynamo::from_item::<_, DateInfo>(item).ok())
|
||||
.collect::<Vec<DateInfo>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
})
|
||||
.map_err(|err| {
|
||||
let msg = err.message().unwrap_or("Unknown error occurred");
|
||||
error!("{}", msg);
|
||||
ServerFnError::new(msg)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
+30
-1
@@ -1,4 +1,33 @@
|
||||
mod admin;
|
||||
mod comparisons;
|
||||
mod dates;
|
||||
pub mod exports;
|
||||
pub mod files;
|
||||
mod providers;
|
||||
mod scholarships;
|
||||
pub mod students;
|
||||
pub(crate) mod tokens;
|
||||
|
||||
pub use self::{comparisons::*, scholarships::*};
|
||||
pub use admin::*;
|
||||
pub use comparisons::*;
|
||||
pub use dates::*;
|
||||
pub use providers::*;
|
||||
pub use scholarships::*;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub static COMPARISONS_TABLE: &str = "leptos-comparisons";
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub static DATES_TABLE: &str = "leptos-dates";
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub static SCHOLARSHIPS_TABLE: &str = "leptos-scholarships";
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub static PROVIDER_CONTACT_TABLE: &str = "leptos-provider-contacts";
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub static MAIN_TABLE_NAME: &str = "scholarships-main";
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub static S3_BUCKET_NAME: &str = "leptos-scholarships";
|
||||
@@ -0,0 +1,82 @@
|
||||
use crate::common::ValueType;
|
||||
use leptos::logging::{debug_log, error};
|
||||
use leptos::prelude::*;
|
||||
use leptos::server_fn::codec::Json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use crate::utils::server::{create_dynamo_client, into_attr_map};
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
static PROVIDER_CONTACT_TABLE: &str = "leptos-provider-contacts";
|
||||
|
||||
#[server]
|
||||
pub async fn get_provider_contact(id: String) -> Result<HashMap<String, ValueType>, ServerFnError> {
|
||||
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
|
||||
use aws_sdk_dynamodb::types::AttributeValue;
|
||||
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
debug_log!("Getting contact info for provider {}", id);
|
||||
|
||||
match client
|
||||
.get_item()
|
||||
.table_name(PROVIDER_CONTACT_TABLE)
|
||||
.key("subject", AttributeValue::S(id))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
let Some(item) = output.item else {
|
||||
return Ok(HashMap::new());
|
||||
};
|
||||
|
||||
let map = item
|
||||
.iter()
|
||||
.map(|(key, val)| {
|
||||
let val_type = ValueType::from(val);
|
||||
|
||||
(key.clone(), val_type)
|
||||
})
|
||||
.collect::<HashMap<String, ValueType>>();
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
Err(err) => {
|
||||
let msg = err.message().unwrap_or("An unknown error occurred.");
|
||||
error!("{}", msg);
|
||||
Err(ServerFnError::new(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[server(input = Json)]
|
||||
pub async fn put_provider_contact(
|
||||
id: String,
|
||||
contact_info: HashMap<String, ValueType>,
|
||||
) -> Result<(), ServerFnError> {
|
||||
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
|
||||
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
let mut item = into_attr_map(contact_info);
|
||||
item.insert(
|
||||
"subject".to_owned(),
|
||||
aws_sdk_dynamodb::types::AttributeValue::S(id),
|
||||
);
|
||||
|
||||
match client
|
||||
.put_item()
|
||||
.table_name(PROVIDER_CONTACT_TABLE)
|
||||
.set_item(Some(item))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => {
|
||||
let msg = err.message().unwrap_or("An unknown error occurred.");
|
||||
error!("{}", msg);
|
||||
Err(ServerFnError::new(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
+192
-16
@@ -1,16 +1,22 @@
|
||||
#[cfg(feature = "ssr")]
|
||||
use aws_sdk_dynamodb::{error::ProvideErrorMetadata, types::AttributeValue};
|
||||
mod imports {
|
||||
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 leptos::leptos_dom::log;
|
||||
use leptos::prelude::ServerFnError;
|
||||
use leptos::server;
|
||||
use leptos::server_fn::codec::Json;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use crate::pages::utils::server_utils::create_dynamo_client;
|
||||
|
||||
#[server(GetScholarshipInfo, endpoint = "/scholarship/info/get")]
|
||||
#[server(GetScholarshipInfo)]
|
||||
pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFnError> {
|
||||
use imports::*;
|
||||
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
// Perform the operation - we just want to return all data that's contained in this entry,
|
||||
@@ -18,7 +24,7 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
|
||||
log!("Getting scholarship info using id {:?}", id);
|
||||
match client
|
||||
.get_item()
|
||||
.table_name("leptos-scholarship-test")
|
||||
.table_name(SCHOLARSHIPS_TABLE)
|
||||
.key("subject", AttributeValue::S(id.clone()))
|
||||
.send()
|
||||
.await
|
||||
@@ -28,21 +34,24 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
|
||||
log!("Found output from API: {:?}", item);
|
||||
Ok(serde_dynamo::from_item(item)?)
|
||||
} else {
|
||||
log!("Couldn't find any values, returning default struct.");
|
||||
Ok(ExpandableInfo::new(id.clone()))
|
||||
log!("Couldn't find scholarship with ID {:?}", id);
|
||||
Err(ServerFnError::new(
|
||||
"Couldn't find scholarship with given ID.",
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let msg = err.message().unwrap_or("An unknown error occurred");
|
||||
error!("{}", msg);
|
||||
Err(ServerFnError::new(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[server(CreateScholarshipInfo, endpoint = "/scholarship/info/create")]
|
||||
#[server(CreateScholarshipInfo, input = Json)]
|
||||
pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerFnError> {
|
||||
use crate::pages::utils::server_utils::create_dynamo_client;
|
||||
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
|
||||
use imports::*;
|
||||
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
@@ -53,7 +62,7 @@ pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerF
|
||||
|
||||
match client
|
||||
.put_item()
|
||||
.table_name("leptos-scholarship-test")
|
||||
.table_name(SCHOLARSHIPS_TABLE)
|
||||
.set_item(Some(serde_dynamo::to_item(&info)?))
|
||||
.send()
|
||||
.await
|
||||
@@ -61,18 +70,56 @@ pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerF
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => {
|
||||
let msg = err.message().unwrap_or("An unknown error occurred");
|
||||
error!("{}", msg);
|
||||
Err(ServerFnError::new(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[server(GetAllScholarshipInfo, endpoint = "/scholarship/info/get-all")]
|
||||
#[server(GetAllScholarshipInfo)]
|
||||
pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnError> {
|
||||
use imports::*;
|
||||
|
||||
let client = create_dynamo_client().await;
|
||||
log!("Getting all scholarship info");
|
||||
debug_log!("Getting all scholarship info");
|
||||
match client.scan().table_name(SCHOLARSHIPS_TABLE).send().await {
|
||||
Ok(output) => {
|
||||
if let Some(items) = output.items {
|
||||
Ok(serde_dynamo::from_items(items)?)
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let msg = err.message().unwrap_or("Unknown error occurred");
|
||||
error!("{}", msg);
|
||||
Err(ServerFnError::new(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets all scholarships that are associated with the given scholarship provider's ID.
|
||||
#[server(GetProviderScholarships)]
|
||||
pub async fn get_provider_scholarships(
|
||||
provider_id: String,
|
||||
) -> Result<Vec<ExpandableInfo>, ServerFnError> {
|
||||
use imports::*;
|
||||
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
debug_log!(
|
||||
"Getting provider scholarships for provider with ID {:?}",
|
||||
provider_id
|
||||
);
|
||||
|
||||
match client
|
||||
.scan()
|
||||
.table_name("leptos-scholarship-test")
|
||||
.table_name(SCHOLARSHIPS_TABLE)
|
||||
.expression_attribute_values(
|
||||
":id",
|
||||
serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?,
|
||||
)
|
||||
.filter_expression("provider_id = :id")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
@@ -84,8 +131,137 @@ pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnE
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new scholarship with a unique ID, and then returns that ID.
|
||||
#[server(RegisterScholarship)]
|
||||
pub async fn register_scholarship(provider_id: String) -> Result<String, ServerFnError> {
|
||||
use imports::*;
|
||||
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
debug_log!(
|
||||
"Creating scholarship for provider with ID {:?}",
|
||||
provider_id
|
||||
);
|
||||
|
||||
let mut current_uuid = Uuid::new_v4().to_string();
|
||||
let mut item = ExpandableInfo::new(current_uuid.clone());
|
||||
item.data.insert(
|
||||
"provider_id".to_string(),
|
||||
ValueType::String(Some(provider_id)),
|
||||
);
|
||||
loop {
|
||||
let ser_item = serde_dynamo::to_item(&item)?;
|
||||
match client
|
||||
.put_item()
|
||||
.table_name(SCHOLARSHIPS_TABLE)
|
||||
.set_item(Some(ser_item))
|
||||
.condition_expression("attribute_not_exists(subject)")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
// Return the uuid that we used.
|
||||
return Ok(current_uuid);
|
||||
}
|
||||
Err(err) => {
|
||||
// Check the error to figure out if it was a key validation error.
|
||||
// If so, retry with new values.
|
||||
match err.code() {
|
||||
Some("ConditionalCheckFailedException") => {
|
||||
log!("Retrying conditional check.");
|
||||
log!("Current error: {:?}", err);
|
||||
current_uuid = Uuid::new_v4().to_string();
|
||||
item.subject = current_uuid.clone();
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
return Err(ServerFnError::new(
|
||||
err.message().unwrap_or("Unknown error occurred"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a provider's scholarship given their provider ID and scholarship ID.
|
||||
#[server(DeleteProviderScholarship)]
|
||||
pub async fn delete_provider_scholarship(
|
||||
provider_id: String,
|
||||
scholarship_id: String,
|
||||
) -> Result<(), ServerFnError> {
|
||||
use imports::*;
|
||||
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
debug_log!(
|
||||
"Deleting scholarship with ID {:?} for provider with ID {:?}",
|
||||
scholarship_id,
|
||||
provider_id
|
||||
);
|
||||
|
||||
// When we delete a scholarship, we need to ensure that the provider's ID matches the scholarship,
|
||||
// otherwise everyone can delete anyone else's scholarships.
|
||||
match client
|
||||
.delete_item()
|
||||
.table_name(SCHOLARSHIPS_TABLE)
|
||||
.key("subject", AttributeValue::S(scholarship_id))
|
||||
.expression_attribute_values(
|
||||
":provider_id",
|
||||
serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?,
|
||||
)
|
||||
.condition_expression("provider_id = :provider_id")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => {
|
||||
let msg = err.message().unwrap_or("Unknown error occurred");
|
||||
error!("{}", msg);
|
||||
Err(ServerFnError::new(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn get_all_scholarships()
|
||||
-> Result<Vec<std::collections::HashMap<String, crate::common::ValueType>>, ServerFnError> {
|
||||
use imports::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
client
|
||||
.scan()
|
||||
.table_name(SCHOLARSHIPS_TABLE)
|
||||
.send()
|
||||
.await
|
||||
.map(|output| {
|
||||
let Some(items) = output.items else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
// Each item should be simply converted from an AttributeValue to a ValueType.
|
||||
item.iter()
|
||||
.map(|(k, v)| (k.clone(), ValueType::from(v)))
|
||||
.collect::<HashMap<String, ValueType>>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.map_err(|err| {
|
||||
let msg = err.message().unwrap_or("Unknown error occurred");
|
||||
error!("{}", msg);
|
||||
ServerFnError::new(msg)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos::server_fn::codec::Json;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::process::Stdio;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
mod imports {
|
||||
pub use super::super::MAIN_TABLE_NAME;
|
||||
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::process::Command;
|
||||
pub use zip::write::SimpleFileOptions;
|
||||
}
|
||||
|
||||
static PDF_TEMPLATE: &str = include_str!("../../../pdf_template.typ");
|
||||
|
||||
#[server(input = Json)]
|
||||
pub async fn put_student_data(
|
||||
subject: String,
|
||||
data_type: String,
|
||||
data_map: HashMap<String, crate::common::ValueType>,
|
||||
) -> Result<(), ServerFnError> {
|
||||
use imports::*;
|
||||
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
let mut data_map_attr = into_attr_map(data_map);
|
||||
data_map_attr.insert(
|
||||
"HK".into(),
|
||||
AttributeValue::S(format!("STUDENT#{}", subject)),
|
||||
);
|
||||
data_map_attr.insert("SK".into(), AttributeValue::S(data_type.to_uppercase()));
|
||||
|
||||
debug_log!("Inserting this item: {:?}", data_map_attr);
|
||||
|
||||
client
|
||||
.put_item()
|
||||
.table_name(MAIN_TABLE_NAME)
|
||||
.set_item(Some(data_map_attr))
|
||||
.send()
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|err| {
|
||||
let msg = format!(
|
||||
"Couldn't put item into Dynamo: {}",
|
||||
err.message().unwrap_or("Unknown error occurred")
|
||||
);
|
||||
error!("{}", msg);
|
||||
ServerFnError::new(msg)
|
||||
})
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn get_student_data(
|
||||
subject: String,
|
||||
data_type: String,
|
||||
) -> Result<HashMap<String, crate::common::ValueType>, ServerFnError> {
|
||||
use imports::*;
|
||||
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
client
|
||||
.query()
|
||||
.table_name(MAIN_TABLE_NAME)
|
||||
.key_condition_expression("HK = :hk AND SK = :sk")
|
||||
.expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{}", subject)))
|
||||
.expression_attribute_values(":sk", AttributeValue::S(data_type.to_uppercase()))
|
||||
.send()
|
||||
.await
|
||||
.map(|output| {
|
||||
// Map the output to a HashMap<String, ValueType>
|
||||
let Some(list) = output.items else {
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
let mut map = HashMap::new();
|
||||
|
||||
let _ = list.into_iter().flatten().for_each(|(k, v)| {
|
||||
map.insert(k, ValueType::from(&v));
|
||||
});
|
||||
|
||||
map
|
||||
})
|
||||
.map_err(|err| {
|
||||
let msg = format!(
|
||||
"Couldn't get item from Dynamo: {}",
|
||||
err.message().unwrap_or("Unknown error occurred")
|
||||
);
|
||||
error!("{}", msg);
|
||||
ServerFnError::new(msg)
|
||||
})
|
||||
}
|
||||
|
||||
/// Gets and flattens all of a student's information from the database, regardless of their defined
|
||||
/// sort key.
|
||||
///
|
||||
/// Note: if sorted data from the `put_student_data` function contains the same fields, this
|
||||
/// function will have unexpected behavior. For example, if a `first_name` field appears in multiple
|
||||
/// locations in the database, all for this single student, they will overwrite each other in no
|
||||
/// specific order.
|
||||
#[server]
|
||||
pub async fn get_all_student_data(
|
||||
subject: String,
|
||||
) -> Result<HashMap<String, crate::common::ValueType>, ServerFnError> {
|
||||
use imports::*;
|
||||
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
client
|
||||
.query()
|
||||
.table_name(MAIN_TABLE_NAME)
|
||||
.key_condition_expression("HK = :hk")
|
||||
.expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{}", subject)))
|
||||
.send()
|
||||
.await
|
||||
.map(|output| {
|
||||
let Some(items) = output.items else {
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
items
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|(k, v)| (k.clone(), ValueType::from(v)))
|
||||
.collect::<HashMap<String, ValueType>>()
|
||||
})
|
||||
.map_err(|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("))", "),)");
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos_oidc::AuthSignal;
|
||||
use leptos_router::hooks::use_navigate;
|
||||
|
||||
#[component]
|
||||
pub fn AuthCallbackPage() -> impl IntoView {
|
||||
// This should wait for authentication, read the values in session storage, and then redirect
|
||||
// to the correct page. The session storage should contain a path, which we'll need to check.
|
||||
|
||||
let auth = expect_context::<AuthSignal>();
|
||||
let navigate = use_navigate();
|
||||
|
||||
Effect::new(move || {
|
||||
// Wait for auth to load, then read information.
|
||||
if let Some(_) = auth.get().authenticated() {
|
||||
// Read the sessionStorage to get the last navigated path.
|
||||
let storage_opt = window().session_storage().ok().flatten();
|
||||
let path = storage_opt
|
||||
.and_then(|storage| storage.get_item("post_login_path").ok().flatten())
|
||||
.unwrap_or_else(|| "/".to_string());
|
||||
|
||||
// Clear storage in case we need to reauth again
|
||||
if let Some(storage) = window().session_storage().ok().flatten() {
|
||||
let _ = storage.remove_item("post_login_path");
|
||||
}
|
||||
|
||||
// Redirect to the stored path
|
||||
navigate(&path, Default::default());
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="flex flex-col w-full mx-auto items-center justify-center">
|
||||
<p class="text-xl font-bold mt-10 mx-auto">"Logging you in..."</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,19 @@
|
||||
use super::UnauthenticatedPage;
|
||||
use super::api::{get_all_scholarship_info, get_comparison_info};
|
||||
use super::home_page::get_submission;
|
||||
use crate::common::{ComparisonData, ExpandableInfo, UserClaims, ValueType};
|
||||
use crate::common::{ComparisonData, ExpandableInfo, ValueType};
|
||||
use crate::components::{ActionButton, Loading, Panel, Row};
|
||||
use leptos::either::Either;
|
||||
use leptos::leptos_dom::logging::console_log;
|
||||
use leptos::logging::log;
|
||||
use leptos::prelude::*;
|
||||
use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated};
|
||||
use leptos_oidc::{AuthLoaded, Authenticated};
|
||||
use crate::utils::get_user_claims;
|
||||
|
||||
#[component]
|
||||
pub fn ComparisonTestPage() -> impl IntoView {
|
||||
let result_msg = RwSignal::new("Found submission, waiting for input.".to_string());
|
||||
let auth = use_context::<AuthSignal>().expect("Couldn't find user information.");
|
||||
let user_claims = Signal::derive(move || {
|
||||
auth.with(|auth| {
|
||||
auth.authenticated().and_then(|data| {
|
||||
data.decoded_access_token::<UserClaims>(Algorithm::RS256, &["account"])
|
||||
})
|
||||
})
|
||||
});
|
||||
let user_claims = get_user_claims();
|
||||
|
||||
// Note that the value passed in MUST be equatable.
|
||||
// We get/unwrap the value repeatedly until we get a simple string value, then clone it so that
|
||||
@@ -63,93 +57,84 @@ pub fn ComparisonTestPage() -> impl IntoView {
|
||||
let student_info = student_resource.get();
|
||||
let comparison_info = comparison_resource.get();
|
||||
let scholarship_info = scholarship_resource.get();
|
||||
|
||||
match (student_info, comparison_info, scholarship_info) {
|
||||
(Some(student), Some(comparisons), Some(scholarships)) => Either::Right(view! {
|
||||
<Panel>
|
||||
<Row>
|
||||
<p>{result_msg}</p>
|
||||
</Row>
|
||||
<Row>
|
||||
<ActionButton on:click=move |_| {
|
||||
// Iterate over all scholarships, get the comparison IDs,
|
||||
// get ComparisonData from the IDs, then perform the
|
||||
// comparisons and return a boolean. Alternatively, return
|
||||
// a list of comparisons that complete and a list that don't.
|
||||
let comp_member = "requirements".to_string();
|
||||
let comparison_results = scholarships.iter().map(|scholarship| {
|
||||
let list = match scholarship.data.get(&comp_member) {
|
||||
Some(ValueType::List(Some(list))) => list,
|
||||
_ => {
|
||||
log!("Cannot find requirements, assuming none.");
|
||||
return Ok(Some(scholarship)) // students will always qualify when there are no requirements
|
||||
}
|
||||
};
|
||||
(Some(student), Some(comparisons), Some(scholarships)) => {
|
||||
Either::Right(
|
||||
|
||||
let mut final_result = Ok(true);
|
||||
|
||||
// The list should exist, and it should contain a series of strings.
|
||||
for val in list {
|
||||
let comp_opt = match val {
|
||||
ValueType::String(Some(comp_id)) => {
|
||||
// Find the comparison
|
||||
comparisons.iter().find(|comp| comp.id == *comp_id)
|
||||
}
|
||||
_ => None
|
||||
};
|
||||
|
||||
let comparison_outcome = match comp_opt {
|
||||
Some(comp) => {
|
||||
// Perform the comparison
|
||||
comp.compare(&student)
|
||||
}
|
||||
_ => Err("".to_string())
|
||||
};
|
||||
|
||||
match comparison_outcome {
|
||||
Ok(true) => {},
|
||||
Ok(false) => final_result = Ok(false),
|
||||
Err(e) => {
|
||||
final_result = Err(e);
|
||||
break;
|
||||
view! {
|
||||
<Panel>
|
||||
<Row>
|
||||
<p>{result_msg}</p>
|
||||
</Row>
|
||||
<Row>
|
||||
<ActionButton on:click=move |_| {
|
||||
let comp_member = "requirements".to_string();
|
||||
let comparison_results = scholarships
|
||||
.iter()
|
||||
.map(|scholarship| {
|
||||
let list = match scholarship.data.get(&comp_member) {
|
||||
Some(ValueType::List(Some(list))) => list,
|
||||
_ => {
|
||||
log!("Cannot find requirements, assuming none.");
|
||||
return Ok(Some(scholarship));
|
||||
}
|
||||
};
|
||||
let mut final_result = Ok(true);
|
||||
for val in list {
|
||||
let comp_opt = match val {
|
||||
ValueType::String(Some(comp_id)) => {
|
||||
comparisons.iter().find(|comp| comp.id == *comp_id)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let comparison_outcome = match comp_opt {
|
||||
Some(comp) => comp.compare(&student.data),
|
||||
_ => Err("".to_string()),
|
||||
};
|
||||
match comparison_outcome {
|
||||
Ok(true) => {}
|
||||
Ok(false) => final_result = Ok(false),
|
||||
Err(e) => {
|
||||
final_result = Err(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
match final_result {
|
||||
Ok(true) => Ok(Some(scholarship)),
|
||||
Ok(false) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<Result<Option<&ExpandableInfo>, String>>>();
|
||||
let mut strings = Vec::new();
|
||||
strings.push("Scholarship results: \n".to_string());
|
||||
for result in comparison_results {
|
||||
match result {
|
||||
Ok(Some(scholarship_info)) => {
|
||||
let name_member = "name".to_string();
|
||||
let scl_name = scholarship_info
|
||||
.data
|
||||
.get(&name_member)
|
||||
.unwrap()
|
||||
.as_string()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
strings.push(scl_name);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(msg) => strings.push(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match final_result {
|
||||
Ok(true) => Ok(Some(scholarship)),
|
||||
Ok(false) => Ok(None),
|
||||
Err(e) => Err(e)
|
||||
}
|
||||
}).collect::<Vec<Result<Option<&ExpandableInfo>, String>>>();
|
||||
|
||||
// Build a display string
|
||||
let mut strings = Vec::new();
|
||||
strings.push("Scholarship results: \n".to_string());
|
||||
for result in comparison_results {
|
||||
match result {
|
||||
Ok(Some(scholarship_info)) => {
|
||||
// Get the scholarship name.
|
||||
let name_member = "name".to_string();
|
||||
let scl_name = scholarship_info.data.get(&name_member).unwrap()
|
||||
.as_string().unwrap().unwrap();
|
||||
strings.push(scl_name);
|
||||
},
|
||||
Ok(None) => {},
|
||||
Err(msg) => strings.push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
let display_text = strings.join("\n").to_string();
|
||||
|
||||
result_msg.set(display_text);
|
||||
}>"Test Comparison"</ActionButton>
|
||||
</Row>
|
||||
</Panel>
|
||||
}),
|
||||
_ => Either::Left(view! {
|
||||
<p>"Failed API requests."</p>
|
||||
})
|
||||
let display_text = strings.join("\n").to_string();
|
||||
result_msg.set(display_text);
|
||||
}>"Test Comparison"</ActionButton>
|
||||
</Row>
|
||||
</Panel>
|
||||
},
|
||||
)
|
||||
}
|
||||
_ => Either::Left(view! { <p>"Failed API requests."</p> }),
|
||||
}
|
||||
}}
|
||||
</Suspense>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
+199
-210
@@ -1,29 +1,30 @@
|
||||
// Server dependencies
|
||||
#[cfg(feature = "ssr")]
|
||||
use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue};
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use serde_dynamo::{from_item, to_item};
|
||||
#[cfg(feature = "ssr")]
|
||||
use std::io::Write;
|
||||
#[cfg(feature = "ssr")]
|
||||
use std::process::{Command, Stdio};
|
||||
#[cfg(feature = "ssr")]
|
||||
use std::io::Write;
|
||||
|
||||
use crate::common::{ExpandableInfo, UserClaims};
|
||||
use crate::common::ExpandableInfo;
|
||||
use crate::pages::UnauthenticatedPage;
|
||||
use crate::components::{
|
||||
ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row,
|
||||
Select,
|
||||
TextFieldType, ValidatedForm
|
||||
};
|
||||
use crate::input;
|
||||
use crate::pages::UnauthenticatedPage;
|
||||
use base64::Engine;
|
||||
use leptos::leptos_dom::logging::console_log;
|
||||
use leptos::prelude::*;
|
||||
use leptos_oidc::{AuthLoaded, Authenticated};
|
||||
use base64::Engine;
|
||||
use leptos::logging::log;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos::wasm_bindgen::JsCast;
|
||||
use leptos::web_sys::HtmlAnchorElement;
|
||||
use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated};
|
||||
use traits::{AsReactive, ReactiveCapture};
|
||||
use leptos::wasm_bindgen::JsCast;
|
||||
use std::collections::HashMap;
|
||||
use crate::utils::get_user_claims;
|
||||
|
||||
/// # Get Student Info
|
||||
/// Gets a student's information given their `subject`.
|
||||
@@ -143,32 +144,74 @@ pub async fn create_example_pdf() -> Result<Vec<u8>, ServerFnError> {
|
||||
/// The main home page component. Contains a simple contact form.
|
||||
#[component]
|
||||
pub fn HomePage() -> impl IntoView {
|
||||
// Creates a reactive value to update the button
|
||||
|
||||
let auth = use_context::<AuthSignal>().expect("Couldn't find user information.");
|
||||
let user_claims = Signal::derive(move || {
|
||||
auth.with(|auth| {
|
||||
auth.authenticated().and_then(|data| {
|
||||
data.decoded_access_token::<UserClaims>(Algorithm::RS256, &["account"])
|
||||
})
|
||||
})
|
||||
//#region Server Resources
|
||||
let user_claims = get_user_claims();
|
||||
let user_subject = Memo::new(move |_| {
|
||||
user_claims.get()
|
||||
.map(|claim| claim.claims.subject.clone())
|
||||
});
|
||||
|
||||
// Note that the value passed in MUST be equatable.
|
||||
// We get/unwrap the value repeatedly until we get a simple string value, then clone it so that
|
||||
// we don't lose access to it in the future, should we need it again.
|
||||
let server_resource: Resource<ExpandableInfo> = Resource::new(
|
||||
move || user_claims.get().map(|claim| claim.claims.subject.clone()),
|
||||
async |opt_username| match opt_username {
|
||||
Some(subject) => get_submission(subject.clone()).await.unwrap_or_else(|e| {
|
||||
console_log(e.to_string().as_str());
|
||||
ExpandableInfo::new(subject)
|
||||
}),
|
||||
None => ExpandableInfo::new("".to_owned()),
|
||||
},
|
||||
let form_data = RwSignal::new(HashMap::new());
|
||||
|
||||
let server_resource = Resource::new(
|
||||
move || user_subject.get(),
|
||||
async move |subject| get_submission(subject.unwrap_or_default()).await
|
||||
);
|
||||
let submit_action = ServerAction::<CreateSampleSubmission>::new();
|
||||
|
||||
Effect::new(move || {
|
||||
match server_resource.get() {
|
||||
Some(Ok(submission)) => {
|
||||
form_data.set(submission.data);
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
log!("Error while getting information: {:?}", err);
|
||||
}
|
||||
_ => {
|
||||
log!("An unknown error occurred.");
|
||||
}
|
||||
}
|
||||
});
|
||||
//#endregion
|
||||
//#region Component State
|
||||
let elements_disabled = Signal::derive(move || {
|
||||
submit_action.pending().get()
|
||||
});
|
||||
//#endregion
|
||||
//#region Event logic
|
||||
let on_submit = move |_| {
|
||||
let mut exp_info = ExpandableInfo::new(user_subject.get().unwrap_or_default());
|
||||
exp_info.data = form_data.get();
|
||||
submit_action.dispatch(CreateSampleSubmission {
|
||||
student_info: exp_info
|
||||
});
|
||||
};
|
||||
|
||||
let pdf_button_click = move |_| {
|
||||
console_log("Attempting to get PDF from server endpoint");
|
||||
spawn_local(async move {
|
||||
let result = create_example_pdf().await;
|
||||
if let Ok(bytes) = result {
|
||||
let base64 = base64::engine::general_purpose::STANDARD
|
||||
.encode(bytes);
|
||||
let data_url = format!(
|
||||
"data:application/pdf;base64,{}",
|
||||
base64,
|
||||
);
|
||||
console_log("Found document, opening in new tab...");
|
||||
let link = document()
|
||||
.create_element("a")
|
||||
.unwrap()
|
||||
.dyn_into::<HtmlAnchorElement>()
|
||||
.unwrap();
|
||||
link.set_href(&*data_url);
|
||||
link.set_target("_blank");
|
||||
link.click();
|
||||
}
|
||||
});
|
||||
};
|
||||
//#endregion
|
||||
|
||||
view! {
|
||||
<AuthLoaded fallback=Loading>
|
||||
<Authenticated unauthenticated=UnauthenticatedPage>
|
||||
@@ -177,195 +220,141 @@ pub fn HomePage() -> impl IntoView {
|
||||
{move || {
|
||||
server_resource
|
||||
.get()
|
||||
.map(|submission| {
|
||||
let expandable_react = submission.as_reactive();
|
||||
let elements_disabled = RwSignal::new(false);
|
||||
let result_msg = Signal::derive(move || {
|
||||
if submit_action.pending().get() {
|
||||
elements_disabled.set(true);
|
||||
"Sending request...".to_owned()
|
||||
} else {
|
||||
elements_disabled.set(false);
|
||||
if let Some(result) = submit_action.value().get() {
|
||||
match result {
|
||||
Ok(_) => "Request sent successfully.".to_owned(),
|
||||
Err(e) => format!("Failed to send request: {:?}", e),
|
||||
}
|
||||
} else {
|
||||
"".to_owned()
|
||||
}
|
||||
}
|
||||
});
|
||||
.map(|_| {
|
||||
view! {
|
||||
<Row>
|
||||
<div class="flex flex-col flex-1" />
|
||||
<Panel>
|
||||
<Row>
|
||||
<h1 class="text-3xl font-bold">
|
||||
"Region 15 Scholarship Application (DEMO)"
|
||||
</h1>
|
||||
</Row>
|
||||
<Row>
|
||||
<p class="text-sm">
|
||||
"This is a very simple demonstration of the scholarship application written in Rust."
|
||||
</p>
|
||||
</Row>
|
||||
<Row>
|
||||
// Text { data_member, label, placeholder }
|
||||
<OutlinedTextField
|
||||
label="First Name:"
|
||||
placeholder="John"
|
||||
disabled=elements_disabled
|
||||
data_member="first_name"
|
||||
data_map=expandable_react.data
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Last Name:"
|
||||
placeholder="Smith"
|
||||
disabled=elements_disabled
|
||||
data_member="last_name"
|
||||
data_map=expandable_react.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Contact Email:"
|
||||
placeholder="student@region15.org"
|
||||
disabled=elements_disabled
|
||||
data_member="contact_email"
|
||||
data_map=expandable_react.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Phone Number:"
|
||||
placeholder="123-456-7890"
|
||||
disabled=elements_disabled
|
||||
data_member="phone_number"
|
||||
data_map=expandable_react.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Street Address:"
|
||||
placeholder="123 Fake Street"
|
||||
disabled=elements_disabled
|
||||
data_member="address"
|
||||
data_map=expandable_react.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Highest Math SAT Score:"
|
||||
placeholder="600"
|
||||
disabled=elements_disabled
|
||||
data_member="math_sat"
|
||||
data_map=expandable_react.data
|
||||
input_type="number"
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
// Radio { data_member, label, items }
|
||||
<RadioList
|
||||
label="Town:"
|
||||
items=vec!["Southbury", "Middlebury"]
|
||||
.into_iter()
|
||||
.map(|s| s.into())
|
||||
.collect()
|
||||
disabled=elements_disabled
|
||||
data_member="town"
|
||||
data_map = expandable_react.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
// Checkbox { data_member, label, items }
|
||||
<CheckboxList
|
||||
label="Favorite Candies:"
|
||||
items=vec!["Twizzlers", "Reese's", "Starburst"]
|
||||
.into_iter()
|
||||
.map(|s| s.into())
|
||||
.collect()
|
||||
disabled=elements_disabled
|
||||
data_member="favorite_candies"
|
||||
data_map=expandable_react.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
// Select { data_member, label, items }
|
||||
<Select
|
||||
label="Gender:"
|
||||
value_list=vec!["Male", "Female", "Prefer not to answer"]
|
||||
.into_iter()
|
||||
.map(|s| s.to_owned())
|
||||
.collect()
|
||||
disabled=elements_disabled
|
||||
data_member="gender"
|
||||
data_map=expandable_react.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<MultiEntry
|
||||
data_map=expandable_react.data
|
||||
data_member="community_involvement"
|
||||
schema=vec![
|
||||
input!(Text, "service_name", "Activity Name:", "Some service activity..."),
|
||||
input!(Number, "service_hours", "Total Service Hours", "20")
|
||||
]
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<ActionButton
|
||||
on:click=move |_| {
|
||||
let captured_map = expandable_react.capture();
|
||||
console_log(
|
||||
format!("Map values: {:?}", captured_map).as_str(),
|
||||
);
|
||||
submit_action
|
||||
.dispatch(CreateSampleSubmission {
|
||||
student_info: captured_map,
|
||||
});
|
||||
}
|
||||
disabled=elements_disabled
|
||||
>
|
||||
"Submit"
|
||||
</ActionButton>
|
||||
</Row>
|
||||
<Row>
|
||||
<ActionButton on:click=move |_| {
|
||||
console_log("Attempting to get PDF from server endpoint");
|
||||
spawn_local(async move {
|
||||
let result = create_example_pdf().await;
|
||||
if let Ok(bytes) = result {
|
||||
let base64 = base64::engine::general_purpose::STANDARD
|
||||
.encode(bytes);
|
||||
let data_url = format!(
|
||||
"data:application/pdf;base64,{}",
|
||||
base64,
|
||||
);
|
||||
console_log("Found document, opening in new tab...");
|
||||
let link = document()
|
||||
.create_element("a")
|
||||
.unwrap()
|
||||
.dyn_into::<HtmlAnchorElement>()
|
||||
.unwrap();
|
||||
link.set_href(&*data_url);
|
||||
link.set_target("_blank");
|
||||
link.click();
|
||||
}
|
||||
});
|
||||
}>"Get PDF"</ActionButton>
|
||||
</Row>
|
||||
<Row>
|
||||
<p>{result_msg}</p>
|
||||
</Row>
|
||||
<ValidatedForm
|
||||
on_submit=Callback::new(on_submit)
|
||||
title="Home Page Test Form"
|
||||
description="This really is just a test."
|
||||
>
|
||||
<Row>
|
||||
<h1 class="text-3xl font-bold">
|
||||
"Region 15 Scholarship Application (DEMO)"
|
||||
</h1>
|
||||
</Row>
|
||||
<Row>
|
||||
<p class="text-sm">
|
||||
"This is a very simple demonstration of the scholarship application written in Rust."
|
||||
</p>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="First Name:"
|
||||
placeholder="John"
|
||||
disabled=elements_disabled
|
||||
data_member="first_name"
|
||||
data_map=form_data
|
||||
required=true
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Last Name:"
|
||||
placeholder="Smith"
|
||||
disabled=elements_disabled
|
||||
data_member="last_name"
|
||||
data_map=form_data
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Contact Email:"
|
||||
placeholder="student@region15.org"
|
||||
disabled=elements_disabled
|
||||
data_member="contact_email"
|
||||
data_map=form_data
|
||||
input_type=TextFieldType::Email(
|
||||
vec!["fakeemail.org".to_string(), "gmail.com".to_string()],
|
||||
)
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Phone Number:"
|
||||
placeholder="123-456-7890"
|
||||
disabled=elements_disabled
|
||||
data_member="phone_number"
|
||||
data_map=form_data
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Street Address:"
|
||||
placeholder="123 Fake Street"
|
||||
disabled=elements_disabled
|
||||
data_member="address"
|
||||
data_map=form_data
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Highest Math SAT Score:"
|
||||
placeholder="600"
|
||||
disabled=elements_disabled
|
||||
data_member="math_sat"
|
||||
data_map=form_data
|
||||
input_type=TextFieldType::Number
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<RadioList
|
||||
label="Town:"
|
||||
items=vec!["Southbury", "Middlebury"]
|
||||
.into_iter()
|
||||
.map(|s| s.into())
|
||||
.collect()
|
||||
disabled=elements_disabled
|
||||
data_member="town"
|
||||
data_map=form_data
|
||||
required=true
|
||||
other_prompt="Other..."
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<CheckboxList
|
||||
label="Favorite Candies:"
|
||||
items=vec!["Twizzlers", "Reese's", "Starburst"]
|
||||
.into_iter()
|
||||
.map(|s| s.into())
|
||||
.collect()
|
||||
disabled=elements_disabled
|
||||
data_member="favorite_candies"
|
||||
data_map=form_data
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<MultiEntry
|
||||
label="Testing community involvement:"
|
||||
data_map=form_data
|
||||
data_member="community_involvement"
|
||||
schema=vec![
|
||||
input!(
|
||||
Text, "service_name", "Activity Name:", true, "Some service activity..."
|
||||
),
|
||||
input!(
|
||||
Number, "service_hours", "Total Service Hours", true, "20"
|
||||
),
|
||||
]
|
||||
/>
|
||||
</Row>
|
||||
</ValidatedForm>
|
||||
</Panel>
|
||||
<div class="flex flex-col flex-1" />
|
||||
</Row>
|
||||
}
|
||||
})
|
||||
}}
|
||||
}} <Row>
|
||||
<ActionButton on:click=pdf_button_click>"Get PDF"</ActionButton>
|
||||
</Row>
|
||||
</Suspense>
|
||||
</Authenticated>
|
||||
</AuthLoaded>
|
||||
}
|
||||
}.into_any()
|
||||
}
|
||||
+208
-156
@@ -4,10 +4,11 @@ use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValu
|
||||
#[cfg(feature = "ssr")]
|
||||
use serde_dynamo::{from_item, to_item};
|
||||
|
||||
use crate::common::{ExpandableInfo, ValueType};
|
||||
use crate::components::{
|
||||
ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select,
|
||||
};
|
||||
#[cfg(feature = "ssr")]
|
||||
use crate::common::ValueType;
|
||||
|
||||
use crate::common::ExpandableInfo;
|
||||
use crate::components::{ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select, TextFieldType, Toast, ToastContext, ToastList, ValidatedForm};
|
||||
use chrono::{FixedOffset, TimeZone};
|
||||
use leptos::Params;
|
||||
use leptos::either::{Either, EitherOf3};
|
||||
@@ -18,7 +19,13 @@ use leptos_router::hooks::{use_navigate, use_params};
|
||||
use leptos_router::params::Params;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use traits::{AsReactive, ReactiveCapture};
|
||||
use leptos_router::components::Outlet;
|
||||
use leptos_animate::animate;
|
||||
use crate::common::animations::pop_in_out;
|
||||
// Loaner page creates a single page that contains the borrowing/returning buttons
|
||||
// and a panel for a form. At first, it will show a fallback view. At the borrowing
|
||||
// path it will show the borrow form, and at the returning path it will show the
|
||||
// return form.
|
||||
|
||||
#[derive(Params, PartialEq)]
|
||||
struct LoanerParams {
|
||||
@@ -191,7 +198,7 @@ async fn create_borrow_entry(input: ExpandableInfo) -> Result<(), ServerFnError>
|
||||
/// create a true admin page for this, simply to be able to survey and override/remove information
|
||||
/// from the lists.
|
||||
#[component]
|
||||
pub fn LoanerPage() -> impl IntoView {
|
||||
pub fn LoanerShell() -> impl IntoView {
|
||||
// Check URL params. Depending on what string is visible here, we'll display a different form.
|
||||
let params = use_params::<LoanerParams>();
|
||||
let form_passed = move || {
|
||||
@@ -203,20 +210,10 @@ pub fn LoanerPage() -> impl IntoView {
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let form_view = move || match form_passed().as_str() {
|
||||
"borrowing" => EitherOf3::A(LoanerBorrowForm),
|
||||
"returning" => EitherOf3::B(LoanerReturnForm),
|
||||
_ => EitherOf3::C("Choose something from the left.".into_view()),
|
||||
};
|
||||
|
||||
// We'll create the page here - the sidebar contains all the buttons, while the form
|
||||
// container either shows forms or shows placeholder text.
|
||||
view! {
|
||||
<Banner
|
||||
title="Chromebook Loaners"
|
||||
logo="/PHS_Stacked_Acronym.png"
|
||||
path="/loaners"
|
||||
/>
|
||||
<Banner title="Chromebook Loaners" logo="/PHS_Stacked_Acronym.png" path="/loaners" />
|
||||
<div class="flex m-5">
|
||||
<div class="flex flex-1 flex-row gap-3">
|
||||
<div id="sidebar" class="flex flex-1 flex-col gap-2">
|
||||
@@ -234,99 +231,134 @@ pub fn LoanerPage() -> impl IntoView {
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-2">
|
||||
<Panel>{form_view}</Panel>
|
||||
<Panel>
|
||||
<Outlet />
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn LoanerFallback() -> impl IntoView {
|
||||
view! {
|
||||
<div class="flex flex-col items-center justify-center" use:animate=pop_in_out()>
|
||||
<div class="text-3xl font-bold mb-2">"Chromebook Loaner Form"</div>
|
||||
<div class="text-lg">"Select an option from the left to begin."</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn LoanerBorrowForm() -> impl IntoView {
|
||||
// Register server actions.
|
||||
let create_entry_action = ServerAction::<CreateBorrowEntry>::new();
|
||||
let navigate = use_navigate();
|
||||
|
||||
let temp = ExpandableInfo::new(uuid::Uuid::new_v4().to_string());
|
||||
let temp_reactive = temp.as_reactive();
|
||||
let elements_disabled = RwSignal::new(false);
|
||||
// Register server actions.
|
||||
let create_entry_action = ServerAction::<CreateBorrowEntry>::new();
|
||||
|
||||
let data = RwSignal::new(HashMap::new());
|
||||
let elements_disabled = Signal::derive(move || create_entry_action.pending().get());
|
||||
|
||||
let on_submit = move |_| {
|
||||
create_entry_action.dispatch(CreateBorrowEntry {
|
||||
input: ExpandableInfo {
|
||||
subject: uuid::Uuid::new_v4().to_string(),
|
||||
data: data.get(),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
//#region Toast Logic
|
||||
let mut toast_context = expect_context::<ToastContext>();
|
||||
Effect::new(move || {
|
||||
if let Some(Ok(_)) = create_entry_action.value().get() {
|
||||
create_entry_action.clear();
|
||||
toast_context.toast(
|
||||
Toast::new()
|
||||
.id(uuid::Uuid::new_v4().to_string())
|
||||
.header("Borrower Added!")
|
||||
.msg("You can take your loaner now.")
|
||||
);
|
||||
navigate("/loaners", Default::default());
|
||||
}
|
||||
});
|
||||
//#endregion
|
||||
|
||||
view! {
|
||||
<div class="flex flex-col justify-center gap-2 mb-1 py-3 px-2">
|
||||
<h2 class="text-2xl font-bold">"Borrower Information"</h2>
|
||||
<p class="text-lg">"Please fill out the form below."</p>
|
||||
</div>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="First Name:"
|
||||
placeholder="John"
|
||||
disabled=elements_disabled
|
||||
data_member="first_name"
|
||||
data_map=temp_reactive.data
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Last Name:"
|
||||
placeholder="Smith"
|
||||
disabled=elements_disabled
|
||||
data_member="last_name"
|
||||
data_map=temp_reactive.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Region 15 Email:"
|
||||
placeholder="example@region15.org"
|
||||
disabled=elements_disabled
|
||||
data_member="email"
|
||||
data_map=temp_reactive.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<Select
|
||||
label="Collateral:"
|
||||
value_list=vec![
|
||||
"Phone".to_string(),
|
||||
"Earbuds/Headphones".to_string(),
|
||||
"Keys".to_string(),
|
||||
"Wallet".to_string(),
|
||||
"Laptop".to_string(),
|
||||
]
|
||||
disabled=elements_disabled
|
||||
data_member="collateral"
|
||||
data_map=temp_reactive.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<Select
|
||||
label="Loan Taken:"
|
||||
value_list=vec![
|
||||
"Chromebook".to_string(),
|
||||
"Charger".to_string(),
|
||||
"Headphones".to_string(),
|
||||
]
|
||||
disabled=elements_disabled
|
||||
data_member="loan"
|
||||
data_map=temp_reactive.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<ActionButton
|
||||
on:click=move |_| {
|
||||
let captured = temp_reactive.capture();
|
||||
log!("Loaner record taken.");
|
||||
log!("{:?}", captured);
|
||||
create_entry_action.dispatch(CreateBorrowEntry {
|
||||
input: captured
|
||||
});
|
||||
navigate("/loaners", Default::default());
|
||||
}
|
||||
>"Submit"</ActionButton>
|
||||
</Row>
|
||||
<ValidatedForm
|
||||
use:animate=pop_in_out()
|
||||
on_submit=Callback::new(on_submit)
|
||||
title="Borrower Information"
|
||||
description="Please fill out the form below."
|
||||
>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="First Name:"
|
||||
placeholder="John"
|
||||
disabled=elements_disabled
|
||||
data_member="first_name"
|
||||
data_map=data
|
||||
required=true
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Last Name:"
|
||||
placeholder="Smith"
|
||||
disabled=elements_disabled
|
||||
data_member="last_name"
|
||||
data_map=data
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Region 15 Email:"
|
||||
placeholder="example@region15.org"
|
||||
disabled=elements_disabled
|
||||
data_member="email"
|
||||
data_map=data
|
||||
input_type=TextFieldType::Email(vec!["region15.org".to_string()])
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<Select
|
||||
label="Collateral:"
|
||||
value_list=vec![
|
||||
"Phone".to_string(),
|
||||
"Earbuds/Headphones".to_string(),
|
||||
"Keys".to_string(),
|
||||
"Wallet".to_string(),
|
||||
"Laptop".to_string(),
|
||||
]
|
||||
disabled=elements_disabled
|
||||
data_member="collateral"
|
||||
data_map=data
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<Select
|
||||
label="Loan Taken:"
|
||||
value_list=vec![
|
||||
"Chromebook".to_string(),
|
||||
"Charger".to_string(),
|
||||
"Headphones".to_string(),
|
||||
]
|
||||
disabled=elements_disabled
|
||||
data_member="loan"
|
||||
data_map=data
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
</ValidatedForm>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn LoanerReturnForm() -> impl IntoView {
|
||||
let navigate = use_navigate();
|
||||
|
||||
// Register server actions
|
||||
let check_in_action = ServerAction::<CheckInLoaner>::new();
|
||||
|
||||
@@ -344,26 +376,25 @@ pub fn LoanerReturnForm() -> impl IntoView {
|
||||
|
||||
let dialog_ref = NodeRef::<Dialog>::new();
|
||||
let chosen_student = RwSignal::new(None::<LoanerReturnOutput>);
|
||||
let dialog_disabled = RwSignal::new(false);
|
||||
let dialog_disabled = Signal::derive(move || check_in_action.pending().get());
|
||||
|
||||
// Create an effect to run after check-in - refreshes the list with another scan request.
|
||||
// This could be beneficial if we ever decided to make multiple loaner kiosks (think SAT days)
|
||||
let mut toasts = expect_context::<ToastContext>();
|
||||
Effect::new(move || {
|
||||
if let Some(Ok(_)) = check_in_action.value().get() {
|
||||
// Check the local loaner list and remove the indicated student from it.
|
||||
return_list_refresh.update(|n| *n += 1);
|
||||
// Return to the main page.
|
||||
if let Some(dialog) = dialog_ref.get() {
|
||||
dialog.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// We can't use a Memo in our custom components for the disabled signal.
|
||||
Effect::new(move || {
|
||||
if check_in_action.pending().get() {
|
||||
dialog_disabled.set(true);
|
||||
} else {
|
||||
dialog_disabled.set(false);
|
||||
toasts.toast(
|
||||
Toast::new()
|
||||
.id(uuid::Uuid::new_v4())
|
||||
.header("Check in successful!")
|
||||
.msg("Don't forget to take your collateral.")
|
||||
);
|
||||
navigate("/loaners", Default::default());
|
||||
check_in_action.clear();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -387,69 +418,90 @@ pub fn LoanerReturnForm() -> impl IntoView {
|
||||
|
||||
view! {
|
||||
<dialog
|
||||
use:animate=pop_in_out()
|
||||
class="p-3 m-auto rounded-md shadow-lg/33 backdrop:backdrop-blur-xs backdrop:transition-backdrop-filter"
|
||||
node_ref=dialog_ref>
|
||||
node_ref=dialog_ref
|
||||
>
|
||||
<h2 class="text-2xl font-bold">"Confirmation"</h2>
|
||||
<h4 class="text-xl mb-3">
|
||||
{move || chosen_student.with(|opt_student| {
|
||||
if let Some(student) = opt_student.as_ref() {
|
||||
format!("Are you sure want to check in {} {}?", student.first_name, student.last_name)
|
||||
} else {
|
||||
"There is no student selected.".to_string()
|
||||
}
|
||||
})}
|
||||
{move || {
|
||||
chosen_student
|
||||
.with(|opt_student| {
|
||||
if let Some(student) = opt_student.as_ref() {
|
||||
format!(
|
||||
"Are you sure want to check in {} {}?",
|
||||
student.first_name,
|
||||
student.last_name,
|
||||
)
|
||||
} else {
|
||||
"There is no student selected.".to_string()
|
||||
}
|
||||
})
|
||||
}}
|
||||
</h4>
|
||||
<ActionButton on:click=close_dialog disabled=dialog_disabled>"Close"</ActionButton>
|
||||
<ActionButton on:click=dialog_check_in disabled=dialog_disabled>"Check In"</ActionButton>
|
||||
<ActionButton on:click=close_dialog disabled=dialog_disabled>
|
||||
"Close"
|
||||
</ActionButton>
|
||||
<ActionButton on:click=dialog_check_in disabled=dialog_disabled>
|
||||
"Check In"
|
||||
</ActionButton>
|
||||
</dialog>
|
||||
<div class="flex flex-col justify-center gap-2 mb-3 py-3 px-2">
|
||||
<h2 class="text-2xl font-bold">"Student List"</h2>
|
||||
<p class="text-lg">"Please select your name from the list."</p>
|
||||
</div>
|
||||
<Suspense fallback=move || "Loading...".into_view()>
|
||||
<div class="flex flex-col gap-2 m-2">
|
||||
|
||||
<div class="flex flex-col gap-2 m-2" use:animate=pop_in_out()>
|
||||
<div class="flex flex-col justify-center gap-2 mb-3 py-3 px-2">
|
||||
<h2 class="text-2xl font-bold">"Student List"</h2>
|
||||
<p class="text-lg">"Please select your name from the list."</p>
|
||||
</div>
|
||||
<Suspense fallback=move || {
|
||||
"Loading...".into_view()
|
||||
}>
|
||||
{move || {
|
||||
match return_list_resource.get() {
|
||||
Some(loaner_list) => {
|
||||
let list_view = loaner_list.iter().map(|student| {
|
||||
let on_click = {
|
||||
let student_click = student.clone();
|
||||
move |_| {
|
||||
log!("Running on_click using student {:?}", student_click);
|
||||
if let Some(dialog) = dialog_ref.get() {
|
||||
chosen_student.set(Some(student_click.clone()));
|
||||
dialog.show_modal().expect("Couldn't show modal dialog.");
|
||||
let list_view = loaner_list
|
||||
.iter()
|
||||
.map(|student| {
|
||||
let on_click = {
|
||||
let student_click = student.clone();
|
||||
move |_| {
|
||||
log!("Running on_click using student {:?}", student_click);
|
||||
if let Some(dialog) = dialog_ref.get() {
|
||||
chosen_student.set(Some(student_click.clone()));
|
||||
dialog.show_modal().expect("Couldn't show modal dialog.");
|
||||
}
|
||||
}
|
||||
};
|
||||
let naive_time = chrono::NaiveDateTime::parse_from_str(
|
||||
&student.date,
|
||||
"%H:%M, %m/%d/%Y",
|
||||
)
|
||||
.unwrap();
|
||||
let time_zone = FixedOffset::west_opt(5 * 3600).unwrap();
|
||||
let student_date = time_zone
|
||||
.from_utc_datetime(&naive_time)
|
||||
.format("%l:%M %p, %m/%d/%Y")
|
||||
.to_string();
|
||||
log!("Found current date {:?}", student_date);
|
||||
|
||||
view! {
|
||||
<div
|
||||
class="flex flex-row p-3 rounded-md cursor-pointer transition-shadow shadow-sm hover:shadow-lg/33"
|
||||
on:click=on_click
|
||||
>
|
||||
<div class="flex-1">
|
||||
{student.first_name.clone()}" "{student.last_name.clone()}
|
||||
</div>
|
||||
<div class="flex-none">{student_date.clone()}</div>
|
||||
</div>
|
||||
}
|
||||
};
|
||||
|
||||
let naive_time = chrono::NaiveDateTime::parse_from_str(&student.date, "%H:%M, %m/%d/%Y")
|
||||
.unwrap();
|
||||
let time_zone = FixedOffset::west_opt(5 * 3600).unwrap();
|
||||
let student_date = time_zone.from_utc_datetime(&naive_time)
|
||||
.format("%l:%M %p, %m/%d/%Y")
|
||||
.to_string();
|
||||
|
||||
log!("Found current date {:?}", student_date);
|
||||
|
||||
view! {
|
||||
<div class="flex flex-row p-3 rounded-md cursor-pointer transition-shadow shadow-sm hover:shadow-lg/33" on:click=on_click>
|
||||
<div class="flex-1">
|
||||
{student.first_name.clone()}" "{student.last_name.clone()}
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
{student_date.clone()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}).collect_view();
|
||||
|
||||
})
|
||||
.collect_view();
|
||||
Either::Left(list_view)
|
||||
},
|
||||
None => Either::Right("No loaners found.".into_view())
|
||||
}
|
||||
None => Either::Right("No loaners found.".into_view()),
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</Suspense>
|
||||
</Suspense>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
+10
-5
@@ -1,15 +1,20 @@
|
||||
mod about_page;
|
||||
mod api;
|
||||
pub mod api;
|
||||
mod comparison_test;
|
||||
mod home_page;
|
||||
mod loaner_page;
|
||||
mod provider_portal;
|
||||
mod scholarship_info;
|
||||
mod test_page;
|
||||
mod unauthenticated_page;
|
||||
mod utils;
|
||||
mod provider_contact;
|
||||
mod auth_callback;
|
||||
mod provider_applicants;
|
||||
mod admin;
|
||||
pub(crate) mod student;
|
||||
mod form_closed_page;
|
||||
|
||||
pub use self::{
|
||||
about_page::*, comparison_test::*, home_page::*, loaner_page::LoanerPage, provider_portal::*,
|
||||
test_page::*, unauthenticated_page::*,
|
||||
about_page::*, comparison_test::*, home_page::*, loaner_page::*, provider_portal::*,
|
||||
unauthenticated_page::*, scholarship_info::*, provider_contact::*,
|
||||
auth_callback::*, provider_applicants::*, admin::*, student::*, form_closed_page::*,
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,162 @@
|
||||
use std::collections::HashMap;
|
||||
use leptos::logging::log;
|
||||
use leptos::prelude::*;
|
||||
use leptos_oidc::{AuthLoaded, Authenticated};
|
||||
use crate::components::{Banner, Loading, OutlinedTextField, Panel, Row, TextFieldType, Toast, ToastContext, ValidatedForm};
|
||||
use crate::pages::UnauthenticatedPage;
|
||||
use crate::utils::get_user_claims;
|
||||
use super::api::{get_provider_contact, PutProviderContact};
|
||||
|
||||
#[component]
|
||||
pub fn ProviderContactPage() -> impl IntoView {
|
||||
// Get user claims
|
||||
let user_claims = get_user_claims();
|
||||
let user_id = Memo::new(move |_| {
|
||||
user_claims.get()
|
||||
.map(|info| info.claims.subject.clone())
|
||||
});
|
||||
|
||||
// Get contact information
|
||||
let contact_info = RwSignal::new(HashMap::new());
|
||||
let contact_resource = Resource::new(
|
||||
move || user_id.get(),
|
||||
async move |id| {
|
||||
let Some(id) = id else {
|
||||
return Ok(HashMap::new());
|
||||
};
|
||||
|
||||
get_provider_contact(id).await
|
||||
}
|
||||
);
|
||||
|
||||
Effect::new(move || {
|
||||
match contact_resource.get() {
|
||||
Some(Ok(map)) => contact_info.set(map),
|
||||
Some(Err(e)) => log!("Error getting contact info: {}", e),
|
||||
None => log!("Contact info resource is not ready yet."),
|
||||
}
|
||||
});
|
||||
|
||||
// Set up submit action
|
||||
let submit_action = ServerAction::<PutProviderContact>::new();
|
||||
|
||||
let on_submit = move || {
|
||||
// Submit the form by reading the contact_info.
|
||||
submit_action.dispatch(PutProviderContact {
|
||||
id: user_id.get().unwrap_or_default(),
|
||||
contact_info: contact_info.get()
|
||||
});
|
||||
};
|
||||
|
||||
let elements_disabled = Signal::derive(move || {
|
||||
submit_action.pending().get()
|
||||
});
|
||||
|
||||
let mut toasts_context = expect_context::<ToastContext>();
|
||||
Effect::watch(
|
||||
move || submit_action.value().get(),
|
||||
move |value, _, _| {
|
||||
log!("Running submit_action effect");
|
||||
let Some(result) = value else {
|
||||
return;
|
||||
};
|
||||
|
||||
let toast = match result {
|
||||
Ok(_) => Toast::new()
|
||||
.id(uuid::Uuid::new_v4())
|
||||
.header("Submission Successful")
|
||||
.msg("You can go back or continue editing your responses."),
|
||||
Err(err) => Toast::new()
|
||||
.id(uuid::Uuid::new_v4())
|
||||
.header("Submission Failed")
|
||||
.msg(err.to_string())
|
||||
};
|
||||
|
||||
untrack(move || {
|
||||
submit_action.clear();
|
||||
toasts_context.toast(toast);
|
||||
});
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
// Display contact form.
|
||||
view! {
|
||||
<Banner title="R15 Scholarships" logo="/PHS_Stacked_Acronym.png" path="/providers" />
|
||||
<AuthLoaded fallback=Loading>
|
||||
<Authenticated unauthenticated=UnauthenticatedPage>
|
||||
<Suspense fallback=Loading>
|
||||
<div class="flex flex-row">
|
||||
<div class="flex flex-col flex-1" />
|
||||
<Panel>
|
||||
<ValidatedForm
|
||||
on_submit=on_submit
|
||||
title="Contact Form"
|
||||
description="Enter your contact information and click submit."
|
||||
>
|
||||
{move || {
|
||||
contact_resource
|
||||
.get()
|
||||
.map(|_| {
|
||||
view! {
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="First Name:"
|
||||
placeholder="John"
|
||||
disabled=elements_disabled
|
||||
data_member="first_name"
|
||||
data_map=contact_info
|
||||
required=true
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Last Name:"
|
||||
placeholder="Smith"
|
||||
disabled=elements_disabled
|
||||
data_member="last_name"
|
||||
data_map=contact_info
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Contact Email:"
|
||||
placeholder="example@mymailservice.com"
|
||||
disabled=elements_disabled
|
||||
data_member="contact_email"
|
||||
data_map=contact_info
|
||||
input_type=TextFieldType::Email(vec!["*".to_string()])
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Phone Number:"
|
||||
placeholder="123-456-7890"
|
||||
disabled=elements_disabled
|
||||
data_member="phone_number"
|
||||
data_map=contact_info
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Street Address:"
|
||||
placeholder="123 Fake Street"
|
||||
disabled=elements_disabled
|
||||
data_member="address"
|
||||
data_map=contact_info
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
}
|
||||
})
|
||||
}}
|
||||
</ValidatedForm>
|
||||
</Panel>
|
||||
<div class="flex flex-col flex-1" />
|
||||
</div>
|
||||
</Suspense>
|
||||
</Authenticated>
|
||||
</AuthLoaded>
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,67 @@
|
||||
use crate::components::{Banner, DashboardButton};
|
||||
use crate::components::{Banner, DashboardButton, DateList, Loading};
|
||||
use leptos::prelude::*;
|
||||
use leptos_oidc::{AuthLoaded, Authenticated};
|
||||
use crate::pages::api::get_important_dates;
|
||||
use crate::pages::UnauthenticatedPage;
|
||||
|
||||
#[component]
|
||||
pub fn ProviderPortal() -> impl IntoView {
|
||||
view! {
|
||||
<Banner
|
||||
title="Provider Dashboard"
|
||||
logo="PHS_Stacked_Acronym.png"
|
||||
path="/"
|
||||
/>
|
||||
<div class="mx-auto px-6">
|
||||
<div class="container mx-auto px-6 py-8 space-y-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<section class="space-y-4">
|
||||
<DashboardButton
|
||||
title="Profile"
|
||||
description="Edit user profile"
|
||||
icon="/Person_Black.png"
|
||||
path="/"
|
||||
/>
|
||||
<DashboardButton
|
||||
title="Create Scholarship"
|
||||
description="Navigate to Scholarship Creation Page"
|
||||
icon="/Create_Black.png"
|
||||
path="/"
|
||||
/>
|
||||
<DashboardButton
|
||||
title="Applicants"
|
||||
description="View Scholarship Applicants"
|
||||
icon="/Form_Black.png"
|
||||
path="/"
|
||||
/>
|
||||
</section>
|
||||
<section class="space-y-4">
|
||||
let dates_resource = OnceResource::new(async move {
|
||||
get_important_dates().await
|
||||
});
|
||||
|
||||
</section>
|
||||
let dates_list = RwSignal::new(Vec::new());
|
||||
|
||||
Effect::new(move || {
|
||||
if let Some(Ok(mut dates)) = dates_resource.get() {
|
||||
dates.sort_by_key(|info| info.date.get_status());
|
||||
dates_list.set(dates);
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<Banner title="Provider Dashboard" logo="PHS_Stacked_Acronym.png" />
|
||||
<AuthLoaded fallback=Loading>
|
||||
<Authenticated unauthenticated=UnauthenticatedPage>
|
||||
<div class="mx-auto px-6">
|
||||
<div class="container mx-auto px-6 py-8 space-y-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<section class="space-y-4">
|
||||
<DashboardButton
|
||||
title="Profile"
|
||||
description="Edit user profile"
|
||||
icon="/Person_Black.png"
|
||||
path="/providers/profile"
|
||||
/>
|
||||
<DashboardButton
|
||||
title="Create Scholarship"
|
||||
description="Navigate to Scholarship Creation Page"
|
||||
icon="/Create_Black.png"
|
||||
path="/providers/scholarships"
|
||||
/>
|
||||
<DashboardButton
|
||||
title="Applicants"
|
||||
description="View Scholarship Applicants"
|
||||
icon="/Form_Black.png"
|
||||
path="/providers/applicants"
|
||||
/>
|
||||
</section>
|
||||
<section class="space-y-4">
|
||||
<div class="rounded-lg shadow-lg/25 overflow-hidden">
|
||||
<div class="bg-red-900 text-white px-4 py-3 shadow-lg">
|
||||
<h3 class="text-white font-bold">Important Dates</h3>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<div class="flex flex-col p-3 gap-3">
|
||||
<DateList dates=dates_list />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Authenticated>
|
||||
</AuthLoaded>
|
||||
}
|
||||
}
|
||||
+685
-226
@@ -1,261 +1,720 @@
|
||||
use super::UnauthenticatedPage;
|
||||
use super::api::{
|
||||
CreateScholarshipInfo, CreateTestComparisons, get_comparison_info, get_scholarship_info,
|
||||
};
|
||||
use crate::common::ComparisonData;
|
||||
use crate::common::{ExpandableInfo, ScholarshipFormParams};
|
||||
use crate::components::{
|
||||
ActionButton, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row,
|
||||
};
|
||||
use leptos::logging::log;
|
||||
use std::collections::HashMap;
|
||||
use leptos::either::Either;
|
||||
use leptos::html::Dialog;
|
||||
use leptos::logging::debug_log;
|
||||
use leptos::prelude::*;
|
||||
use leptos_oidc::{AuthLoaded, Authenticated};
|
||||
use leptos_router::hooks::use_params;
|
||||
use traits::{AsReactive, ReactiveCapture};
|
||||
use leptos_router::hooks::{use_navigate, use_params};
|
||||
use crate::common::{ComparisonData, ExpandableInfo, ScholarshipFormParams, SubmitStatus, ValueType};
|
||||
use crate::components::{ActionButton, Banner, ChipsList, Header, Loading, OutlinedTextField, Panel, RadioList, Row, TextFieldType, Toast, ToastContext, ToastList, ValidatedForm};
|
||||
use super::UnauthenticatedPage;
|
||||
use crate::utils::get_user_claims;
|
||||
use super::api::{get_provider_scholarships, get_scholarship_info, CreateScholarshipInfo, RegisterScholarship, DeleteProviderScholarship, get_comparisons_categorized};
|
||||
|
||||
|
||||
/// # Scholarship Info Page
|
||||
/// This page will handle creating or editing scholarship information given a specific
|
||||
/// This page will handle creating or editing scholarship information given a specific
|
||||
/// scholarship ID and scholarship provider subject number. The form itself will only
|
||||
/// track a single scholarship (for now) and so selection of what scholarship will be edited
|
||||
/// must be handled *before* navigation to this page.
|
||||
///
|
||||
/// In its current testing form, we'll be using a single fixed ID. This absolutely *MUST* be
|
||||
/// changed before a full release.
|
||||
#[component]
|
||||
pub fn ScholarshipInfoPage() -> impl IntoView {
|
||||
// let scholarship_id = String::from("test-scholarship-id2");
|
||||
// Changes to this signal update the scholarship list.
|
||||
let list_refresh = RwSignal::new(0);
|
||||
|
||||
let url_params = use_params::<ScholarshipFormParams>();
|
||||
let scholarship_id = move || {
|
||||
url_params
|
||||
.read()
|
||||
let scholarship_id = Memo::new(move |_| {
|
||||
url_params.read()
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|params| params.id.clone())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
// let create_test_comps = ServerAction::<CreateTestComparisons>::new();
|
||||
|
||||
let on_delete = move || {
|
||||
let navigate = use_navigate();
|
||||
navigate("/providers/scholarships", Default::default());
|
||||
};
|
||||
|
||||
let get_scholarship_action: Resource<ExpandableInfo> = Resource::new(
|
||||
move || scholarship_id().clone(),
|
||||
async |id| {
|
||||
get_scholarship_info(id.clone())
|
||||
.await
|
||||
// Note that this really should notify us of an error and then redirect or ask
|
||||
// the user to try again.
|
||||
.unwrap_or_else(|e| {
|
||||
log!("Failed to get scholarship info: {:?}", e);
|
||||
log!("Using default ExpandableInfo");
|
||||
ExpandableInfo::new(id)
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
let get_comparison_info: Resource<Vec<ComparisonData>> = Resource::new(
|
||||
move || Option::<String>::None, // There's no input to this function.
|
||||
async |_| {
|
||||
get_comparison_info().await.unwrap_or_else(|e| {
|
||||
log!("Failed to get comparison info: {:?}", e);
|
||||
Vec::new()
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
let create_scholarship_action = ServerAction::<CreateScholarshipInfo>::new();
|
||||
let create_test_comps = ServerAction::<CreateTestComparisons>::new();
|
||||
|
||||
|
||||
// Remember that we only want to show the page after we've got authentication information
|
||||
// and if there's data for us to use.
|
||||
view! {
|
||||
<AuthLoaded fallback=Loading>
|
||||
<Authenticated unauthenticated=UnauthenticatedPage>
|
||||
<Suspense fallback=Loading>
|
||||
<Row>
|
||||
<Banner title="R15 Scholarships" logo="/PHS_Stacked_Acronym.png" path="/providers" />
|
||||
<ToastList>
|
||||
<AuthLoaded fallback=Loading>
|
||||
<Authenticated unauthenticated=UnauthenticatedPage>
|
||||
<div class="flex flex-row align-top items-start">
|
||||
<div class="flex flex-col flex-1" />
|
||||
{move || Suspend::new(async move {
|
||||
let response = get_scholarship_action.get();
|
||||
let comparisons = get_comparison_info.get();
|
||||
|
||||
response
|
||||
.zip(comparisons)
|
||||
.map(|(response, comparison_list)| view! {
|
||||
<ScholarshipForm
|
||||
response=response
|
||||
comparison_list=comparison_list
|
||||
submit_action=create_scholarship_action
|
||||
/>
|
||||
})
|
||||
.collect_view()
|
||||
})}
|
||||
<div class="hidden">
|
||||
<Row>
|
||||
<ActionButton
|
||||
on:click=move |_| {
|
||||
create_test_comps.dispatch(CreateTestComparisons {});
|
||||
}
|
||||
>"Create test comparisons"</ActionButton>
|
||||
</Row>
|
||||
</div>
|
||||
<ScholarshipList refresh_token=list_refresh on_delete=on_delete />
|
||||
<Transition fallback=Loading>
|
||||
<ScholarshipForm
|
||||
scholarship_id=scholarship_id
|
||||
on_submit=move || {
|
||||
list_refresh.update(|n| *n += 1);
|
||||
}
|
||||
/>
|
||||
</Transition>
|
||||
<div class="flex flex-col flex-1" />
|
||||
</Row>
|
||||
</Suspense>
|
||||
</Authenticated>
|
||||
</AuthLoaded>
|
||||
</div>
|
||||
</Authenticated>
|
||||
</AuthLoaded>
|
||||
</ToastList>
|
||||
}
|
||||
}
|
||||
|
||||
/// # Scholarship List Component
|
||||
///
|
||||
/// This component should always be wrapped in an Authenticated component. Assuming that a provider
|
||||
/// is authenticated, this component will get all the scholarships that this provider owns and
|
||||
/// will display them in a list, giving them the option to edit, delete, or create new scholarships.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```
|
||||
/// let refresh = RwSignal::new(0);
|
||||
///
|
||||
/// view! {
|
||||
/// <AuthLoaded>
|
||||
/// <Authenticated>
|
||||
/// <ScholarshipList
|
||||
/// refresh_token=refresh
|
||||
/// on_delete=move || log!("This is run when an item is deleted on the server.")
|
||||
/// />
|
||||
/// </Authenticated>
|
||||
/// </AuthLoaded>
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
fn ScholarshipList(
|
||||
/// A signal that refreshes the list when changed. Most times, we don't need to worry about the
|
||||
/// user reloading our page 2 billion times.
|
||||
#[prop()] refresh_token: RwSignal<i32>,
|
||||
/// A callback that is run on successful server-side deletion of an item.
|
||||
#[prop(into)] on_delete: Callback<()>
|
||||
) -> impl IntoView {
|
||||
// List state
|
||||
let pending_delete = RwSignal::new(None::<ExpandableInfo>);
|
||||
let delete_dialog_ref = NodeRef::<Dialog>::new();
|
||||
|
||||
// Get scholarship provider information from authentication. This element will not display
|
||||
// unless that information is available.
|
||||
let user_claims = get_user_claims();
|
||||
let provider_id = Memo::new(move |_|
|
||||
user_claims.get()
|
||||
.as_ref()
|
||||
.map(|info| info.claims.subject.clone())
|
||||
);
|
||||
|
||||
// Register scholarship server resource
|
||||
let scholarships = Resource::new(
|
||||
move || {
|
||||
(refresh_token.get(), provider_id.get())
|
||||
},
|
||||
|(_, provider_id)| async move {
|
||||
debug_log!("Fetching from scholarship API.");
|
||||
let Some(provider_id) = provider_id else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
// Get the scholarships
|
||||
get_provider_scholarships(provider_id).await
|
||||
}
|
||||
);
|
||||
|
||||
// Register scholarship creation/deletion actions
|
||||
let create_action = ServerAction::<RegisterScholarship>::new();
|
||||
let delete_action = ServerAction::<DeleteProviderScholarship>::new();
|
||||
|
||||
// Button handlers
|
||||
let create_on_click = move |_| {
|
||||
create_action.dispatch(RegisterScholarship {
|
||||
provider_id: provider_id.get().unwrap()
|
||||
});
|
||||
};
|
||||
|
||||
let on_item_delete = move |s| {
|
||||
pending_delete.set(Some(s));
|
||||
};
|
||||
|
||||
// Register scholarship creation effect
|
||||
Effect::new(move || {
|
||||
// We want to wait for the action to complete first, but then find whether the request
|
||||
// succeeded.
|
||||
if let Some(Ok(_)) = create_action.value().get() {
|
||||
// Refetch and clear. We could alter this slightly to get the new element and
|
||||
// update the list on the client side only (no refetch). We also want to set the name
|
||||
// of the new scholarship before it's created on the server.
|
||||
scholarships.refetch();
|
||||
create_action.clear();
|
||||
}
|
||||
});
|
||||
|
||||
// Register scholarship deletion effects
|
||||
let modal_disabled = Signal::derive(move || delete_action.pending().get());
|
||||
Effect::new(move || {
|
||||
if let Some(Ok(_)) = delete_action.value().get() {
|
||||
scholarships.refetch();
|
||||
pending_delete.set(None);
|
||||
delete_action.clear();
|
||||
on_delete.run(());
|
||||
}
|
||||
});
|
||||
|
||||
// Register scholarship pending delete effects
|
||||
Effect::new(move || {
|
||||
if let Some(dialog) = delete_dialog_ref.get() {
|
||||
if pending_delete.get().is_some() {
|
||||
// Find the modal dialog and show it. Ask for the user's confirmation. Afterward,
|
||||
// take the correct action.
|
||||
dialog.show_modal()
|
||||
.expect("Couldn't show dialog. Is it already open?");
|
||||
} else {
|
||||
dialog.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let modal_on_click_continue = move |_| {
|
||||
let subject = pending_delete
|
||||
.get()
|
||||
.map(|s| s.subject.clone())
|
||||
.unwrap_or_default();
|
||||
debug_log!("Deleting scholarship with subject {}", subject);
|
||||
delete_action
|
||||
.dispatch(DeleteProviderScholarship {
|
||||
scholarship_id: subject,
|
||||
provider_id: provider_id.get().unwrap_or_default(),
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<dialog
|
||||
node_ref=delete_dialog_ref
|
||||
class="m-auto p-5 rounded-lg shadow-xl/50
|
||||
backdrop:backdrop-blur-xs backdrop:transition-backdrop-filter"
|
||||
>
|
||||
<h2 class="text-2xl font-bold">"Confirm Deletion"</h2>
|
||||
<p class="mb-3">
|
||||
"Are you sure you want to delete "
|
||||
{move || {
|
||||
pending_delete
|
||||
.get()
|
||||
.map(|s| {
|
||||
s.data
|
||||
.get("name")
|
||||
.unwrap_or(&ValueType::String(None))
|
||||
.as_string()
|
||||
.unwrap_or_default()
|
||||
.unwrap_or("this unnamed scholarship".to_string())
|
||||
})
|
||||
}} "?"
|
||||
</p>
|
||||
<div class="flex mx-auto items-center justify-center">
|
||||
<ActionButton on:click=modal_on_click_continue disabled=modal_disabled>
|
||||
"Yes"
|
||||
</ActionButton>
|
||||
<ActionButton on:click=move |_| pending_delete.set(None)>"No"</ActionButton>
|
||||
</div>
|
||||
</dialog>
|
||||
<div class="w-75">
|
||||
<Panel>
|
||||
<div class="pt-2">
|
||||
<h2 class="text-xl font-bold text-center">"Scholarships"</h2>
|
||||
</div>
|
||||
<Transition fallback=Loading>
|
||||
<div class="flex flex-col gap-2">
|
||||
{move || {
|
||||
scholarships
|
||||
.get()
|
||||
.map(|result| {
|
||||
match result {
|
||||
Ok(list) => {
|
||||
let views = list
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
view! {
|
||||
<ScholarshipListEntry
|
||||
scholarship=entry.clone()
|
||||
on_delete=Callback::new(on_item_delete)
|
||||
/>
|
||||
}
|
||||
});
|
||||
Either::Left(views.collect_view())
|
||||
}
|
||||
Err(err) => {
|
||||
Either::Right(
|
||||
view! {
|
||||
<div>
|
||||
<p>"Failed to load scholarship list: "{err.to_string()}</p>
|
||||
</div>
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
}}
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="p-2 bg-red-800 rounded-md text-center text-white hover:bg-red-900 transition-all cursor-pointer"
|
||||
on:click=create_on_click
|
||||
>
|
||||
"Create New"
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Panel>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// # Scholarship List Entry Component
|
||||
///
|
||||
/// Displays a single scholarship within the [`ScholarshipList`] component.
|
||||
///
|
||||
/// Example usage (within the [`ScholarshipList`]):
|
||||
/// ```
|
||||
/// view! {
|
||||
/// <ScholarshipListEntry
|
||||
/// scholarship= /* An ExpandableInfo object containing scholarship info */
|
||||
/// on_delete=move |_| log!("This is a callback on successful deletion.")
|
||||
/// />
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
fn ScholarshipListEntry(
|
||||
/// The scholarship information. This element will attempt to get the name of the scholarship
|
||||
/// using its `name` field, and also utilizes the `subject` field.
|
||||
#[prop()] scholarship: ExpandableInfo,
|
||||
/// A [`Callback<ExpandableInfo>`] that runs upon successful deletion of the scholarship.
|
||||
#[prop(into)] on_delete: Callback<ExpandableInfo, ()>,
|
||||
) -> impl IntoView {
|
||||
let navigate = use_navigate();
|
||||
let user_claims = get_user_claims();
|
||||
let _user_id = Memo::new(move |_| {
|
||||
user_claims.get()
|
||||
.map(|info| info.claims.subject.clone())
|
||||
});
|
||||
|
||||
// Get the values that we need out of the info. We just need the subject and the name of
|
||||
// the scholarship.
|
||||
let name = scholarship.data.get("name")
|
||||
.unwrap_or(&ValueType::String(None))
|
||||
.as_string().unwrap_or_default()
|
||||
.unwrap_or("Unnamed Scholarship".to_string());
|
||||
|
||||
// Button click handlers
|
||||
let edit_click = {
|
||||
let subject = scholarship.subject.clone();
|
||||
move |_| {
|
||||
let path = format!("/providers/scholarships/{}", subject);
|
||||
navigate(path.as_str(), Default::default());
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="flex flex-col rounded-md border-light inset-shadow-xs shadow-md my-2 transition-all hover:shadow-lg/33">
|
||||
<div class="p-2 text-center">
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
<div class="flex flex-row">
|
||||
<div
|
||||
class="p-2 flex-1 bg-red-800 border-r border-white rounded-bl-md text-white text-center
|
||||
hover:bg-red-900 cursor-pointer transition-all"
|
||||
on:click=edit_click
|
||||
>
|
||||
"Edit"
|
||||
</div>
|
||||
<div
|
||||
class="p-2 flex-1 bg-red-800 border-l border-white rounded-br-md text-white text-center
|
||||
hover:bg-red-900 cursor-pointer transition-all"
|
||||
on:click=move |_| on_delete.run(scholarship.clone())
|
||||
>
|
||||
"Delete"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// # Scholarship Edit Form Component
|
||||
///
|
||||
/// Displays all information about a scholarship and allows the provider to edit that information.
|
||||
/// All information is only saved when the submit button is clicked.
|
||||
///
|
||||
/// The component will call the `on_submit` callback when the API returns an `Ok` result. In the
|
||||
/// event that the API call fails, it will not call anything. This is mainly used to allow updates
|
||||
/// to neighboring components from the parent.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```
|
||||
/// view! {
|
||||
/// <ScholarshipForm
|
||||
/// scholarship_id="SomeScholarshipID"
|
||||
/// on_submit=move || log!("This is a callback!")
|
||||
/// />
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
fn ScholarshipForm(
|
||||
response: ExpandableInfo,
|
||||
comparison_list: Vec<ComparisonData>,
|
||||
submit_action: ServerAction<CreateScholarshipInfo>,
|
||||
/// The ID of the scholarship to edit. The form will handle getting all information about the
|
||||
/// scholarship and submitting new information.
|
||||
#[prop(into)] scholarship_id: Signal<Option<String>>,
|
||||
/// A callback that is run when the form successfully submits new data. In the event that the
|
||||
/// submission fails, this function is not run.
|
||||
#[prop(into)] on_submit: Callback<()>
|
||||
) -> impl IntoView {
|
||||
let reactive_info = response.as_reactive();
|
||||
let elements_disabled = RwSignal::new(false);
|
||||
let result_msg = Signal::derive(move || {
|
||||
if submit_action.pending().get() {
|
||||
elements_disabled.set(true);
|
||||
"Sending request...".to_string()
|
||||
} else {
|
||||
elements_disabled.set(false);
|
||||
if let Some(result) = submit_action.value().get() {
|
||||
match result {
|
||||
Ok(()) => "Request sent successfully".to_string(),
|
||||
Err(err) => {
|
||||
format!("An error occurred: {:?}", err)
|
||||
}
|
||||
}
|
||||
//#region Form Data Setup
|
||||
|
||||
let scholarship_info = Resource::new(
|
||||
move || scholarship_id.get(),
|
||||
async move |opt_id| {
|
||||
if let Some(id) = opt_id {
|
||||
get_scholarship_info(id).await
|
||||
} else {
|
||||
"".to_string()
|
||||
Err(ServerFnError::new("Cannot get from server with no ID."))
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
let form_data = RwSignal::new(HashMap::new());
|
||||
let chips_data = RwSignal::new(HashMap::new());
|
||||
|
||||
Effect::new(move || {
|
||||
if let Some(Ok(scholarship)) = scholarship_info.get() {
|
||||
// Get the default chips data.
|
||||
let chips_default = if let Some(ValueType::Map(Some(map))) = scholarship.data.get("requirements") {
|
||||
debug_log!("Found requirements map: {:?}", map);
|
||||
map.clone()
|
||||
} else {
|
||||
debug_log!("Couldn't find requirements map in scholarship info. Using empty map.");
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
chips_data.set(chips_default);
|
||||
form_data.set(scholarship.data);
|
||||
}
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region Comparison Logic
|
||||
|
||||
let comparison_lists = Resource::new(
|
||||
move || scholarship_id.get().is_some(),
|
||||
async move |_| {
|
||||
get_comparisons_categorized().await
|
||||
}
|
||||
);
|
||||
|
||||
let comparison_lists_sorted = Signal::derive(move || {
|
||||
if let Some(Ok(map)) = comparison_lists.get() {
|
||||
let mut map = map.clone();
|
||||
map
|
||||
.into_iter()
|
||||
.map(|(cat, mut list)| {
|
||||
list.sort_by_key(|comp| comp.display_text.clone());
|
||||
(cat, list)
|
||||
})
|
||||
.collect::<HashMap<String, Vec<ComparisonData>>>()
|
||||
} else {
|
||||
HashMap::new()
|
||||
}
|
||||
});
|
||||
|
||||
let get_comp_category = move |category: &str| {
|
||||
comparison_lists_sorted.get()
|
||||
.get(category)
|
||||
.cloned()
|
||||
.unwrap_or(Vec::new())
|
||||
};
|
||||
|
||||
let get_comp_ids = move |category: &str| {
|
||||
get_comp_category(category)
|
||||
.into_iter()
|
||||
.map(|comp| comp.id)
|
||||
.collect::<Vec<String>>()
|
||||
};
|
||||
|
||||
let get_comp_text = move |category: &str| {
|
||||
get_comp_category(category)
|
||||
.into_iter()
|
||||
.map(|comp| comp.display_text)
|
||||
.collect::<Vec<String>>()
|
||||
};
|
||||
|
||||
// Get comparison lists.
|
||||
let sports_ids = Signal::derive(move || get_comp_ids("Sports Participation"));
|
||||
let sports_text = Signal::derive(move || get_comp_text("Sports Participation"));
|
||||
|
||||
let misc_ids = Signal::derive(move || get_comp_ids("Additional Eligibility Factors"));
|
||||
let misc_text = Signal::derive(move || get_comp_text("Additional Eligibility Factors"));
|
||||
|
||||
let comm_ids = Signal::derive(move || get_comp_ids("Community Service"));
|
||||
let comm_text = Signal::derive(move || get_comp_text("Community Service"));
|
||||
|
||||
let res_ids = Signal::derive(move || get_comp_ids("Residency"));
|
||||
let res_text = Signal::derive(move || get_comp_text("Residency"));
|
||||
|
||||
let gpa_ids = Signal::derive(move || get_comp_ids("GPA Limits"));
|
||||
let gpa_text = Signal::derive(move || get_comp_text("GPA Limits"));
|
||||
|
||||
let major_ids = Signal::derive(move || get_comp_ids("Majors"));
|
||||
let major_text = Signal::derive(move || get_comp_text("Majors"));
|
||||
|
||||
//#endregion
|
||||
//#region Submission Logic
|
||||
|
||||
let submit_action = ServerAction::<CreateScholarshipInfo>::new();
|
||||
|
||||
let submit_status = RwSignal::new(SubmitStatus::Idle);
|
||||
let elements_disabled = Signal::derive(move || {
|
||||
matches!(submit_action.pending().get(), true)
|
||||
});
|
||||
|
||||
Effect::new(move || {
|
||||
if submit_action.pending().get() {
|
||||
submit_status.set(SubmitStatus::Sending);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(result) = submit_action.value().get() {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
submit_status.set(SubmitStatus::Success);
|
||||
on_submit.run(());
|
||||
},
|
||||
Err(_err) => submit_status.set(SubmitStatus::Error(()))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// We'll collect the scholarship's name, num_awards, amount_per_award, total_awards,
|
||||
// fafsa_required, award_to, transcript_required, recipient_selection, essay_requirement (and prompt)
|
||||
// award_night_remarks
|
||||
let mut toasts = expect_context::<ToastContext>();
|
||||
|
||||
let on_submit = move |_| {
|
||||
let mut info = ExpandableInfo::new(scholarship_id.get().unwrap_or_default());
|
||||
info.data = form_data.get();
|
||||
info.data.insert("requirements".to_string(), ValueType::Map(Some(chips_data.get())));
|
||||
|
||||
debug_log!("Map values: {:?}", info.data);
|
||||
submit_action.dispatch(CreateScholarshipInfo {
|
||||
info
|
||||
});
|
||||
};
|
||||
|
||||
Effect::watch(
|
||||
move || submit_action.value().get(),
|
||||
move |value, _, _| {
|
||||
debug_log!("Running submit_action effect");
|
||||
let Some(result) = value else {
|
||||
return;
|
||||
};
|
||||
|
||||
let toast = match result {
|
||||
Ok(_) => Toast::new()
|
||||
.id(uuid::Uuid::new_v4())
|
||||
.header("Submission Successful")
|
||||
.msg("You can go back or continue editing your responses."),
|
||||
Err(err) => Toast::new()
|
||||
.id(uuid::Uuid::new_v4())
|
||||
.header("Submission Failed")
|
||||
.msg(err.to_string())
|
||||
};
|
||||
|
||||
untrack(move || {
|
||||
submit_action.clear();
|
||||
toasts.toast(toast);
|
||||
});
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
view! {
|
||||
<Panel>
|
||||
<Row>
|
||||
<h1 class="text-3xl font-bold">
|
||||
"Scholarship Info Form (test)"
|
||||
</h1>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Scholarship Name"
|
||||
placeholder="Example Scholarship Name"
|
||||
disabled=elements_disabled
|
||||
data_member="name"
|
||||
data_map=reactive_info.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Amount per award: (if multiple, enter the highest value)"
|
||||
placeholder="Enter an amount..."
|
||||
disabled=elements_disabled
|
||||
data_member="amount_per_award"
|
||||
data_map=reactive_info.data
|
||||
input_type="number"
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Total number of awards:"
|
||||
placeholder="Enter an amount..."
|
||||
disabled=elements_disabled
|
||||
data_member="num_awards"
|
||||
data_map=reactive_info.data
|
||||
input_type="number"
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Total amount of all awards:"
|
||||
placeholder="Enter an amount..."
|
||||
disabled=elements_disabled
|
||||
data_member="total_awards"
|
||||
data_map=reactive_info.data
|
||||
input_type="number"
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<RadioList
|
||||
data_member="fafsa_required"
|
||||
data_map=reactive_info.data
|
||||
items=vec!["Yes".to_string(), "No".to_string()]
|
||||
disabled=elements_disabled
|
||||
label="Do you require student financial information?"
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<RadioList
|
||||
data_member="transcript_required"
|
||||
data_map=reactive_info.data
|
||||
items=vec!["Yes".to_string(), "No".to_string()]
|
||||
disabled=elements_disabled
|
||||
label="Do you require a student transcript?"
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<RadioList
|
||||
data_member="award_to"
|
||||
data_map=reactive_info.data
|
||||
items=vec!["School".to_string(), "Student".to_string()]
|
||||
disabled=elements_disabled
|
||||
label="Will the award be made to the school or the student?"
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<RadioList
|
||||
data_member="essay_required"
|
||||
data_map=reactive_info.data
|
||||
items=vec!["Yes".to_string(), "No".to_string()]
|
||||
disabled=elements_disabled
|
||||
label="Do you require a student essay?"
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<RadioList
|
||||
data_member="essay_prompt"
|
||||
data_map=reactive_info.data
|
||||
items=vec!["Test 1", "Test 2", "Test 3"].iter().map(|s| s.to_string()).collect()
|
||||
disabled=elements_disabled
|
||||
label="If so, select a prompt from the list below."
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<ChipsList
|
||||
label="Scholarship Requirements"
|
||||
data_member="requirements"
|
||||
data_map=reactive_info.data
|
||||
values=comparison_list
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|comp| comp.id)
|
||||
.collect()
|
||||
displayed_text=comparison_list
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|comp| comp.display_text)
|
||||
.collect()
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<ActionButton
|
||||
disabled=elements_disabled
|
||||
on:click=move |_| {
|
||||
let captured = reactive_info.capture();
|
||||
log!("Map values: {:?}", captured);
|
||||
submit_action.dispatch(CreateScholarshipInfo {
|
||||
info: captured
|
||||
});
|
||||
}
|
||||
>"Submit"</ActionButton>
|
||||
</Row>
|
||||
<Row>
|
||||
<p>{result_msg}</p>
|
||||
</Row>
|
||||
<Show
|
||||
when=move || scholarship_id.get().is_some()
|
||||
fallback=|| {
|
||||
view! { <p>"Choose a scholarship from the right, or create a new one."</p> }
|
||||
}
|
||||
>
|
||||
<Suspense fallback=Loading>
|
||||
{move || {
|
||||
scholarship_info
|
||||
.get()
|
||||
.map(|res_scholarship| {
|
||||
match res_scholarship {
|
||||
Ok(_) => {
|
||||
Either::Left(
|
||||
view! {
|
||||
<ValidatedForm
|
||||
on_submit=Callback::new(on_submit)
|
||||
title="Scholarship Info Form"
|
||||
description="Create or edit a scholarship. Input all information and click Submit."
|
||||
>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Scholarship Name"
|
||||
placeholder="Example Scholarship Name"
|
||||
disabled=elements_disabled
|
||||
data_member="name"
|
||||
data_map=form_data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Amount per award: (if multiple, enter the highest value)"
|
||||
placeholder="Enter an amount..."
|
||||
disabled=elements_disabled
|
||||
data_member="amount_per_award"
|
||||
data_map=form_data
|
||||
input_type=TextFieldType::Number
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Total number of awards:"
|
||||
placeholder="Enter an amount..."
|
||||
disabled=elements_disabled
|
||||
data_member="num_awards"
|
||||
data_map=form_data
|
||||
input_type=TextFieldType::Number
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Total amount of all award(s): (estimates are acceptable)"
|
||||
placeholder="Enter an amount..."
|
||||
disabled=elements_disabled
|
||||
data_member="total_awards"
|
||||
data_map=form_data
|
||||
input_type=TextFieldType::Number
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<RadioList
|
||||
data_member="fafsa_required"
|
||||
data_map=form_data
|
||||
items=vec!["Yes".to_string(), "No".to_string()]
|
||||
disabled=elements_disabled
|
||||
label="Do you require student FAFSA information?"
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<RadioList
|
||||
data_member="transcript_required"
|
||||
data_map=form_data
|
||||
items=vec!["Yes".to_string(), "No".to_string()]
|
||||
disabled=elements_disabled
|
||||
label="Do you require a student transcript?"
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<RadioList
|
||||
data_member="award_to"
|
||||
data_map=form_data
|
||||
items=vec!["University".to_string(), "Student".to_string()]
|
||||
disabled=elements_disabled
|
||||
label="Will the award be made to the school or the student?"
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<RadioList
|
||||
data_member="essay_prompt"
|
||||
data_map=form_data
|
||||
items=vec![
|
||||
"Write an essay describing your major/career direction. Include any abilities, interests, experiences, employment or clubs you have had that have affected your direction. If you are undecided, indicate why.",
|
||||
"Why is community service important? How has it personally affected you and your goals and what role do you hope to play in it in the future?",
|
||||
"How has participation in sports been beneficial to your personal, academic, and emotional growth?",
|
||||
]
|
||||
.iter()
|
||||
.map(|prompt| prompt.to_string())
|
||||
.collect()
|
||||
disabled=elements_disabled
|
||||
label="Select an essay prompt, or leave blank if none is required:"
|
||||
other_prompt="Custom prompt..."
|
||||
/>
|
||||
</Row>
|
||||
<Header
|
||||
title="Eligibility Requirements"
|
||||
description="Choose any requirements that this scholarship may have. If there are no extra requirements,
|
||||
leave all options blank, however please note that this will apply all students to your scholarship."
|
||||
/>
|
||||
<Row>
|
||||
<ChipsList
|
||||
label="GPA Requirements"
|
||||
data_member="gpa"
|
||||
data_map=chips_data
|
||||
values=gpa_ids
|
||||
displayed_text=gpa_text
|
||||
allows_multiple=false
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<ChipsList
|
||||
label="Sports and Activities"
|
||||
data_member="sports_participation"
|
||||
data_map=chips_data
|
||||
values=sports_ids
|
||||
displayed_text=sports_text
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<ChipsList
|
||||
label="Community Service"
|
||||
data_member="community_service"
|
||||
data_map=chips_data
|
||||
values=comm_ids
|
||||
displayed_text=comm_text
|
||||
allows_multiple=false
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<ChipsList
|
||||
label="Residency"
|
||||
data_member="residency"
|
||||
data_map=chips_data
|
||||
values=res_ids
|
||||
displayed_text=res_text
|
||||
allows_multiple=false
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<ChipsList
|
||||
label="Majors"
|
||||
data_member="major"
|
||||
data_map=chips_data
|
||||
values=major_ids
|
||||
displayed_text=major_text
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<ChipsList
|
||||
label="Additional Eligibility Factors"
|
||||
data_member="misc"
|
||||
data_map=chips_data
|
||||
values=misc_ids
|
||||
displayed_text=misc_text
|
||||
/>
|
||||
</Row>
|
||||
</ValidatedForm>
|
||||
},
|
||||
)
|
||||
}
|
||||
Err(err) => {
|
||||
Either::Right(
|
||||
view! {
|
||||
<p>"Error while getting scholarship: "{err.to_string()}</p>
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
}}
|
||||
</Suspense>
|
||||
</Show>
|
||||
</Panel>
|
||||
}
|
||||
}.into_any()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use crate::components::{Loading, OutlinedTextField, TextFieldType, ValidatedForm};
|
||||
use crate::pages::student::form_setup::use_student_form;
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn StudentAcademicsPage() -> impl IntoView {
|
||||
let controller = use_student_form("academics", true);
|
||||
|
||||
view! {
|
||||
<Suspense fallback=Loading>
|
||||
{move || {
|
||||
controller
|
||||
.data_resource
|
||||
.get()
|
||||
.map(|_| {
|
||||
view! {
|
||||
<div class="flex flex-1" />
|
||||
<div class="flex flex-col flex-2 mt-6">
|
||||
<ValidatedForm
|
||||
title="Academic Information"
|
||||
description="This information will allow you to be eligible for any scholarships that have specific academic/grade requirements."
|
||||
on_submit=controller.submit_action
|
||||
disabled=controller.submit_pending
|
||||
>
|
||||
<OutlinedTextField
|
||||
label="Unweighted GPA:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="unweighted_gpa"
|
||||
placeholder="4.0"
|
||||
required=true
|
||||
input_type=TextFieldType::Number
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Weighted GPA:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="weighted_gpa"
|
||||
placeholder="4.5"
|
||||
required=true
|
||||
input_type=TextFieldType::Number
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Highest SAT Score:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="sat_score"
|
||||
placeholder="1600"
|
||||
required=false
|
||||
input_type=TextFieldType::Number
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Highest ACT Score:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="act_score"
|
||||
placeholder="36"
|
||||
required=false
|
||||
input_type=TextFieldType::Number
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Academic Honors:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="academic_honors"
|
||||
placeholder="High Honors, etc."
|
||||
required=false
|
||||
input_type=TextFieldType::Text
|
||||
/>
|
||||
</ValidatedForm>
|
||||
</div>
|
||||
<div class="flex flex-1" />
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
}}
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use leptos::prelude::*;
|
||||
use crate::components::DashboardButton;
|
||||
|
||||
#[component]
|
||||
pub fn AdditionalPage() -> impl IntoView {
|
||||
view! {
|
||||
<div class="flex-1" />
|
||||
<div class="flex flex-2 flex-col gap-4 items-center m-4">
|
||||
<div class="font-bold text-3xl">"Student Additional Information Forms"</div>
|
||||
<div class="text-lg">
|
||||
"All forms listed under this tab are for additional eligibility factors that some scholarships require."
|
||||
</div>
|
||||
<div class="text-lg">
|
||||
"None of these forms are required, but they may increase the total number of scholarships that you are eligible to be applied to."
|
||||
</div>
|
||||
<DashboardButton
|
||||
title="Scholarship Book"
|
||||
description="View scholarship requirements"
|
||||
icon="/Book.png"
|
||||
path="https://docs.google.com/document/d/1qVs0Lwvwl1bVGHznocoev3AF56KlhTfs/view"
|
||||
/>
|
||||
<DashboardButton
|
||||
title="Academic Information"
|
||||
description="Grades, tests, and more"
|
||||
path="/students/additional/academics"
|
||||
/>
|
||||
<DashboardButton
|
||||
title="Athletics"
|
||||
description="Sports and outdoors"
|
||||
path="/students/additional/athletics"
|
||||
/>
|
||||
<DashboardButton
|
||||
title="Work Experience"
|
||||
description="Add job information"
|
||||
path="/students/additional/work-experience"
|
||||
/>
|
||||
<DashboardButton
|
||||
title="Extracurriculars"
|
||||
description="Clubs and activities"
|
||||
path="/students/additional/extracurriculars"
|
||||
/>
|
||||
<DashboardButton
|
||||
title="University Information"
|
||||
description="Where are you going to school?"
|
||||
path="/students/additional/university"
|
||||
/>
|
||||
<DashboardButton
|
||||
title="Family Information"
|
||||
description="General parent and sibling information"
|
||||
path="/students/additional/family-info"
|
||||
/>
|
||||
<DashboardButton
|
||||
title="Scholarship Specifics"
|
||||
description="Scholarship-specific questions"
|
||||
path="/students/additional/specifics"
|
||||
/>
|
||||
<DashboardButton
|
||||
title="Financials"
|
||||
description="FAFSA SAR Upload"
|
||||
path="/students/additional/financials"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use crate::components::{Loading, MultiEntry, ValidatedForm};
|
||||
use crate::input;
|
||||
use crate::pages::student::form_setup::use_student_form;
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn StudentAthleticsPage() -> impl IntoView {
|
||||
let controller = use_student_form("athletics", true);
|
||||
|
||||
view! {
|
||||
<Show when=move || controller.data_resource.get().is_some() fallback=Loading>
|
||||
<div class="flex flex-1" />
|
||||
<div class="flex flex-col flex-2 mt-6">
|
||||
<ValidatedForm
|
||||
title="Athletics Information"
|
||||
description="This information will allow you to be eligible for any scholarships that have specific sports requirements."
|
||||
on_submit=controller.submit_action
|
||||
disabled=controller.submit_pending
|
||||
>
|
||||
<MultiEntry
|
||||
label="Athletics Activities:"
|
||||
description="Leave blank if not applicable."
|
||||
data_map=controller.data_map
|
||||
data_member="sports_participation"
|
||||
schema=vec![
|
||||
input!(
|
||||
Select, "sport_name", "Sport Name:", true, [
|
||||
"Football",
|
||||
"Soccer",
|
||||
"Cheerleading",
|
||||
"Field Hockey",
|
||||
"Swimming",
|
||||
"Golf",
|
||||
"Basketball",
|
||||
"Track",
|
||||
"Gymnastics",
|
||||
"Ice Hockey",
|
||||
"Ski",
|
||||
"Wrestling",
|
||||
"Lacrosse",
|
||||
"Softball",
|
||||
"Tennis",
|
||||
"Baseball",
|
||||
"Volleyball"
|
||||
]
|
||||
),
|
||||
input!(
|
||||
Checkbox, "grades", "Grades Participated:", true, [
|
||||
"9th",
|
||||
"10th",
|
||||
"11th",
|
||||
"12th"
|
||||
]
|
||||
),
|
||||
input!(
|
||||
Text, "achievements", "Special Achievements:", false, "Example 1, Example 2, etc..."
|
||||
),
|
||||
]
|
||||
/>
|
||||
</ValidatedForm>
|
||||
</div>
|
||||
<div class="flex flex-1" />
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use crate::components::{Loading, OutlinedTextField, Select, TextFieldType, ValidatedForm};
|
||||
use crate::pages::student::form_setup::use_student_form;
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn StudentDemographicsPage() -> impl IntoView {
|
||||
// This page should show a form that the students need to fill out.
|
||||
// It's a simple form, really nothing more than a contact form.
|
||||
// I'm going to test out creating this form without using a panel - we'll just use the page as
|
||||
// it is with a specially spaced invisible div instead of a shaded container.
|
||||
|
||||
let controller = use_student_form("demographics", true);
|
||||
|
||||
view! {
|
||||
<Show when=move || controller.data_resource.get().is_some() fallback=Loading>
|
||||
<div class="flex flex-1" />
|
||||
<div class="flex flex-col flex-2 mt-6">
|
||||
<ValidatedForm
|
||||
title="Student Demographic Form"
|
||||
description="Here you'll set up some basic information about yourself."
|
||||
on_submit=controller.submit_action
|
||||
disabled=controller.submit_pending
|
||||
>
|
||||
<OutlinedTextField
|
||||
label="First Name:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="first_name"
|
||||
placeholder="John"
|
||||
input_type=TextFieldType::Text
|
||||
required=true
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Last Name:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="last_name"
|
||||
placeholder="Smith"
|
||||
input_type=TextFieldType::Text
|
||||
required=true
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Date of Birth:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="dob"
|
||||
placeholder="MM/DD/YYYY"
|
||||
input_type=TextFieldType::Text
|
||||
required=true
|
||||
/>
|
||||
<Select
|
||||
label="Gender:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="gender"
|
||||
required=true
|
||||
value_list=vec![
|
||||
"Male".to_string(),
|
||||
"Female".to_string(),
|
||||
"Prefer not to answer/Other".to_string(),
|
||||
]
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Student ID Number:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="id_number"
|
||||
placeholder="Your 7 digit number..."
|
||||
input_type=TextFieldType::Number
|
||||
required=true
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Preferred Email:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="email"
|
||||
placeholder="me@example.com"
|
||||
input_type=TextFieldType::Email(vec!["*".to_string()])
|
||||
required=true
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Preferred Phone Number:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="phone_number"
|
||||
placeholder="000-000-0000"
|
||||
input_type=TextFieldType::Text
|
||||
required=true
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Street Address:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="street_address"
|
||||
placeholder="234 Judd Rd"
|
||||
input_type=TextFieldType::Text
|
||||
required=true
|
||||
/>
|
||||
<Select
|
||||
label="Town:"
|
||||
disabled=controller.submit_pending
|
||||
value_list=vec!["Southbury".to_string(), "Middlebury".to_string()]
|
||||
data_map=controller.data_map
|
||||
data_member="town"
|
||||
required=true
|
||||
/>
|
||||
</ValidatedForm>
|
||||
</div>
|
||||
<div class="flex flex-1" />
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
use crate::common::{ComparisonData, ExpandableInfo, ValueType};
|
||||
use crate::components::{FileDrop, Header, Loading};
|
||||
use crate::pages::api::files::list_files;
|
||||
use crate::pages::api::students::get_all_student_data;
|
||||
use crate::pages::api::{get_all_scholarship_info, get_comparison_info};
|
||||
use crate::utils::get_user_claims;
|
||||
use leptos::logging::{debug_error, debug_log};
|
||||
use leptos::prelude::*;
|
||||
use leptos_oidc::AuthSignal;
|
||||
|
||||
#[component]
|
||||
pub fn StudentEligibilityPage() -> impl IntoView {
|
||||
// This page will show all scholarships that the student is eligible for.
|
||||
// The scholarships are obviously pulled using a readonly API, and we'll also need to resolve
|
||||
// all of the relation IDs that are stored inside of it.
|
||||
// Then, for each scholarship found, we'll check every relation and filter out scholarships that
|
||||
// do not pass the check. We'll show some sort of simple list to indicate what scholarships passed
|
||||
// the check, and another to indicate which ones didn't.
|
||||
|
||||
// My only reservation with showing the scholarships that aren't valid is that people will want
|
||||
// to know why. That's hard information to keep track of right now.
|
||||
|
||||
let auth = expect_context::<AuthSignal>();
|
||||
let user_claims = get_user_claims();
|
||||
let user_id = Memo::new(move |_| user_claims.get().map(|info| info.claims.subject.clone()));
|
||||
let access_token = Memo::new(move |_| auth.get().authenticated().map(|a| a.access_token()));
|
||||
|
||||
let form_id = StoredValue::new("scholarship_essays".to_string());
|
||||
|
||||
let refresh_trigger = Trigger::new();
|
||||
|
||||
let scholarships_resource = Resource::new(
|
||||
move || refresh_trigger.track(),
|
||||
async move |_| get_all_scholarship_info().await,
|
||||
);
|
||||
let relations_resource = Resource::new(
|
||||
move || refresh_trigger.track(),
|
||||
async move |_| get_comparison_info().await,
|
||||
);
|
||||
let student_resource = Resource::new(
|
||||
move || (user_id.get().unwrap_or_default(), refresh_trigger.track()),
|
||||
async move |(user_id, _)| get_all_student_data(user_id).await,
|
||||
);
|
||||
|
||||
view! {
|
||||
<div class="flex-1" />
|
||||
<div class="flex-2 mb-4 flex flex-col">
|
||||
<Suspense fallback=Loading>
|
||||
{move || {
|
||||
let Some(Ok(scholarships_list)) = scholarships_resource.get() else {
|
||||
return view! {}.into_any();
|
||||
};
|
||||
let Some(Ok(relations_list)) = relations_resource.get() else {
|
||||
return view! {}.into_any();
|
||||
};
|
||||
let Some(Ok(student_info)) = student_resource.get() else {
|
||||
return view! {}.into_any();
|
||||
};
|
||||
let valid_list = scholarships_list
|
||||
.iter()
|
||||
.filter_map(|scholarship| {
|
||||
let scholarship_name = scholarship
|
||||
.data
|
||||
.get("name")
|
||||
.unwrap_or(&ValueType::String(None))
|
||||
.as_string()
|
||||
.ok()?
|
||||
.unwrap_or_default();
|
||||
debug_log!("Checking scholarship: {}", scholarship_name);
|
||||
let requirements_map = scholarship
|
||||
.data
|
||||
.get("requirements")
|
||||
.unwrap_or(&ValueType::Map(None))
|
||||
.as_map()
|
||||
.ok()?
|
||||
.unwrap_or_default()
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<ValueType>>();
|
||||
let resolved_requirements = requirements_map
|
||||
.iter()
|
||||
.map(|list_val| {
|
||||
let list = list_val
|
||||
.as_list()
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
list.iter()
|
||||
.filter_map(|v| {
|
||||
let id_string = v
|
||||
.as_string()
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
relations_list
|
||||
.iter()
|
||||
.find(|relation| relation.id == id_string)
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<ComparisonData>>()
|
||||
})
|
||||
.collect::<Vec<Vec<ComparisonData>>>();
|
||||
debug_log!(
|
||||
"Resolved requirements: {:?}", resolved_requirements.iter().flatten().map(|rel| rel.display_text.clone()).collect::<Vec<String>>()
|
||||
);
|
||||
let valid = !scholarship_name.is_empty()
|
||||
&& resolved_requirements
|
||||
.iter()
|
||||
.all(|list| {
|
||||
debug_log!("Checking list: {:?}", list);
|
||||
if list.is_empty() {
|
||||
true
|
||||
} else {
|
||||
list.iter()
|
||||
.any(|requirement| {
|
||||
let result = requirement.compare(&student_info);
|
||||
match result.as_ref() {
|
||||
Ok(status) => {
|
||||
debug_log!(
|
||||
"Requirement with id {} is Ok({})", requirement.id, status
|
||||
);
|
||||
status.clone()
|
||||
}
|
||||
Err(e) => {
|
||||
debug_error!(
|
||||
"Requirement with id {} failed: {}", requirement.id, e
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
if valid {
|
||||
debug_log!("Scholarship is valid.");
|
||||
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 {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<
|
||||
Vec<(ExpandableInfo, Resource<Result<Vec<String>, ServerFnError>>)>,
|
||||
>();
|
||||
|
||||
view! {
|
||||
<Header
|
||||
title="Eligible Scholarships"
|
||||
description="This is the list of scholarships that you are eligible for, as well as those that require essays.
|
||||
Once you have fulfilled the essay requirement, you will be automatically applied for that scholarship.
|
||||
If there is no essay requirement, you have already been applied."
|
||||
/>
|
||||
<div>
|
||||
<For
|
||||
each=move || valid_list.clone()
|
||||
key=|(scholarship, _)| { scholarship.subject.clone() }
|
||||
children=move |(scholarship, resource)| {
|
||||
let scholarship_id = StoredValue::new(
|
||||
scholarship.subject.clone(),
|
||||
);
|
||||
let scholarship_name = StoredValue::new(
|
||||
scholarship
|
||||
.data
|
||||
.get("name")
|
||||
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
let scholarship_essay = StoredValue::new(
|
||||
scholarship
|
||||
.data
|
||||
.get("essay_prompt")
|
||||
.map(|v| v.as_string().ok().flatten().unwrap_or_default())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
|
||||
view! {
|
||||
<div class="m-1.5 flex flex-col gap-2 shadow-lg rounded-lg">
|
||||
<div class="rounded-t-lg bg-red-900 p-2 text-white font-bold">
|
||||
{scholarship_name.get_value()}
|
||||
</div>
|
||||
|
||||
<div class="p-2 flex flex-col gap-2">
|
||||
<Show
|
||||
when=move || !scholarship_essay.get_value().is_empty()
|
||||
fallback=move || {
|
||||
view! {
|
||||
<div>"This scholarship does not require an essay."</div>
|
||||
}
|
||||
}
|
||||
>
|
||||
<div>{scholarship_essay.get_value()}</div>
|
||||
<FileDrop
|
||||
form_id=form_id.get_value()
|
||||
name=scholarship_id.get_value()
|
||||
existing_files=resource
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
}}
|
||||
</Suspense>
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use leptos::prelude::*;
|
||||
use crate::components::{Loading, MultiEntry, OutlinedTextField, TextFieldType, ValidatedForm};
|
||||
use crate::input;
|
||||
use crate::pages::student::form_setup::use_student_form;
|
||||
|
||||
#[component]
|
||||
pub fn StudentExtracurricularsPage() -> impl IntoView {
|
||||
let controller = use_student_form("demographics", true);
|
||||
|
||||
view! {
|
||||
<Show when=move || controller.data_resource.get().is_some() fallback=Loading>
|
||||
<div class="flex flex-1" />
|
||||
<div class="flex flex-col flex-2 mt-6">
|
||||
<ValidatedForm
|
||||
title="Extracurricular and Community Service Information"
|
||||
description="This information will allow you to be eligible for any scholarships that have specific extracurricular requirements."
|
||||
on_submit=controller.submit_action
|
||||
disabled=controller.submit_pending
|
||||
>
|
||||
// Inputs here.
|
||||
<OutlinedTextField
|
||||
label="Total number of service hours:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="service_hours"
|
||||
placeholder="Any number..."
|
||||
input_type=TextFieldType::Number
|
||||
required=true
|
||||
/>
|
||||
<MultiEntry
|
||||
label="Extracurricular Activities:"
|
||||
description="Leave blank if not applicable."
|
||||
data_map=controller.data_map
|
||||
data_member="extracurricular"
|
||||
schema=vec![
|
||||
input!(
|
||||
Text, "activity_name", "Activity Name:", true, "Some activity..."
|
||||
),
|
||||
input!(Number, "num_hours", "Number of hours completed:", true, "40"),
|
||||
input!(Number, "num_weeks", "Number of weeks participated:", true, "3"),
|
||||
input!(
|
||||
Text, "special_involvement", "Any special involvement:", false, "Club president, etc."
|
||||
),
|
||||
input!(
|
||||
Checkbox, "grades", "Grades Participated:", true, ["9th", "10th", "11th", "12th"]
|
||||
),
|
||||
]
|
||||
/>
|
||||
</ValidatedForm>
|
||||
</div>
|
||||
<div class="flex flex-1" />
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
use crate::components::{Loading, OutlinedTextField, TextFieldType, ValidatedForm};
|
||||
use crate::pages::student::form_setup::use_student_form;
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn StudentFamilyPage() -> impl IntoView {
|
||||
let controller = use_student_form("family", true);
|
||||
|
||||
view! {
|
||||
<Show when=move || controller.data_resource.get().is_some() fallback=Loading>
|
||||
<div class="flex flex-1" />
|
||||
<div class="flex flex-col flex-2 mt-6">
|
||||
<ValidatedForm
|
||||
title="Family Information"
|
||||
description="General family information. You must fill out information about at least one parent."
|
||||
on_submit=controller.submit_action
|
||||
disabled=controller.submit_pending
|
||||
>
|
||||
// Inputs here.
|
||||
<OutlinedTextField
|
||||
label="Total number of children in family:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="num_children"
|
||||
placeholder="Any number..."
|
||||
input_type=TextFieldType::Number
|
||||
required=true
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Total number of children currently attending college:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="num_children_college"
|
||||
placeholder="Any number..."
|
||||
input_type=TextFieldType::Number
|
||||
required=true
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Parent/Guardian 1 Name:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="parent_one_name"
|
||||
placeholder="John Smith"
|
||||
input_type=TextFieldType::Text
|
||||
required=true
|
||||
/>
|
||||
// TODO Could this be a select dropdown?
|
||||
<OutlinedTextField
|
||||
label="Parent/Guardian 1 Relationship:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="parent_one_relationship"
|
||||
placeholder="Mother/Father"
|
||||
input_type=TextFieldType::Text
|
||||
required=true
|
||||
/>
|
||||
// TODO Get a better example
|
||||
<OutlinedTextField
|
||||
label="Parent/Guardian 1 Occupation:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="parent_one_occupation"
|
||||
placeholder="Milkman"
|
||||
input_type=TextFieldType::Text
|
||||
required=true
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Parent/Guardian 1 Employer:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="parent_one_employer"
|
||||
placeholder="Example Employer"
|
||||
input_type=TextFieldType::Text
|
||||
required=true
|
||||
/>
|
||||
|
||||
<OutlinedTextField
|
||||
label="Parent/Guardian 2 Name:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="parent_two_name"
|
||||
placeholder="John Smith"
|
||||
input_type=TextFieldType::Text
|
||||
required=false
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Parent/Guardian 2 Relationship:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="parent_two_relationship"
|
||||
placeholder="Mother/Father"
|
||||
input_type=TextFieldType::Text
|
||||
required=false
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Parent/Guardian 2 Occupation:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="parent_two_occupation"
|
||||
placeholder="Milkman"
|
||||
input_type=TextFieldType::Text
|
||||
required=false
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Parent/Guardian 2 Employer:"
|
||||
disabled=controller.submit_pending
|
||||
data_map=controller.data_map
|
||||
data_member="parent_two_employer"
|
||||
placeholder="Example Employer"
|
||||
input_type=TextFieldType::Text
|
||||
required=false
|
||||
/>
|
||||
</ValidatedForm>
|
||||
</div>
|
||||
<div class="flex flex-1" />
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use crate::common::ValueType;
|
||||
use crate::components::{Toast, ToastContext};
|
||||
use crate::pages::api::students::{PutStudentData, get_student_data};
|
||||
use crate::utils::get_user_claims;
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct StudentFormInfo {
|
||||
pub data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
pub user_id: Memo<Option<String>>,
|
||||
pub submit_action: Callback<()>,
|
||||
pub refresh_trigger: Trigger,
|
||||
pub data_resource: Resource<Result<HashMap<String, ValueType>, ServerFnError>>,
|
||||
pub submit_pending: Memo<bool>,
|
||||
}
|
||||
|
||||
pub fn use_student_form(
|
||||
form_type: impl Into<Signal<String>>,
|
||||
enable_toasts: bool,
|
||||
) -> StudentFormInfo {
|
||||
let form_type = form_type.into();
|
||||
|
||||
let user_claims = get_user_claims();
|
||||
let user_id = Memo::new(move |_| user_claims.get().map(|info| info.claims.subject.clone()));
|
||||
|
||||
let data_map = RwSignal::new(HashMap::new());
|
||||
|
||||
let refresh_trigger = Trigger::new();
|
||||
let data_resource = Resource::new(
|
||||
move || (user_id.get(), form_type.get(), refresh_trigger.track()),
|
||||
async move |(id, form_type, _)| {
|
||||
let Some(id) = id else {
|
||||
return Ok(HashMap::new());
|
||||
};
|
||||
get_student_data(id, form_type).await
|
||||
},
|
||||
);
|
||||
|
||||
Effect::new(move || {
|
||||
data_resource.with(|map_opt| {
|
||||
let Some(Ok(map)) = map_opt else {
|
||||
return;
|
||||
};
|
||||
|
||||
data_map.set(map.to_owned());
|
||||
});
|
||||
});
|
||||
|
||||
let submit_action = ServerAction::<PutStudentData>::new();
|
||||
let on_submit = move || {
|
||||
if let Some(id) = user_id.get() {
|
||||
submit_action.dispatch(PutStudentData {
|
||||
subject: id,
|
||||
data_type: form_type.get(),
|
||||
data_map: data_map.get(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if enable_toasts {
|
||||
let mut toasts = expect_context::<ToastContext>();
|
||||
Effect::watch(
|
||||
move || submit_action.value().get(),
|
||||
move |value, _, _| {
|
||||
let Some(result) = value else {
|
||||
return;
|
||||
};
|
||||
|
||||
let toast = match result {
|
||||
Ok(_) => Toast::new()
|
||||
.id(Uuid::new_v4())
|
||||
.header("Submission Successful")
|
||||
.msg("You may continue editing or fill out another form."),
|
||||
Err(e) => Toast::new()
|
||||
.id(Uuid::new_v4())
|
||||
.header("Submission Failed")
|
||||
.msg(e.to_string()),
|
||||
};
|
||||
|
||||
toasts.toast(toast);
|
||||
untrack(move || submit_action.clear());
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
StudentFormInfo {
|
||||
data_map,
|
||||
refresh_trigger,
|
||||
data_resource,
|
||||
user_id,
|
||||
submit_action: on_submit.into(),
|
||||
submit_pending: submit_action.pending(),
|
||||
}
|
||||
}
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user