Compare commits

...
113 Commits
Author SHA1 Message Date
Lucas Vas 032e978a34 Merge pull request #56 from R15-Software-Dev/feat/table-changes
Final provider changes
2026-01-19 14:44:27 -05:00
alphafetus 11012bb567 feat: add applicants page fallback 2026-01-19 13:14:09 -05:00
alphafetus 354875186f chore: remove unused imports 2026-01-19 13:04:50 -05:00
alphafetus c0d3288e28 chore: simplify test page 2026-01-19 13:04:28 -05:00
alphafetus e508e0a916 chore: change to production tables
This should be handled by environment variables in the future.
2026-01-19 12:44:51 -05:00
Lucas Vas 444e5ffb72 Merge pull request #55 from R15-Software-Dev/ci/remove-debug-info
Release preparation for forms
2026-01-16 23:18:48 -05:00
Lucas Vas 67586d0424 Merge pull request #53 from R15-Software-Dev/feat/back-button
Create back button
2026-01-16 21:40:51 -05:00
alphafetus cc83d3f015 chore: additional styling changes 2026-01-16 21:35:45 -05:00
alphafetus bab6cdae28 chore: small reorganization to help error appearances 2026-01-16 21:27:49 -05:00
alphafetus 116d02029c chore: sort comparisons by alpha order 2026-01-16 21:24:34 -05:00
alphafetus b6bc412f56 chore: small wording change 2026-01-16 21:14:51 -05:00
alphafetus 84f27e3c9e chore: restyle loading component 2026-01-16 21:13:45 -05:00
alphafetus 0b2dc1b0c1 chore: various style changes throughout the app 2026-01-16 21:06:44 -05:00
alphafetus 081d4ca5ca chore: remove unused routes 2026-01-16 20:40:37 -05:00
alphafetus b17c462abb chore: remove debug information from form pages 2026-01-16 20:38:10 -05:00
alphafetus 0c00c59162 feat: implement back button for banner
This button is intended to bring the user all the way back to the specified dashboard. Another could be created to exit the form, but I don't think that's needed for now.
2026-01-16 20:27:01 -05:00
alphafetus ce05be8c02 Merge branch 'main' into feat/back-button 2026-01-16 10:07:44 -05:00
alphafetus 95b69a36a8 fix: remove infinite loop in submit_action effects 2026-01-16 10:07:19 -05:00
alphafetus e24dcd0a90 style: rustfmt run 2026-01-16 09:19:07 -05:00
Lucas Vas 9ebab0689a Merge pull request #50 from R15-Software-Dev/feat/date-component
Add date list component
2026-01-16 09:02:18 -05:00
alphafetus 84212d2360 fix: apply into_any on pages that will not compile
This is a production-only build failure due to type explosion.
2026-01-16 08:54:41 -05:00
alphafetus 0b4aae4db3 Merge branch 'main' into feat/date-component
# Conflicts:
#	src/components/lists/single/radio.rs
#	src/pages/home_page.rs
2026-01-16 08:27:27 -05:00
alphafetus c3dbe3e2ac style: remove annoying indent 2026-01-16 08:20:31 -05:00
alphafetus 87aa104055 feat: make banner text larger 2026-01-16 08:20:05 -05:00
alphafetus 6105fa3bd5 feat: add dates list to provider portal 2026-01-16 08:19:52 -05:00
alphafetus bde15de634 chore: remove testing dates list 2026-01-16 08:19:18 -05:00
alphafetus 7d7c96e642 feat: sort dates list before displaying the list 2026-01-15 13:57:59 -05:00
alphafetus f15d151694 feat: create date api and dynamo context
The DynamoClient context should be used across the entire application - there are some significant performance improvements when under real load.
2026-01-15 13:57:12 -05:00
alphafetus 5ea7461a4d chore: add new appearance for closed ranges 2026-01-15 12:13:24 -05:00
alphafetus 67d0f766f8 feat: implement automatic date status determination 2026-01-15 12:09:48 -05:00
alphafetus 09de9fcf91 feat: implement provider and student specific login contexts 2026-01-15 09:48:21 -05:00
alphafetus d42573bf65 feat: implement auth callback page
This page will allow users to redirect to their previous path when they sign in.
2026-01-15 08:37:43 -05:00
alphafetus 4e8f4a3c5e style: automatic rustfmt run 2026-01-15 07:30:32 -05:00
alphafetus 9eacc9a618 temp: pc change (add server function and date comparison) 2026-01-15 00:54:55 -05:00
alphafetus 6fdf7f0113 feat: add improvements to the date component 2026-01-14 12:56:32 -05:00
alphafetus e143149dac merge: add missing import 2026-01-14 11:38:53 -05:00
alphafetus 26ee1cf7b4 Merge branch 'main' into feat/date-component
# Conflicts:
#	src/app.rs
#	src/common/mod.rs
#	src/components/mod.rs
#	src/pages/api/scholarships.rs
#	src/pages/home_page.rs
#	src/pages/mod.rs
#	src/pages/scholarship_info.rs
2026-01-14 11:24:27 -05:00
Lucas Vas 73392d0f42 Merge pull request #49 from R15-Software-Dev/fix/toast-display-error
Show error toasts on failed submissions
2026-01-14 11:05:08 -05:00
Lucas Vas 9f208fb297 Merge pull request #47 from R15-Software-Dev/feat/radio-other
Add "Other" option to radio buttons
2026-01-14 09:43:29 -05:00
alphafetus ed0a9a0fff fix: show error toasts on failed submissions 2026-01-14 09:42:02 -05:00
Lucas Vas c4d5c061a7 Merge pull request #46 from R15-Software-Dev/feat/email-options
Create new email validation system
2026-01-14 09:32:07 -05:00
alphafetus f3e2a0cb83 feat: implement new email validation system 2026-01-14 08:42:07 -05:00
alphafetus 7cf2d132ae feat: add essay prompts to scholarship info form 2026-01-14 08:12:32 -05:00
alphafetus 6a9ce30834 fix: small hydration error, don't select other by default 2026-01-14 08:12:12 -05:00
alphafetus b19b696490 feat: implement other field for radio buttons 2026-01-14 07:47:08 -05:00
Lucas Vas 1d58e99878 Merge pull request #45 from R15-Software-Dev/feature/categorize-chips
Add major comparisons
2026-01-13 22:23:57 -05:00
alphafetus 8af64cbf35 feat: create static global constants for table names 2026-01-13 16:13:46 -05:00
alphafetus 5f6e0edab2 fix: create cleanup handlers for lists 2026-01-13 16:01:17 -05:00
alphafetus 22d2c96520 feat: add major comparisons to scholarship info form 2026-01-13 15:52:38 -05:00
alphafetus c1f0c0cb81 fix: add hydration effect to selectable lists 2026-01-13 15:52:15 -05:00
Lucas Vas df4e55369b Merge pull request #44 from R15-Software-Dev/feature/categorize-chips
A massive, unorganized PR.
2026-01-13 04:50:27 -05:00
alphafetus c693ed9190 feat: implement nested routes for loaner form
We're using this as our testing ground before attempting to refactor all other pages as well.
2026-01-13 04:39:13 -05:00
alphafetus 8147ec3e00 feat: make into_attr_map public 2026-01-13 00:09:04 -05:00
alphafetus 14d89fc696 feat: create new header component
This component is now required for forms.
2026-01-12 16:14:24 -05:00
alphafetus 5d72a8f949 fix: clear submit action value on toast 2026-01-12 15:56:26 -05:00
alphafetus de7b8633ff feat: make toasts available on all routes 2026-01-12 15:52:17 -05:00
alphafetus 865410b6f2 style: apply rustfmt 2026-01-12 14:55:32 -05:00
alphafetus 0742e833da feat: finalize toast implementation 2026-01-12 14:43:05 -05:00
alphafetus a31c44b794 feat: add leptos_animate crate 2026-01-11 20:49:27 -05:00
alphafetus f479fb8030 feat: some comparison naming and category changes 2026-01-09 10:35:12 -05:00
alphafetus 0401027e7d feat: implement new provider contact form 2026-01-09 01:08:50 -05:00
alphafetus 97ad9ceee7 feat: implement server-side conversions
The ValueType enum that we've created can now be converted directly into an AWS DynamoDB AttributeValue type, meaning that storage on the server does not require the application to store serialized maps of data.
Benefits of this: queries using secondary indexes, simpler filtering, less storage required per database item, faster parsing.
We can also start to move away from the ExpandableInfo struct, as we really just use the HashMap inside it.
2026-01-09 00:56:23 -05:00
alphafetus 247d0dfa73 chore: pc change 1/8/26 2026-01-08 08:40:31 -05:00
alphafetus af1250350a feat: add post confirmation lambda hook 2026-01-07 23:15:22 -05:00
alphafetus 8d9faf126d feat: reorganize and add single selection to chip lists 2026-01-07 00:22:46 -05:00
alphafetus 105e6b3e55 feat: touch up comparisons 2026-01-06 14:48:20 -05:00
Lucas Vas b2a361a02f Merge pull request #41 from R15-Software-Dev/feat/error-handling
Implement error handling
2026-01-06 14:06:06 -05:00
alphafetus 6891a192c1 fix: type erase some components for reduced compile-time recursion 2026-01-05 18:10:31 -05:00
alphafetus a2f69a83c2 feat: optimize imports to remove warnings 2026-01-05 15:56:28 -05:00
alphafetus 9c7b7f1d4f feat: set and display select default value properly 2026-01-05 15:32:50 -05:00
alphafetus cb888d2bd6 feat: make inputs required in loaner page 2026-01-05 15:22:53 -05:00
alphafetus e0b54b96f9 feat: prevent short-circuit evaluation for validation 2026-01-05 15:20:04 -05:00
alphafetus f08fee1246 refactor: move all lists into their own module 2026-01-04 23:18:02 -05:00
alphafetus d9951000e2 feat: use checkbox logic for chips component 2026-01-04 22:56:29 -05:00
alphafetus bad5e994b6 feat: implement validation for radio list component 2026-01-03 16:00:37 -05:00
alphafetus b0b6f97b11 feat: implement validation for select component 2026-01-02 17:53:28 -05:00
alphafetus 10d001fd6b docs: fully document checkbox list 2026-01-02 16:45:07 -05:00
meraghon f401e994a4 Merge branch 'main' into feat/date-component
# Conflicts:
#	src/app.rs
#	src/common/mod.rs
2026-01-02 15:35:09 -05:00
meraghon bb9eb465f4 Display date status 2026-01-02 15:30:40 -05:00
alphafetus f863b4db18 feat: add validation in checkbox list 2026-01-01 04:02:36 -05:00
alphafetus 94c2930d2a feat: signal refactor for loaner page 2025-12-29 19:08:28 -05:00
alphafetus 073819c6c4 style: reorder some imports 2025-12-29 18:31:35 -05:00
alphafetus 85ec857e38 feat: sweeping improvements to home_page.rs
Completely refactors the ownership of all data such that server resources are loaded into a signal asynchronously, then displayed in the form properly. All button handlers are also now separately defined functions, instead of being inline.
2025-12-29 18:26:26 -05:00
alphafetus 74e7111820 feat: implement new ValidatedForm and error handling for text fields
Note that text fields only accept Region 15 email addresses right now. We should add extra domains in the future.
2025-12-29 18:07:13 -05:00
Lucas Vas 139ffdbe55 Merge pull request #33 from R15-Software-Dev/feature/scholarship-edit-form
Implement provider scholarship edit form
2025-12-22 12:49:27 -05:00
meraghon 53fd3a56c4 add example usages for Single and Range 2025-12-22 11:40:52 -05:00
meraghon 24c20f4199 deleted unused file 2025-12-22 10:37:05 -05:00
meraghon 7cf2facb18 function renders status of each state 2025-12-22 10:36:56 -05:00
meraghon 03f7adb74d Created DateStatus enum 2025-12-22 10:36:07 -05:00
alphafetus 8d90145361 fix: redirect user when deleting scholarship 2025-12-22 08:27:17 -05:00
meraghon 9e7fa3afcc Merge branch 'main' into feat/date-component
# Conflicts:
#	src/app.rs
#	src/common/mod.rs
2025-12-22 08:05:00 -05:00
alphafetus 1d41d1cbf5 Merge branch 'main' into feature/scholarship-edit-form
# Conflicts:
#	src/app.rs
#	src/common/mod.rs
#	src/pages/api/scholarships.rs
#	src/pages/comparison_test.rs
#	src/pages/home_page.rs
#	src/pages/mod.rs
#	src/pages/scholarship_info.rs
2025-12-22 07:50:04 -05:00
alphafetus 2324c493d4 docs: add comments to scholarship edit form 2025-12-21 22:17:40 -05:00
alphafetus c2fdd30eea feat: refresh scholarship list on form submission 2025-12-21 22:07:22 -05:00
alphafetus 05d8e1527e feat: allow users to confirm deletion of scholarships 2025-12-21 13:27:07 -05:00
alphafetus f74e5ca093 feat: improve error handling and add documentation 2025-12-19 12:48:22 -05:00
alphafetus ddfac3dc2e feat: redirect from loaner check in to main page 2025-12-19 12:26:02 -05:00
alphafetus bab47ac4af feat: improve getting server resources 2025-12-19 12:20:05 -05:00
alphafetus b687125d9b feat: add edit, delete, and fallback capability to providers/scholarships 2025-12-19 11:47:43 -05:00
meraghon 5e9e9bcf58 Test date range enum 2025-12-19 10:28:19 -05:00
meraghon fab172c4d1 Implement function for date range enum in date.rs 2025-12-19 10:28:07 -05:00
meraghon 38a0482b8c Date range enum 2025-12-19 10:27:43 -05:00
alphafetus a0049d4797 feat: create scholarship list skeleton
Next up is linking the scholarship IDs that are contained in the link parameters to the form that's displayed upon clicking "Edit".
2025-12-18 14:29:27 -05:00
meraghon ae320f9763 Merge branch 'main' into feat/date-component
# Conflicts:
#	src/common/mod.rs
#	src/components/mod.rs
2025-12-12 13:16:49 -05:00
meraghon d43a6ed153 Test date element with hard-coded struct info 2025-12-12 12:13:55 -05:00
meraghon e3ea13d07d Implement date element with struct 2025-12-12 12:13:41 -05:00
meraghon 7abb1c369f Create DateInfo struct 2025-12-12 12:13:25 -05:00
meraghon 5ad00e69af Create struct file 2025-12-09 11:51:28 -05:00
meraghon bbeb713fd6 Pictures need to start with "/" 2025-12-05 12:40:12 -05:00
meraghon 4115d24bd8 Added status to date 2025-12-05 12:39:41 -05:00
meraghon 3b1bf88567 Test in frontend 2025-12-05 11:59:39 -05:00
meraghon d5659f6da7 Added to mod.rs 2025-12-05 11:59:30 -05:00
meraghon 7645ecc240 Framework for date component design 2025-12-05 11:59:22 -05:00
69 changed files with 4987 additions and 2416 deletions

No files matched your search

+10 -1
View File
@@ -1,2 +1,11 @@
[target.'cfg(target_os = "linux")'] [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
+832 -755
View File
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -14,7 +14,7 @@ console_error_panic_hook = { version = "0.1", optional = true}
leptos_axum = { version = "0.8", optional = true } leptos_axum = { version = "0.8", optional = true }
leptos_meta = { version = "0.8" } leptos_meta = { version = "0.8" }
tokio = { version = "1", features = ["rt-multi-thread"], optional = true } tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
wasm-bindgen = { version = "=0.2.100", optional = true } wasm-bindgen = { version = "=0.2.106", optional = true }
aws-sdk-dynamodb = { version = "1", optional = true } aws-sdk-dynamodb = { version = "1", optional = true }
aws-config = { version = "1.6.2", optional = true } aws-config = { version = "1.6.2", optional = true }
serde = "1.0.219" serde = "1.0.219"
@@ -23,11 +23,15 @@ uuid = { version = "1.17.0", features = ["serde", "v4", "js"] }
leptos_oidc = "0.9.0" leptos_oidc = "0.9.0"
base64 = "0.22.1" base64 = "0.22.1"
chrono = "0.4.42" chrono = "0.4.42"
leptos_animate = "0.1.9"
macros = { path = "./macros" } macros = { path = "./macros" }
traits = { path = "./traits" } traits = { path = "./traits" }
icondata = { version = "0.7.0", features = ["font-awesome"] }
leptos_icons = "0.7.1"
[features] [features]
default = ["ssr"]
hydrate = [ hydrate = [
"leptos/hydrate", "leptos/hydrate",
"dep:console_error_panic_hook", "dep:console_error_panic_hook",
+76 -126
View File
@@ -1,136 +1,86 @@
Forms should be built for this application with a series of JSON-type files. To track errors and force form input validation, we're going to create a custom `ValidatedForm` component.
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.
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>>`)
``` 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.
"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",
]
}
]
]
}
```
...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.
``` I think they should be shown in these situations:
struct FormDefinitionDoc { - Text inputs: on blur - we don't want to scream at users as they type.
Vec<Section> section_defs; - Selectable inputs: on change and form submit - only show the message if the value is invalid, and not at mount.
// and any extra values that should be applied
// across the entire form, like settings. 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 { // We'll store this in a Vec<RwSignal<InputState>>
Vec<QuestionComponent> questions; struct InputState {
// each section may have a series of settings including how to submit, /// Determines if there is an error and provides an error message.
// when to submit, and where to submit to at the very least. error: Signal<ValidationState>,
/// Determines if the form has requested to show error messages.
show_message: RwSignal<bool>
} }
struct QuestionComponent { // Define a new type for ease of use.
label: String, type ValidationVec = Vec<RwSignal<InputState>>;
placeholder: String,
possible_values: HashMap<str, str>, // Parent form component
// there are definitely values that i'll find while creating this structure. #[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.
+1
View File
@@ -0,0 +1 @@
target
+12
View File
@@ -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"
+52
View File
@@ -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).
+76
View File
@@ -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())
}
}
+11
View File
@@ -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
}
+46 -63
View File
@@ -1,12 +1,11 @@
use crate::pages::{AboutPage, ComparisonTestPage, HomePage, LoanerPage, ProviderPortal, TestPage}; use crate::pages::{ProviderPortal, ScholarshipInfoPage, ProviderContactPage, LoanerShell, LoanerFallback, LoanerBorrowForm, LoanerReturnForm, AuthCallbackPage, ApplicantsPageFallback};
use leptos::leptos_dom::log;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge}; use leptos_oidc::AuthSignal;
use leptos_router::{ use leptos_router::{components::{Route, Router, Routes, ParentRoute}, path, MatchNestedRoutes};
components::{Route, Router, Routes}, use leptos_router::any_nested_route::IntoAnyNestedRoute;
path, use crate::components::login::ProviderLoginContext;
}; use crate::components::ToastList;
pub fn shell(options: LeptosOptions) -> impl IntoView { pub fn shell(options: LeptosOptions) -> impl IntoView {
view! { view! {
@@ -34,10 +33,10 @@ pub fn App() -> impl IntoView {
view! { view! {
// injects a stylesheet into the document <head> // injects a stylesheet into the document <head>
// id=leptos means cargo-leptos will hot-reload this stylesheet // id=leptos means cargo-leptos will hot-reload this stylesheet
<Stylesheet id="leptos" href="/pkg/scholarships-rs-wasm.css"/> <Stylesheet id="leptos" href="/pkg/scholarships-rs-wasm.css" />
// sets the document title // sets the document title
<Title text="R15 Scholarship App DEV"/> <Title text="R15 Scholarship App DEV" />
// content for this welcome page // content for this welcome page
<Router> <Router>
@@ -46,77 +45,61 @@ pub fn App() -> impl IntoView {
} }
} }
fn use_origin() -> String {
#[cfg(target_arch = "wasm32")]
{
use leptos::web_sys;
// Just get the current URL origin.
web_sys::window()
.unwrap()
.location()
.origin()
.unwrap_or("http://localhost:3000/".to_string())
}
#[cfg(not(target_arch = "wasm32"))]
{
// Read the expected origin out of an environment variable.
std::env::var("LP_SITE_ORIGIN").unwrap_or("http://localhost:3000/".to_string())
}
}
#[component] #[component]
pub fn AppWithRoutes() -> impl IntoView { pub fn AppWithRoutes() -> impl IntoView {
provide_meta_context(); provide_meta_context();
let current_origin = use_origin();
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! { view! {
// injects a stylesheet into the document <head> // injects a stylesheet into the document <head>
// id=leptos means cargo-leptos will hot-reload this stylesheet // id=leptos means cargo-leptos will hot-reload this stylesheet
<Stylesheet id="leptos" href="/pkg/scholarships-rs-wasm.css" /> <Stylesheet id="leptos" href="/pkg/scholarships-rs-wasm.css" />
// <AuthLoading><p>"Authentication is loading"</p></AuthLoading>
// <AuthErrorContext><AuthErrorView/></AuthErrorContext>
// sets the document title // sets the document title
<Title text="R15 Scholarship App DEV" /> <Title text="R15 Scholarship App DEV" />
<main> <main>
// TODO Create a 404 page <ToastList>
<Routes fallback=|| "Page not found.".into_view()> // TODO Create a 404 page
<Route path=path!("") view=HomePage/> <Routes fallback=|| "Page not found.".into_view()>
<Route path=path!("about") view=AboutPage/> <ProviderRoutes />
<Route path=path!("test_page") view=TestPage/> <LoanerRoutes />
<Route path=path!("comparison") view=ComparisonTestPage /> </Routes>
<Route path=path!("providers") view=ProviderPortal /> </ToastList>
<Route path=path!("loaners") view=LoanerPage />
<Route path=path!("loaners/:form_name") view=LoanerPage />
</Routes>
</main> </main>
} }
} }
#[component(transparent)]
fn ProviderRoutes() -> impl MatchNestedRoutes + Clone {
view! {
<ParentRoute path=path!("/providers") view=ProviderLoginContext>
<Route path=path!("") view=ProviderPortal />
<Route path=path!("/applicants") view=ApplicantsPageFallback />
<Route path=path!("/callback") view=AuthCallbackPage />
<Route path=path!("/profile") view=ProviderContactPage />
<ParentRoute path=path!("/scholarships") view=ScholarshipInfoPage>
<Route path=path!(":id") view=ScholarshipInfoPage />
<Route path=path!("") view=ScholarshipInfoPage />
</ParentRoute>
</ParentRoute>
}
.into_inner()
.into_any_nested_route()
}
#[component(transparent)]
fn LoanerRoutes() -> impl MatchNestedRoutes + Clone {
view! {
<ParentRoute path=path!("loaners") view=LoanerShell>
<Route path=path!("") view=LoanerFallback />
<Route path=path!("borrowing") view=LoanerBorrowForm />
<Route path=path!("returning") view=LoanerReturnForm />
</ParentRoute>
}
.into_inner()
.into_any_nested_route()
}
#[component] #[component]
pub fn AuthErrorView() -> impl IntoView { pub fn AuthErrorView() -> impl IntoView {
let auth = use_context::<AuthSignal>().expect("AuthError: RwSignal<AuthStore> was not found"); let auth = use_context::<AuthSignal>().expect("AuthError: RwSignal<AuthStore> was not found");
+14
View File
@@ -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"),
)
}
+1
View File
@@ -23,6 +23,7 @@ pub struct ComparisonData {
} }
impl ComparisonData { impl ComparisonData {
#[allow(unused)]
pub fn new( pub fn new(
id: impl Into<String>, id: impl Into<String>,
member: impl Into<String>, member: impl Into<String>,
-1
View File
@@ -3,7 +3,6 @@ mod lists;
mod number; mod number;
mod text; mod text;
mod traits; mod traits;
mod utils;
pub use self::{base::*, lists::*, number::*, text::*, traits::*}; pub use self::{base::*, lists::*, number::*, text::*, traits::*};
-28
View File
@@ -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)
}
+63
View File
@@ -0,0 +1,63 @@
use chrono::{DateTime, FixedOffset, Local};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DateInfo {
pub id: String,
pub title: String,
pub date: DateRange,
pub description: String,
}
impl DateInfo {
pub fn get_start_date(&self) -> DateTime<FixedOffset> {
match self.date {
DateRange::Single(date) | DateRange::Range(date, _) => date,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DateRange {
Single(DateTime<FixedOffset>),
Range(DateTime<FixedOffset>, DateTime<FixedOffset>),
}
impl DateRange {
pub fn get_status(&self) -> DateStatus {
match self {
DateRange::Single(_) => DateStatus::Blank,
DateRange::Range(start_date, end_date) => {
let local = Local::now();
// Check the amount of time from now to the start date. Values that are negative
// indicate that the start date has already passed.
if dbg!(start_date.signed_duration_since(local).num_days()) > 0 {
DateStatus::Upcoming
} else {
// Check the amount of time from now to the end date. The value will be positive if
// the end date is in the future. We will be specific to the minute.
let duration_until = dbg!(end_date).signed_duration_since(local);
let num_days = dbg!(duration_until.num_days());
let num_mins = dbg!(duration_until.num_minutes());
if num_mins <= 0 {
DateStatus::Closed
} else if num_days <= 5 {
DateStatus::Deadline
} else {
DateStatus::Open
}
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd, Eq, Ord)]
pub enum DateStatus {
Closed,
Deadline,
Open,
Upcoming,
Blank,
}
+34 -37
View File
@@ -1,8 +1,6 @@
use crate::common::ValueType; use crate::common::ValueType;
use crate::components::{CheckboxList, OutlinedTextField, RadioList, Row, Select}; use crate::components::{CheckboxList, OutlinedTextField, RadioList, Row, Select, TextFieldType};
use leptos::either::EitherOf5; use leptos::prelude::*;
use leptos::prelude::RwSignal;
use leptos::{IntoView, view};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
@@ -11,85 +9,82 @@ use std::collections::HashMap;
pub enum InputType { pub enum InputType {
/// Represents a text input. Requires a `String` key, `String` label, and `String` placeholder. /// Represents a text input. Requires a `String` key, `String` label, and `String` placeholder.
/// The entered value will be returned as a single `String` value. /// 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. /// 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 /// The entered value will be returned as a `String`, and will be parseable into any number type
/// (i.e. an `i32`, `i64`, etc) /// (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` /// 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`. /// 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` /// 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>`. /// 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 /// 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`. /// 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 { impl InputType {
pub fn into_view(self, data_map: RwSignal<HashMap<String, ValueType>>) -> impl IntoView { pub fn into_view(self, data_map: RwSignal<HashMap<String, ValueType>>) -> impl IntoView {
view! { view! {
{match self { {match self {
InputType::Text(member, label, placeholder) => InputType::Text(member, label, placeholder, required) =>
EitherOf5::A(view! { view! {
<Row> <Row>
<OutlinedTextField <OutlinedTextField
label = label.clone() label = label.clone()
placeholder = placeholder.clone() placeholder = placeholder.clone()
data_member = member.clone() data_member = member.clone()
data_map = data_map data_map = data_map
required = required
/> />
</Row> </Row>
}), }.into_any(),
InputType::Number(member, label, placeholder) => InputType::Number(member, label, placeholder, required) =>
EitherOf5::B(view! { view! {
<OutlinedTextField <OutlinedTextField
label = label.clone() label = label.clone()
placeholder = placeholder.clone() placeholder = placeholder.clone()
data_member = member.clone() data_member = member.clone()
data_map = data_map data_map = data_map
input_type = "number" input_type = TextFieldType::Number
required = required
/> />
}), }.into_any(),
InputType::Checkbox(member, label, options) => InputType::Checkbox(member, label, options, required) =>
EitherOf5::C(view! { view! {
<CheckboxList <CheckboxList
label = label.clone() label = label.clone()
items = options.clone() items = options.clone()
data_member = member.clone() data_member = member.clone()
data_map = data_map data_map = data_map
required = required
/> />
}), }.into_any(),
InputType::Radio(member, label, options) => InputType::Radio(member, label, options, required) =>
EitherOf5::D(view! { view! {
<RadioList <RadioList
label = label.clone() label = label.clone()
data_member = member.clone() data_member = member.clone()
data_map = data_map data_map = data_map
items = options.clone() items = options.clone()
required = required
/> />
}), }.into_any(),
InputType::Select(member, label, options) => InputType::Select(member, label, options, required) =>
EitherOf5::E(view! { view! {
<Select <Select
label = label.clone() label = label.clone()
value_list = options.clone() value_list = options.clone()
data_member = member.clone() data_member = member.clone()
data_map = data_map data_map = data_map
required = required
/> />
}) }.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. /// Shorthand macro for creating `InputType` enums.
@@ -104,19 +99,21 @@ impl InputType {
/// ``` /// ```
#[macro_export] #[macro_export]
macro_rules! input { 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( $crate::common::InputType::$variant(
$key.to_string(), $key.to_string(),
$label.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( $crate::common::InputType::$variant(
$key.to_string(), $key.to_string(),
$label.to_string(), $label.to_string(),
$placeholder.to_string() $placeholder.to_string(),
$required
) )
}; };
} }
+5 -1
View File
@@ -1,10 +1,14 @@
mod comparison; mod comparison;
mod date_info;
mod expandable_info; mod expandable_info;
mod form_params; mod form_params;
mod form_schema; mod form_schema;
mod submit_status;
mod user; mod user;
mod value_type; mod value_type;
pub mod animations;
pub use self::{ 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::*,
}; };
+7
View File
@@ -0,0 +1,7 @@
#[derive(Debug, Clone)]
pub enum SubmitStatus {
Idle,
Sending,
Success,
Error(()),
}
+28
View File
@@ -113,3 +113,31 @@ impl Display for ValueType {
write!(f, "{}", str) 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 Into<AttributeValue> for ValueType {
fn into(self) -> AttributeValue {
match self {
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()),
}
}
}
+4 -4
View File
@@ -2,14 +2,14 @@ use leptos::prelude::*;
#[component] #[component]
pub fn ActionButton( pub fn ActionButton(
#[prop(optional, default = String::from("button"))] button_type: String, #[prop(default = String::from("button"), into)] button_type: String,
#[prop(optional, default = RwSignal::new(false))] disabled: RwSignal<bool>, #[prop(optional, into)] disabled: Signal<bool>,
children: Children, children: Children,
) -> impl IntoView { ) -> impl IntoView {
view! { view! {
<button <button
class="m-1.5 p-2.5 rounded-lg font-semibold text-white transition-all duration-400" class="m-1.5 p-2.5 min-w-20 rounded-lg font-semibold text-white transition-all duration-400 cursor-pointer"
class=(["bg-red-700", "hover:bg-red-800"], move || !disabled.get()) class=(["bg-red-800", "hover:bg-red-900"], move || !disabled.get())
class=(["bg-gray-600", "pointer-events-none"], move || disabled.get()) class=(["bg-gray-600", "pointer-events-none"], move || disabled.get())
r#type=button_type r#type=button_type
disabled=move || disabled.get() disabled=move || disabled.get()
+39 -13
View File
@@ -1,5 +1,6 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos_router::hooks::use_navigate; use leptos_router::hooks::use_navigate;
use leptos_icons::Icon;
/// # Banner Component /// # Banner Component
/// ///
@@ -23,26 +24,51 @@ pub fn Banner(
#[prop(into)] #[prop(into)]
logo: String, logo: String,
/// The route that the navigates user to a page (home page). /// The route that the navigates user to a page (home page).
#[prop(into, default = String::from("/"))] #[prop(into, optional)]
path: String, path: Signal<String>,
) -> impl IntoView { ) -> impl IntoView {
let navigate = use_navigate(); let navigate = use_navigate();
let on_click = move |_| navigate(&path, Default::default()); let show_back = Signal::derive(move || !path.get().is_empty());
let on_click = move |_| {
let path = path.get();
if !path.is_empty() {
navigate(&path, Default::default());
}
};
view! { view! {
<div class="mx-auto"> <div class="mx-auto">
<div class="bg-red-900 shadow-lg shadow-[inset_0_2px_4px_0_rgba(0,0,0,0.3),0_4px_6px_-1px_rgba(127,29,29,0.3),0_2px_4px_-1px_rgba(127,29,29,0.2)]"> <div class="bg-red-900 shadow-lg shadow-[inset_0_2px_4px_0_rgba(0,0,0,0.3),0_4px_6px_-1px_rgba(127,29,29,0.3),0_2px_4px_-1px_rgba(127,29,29,0.2)]">
<div class="flex items-center gap-4 p-6"> <div class="flex flex-row items-center gap-4 p-6 pb-2">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<img src={logo.clone()} class="h-10 w-10 cursor-pointer" alt="icon" on:click=on_click/> <img
</div> src=logo.clone()
<div class="flex-1"> class="h-10 w-10 cursor-pointer"
<h2 class="text-white text-lg font-bold">{title.clone()}</h2> alt="icon"
</div> on:click=on_click
/>
</div>
<div class="flex-1">
<h2 class="text-white text-2xl font-bold">{title.clone()}</h2>
</div>
</div>
<div
class="flex flex-row items-center gap-4"
class=("p-2", move || !show_back.get())
>
<Show when=move || show_back.get()>
<a
href=path
class="flex flex-row items-center gap-2 m-2 ml-14 text-gray-200 hover:underline"
>
<Icon icon=icondata::FaArrowLeftSolid />
<span>"Back to Dashboard"</span>
</a>
</Show>
</div>
</div> </div>
</div>
</div> </div>
} }
} }
-125
View File
@@ -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>
}
}
-147
View File
@@ -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>
}
}
+8 -11
View File
@@ -41,21 +41,18 @@ pub fn DashboardButton(
<button <button
type="button" type="button"
class="dashboard-button flex items-start gap-3 rounded-lg bg-white border-grey-300 p-6 class="dashboard-button flex items-start gap-3 rounded-lg bg-white border-grey-300 p-6
hover:bg-gray-100 transition cursor-pointer w-full text-left hover:bg-gray-100 transition cursor-pointer w-full text-left
shadow-[inset_0_0_6px_rgba(0,0,0,0.12)]" shadow-[inset_0_0_6px_rgba(0,0,0,0.12)]"
on:click=on_click> on:click=on_click
>
<div class="flex flex-col"> <div class="flex flex-col">
<img src={icon.clone()} class="h-8 w-8" alt="icon"/> <img src=icon.clone() class="h-8 w-8" alt="icon" />
<h3 class="font-semibold text-base"> <h3 class="font-semibold text-base">{title.clone()}</h3>
{title.clone()}
</h3>
<p class="text-sm text-gray-600 pt-6"> <p class="text-sm text-gray-600 pt-6">{description.clone()}</p>
{description.clone()}
</p>
</div> </div>
</button> </button>
} }.into_any()
} }
+124
View File
@@ -0,0 +1,124 @@
use crate::common::{DateInfo, DateRange, DateStatus};
use leptos::prelude::*;
fn render_dates(dates: DateRange) -> impl IntoView {
let date_text = match dates {
DateRange::Single(date) => format!("{}", date.format("%A %B %d, %I:%M%p")),
DateRange::Range(start_date, end_date) =>
format!("{} - {}",
start_date.format("%D %I:%M%p"),
end_date.format("%D %I:%M%p")
),
};
view! { <span>{move || date_text.clone()}</span> }
}
fn render_status(date: DateRange) -> impl IntoView {
let status = date.get_status();
let status_text = match dbg!(status.clone()) {
DateStatus::Blank => "",
DateStatus::Upcoming => "Upcoming",
DateStatus::Open => "Open",
DateStatus::Deadline => "Deadline",
DateStatus::Closed => "Closed",
};
let background_class = match status {
DateStatus::Blank => "hidden border-none",
DateStatus::Upcoming => "bg-yellow-100 text-yellow-800 border-yellow-100",
DateStatus::Open => "bg-green-100 text-green-800 border-green-100",
DateStatus::Deadline => "bg-red-100 text-red-800 border-red-100",
DateStatus::Closed => "bg-red-700 text-white border-red-700"
};
let classes =
format!("inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs
font-medium w-fit whitespace-nowrap shrink-0 {}",
background_class
);
view! { <span class=classes>{move || status_text}</span> }
}
/// # Date Component
/// A single date, for use in a [`DateList`] component.
///
/// Example usage:
/// ```
/// let date_info = DateInfo {
/// title: "Example Title".to_string(),
/// date: DateRange::Single("January 1st, 1990".to_string()),
/// description: "A description of the date.".to_string(),
/// status: DateStatus::Upcoming
/// }
///
/// view! {
/// <Date
/// info=date_info
/// />
/// }
/// ```
#[component]
fn Date(
/// Struct of title, date, and description
#[prop(into)] info: Signal<DateInfo>,
) -> impl IntoView {
view! {
<div class="flex items-start gap-3 rounded-lg border-grey-300 p-3
w-full text-left shadow-lg">
// <div class="flex-shrink-0 mt-0.5">
// <img src={icon} class="h-4 w-4" alt="icon"/>
// </div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="font-bold text-md text-base">{info.get().title}</h3>
<h3 class="text-md text-base mt-1">
{move || render_dates(info.get().date)}
</h3>
<p class="text-sm text-gray-600">{info.get().description}</p>
</div>
<div class="flex-shrink-0 ml-2">{move || render_status(info.get().date)}</div>
</div>
</div>
</div>
}
}
///# Dates List Component
/// Displays a list of important dates in a column, with badges that draw attention to dates that
/// are more important.
///
/// Example usage:
/// ```
/// let dates = vec![
/// DateInfo {
/// title: "Example Title".to_string(),
/// date: DateRange::Single("January 1st, 1990".to_string()),
/// description: "A description of the date.".to_string(),
/// status: DateStatus::Upcoming
/// }
/// ]
///
/// view! {
/// <DateList dates=dates />
/// }
/// ```
#[component]
pub fn DateList(
#[prop(into)] dates: Signal<Vec<DateInfo>>,
) -> impl IntoView {
view! {
{move || {
dates
.get()
.iter()
.map(|info| {
view! { <Date info=info.clone() /> }
})
.collect_view()
}}
}.into_any()
}
+40
View File
@@ -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>
}
}
+6
View File
@@ -0,0 +1,6 @@
mod multi;
mod single;
pub use {
multi::*, single::*
};
+183
View File
@@ -0,0 +1,183 @@
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 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=data_member
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
}
+170
View File
@@ -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>
}
}
+6
View File
@@ -0,0 +1,6 @@
mod utils;
mod checkbox;
mod chips;
pub use checkbox::*;
pub use chips::*;
+110
View File
@@ -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,
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod radio;
pub mod select;
pub use radio::*;
pub use select::*;
+298
View File
@@ -0,0 +1,298 @@
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>,
) -> 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=data_member
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
}
+135
View File
@@ -0,0 +1,135 @@
use crate::common::ValueType;
use leptos::ev::{Event, Targeted};
use leptos::prelude::*;
use leptos::web_sys::HtmlSelectElement;
use std::collections::HashMap;
use leptos::logging::{log, warn};
use crate::components::{use_validation_context, InputState, ValidationState};
fn validate(required: bool, value: String) -> ValidationState {
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()] value_list: 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 default_raw_value = data_map.get_untracked()
.get(&data_member.get_untracked())
.cloned()
.unwrap_or_default()
.as_string().unwrap_or_default().unwrap_or_default();
let raw_value = RwSignal::new(default_raw_value);
Effect::new(move || {
data_map.update(|map| {
map.insert(data_member.get(), ValueType::String(Some(raw_value.get())));
});
});
//#endregion
//#region Validation Logic
let error = RwSignal::new(
validate(required.get_untracked(), raw_value.get_untracked())
);
warn!("Current error state: {:?}", error.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(), 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();
log!("Found value {}", value);
raw_value.set(value.clone());
error.set(validate(required.get(), raw_value.get()));
dirty.set(true);
data_map.update(|map| {
if let Some(val) = map.get_mut(&data_member.get()) {
*val = ValueType::String(Some(value.clone()));
} else {
map.insert(data_member.get(), ValueType::String(Some(value)));
}
});
};
//#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"
on:change:target=on_change
disabled=disabled
>
// This closure handles the display of the options.
<option
value=""
disabled
hidden
selected=move || raw_value.get().is_empty()
>
"Select one..."
</option>
{move || {
value_list
.iter()
.map(move |value| {
let display = value.to_owned();
view! { <option value=display>{display.clone()}</option> }
})
.collect::<Vec<_>>()
}}
</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>
}
}
+2 -2
View File
@@ -3,8 +3,8 @@ use leptos::prelude::*;
#[component] #[component]
pub fn Loading() -> impl IntoView { pub fn Loading() -> impl IntoView {
view! { view! {
<div> <div class="size-20 mx-auto">
<img src="loading.gif" /> <img src="/loading.gif" />
</div> </div>
} }
} }
+5
View File
@@ -0,0 +1,5 @@
mod provider;
pub use provider::*;
mod student;
pub use student::*;
pub(crate) mod setup;
+28
View File
@@ -0,0 +1,28 @@
use leptos::prelude::*;
use leptos_router::components::Outlet;
use super::setup::provide_auth_context;
///# Provider Login Context Component
///
/// This component handles providing context to the provider login information.
/// It creates the corresponding [`AuthSignal`], specific to the student login page and redirects.
///
/// Example usage:
/// ```
/// view! {
/// <ProviderLoginContext>
/// // The rest of the student application. Don't forget AuthLoaded and
/// // Authenticated components!
/// </ProviderLoginContext>
/// }
#[component]
pub fn ProviderLoginContext() -> impl IntoView {
provide_auth_context(
crate::utils::use_origin(),
"https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Lfjuy5zaM",
"56c2bqvl021rv8d5mq36blt7jv",
"/providers/callback"
);
view! { <Outlet /> }
}
+24
View File
@@ -0,0 +1,24 @@
use leptos::prelude::*;
use leptos_oidc::{Auth, AuthParameters, Challenge};
pub fn provide_auth_context(
origin: String,
issuer: impl Into<String>,
client_id: impl Into<String>,
redirect_path: impl Into<String>,
) {
let params = AuthParameters {
issuer: issuer.into(),
client_id: client_id.into(),
redirect_uri: format!("{}{}", origin, redirect_path.into()),
post_logout_redirect_uri: origin,
scope: Some("openid%20profile%20email".into()),
audience: None,
challenge: Challenge::None,
};
let auth = Auth::signal();
provide_context(auth);
let _ = Auth::init(params);
}
+28
View File
@@ -0,0 +1,28 @@
use leptos::prelude::*;
use leptos_router::components::Outlet;
use super::setup::provide_auth_context;
///# Student Login Context Component
///
/// This component handles providing context to the student login information.
/// It creates the corresponding [`AuthSignal`], specific to the student login page and redirects.
///
/// Example usage:
/// ```
/// view! {
/// <StudentLoginContext>
/// // The rest of the student application. Don't forget AuthLoaded and
/// // Authenticated components!
/// </StudentLoginContext>
/// }
#[component]
pub fn StudentLoginContext() -> impl IntoView {
provide_auth_context(
crate::utils::use_origin(),
"https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Lfjuy5zaM",
"10jr2h3vtpu9n7gj46pvg5qo2q",
"/students/callback"
);
view! { <Outlet /> }
}
+9 -6
View File
@@ -1,18 +1,21 @@
mod action_button; mod action_button;
mod banner; mod banner;
mod checkbox;
mod chips;
mod dashboard_button; mod dashboard_button;
mod date;
mod loading; mod loading;
mod multi_entry; mod multi_entry;
mod panel; mod panel;
mod radio;
mod row; mod row;
mod select;
mod text_field; mod text_field;
mod utils; mod utils;
mod validated_form;
mod lists;
mod toasts;
mod header;
pub mod login;
pub use self::{ pub use self::{
action_button::*, banner::*, checkbox::*, chips::*, dashboard_button::*, loading::*, action_button::*, banner::*, dashboard_button::*, lists::*,
multi_entry::*, panel::*, radio::*, row::*, select::*, text_field::*, loading::*, multi_entry::*, panel::*, row::*, text_field::*,
validated_form::*, toasts::*, header::*, date::*
}; };
-12
View File
@@ -73,9 +73,6 @@ pub fn MultiEntry(
/// component's `data_member` input. /// component's `data_member` input.
#[prop()] #[prop()]
data_map: RwSignal<HashMap<String, ValueType>>, 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. /// The type and order of the inputs for each `Entry` component within this `MultiEntry` component.
#[prop(optional)] #[prop(optional)]
schema: Vec<InputType>, schema: Vec<InputType>,
@@ -132,7 +129,6 @@ pub fn MultiEntry(
view! { view! {
<Entry <Entry
data_map = map_signal data_map = map_signal
name_member = name_member.clone()
schema = schema.clone() schema = schema.clone()
/> />
} }
@@ -186,14 +182,6 @@ fn Entry(
/// data members those inputs modify. /// data members those inputs modify.
#[prop()] #[prop()]
schema: Vec<InputType>, 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,
) -> impl IntoView { ) -> impl IntoView {
view! { view! {
<div class="flex flex-col flex-1 p-2 rounded-sm transition-shadow shadow-sm hover:shadow-lg/30"> <div class="flex flex-col flex-1 p-2 rounded-sm transition-shadow shadow-sm hover:shadow-lg/30">
-112
View File
@@ -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>
}
}
-58
View File
@@ -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>
}
}
+137 -64
View File
@@ -1,69 +1,139 @@
use crate::common::ValueType; use crate::common::ValueType;
use leptos::leptos_dom::logging::console_log;
use leptos::prelude::*; use leptos::prelude::*;
use std::collections::HashMap; use std::collections::HashMap;
use leptos::html::Input;
use crate::components::{use_validation_context, InputState};
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 {
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::<i32>().is_ok() {
ValidationState::Valid
} else {
ValidationState::Invalid("Value is not a valid number.".to_string())
}
}
#[component] #[component]
pub fn OutlinedTextField( pub fn OutlinedTextField(
#[prop(optional, into)] placeholder: String, #[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, // should this be an RwSignal??
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>, #[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop(optional)] value: RwSignal<ValueType>, #[prop(optional, into)] input_type: Signal<TextFieldType>,
#[prop(default = "text".to_owned(), into)] input_type: String, #[prop(optional, into)] disabled: Signal<bool>,
#[prop(optional)] disabled: RwSignal<bool>,
#[prop(optional)] error: RwSignal<bool>,
#[prop(optional, into)] name: String, #[prop(optional, into)] name: String,
#[prop(optional, into)] label: String, #[prop(optional, into)] label: String,
#[prop(optional, into)] error_text: RwSignal<String>, #[prop(optional, into)] required: Signal<bool>
) -> impl IntoView { ) -> impl IntoView {
let input_ref = NodeRef::<Input>::new();
// Register this input's validation signal.
let validator_context = use_validation_context()
.expect("FormValidSignal was not found");
let raw_value = Signal::derive({
let data_member = data_member.clone();
move ||
data_map
.get()
.get(&data_member)
.unwrap_or(&ValueType::String(None))
.to_string()
});
let dirty = RwSignal::new(false);
let error = Signal::derive(move ||
validate(required.get(), &raw_value.get(), &input_type.get())
);
let show_errors = Signal::derive(move || {
dirty.get() && matches!(error.get(), ValidationState::Invalid(_))
});
let validator = RwSignal::new(InputState::new(data_member.clone(), error.clone(), dirty.clone()));
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 str_type = Signal::derive(move || match input_type.get_untracked() {
TextFieldType::Number => "number",
_ => "text" // This is fine even for emails, as we're doing our own validation.
});
let on_blur = move |_| {
// Check validity of input's current value. The value will be updated by on_input,
// and it should be a raw input (not parsed into a ValueType).
dirty.set(true);
};
// This function parses the input into the correct type. It only accepts numbers or strings as // This function parses the input into the correct type. It only accepts numbers or strings as
// valid types, and parses them accordingly. // valid types, and parses them accordingly.
let on_input = { let on_input = {
let input_type = input_type.clone();
let data_member = data_member.clone(); let data_member = data_member.clone();
move |e| { move |e| {
let to_parse = event_target_value(&e); let to_parse = event_target_value(&e);
match input_type.as_str() { let into_map = match input_type.get() {
"text" => { TextFieldType::Number => ValueType::Number(Some(to_parse)),
// String parse function. It will always parse, but make sure the error _ => ValueType::String(Some(to_parse))
// state is set properly anyway. };
error.set(false);
value.set(ValueType::String(Some(to_parse.clone()))); // Edit the data map. Note that this should be uncoupled in the future - we don't want
data_map.update(|map| { // to update the map inside the component definition, but inside the page/form that
map.insert(data_member.clone(), ValueType::String(Some(to_parse))); // created this input.
}); data_map.update(|map| {
} map.insert(data_member.clone(), into_map);
"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);
}
}
} }
}; };
@@ -72,26 +142,29 @@ pub fn OutlinedTextField(
<label class="flex flex-col flex-1"> <label class="flex flex-col flex-1">
<span class="block ml-1.5 mb-0 font-bold">{label}</span> <span class="block ml-1.5 mb-0 font-bold">{label}</span>
<input <input
node_ref=input_ref
class="border-2 m-1.5 p-1.5 mt-0 rounded-md bg-transparent relative flex-1 class="border-2 m-1.5 p-1.5 mt-0 rounded-md bg-transparent relative flex-1
transition-all duration-150 transition-all duration-150
border-red-700 bg-transparent border-red-700 bg-transparent
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33" disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
r#type={input_type} r#type=str_type
disabled={disabled} disabled=disabled
placeholder={placeholder} placeholder=placeholder
prop:name=name prop:name=name
prop:value=move || data_map prop:value=raw_value
.get() // HashMap
.get(&data_member) // Option<ValueType>
.unwrap_or(&ValueType::String(None)) // ValueType
.to_string()
on:input=on_input on:input=on_input
on:blur=on_blur
/> />
<div <Show when=move || show_errors.get()>
class="text-red-600 text-sm mr-1.5 ml-1.5" <div class="text-red-600 text-sm mr-1.5 ml-1.5">
class=(["hidden"], move || !error.get())> {move || {
{error_text} match error.get() {
</div> ValidationState::Invalid(msg) => msg,
_ => "There is no error - should not see this message.".to_string(),
}
}}
</div>
</Show>
</label> </label>
</div> </div>
} }
+4
View File
@@ -0,0 +1,4 @@
mod toast_list;
mod toast;
pub use toast_list::*;
+37
View File
@@ -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>
}
}
+91
View File
@@ -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
}
+172
View File
@@ -0,0 +1,172 @@
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)]
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>,
) -> 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"
novalidate
on:submit=submit_success_event
>
<Header
title=title
description=description
/>
{children()}
<ActionButton
on:click=move |_| {
log!("{:?}", can_submit.get());
}
button_type="submit"
>
"Submit"
</ActionButton>
</form>
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ pub mod app;
mod common; mod common;
mod components; mod components;
mod pages; mod pages;
mod utils; pub mod utils;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen] #[wasm_bindgen::prelude::wasm_bindgen]
+19 -4
View File
@@ -6,6 +6,11 @@ async fn main() {
use leptos::prelude::*; use leptos::prelude::*;
use leptos_axum::{LeptosRoutes, generate_route_list}; use leptos_axum::{LeptosRoutes, generate_route_list};
use scholarships_rs::app::*; use scholarships_rs::app::*;
use scholarships_rs::utils::server::create_dynamo_client;
use std::sync::Arc;
let dynamo_client = create_dynamo_client().await;
let shared_client = Arc::new(dynamo_client);
let conf = get_configuration(None).unwrap(); let conf = get_configuration(None).unwrap();
let addr = conf.leptos_options.site_addr; let addr = conf.leptos_options.site_addr;
@@ -14,10 +19,20 @@ async fn main() {
let routes = generate_route_list(App); let routes = generate_route_list(App);
let app = Router::new() let app = Router::new()
.leptos_routes(&leptos_options, routes, { .leptos_routes_with_context(
let leptos_options = leptos_options.clone(); &leptos_options,
move || shell(leptos_options.clone()) routes,
}) {
let shared_client = Arc::clone(&shared_client);
move || {
provide_context(Arc::clone(&shared_client));
}
},
{
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
}
)
.fallback(leptos_axum::file_and_error_handler(shell)) .fallback(leptos_axum::file_and_error_handler(shell))
.with_state(leptos_options); .with_state(leptos_options);
+152 -30
View File
@@ -1,19 +1,26 @@
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use crate::pages::utils::server_utils::create_dynamo_client; use crate::utils::server::create_dynamo_client;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use aws_sdk_dynamodb::error::ProvideErrorMetadata; use aws_sdk_dynamodb::error::ProvideErrorMetadata;
#[cfg(feature = "ssr")]
use leptos::logging::log;
use crate::common::{ use crate::common::{
ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison, ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison,
TextComparison, TextListComparison, ValueType, TextComparison, TextListComparison, ValueType,
}; };
use leptos::leptos_dom::log; use std::collections::HashMap;
use leptos::prelude::ServerFnError; use leptos::prelude::ServerFnError;
use leptos::server; use leptos::server;
#[cfg(feature = "ssr")]
static COMPARISONS_TABLE: &str = "leptos-comparisons";
/// Helper function to create all sports comparisons. Only used to create initial /// Helper function to create all sports comparisons. Only used to create initial
/// comparison data, and should be removed upon completion of a comparison editor page. /// comparison data, and should be removed upon completion of a comparison editor page.
#[allow(unused)]
fn create_sports_comparisons() -> Vec<ComparisonData> { fn create_sports_comparisons() -> Vec<ComparisonData> {
vec![ vec![
ComparisonData::new( ComparisonData::new(
@@ -184,11 +191,74 @@ fn create_sports_comparisons() -> Vec<ComparisonData> {
] ]
} }
#[server(CreateTestComparisons, endpoint = "/comparisons/create-test")] fn create_major_comparison(
pub async fn create_test_comparisons() -> Result<(), ServerFnError> { id: impl Into<String>,
let client = create_dynamo_client().await; 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_3",
"weighted_gpa",
ComparisonType::Number(NumberComparison::GreaterThanOrEqual),
ValueType::Number(Some(3.0.to_string())),
"GPA Limits",
"GPA >= 3.0",
);
let test_comp = ComparisonData::new( let test_comp = ComparisonData::new(
"math_sat_comp", "math_sat_comp",
@@ -199,20 +269,6 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
"Math SAT Score > 650", "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( let service_hours_20 = ComparisonData::new(
"service_hours_20", "service_hours_20",
"community_involvement", "community_involvement",
@@ -223,8 +279,22 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
))), ))),
)), )),
ValueType::Number(Some(20.to_string())), ValueType::Number(Some(20.to_string())),
"Community Involvement", "Community Service",
"20 service hours", "20+ service hours",
);
let service_hours_25 = ComparisonData::new(
"service_hours_25",
"community_involvement",
ComparisonType::MapList(MapListComparison::FlattenToNumberList(
"service_hours".to_string(),
Box::new(NumberListComparison::Sum(Box::new(
NumberComparison::GreaterThanOrEqual,
))),
)),
ValueType::Number(Some(25.to_string())),
"Community Service",
"25+ service hours",
); );
let service_hours_30 = ComparisonData::new( let service_hours_30 = ComparisonData::new(
@@ -237,8 +307,8 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
))), ))),
)), )),
ValueType::Number(Some(30.to_string())), ValueType::Number(Some(30.to_string())),
"Community Involvement", "Community Service",
"30 service hours", "30+ service hours",
); );
let residency_southbury = ComparisonData::new( let residency_southbury = ComparisonData::new(
@@ -264,7 +334,7 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
"attended_bas", "attended_bas",
ComparisonType::Text(TextComparison::Matches), ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())), ValueType::String(Some("Yes".to_string())),
"Miscellaneous", "Additional Eligibility Factors",
"Attended BAS", "Attended BAS",
); );
@@ -273,7 +343,7 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
"middsouth_church", "middsouth_church",
ComparisonType::Text(TextComparison::Matches), ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())), ValueType::String(Some("Yes".to_string())),
"Miscellaneous", "Additional Eligibility Factors",
"Member of Midd-South Church", "Member of Midd-South Church",
); );
@@ -282,14 +352,15 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
"family_military_service", "family_military_service",
ComparisonType::Text(TextComparison::Matches), ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())), ValueType::String(Some("Yes".to_string())),
"Miscellaneous", "Additional Eligibility Factors",
"Family with Military Service", "Family with Military Service",
); );
let mut sports_comps = create_sports_comparisons(); let mut sports_comps = create_sports_comparisons();
let mut major_comps = Vec::<ComparisonData>::new(); let mut major_comps = create_major_comparisons();
let mut comp_list = vec![ let mut comp_list = vec![
gpa_3,
test_comp, test_comp,
service_hours_20, service_hours_20,
service_hours_25, service_hours_25,
@@ -304,10 +375,21 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
comp_list.append(&mut sports_comps); comp_list.append(&mut sports_comps);
comp_list.append(&mut major_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 { for comparison in comp_list {
if let Err(err) = client if let Err(err) = client
.put_item() .put_item()
.table_name("leptos-comparison-test") .table_name(COMPARISONS_TABLE)
.set_item(Some(serde_dynamo::to_item(&comparison)?)) .set_item(Some(serde_dynamo::to_item(&comparison)?))
.send() .send()
.await .await
@@ -330,7 +412,7 @@ pub async fn get_comparison_info() -> Result<Vec<ComparisonData>, ServerFnError>
// id and display text, but we'll return the whole thing. // id and display text, but we'll return the whole thing.
match client match client
.scan() .scan()
.table_name("leptos-comparison-test") .table_name(COMPARISONS_TABLE)
.send() .send()
.await .await
{ {
@@ -348,3 +430,43 @@ pub async fn get_comparison_info() -> Result<Vec<ComparisonData>, ServerFnError>
} }
} }
} }
#[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 {
Err(ServerFnError::new("Couldn't find any comparisons."))
}
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
Err(ServerFnError::new(msg))
}
}
}
+78
View File
@@ -0,0 +1,78 @@
use std::collections::HashMap;
use leptos::logging::log;
use leptos::prelude::*;
use crate::common::DateInfo;
#[cfg(feature = "ssr")]
static DATES_TABLE: &str = "leptos-dates";
#[cfg(feature = "ssr")]
use aws_sdk_dynamodb::{
types::{WriteRequest, PutRequest},
Client as DynamoClient
};
#[cfg(feature = "ssr")]
use crate::utils::server::create_dynamo_client;
#[cfg(feature = "ssr")]
use std::sync::Arc;
#[server]
pub async fn create_dates(dates: Vec<DateInfo>) -> Result<(), ServerFnError> {
// Serialize and put dates into the database.
let client = create_dynamo_client().await;
log!("Creating dates: {:?}", dates);
let requests = dates
.into_iter()
.map(|date| {
let item = serde_dynamo::to_item(&date)
.unwrap_or_default();
let put_request = PutRequest::builder()
.set_item(Some(item))
.build()
.expect("Couldn't build request, item is not present");
WriteRequest::builder().put_request(put_request).build()
})
.collect::<Vec<WriteRequest>>();
let mut request_map = HashMap::new();
request_map.insert(DATES_TABLE.to_string(), requests);
client
.batch_write_item()
.set_request_items(Some(request_map))
.send()
.await
.map(|_| ())
.map_err(ServerFnError::from)
}
#[server]
pub async fn get_important_dates() -> Result<Vec<DateInfo>, ServerFnError> {
let client = create_dynamo_client().await;
client
.scan()
.table_name(DATES_TABLE)
.send()
.await
.map(|output| {
if let Some(items) = output.items {
items
.into_iter()
.filter_map(|item| {
serde_dynamo::from_item::<_, DateInfo>(item)
.ok()
})
.collect::<Vec<DateInfo>>()
} else {
Vec::new()
}
})
.map_err(ServerFnError::from)
}
+4 -1
View File
@@ -1,4 +1,7 @@
mod comparisons; mod comparisons;
pub use comparisons::*;
mod scholarships; mod scholarships;
pub use scholarships::*;
mod dates;
pub use dates::*;
pub use self::{comparisons::*, scholarships::*};
+124 -16
View File
@@ -1,15 +1,26 @@
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use aws_sdk_dynamodb::{error::ProvideErrorMetadata, types::AttributeValue}; use aws_sdk_dynamodb::{
types::AttributeValue,
error::ProvideErrorMetadata,
};
#[cfg(feature = "ssr")]
use leptos::leptos_dom::log;
#[cfg(feature = "ssr")]
use uuid::Uuid;
#[cfg(feature = "ssr")]
use crate::utils::server::{create_dynamo_client, into_attr_map};
#[cfg(feature = "ssr")]
use crate::common::ValueType;
use crate::common::ExpandableInfo; use crate::common::ExpandableInfo;
use leptos::leptos_dom::log;
use leptos::prelude::ServerFnError; use leptos::prelude::ServerFnError;
use leptos::server; use leptos::server;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use crate::pages::utils::server_utils::create_dynamo_client; static SCHOLARSHIPS_TABLE: &str = "leptos-scholarships";
#[server(GetScholarshipInfo, endpoint = "/scholarship/info/get")] #[server(GetScholarshipInfo)]
pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFnError> { pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFnError> {
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
@@ -18,7 +29,7 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
log!("Getting scholarship info using id {:?}", id); log!("Getting scholarship info using id {:?}", id);
match client match client
.get_item() .get_item()
.table_name("leptos-scholarship-test") .table_name(SCHOLARSHIPS_TABLE)
.key("subject", AttributeValue::S(id.clone())) .key("subject", AttributeValue::S(id.clone()))
.send() .send()
.await .await
@@ -28,8 +39,8 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
log!("Found output from API: {:?}", item); log!("Found output from API: {:?}", item);
Ok(serde_dynamo::from_item(item)?) Ok(serde_dynamo::from_item(item)?)
} else { } else {
log!("Couldn't find any values, returning default struct."); log!("Couldn't find scholarship with ID {:?}", id);
Ok(ExpandableInfo::new(id.clone())) Err(ServerFnError::new("Couldn't find scholarship with given ID."))
} }
} }
Err(err) => { Err(err) => {
@@ -39,21 +50,17 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
} }
} }
#[server(CreateScholarshipInfo, endpoint = "/scholarship/info/create")] #[server(CreateScholarshipInfo)]
pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerFnError> { pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerFnError> {
use crate::pages::utils::server_utils::create_dynamo_client;
use aws_sdk_dynamodb::error::ProvideErrorMetadata; use aws_sdk_dynamodb::error::ProvideErrorMetadata;
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
log!( log!("Creating or updating scholarship with ID {:?}", info.subject);
"Creating or updating scholarship with ID {:?}",
info.subject
);
match client match client
.put_item() .put_item()
.table_name("leptos-scholarship-test") .table_name(SCHOLARSHIPS_TABLE)
.set_item(Some(serde_dynamo::to_item(&info)?)) .set_item(Some(serde_dynamo::to_item(&info)?))
.send() .send()
.await .await
@@ -66,13 +73,42 @@ pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerF
} }
} }
#[server(GetAllScholarshipInfo, endpoint = "/scholarship/info/get-all")] #[server(GetAllScholarshipInfo)]
pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnError> { pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnError> {
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
log!("Getting all scholarship info"); log!("Getting all scholarship info");
match client match client
.scan() .scan()
.table_name("leptos-scholarship-test") .table_name(SCHOLARSHIPS_TABLE)
.send()
.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("An unknown error occurred");
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> {
let client = create_dynamo_client().await;
log!("Getting provider scholarships for provider with ID {:?}", provider_id);
match client
.scan()
.table_name(SCHOLARSHIPS_TABLE)
.expression_attribute_values(":id", serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?)
.filter_expression("provider_id = :id")
.send() .send()
.await .await
{ {
@@ -89,3 +125,75 @@ pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnE
} }
} }
} }
/// 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> {
let client = create_dynamo_client().await;
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("An 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> {
let client = create_dynamo_client().await;
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("An unknown error occurred");
Err(ServerFnError::new(msg))
}
}
}
+37
View File
@@ -0,0 +1,37 @@
use leptos::prelude::*;
use leptos_oidc::AuthSignal;
use leptos_router::hooks::use_navigate;
#[component]
pub fn AuthCallbackPage() -> impl IntoView {
// This should wait for authentication, read the values in session storage, and then redirect
// to the correct page. The session storage should contain a path, which we'll need to check.
let auth = expect_context::<AuthSignal>();
let navigate = use_navigate();
Effect::new(move || {
// Wait for auth to load, then read information.
if let Some(_) = auth.get().authenticated() {
// Read the sessionStorage to get the last navigated path.
let storage_opt = window().session_storage().ok().flatten();
let path = storage_opt
.and_then(|storage| storage.get_item("post_login_path").ok().flatten())
.unwrap_or_else(|| "/".to_string());
// Clear storage in case we need to reauth again
if let Some(storage) = window().session_storage().ok().flatten() {
let _ = storage.remove_item("post_login_path");
}
// Redirect to the stored path
navigate(&path, Default::default());
}
});
view! {
<div class="flex flex-col w-full mx-auto items-center justify-center">
<p class="text-xl font-bold mt-10 mx-auto">"Logging you in..."</p>
</div>
}
}
+78 -93
View File
@@ -1,25 +1,19 @@
use super::UnauthenticatedPage; use super::UnauthenticatedPage;
use super::api::{get_all_scholarship_info, get_comparison_info}; use super::api::{get_all_scholarship_info, get_comparison_info};
use super::home_page::get_submission; use super::home_page::get_submission;
use crate::common::{ComparisonData, ExpandableInfo, UserClaims, ValueType}; use crate::common::{ComparisonData, ExpandableInfo, ValueType};
use crate::components::{ActionButton, Loading, Panel, Row}; use crate::components::{ActionButton, Loading, Panel, Row};
use leptos::either::Either; use leptos::either::Either;
use leptos::leptos_dom::logging::console_log; use leptos::leptos_dom::logging::console_log;
use leptos::logging::log; use leptos::logging::log;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated}; use leptos_oidc::{AuthLoaded, Authenticated};
use crate::utils::get_user_claims;
#[component] #[component]
pub fn ComparisonTestPage() -> impl IntoView { pub fn ComparisonTestPage() -> impl IntoView {
let result_msg = RwSignal::new("Found submission, waiting for input.".to_string()); 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 = get_user_claims();
let user_claims = Signal::derive(move || {
auth.with(|auth| {
auth.authenticated().and_then(|data| {
data.decoded_access_token::<UserClaims>(Algorithm::RS256, &["account"])
})
})
});
// Note that the value passed in MUST be equatable. // 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 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 student_info = student_resource.get();
let comparison_info = comparison_resource.get(); let comparison_info = comparison_resource.get();
let scholarship_info = scholarship_resource.get(); let scholarship_info = scholarship_resource.get();
match (student_info, comparison_info, scholarship_info) { match (student_info, comparison_info, scholarship_info) {
(Some(student), Some(comparisons), Some(scholarships)) => Either::Right(view! { (Some(student), Some(comparisons), Some(scholarships)) => {
<Panel> Either::Right(
<Row>
<p>{result_msg}</p>
</Row>
<Row>
<ActionButton on:click=move |_| {
// Iterate over all scholarships, get the comparison IDs,
// get ComparisonData from the IDs, then perform the
// comparisons and return a boolean. Alternatively, return
// a list of comparisons that complete and a list that don't.
let comp_member = "requirements".to_string();
let comparison_results = scholarships.iter().map(|scholarship| {
let list = match scholarship.data.get(&comp_member) {
Some(ValueType::List(Some(list))) => list,
_ => {
log!("Cannot find requirements, assuming none.");
return Ok(Some(scholarship)) // students will always qualify when there are no requirements
}
};
let mut final_result = Ok(true); view! {
<Panel>
// The list should exist, and it should contain a series of strings. <Row>
for val in list { <p>{result_msg}</p>
let comp_opt = match val { </Row>
ValueType::String(Some(comp_id)) => { <Row>
// Find the comparison <ActionButton on:click=move |_| {
comparisons.iter().find(|comp| comp.id == *comp_id) let comp_member = "requirements".to_string();
} let comparison_results = scholarships
_ => None .iter()
}; .map(|scholarship| {
let list = match scholarship.data.get(&comp_member) {
let comparison_outcome = match comp_opt { Some(ValueType::List(Some(list))) => list,
Some(comp) => { _ => {
// Perform the comparison log!("Cannot find requirements, assuming none.");
comp.compare(&student) return Ok(Some(scholarship));
} }
_ => Err("".to_string()) };
}; let mut final_result = Ok(true);
for val in list {
match comparison_outcome { let comp_opt = match val {
Ok(true) => {}, ValueType::String(Some(comp_id)) => {
Ok(false) => final_result = Ok(false), comparisons.iter().find(|comp| comp.id == *comp_id)
Err(e) => { }
final_result = Err(e); _ => None,
break; };
let comparison_outcome = match comp_opt {
Some(comp) => comp.compare(&student),
_ => Err("".to_string()),
};
match comparison_outcome {
Ok(true) => {}
Ok(false) => final_result = Ok(false),
Err(e) => {
final_result = Err(e);
break;
}
}
}
match final_result {
Ok(true) => Ok(Some(scholarship)),
Ok(false) => Ok(None),
Err(e) => Err(e),
}
})
.collect::<Vec<Result<Option<&ExpandableInfo>, String>>>();
let mut strings = Vec::new();
strings.push("Scholarship results: \n".to_string());
for result in comparison_results {
match result {
Ok(Some(scholarship_info)) => {
let name_member = "name".to_string();
let scl_name = scholarship_info
.data
.get(&name_member)
.unwrap()
.as_string()
.unwrap()
.unwrap();
strings.push(scl_name);
}
Ok(None) => {}
Err(msg) => strings.push(msg),
} }
} }
} let display_text = strings.join("\n").to_string();
result_msg.set(display_text);
match final_result { }>"Test Comparison"</ActionButton>
Ok(true) => Ok(Some(scholarship)), </Row>
Ok(false) => Ok(None), </Panel>
Err(e) => Err(e) },
} )
}).collect::<Vec<Result<Option<&ExpandableInfo>, String>>>(); }
_ => Either::Left(view! { <p>"Failed API requests."</p> }),
// Build a display string
let mut strings = Vec::new();
strings.push("Scholarship results: \n".to_string());
for result in comparison_results {
match result {
Ok(Some(scholarship_info)) => {
// Get the scholarship name.
let name_member = "name".to_string();
let scl_name = scholarship_info.data.get(&name_member).unwrap()
.as_string().unwrap().unwrap();
strings.push(scl_name);
},
Ok(None) => {},
Err(msg) => strings.push(msg)
}
}
let display_text = strings.join("\n").to_string();
result_msg.set(display_text);
}>"Test Comparison"</ActionButton>
</Row>
</Panel>
}),
_ => Either::Left(view! {
<p>"Failed API requests."</p>
})
} }
}} }}
</Suspense> </Suspense>
+211 -210
View File
@@ -1,29 +1,30 @@
// Server dependencies // Server dependencies
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue}; use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue};
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use serde_dynamo::{from_item, to_item}; use serde_dynamo::{from_item, to_item};
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use std::io::Write;
#[cfg(feature = "ssr")]
use std::process::{Command, Stdio}; 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::{ use crate::components::{
ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row, ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row,
Select, Select, TextFieldType, ValidatedForm
}; };
use crate::input; use crate::input;
use crate::pages::UnauthenticatedPage;
use base64::Engine;
use leptos::leptos_dom::logging::console_log; use leptos::leptos_dom::logging::console_log;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated};
use base64::Engine;
use leptos::logging::log;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use leptos::wasm_bindgen::JsCast;
use leptos::web_sys::HtmlAnchorElement; use leptos::web_sys::HtmlAnchorElement;
use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated}; use leptos::wasm_bindgen::JsCast;
use traits::{AsReactive, ReactiveCapture}; use std::collections::HashMap;
use crate::utils::get_user_claims;
/// # Get Student Info /// # Get Student Info
/// Gets a student's information given their `subject`. /// Gets a student's information given their `subject`.
@@ -143,32 +144,74 @@ pub async fn create_example_pdf() -> Result<Vec<u8>, ServerFnError> {
/// The main home page component. Contains a simple contact form. /// The main home page component. Contains a simple contact form.
#[component] #[component]
pub fn HomePage() -> impl IntoView { pub fn HomePage() -> impl IntoView {
// Creates a reactive value to update the button //#region Server Resources
let user_claims = get_user_claims();
let auth = use_context::<AuthSignal>().expect("Couldn't find user information."); let user_subject = Memo::new(move |_| {
let user_claims = Signal::derive(move || { user_claims.get()
auth.with(|auth| { .map(|claim| claim.claims.subject.clone())
auth.authenticated().and_then(|data| {
data.decoded_access_token::<UserClaims>(Algorithm::RS256, &["account"])
})
})
}); });
// Note that the value passed in MUST be equatable. let form_data = RwSignal::new(HashMap::new());
// 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::new(
let server_resource: Resource<ExpandableInfo> = Resource::new( move || user_subject.get(),
move || user_claims.get().map(|claim| claim.claims.subject.clone()), async move |subject| get_submission(subject.unwrap_or_default()).await
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 submit_action = ServerAction::<CreateSampleSubmission>::new(); 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! { view! {
<AuthLoaded fallback=Loading> <AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage> <Authenticated unauthenticated=UnauthenticatedPage>
@@ -177,195 +220,153 @@ pub fn HomePage() -> impl IntoView {
{move || { {move || {
server_resource server_resource
.get() .get()
.map(|submission| { .map(|_| {
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()
}
}
});
view! { view! {
<Row> <Row>
<div class="flex flex-col flex-1" /> <div class="flex flex-col flex-1" />
<Panel> <Panel>
<Row> <ValidatedForm
<h1 class="text-3xl font-bold"> on_submit=Callback::new(on_submit)
"Region 15 Scholarship Application (DEMO)" title="Home Page Test Form"
</h1> description="This really is just a test."
</Row> >
<Row> <Row>
<p class="text-sm"> <h1 class="text-3xl font-bold">
"This is a very simple demonstration of the scholarship application written in Rust." "Region 15 Scholarship Application (DEMO)"
</p> </h1>
</Row> </Row>
<Row> <Row>
// Text { data_member, label, placeholder } <p class="text-sm">
<OutlinedTextField "This is a very simple demonstration of the scholarship application written in Rust."
label="First Name:" </p>
placeholder="John" </Row>
disabled=elements_disabled <Row>
data_member="first_name" <OutlinedTextField
data_map=expandable_react.data label="First Name:"
/> placeholder="John"
<OutlinedTextField disabled=elements_disabled
label="Last Name:" data_member="first_name"
placeholder="Smith" data_map=form_data
disabled=elements_disabled required=true
data_member="last_name" />
data_map=expandable_react.data <OutlinedTextField
/> label="Last Name:"
</Row> placeholder="Smith"
<Row> disabled=elements_disabled
<OutlinedTextField data_member="last_name"
label="Contact Email:" data_map=form_data
placeholder="student@region15.org" required=true
disabled=elements_disabled />
data_member="contact_email" </Row>
data_map=expandable_react.data <Row>
/> <OutlinedTextField
</Row> label="Contact Email:"
<Row> placeholder="student@region15.org"
<OutlinedTextField disabled=elements_disabled
label="Phone Number:" data_member="contact_email"
placeholder="123-456-7890" data_map=form_data
disabled=elements_disabled input_type=TextFieldType::Email(
data_member="phone_number" vec!["fakeemail.org".to_string(), "gmail.com".to_string()],
data_map=expandable_react.data )
/> required=true
</Row> />
<Row> </Row>
<OutlinedTextField <Row>
label="Street Address:" <OutlinedTextField
placeholder="123 Fake Street" label="Phone Number:"
disabled=elements_disabled placeholder="123-456-7890"
data_member="address" disabled=elements_disabled
data_map=expandable_react.data data_member="phone_number"
/> data_map=form_data
</Row> required=true
<Row> />
<OutlinedTextField </Row>
label="Highest Math SAT Score:" <Row>
placeholder="600" <OutlinedTextField
disabled=elements_disabled label="Street Address:"
data_member="math_sat" placeholder="123 Fake Street"
data_map=expandable_react.data disabled=elements_disabled
input_type="number" data_member="address"
/> data_map=form_data
</Row> required=true
<Row> />
// Radio { data_member, label, items } </Row>
<RadioList <Row>
label="Town:" <OutlinedTextField
items=vec!["Southbury", "Middlebury"] label="Highest Math SAT Score:"
.into_iter() placeholder="600"
.map(|s| s.into()) disabled=elements_disabled
.collect() data_member="math_sat"
disabled=elements_disabled data_map=form_data
data_member="town" input_type=TextFieldType::Number
data_map = expandable_react.data required=true
/> />
</Row> </Row>
<Row> <Row>
// Checkbox { data_member, label, items } <RadioList
<CheckboxList label="Town:"
label="Favorite Candies:" items=vec!["Southbury", "Middlebury"]
items=vec!["Twizzlers", "Reese's", "Starburst"] .into_iter()
.into_iter() .map(|s| s.into())
.map(|s| s.into()) .collect()
.collect() disabled=elements_disabled
disabled=elements_disabled data_member="town"
data_member="favorite_candies" data_map=form_data
data_map=expandable_react.data required=true
/> other_prompt="Other..."
</Row> />
<Row> </Row>
// Select { data_member, label, items } <Row>
<Select <CheckboxList
label="Gender:" label="Favorite Candies:"
value_list=vec!["Male", "Female", "Prefer not to answer"] items=vec!["Twizzlers", "Reese's", "Starburst"]
.into_iter() .into_iter()
.map(|s| s.to_owned()) .map(|s| s.into())
.collect() .collect()
disabled=elements_disabled disabled=elements_disabled
data_member="gender" data_member="favorite_candies"
data_map=expandable_react.data data_map=form_data
/> required=true
</Row> />
<Row> </Row>
<MultiEntry <Row>
data_map=expandable_react.data <Select
data_member="community_involvement" label="Gender:"
schema=vec![ value_list=vec!["Male", "Female", "Prefer not to answer"]
input!(Text, "service_name", "Activity Name:", "Some service activity..."), .into_iter()
input!(Number, "service_hours", "Total Service Hours", "20") .map(|s| s.to_owned())
] .collect()
/> disabled=elements_disabled
</Row> data_member="gender"
<Row> data_map=form_data
<ActionButton required=true
on:click=move |_| { />
let captured_map = expandable_react.capture(); </Row>
console_log( <Row>
format!("Map values: {:?}", captured_map).as_str(), <MultiEntry
); data_map=form_data
submit_action data_member="community_involvement"
.dispatch(CreateSampleSubmission { schema=vec![
student_info: captured_map, input!(
}); Text, "service_name", "Activity Name:", true, "Some service activity..."
} ),
disabled=elements_disabled input!(
> Number, "service_hours", "Total Service Hours", true, "20"
"Submit" ),
</ActionButton> ]
</Row> />
<Row> </Row>
<ActionButton on:click=move |_| { </ValidatedForm>
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>
</Panel> </Panel>
<div class="flex flex-col flex-1" /> <div class="flex flex-col flex-1" />
</Row> </Row>
} }
}) })
}} }} <Row>
<ActionButton on:click=pdf_button_click>"Get PDF"</ActionButton>
</Row>
</Suspense> </Suspense>
</Authenticated> </Authenticated>
</AuthLoaded> </AuthLoaded>
} }.into_any()
} }
+208 -156
View File
@@ -4,10 +4,11 @@ use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValu
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use serde_dynamo::{from_item, to_item}; use serde_dynamo::{from_item, to_item};
use crate::common::{ExpandableInfo, ValueType}; #[cfg(feature = "ssr")]
use crate::components::{ use crate::common::ValueType;
ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select,
}; use crate::common::ExpandableInfo;
use crate::components::{ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select, TextFieldType, Toast, ToastContext, ToastList, ValidatedForm};
use chrono::{FixedOffset, TimeZone}; use chrono::{FixedOffset, TimeZone};
use leptos::Params; use leptos::Params;
use leptos::either::{Either, EitherOf3}; use leptos::either::{Either, EitherOf3};
@@ -18,7 +19,13 @@ use leptos_router::hooks::{use_navigate, use_params};
use leptos_router::params::Params; use leptos_router::params::Params;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; 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)] #[derive(Params, PartialEq)]
struct LoanerParams { 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 /// create a true admin page for this, simply to be able to survey and override/remove information
/// from the lists. /// from the lists.
#[component] #[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. // Check URL params. Depending on what string is visible here, we'll display a different form.
let params = use_params::<LoanerParams>(); let params = use_params::<LoanerParams>();
let form_passed = move || { let form_passed = move || {
@@ -203,20 +210,10 @@ pub fn LoanerPage() -> impl IntoView {
.unwrap_or_default() .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 // We'll create the page here - the sidebar contains all the buttons, while the form
// container either shows forms or shows placeholder text. // container either shows forms or shows placeholder text.
view! { view! {
<Banner <Banner title="Chromebook Loaners" logo="/PHS_Stacked_Acronym.png" path="/loaners" />
title="Chromebook Loaners"
logo="/PHS_Stacked_Acronym.png"
path="/loaners"
/>
<div class="flex m-5"> <div class="flex m-5">
<div class="flex flex-1 flex-row gap-3"> <div class="flex flex-1 flex-row gap-3">
<div id="sidebar" class="flex flex-1 flex-col gap-2"> <div id="sidebar" class="flex flex-1 flex-col gap-2">
@@ -234,99 +231,134 @@ pub fn LoanerPage() -> impl IntoView {
/> />
</div> </div>
<div class="flex flex-2"> <div class="flex flex-2">
<Panel>{form_view}</Panel> <Panel>
<Outlet />
</Panel>
</div> </div>
</div> </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] #[component]
pub fn LoanerBorrowForm() -> impl IntoView { pub fn LoanerBorrowForm() -> impl IntoView {
// Register server actions.
let create_entry_action = ServerAction::<CreateBorrowEntry>::new();
let navigate = use_navigate(); let navigate = use_navigate();
let temp = ExpandableInfo::new(uuid::Uuid::new_v4().to_string()); // Register server actions.
let temp_reactive = temp.as_reactive(); let create_entry_action = ServerAction::<CreateBorrowEntry>::new();
let elements_disabled = RwSignal::new(false);
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! { view! {
<div class="flex flex-col justify-center gap-2 mb-1 py-3 px-2"> <ValidatedForm
<h2 class="text-2xl font-bold">"Borrower Information"</h2> use:animate=pop_in_out()
<p class="text-lg">"Please fill out the form below."</p> on_submit=Callback::new(on_submit)
</div> title="Borrower Information"
<Row> description="Please fill out the form below."
<OutlinedTextField >
label="First Name:" <Row>
placeholder="John" <OutlinedTextField
disabled=elements_disabled label="First Name:"
data_member="first_name" placeholder="John"
data_map=temp_reactive.data disabled=elements_disabled
/> data_member="first_name"
<OutlinedTextField data_map=data
label="Last Name:" required=true
placeholder="Smith" />
disabled=elements_disabled <OutlinedTextField
data_member="last_name" label="Last Name:"
data_map=temp_reactive.data placeholder="Smith"
/> disabled=elements_disabled
</Row> data_member="last_name"
<Row> data_map=data
<OutlinedTextField required=true
label="Region 15 Email:" />
placeholder="example@region15.org" </Row>
disabled=elements_disabled <Row>
data_member="email" <OutlinedTextField
data_map=temp_reactive.data label="Region 15 Email:"
/> placeholder="example@region15.org"
</Row> disabled=elements_disabled
<Row> data_member="email"
<Select data_map=data
label="Collateral:" input_type=TextFieldType::Email(vec!["region15.org".to_string()])
value_list=vec![ required=true
"Phone".to_string(), />
"Earbuds/Headphones".to_string(), </Row>
"Keys".to_string(), <Row>
"Wallet".to_string(), <Select
"Laptop".to_string(), label="Collateral:"
] value_list=vec![
disabled=elements_disabled "Phone".to_string(),
data_member="collateral" "Earbuds/Headphones".to_string(),
data_map=temp_reactive.data "Keys".to_string(),
/> "Wallet".to_string(),
</Row> "Laptop".to_string(),
<Row> ]
<Select disabled=elements_disabled
label="Loan Taken:" data_member="collateral"
value_list=vec![ data_map=data
"Chromebook".to_string(), required=true
"Charger".to_string(), />
"Headphones".to_string(), </Row>
] <Row>
disabled=elements_disabled <Select
data_member="loan" label="Loan Taken:"
data_map=temp_reactive.data value_list=vec![
/> "Chromebook".to_string(),
</Row> "Charger".to_string(),
<Row> "Headphones".to_string(),
<ActionButton ]
on:click=move |_| { disabled=elements_disabled
let captured = temp_reactive.capture(); data_member="loan"
log!("Loaner record taken."); data_map=data
log!("{:?}", captured); required=true
create_entry_action.dispatch(CreateBorrowEntry { />
input: captured </Row>
}); </ValidatedForm>
navigate("/loaners", Default::default());
}
>"Submit"</ActionButton>
</Row>
} }
} }
#[component] #[component]
pub fn LoanerReturnForm() -> impl IntoView { pub fn LoanerReturnForm() -> impl IntoView {
let navigate = use_navigate();
// Register server actions // Register server actions
let check_in_action = ServerAction::<CheckInLoaner>::new(); let check_in_action = ServerAction::<CheckInLoaner>::new();
@@ -344,26 +376,25 @@ pub fn LoanerReturnForm() -> impl IntoView {
let dialog_ref = NodeRef::<Dialog>::new(); let dialog_ref = NodeRef::<Dialog>::new();
let chosen_student = RwSignal::new(None::<LoanerReturnOutput>); 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. // 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) // 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 || { Effect::new(move || {
if let Some(Ok(_)) = check_in_action.value().get() { if let Some(Ok(_)) = check_in_action.value().get() {
// Check the local loaner list and remove the indicated student from it. // Return to the main page.
return_list_refresh.update(|n| *n += 1);
if let Some(dialog) = dialog_ref.get() { if let Some(dialog) = dialog_ref.get() {
dialog.close(); dialog.close();
} }
} toasts.toast(
}); Toast::new()
.id(uuid::Uuid::new_v4())
// We can't use a Memo in our custom components for the disabled signal. .header("Check in successful!")
Effect::new(move || { .msg("Don't forget to take your collateral.")
if check_in_action.pending().get() { );
dialog_disabled.set(true); navigate("/loaners", Default::default());
} else { check_in_action.clear();
dialog_disabled.set(false);
} }
}); });
@@ -387,69 +418,90 @@ pub fn LoanerReturnForm() -> impl IntoView {
view! { view! {
<dialog <dialog
use:animate=pop_in_out()
class="p-3 m-auto rounded-md shadow-lg/33 backdrop:backdrop-blur-xs backdrop:transition-backdrop-filter" class="p-3 m-auto rounded-md shadow-lg/33 backdrop:backdrop-blur-xs backdrop:transition-backdrop-filter"
node_ref=dialog_ref> node_ref=dialog_ref
>
<h2 class="text-2xl font-bold">"Confirmation"</h2> <h2 class="text-2xl font-bold">"Confirmation"</h2>
<h4 class="text-xl mb-3"> <h4 class="text-xl mb-3">
{move || chosen_student.with(|opt_student| { {move || {
if let Some(student) = opt_student.as_ref() { chosen_student
format!("Are you sure want to check in {} {}?", student.first_name, student.last_name) .with(|opt_student| {
} else { if let Some(student) = opt_student.as_ref() {
"There is no student selected.".to_string() format!(
} "Are you sure want to check in {} {}?",
})} student.first_name,
student.last_name,
)
} else {
"There is no student selected.".to_string()
}
})
}}
</h4> </h4>
<ActionButton on:click=close_dialog disabled=dialog_disabled>"Close"</ActionButton> <ActionButton on:click=close_dialog disabled=dialog_disabled>
<ActionButton on:click=dialog_check_in disabled=dialog_disabled>"Check In"</ActionButton> "Close"
</ActionButton>
<ActionButton on:click=dialog_check_in disabled=dialog_disabled>
"Check In"
</ActionButton>
</dialog> </dialog>
<div class="flex flex-col justify-center gap-2 mb-3 py-3 px-2">
<h2 class="text-2xl font-bold">"Student List"</h2> <div class="flex flex-col gap-2 m-2" use:animate=pop_in_out()>
<p class="text-lg">"Please select your name from the list."</p> <div class="flex flex-col justify-center gap-2 mb-3 py-3 px-2">
</div> <h2 class="text-2xl font-bold">"Student List"</h2>
<Suspense fallback=move || "Loading...".into_view()> <p class="text-lg">"Please select your name from the list."</p>
<div class="flex flex-col gap-2 m-2"> </div>
<Suspense fallback=move || {
"Loading...".into_view()
}>
{move || { {move || {
match return_list_resource.get() { match return_list_resource.get() {
Some(loaner_list) => { Some(loaner_list) => {
let list_view = loaner_list.iter().map(|student| { let list_view = loaner_list
let on_click = { .iter()
let student_click = student.clone(); .map(|student| {
move |_| { let on_click = {
log!("Running on_click using student {:?}", student_click); let student_click = student.clone();
if let Some(dialog) = dialog_ref.get() { move |_| {
chosen_student.set(Some(student_click.clone())); log!("Running on_click using student {:?}", student_click);
dialog.show_modal().expect("Couldn't show modal dialog."); if let Some(dialog) = dialog_ref.get() {
chosen_student.set(Some(student_click.clone()));
dialog.show_modal().expect("Couldn't show modal dialog.");
}
} }
};
let naive_time = chrono::NaiveDateTime::parse_from_str(
&student.date,
"%H:%M, %m/%d/%Y",
)
.unwrap();
let time_zone = FixedOffset::west_opt(5 * 3600).unwrap();
let student_date = time_zone
.from_utc_datetime(&naive_time)
.format("%l:%M %p, %m/%d/%Y")
.to_string();
log!("Found current date {:?}", student_date);
view! {
<div
class="flex flex-row p-3 rounded-md cursor-pointer transition-shadow shadow-sm hover:shadow-lg/33"
on:click=on_click
>
<div class="flex-1">
{student.first_name.clone()}" "{student.last_name.clone()}
</div>
<div class="flex-none">{student_date.clone()}</div>
</div>
} }
}; })
.collect_view();
let naive_time = chrono::NaiveDateTime::parse_from_str(&student.date, "%H:%M, %m/%d/%Y")
.unwrap();
let time_zone = FixedOffset::west_opt(5 * 3600).unwrap();
let student_date = time_zone.from_utc_datetime(&naive_time)
.format("%l:%M %p, %m/%d/%Y")
.to_string();
log!("Found current date {:?}", student_date);
view! {
<div class="flex flex-row p-3 rounded-md cursor-pointer transition-shadow shadow-sm hover:shadow-lg/33" on:click=on_click>
<div class="flex-1">
{student.first_name.clone()}" "{student.last_name.clone()}
</div>
<div class="flex-none">
{student_date.clone()}
</div>
</div>
}
}).collect_view();
Either::Left(list_view) Either::Left(list_view)
}, }
None => Either::Right("No loaners found.".into_view()) None => Either::Right("No loaners found.".into_view()),
} }
}} }}
</div> </Suspense>
</Suspense> </div>
} }
} }
+6 -3
View File
@@ -7,9 +7,12 @@ mod provider_portal;
mod scholarship_info; mod scholarship_info;
mod test_page; mod test_page;
mod unauthenticated_page; mod unauthenticated_page;
mod utils; mod provider_contact;
mod auth_callback;
mod provider_applicants;
pub use self::{ pub use self::{
about_page::*, comparison_test::*, home_page::*, loaner_page::LoanerPage, provider_portal::*, about_page::*, comparison_test::*, home_page::*, loaner_page::*, provider_portal::*,
test_page::*, unauthenticated_page::*, test_page::*, unauthenticated_page::*, scholarship_info::*, provider_contact::*,
auth_callback::*, provider_applicants::*
}; };
+15
View File
@@ -0,0 +1,15 @@
use leptos::prelude::*;
use crate::components::Banner;
#[component]
pub fn ApplicantsPageFallback() -> impl IntoView {
view! {
<Banner title="R15 Scholarships" logo="/PHS_Stacked_Acronym.png" path="/providers" />
<div class="flex flex-col gap-4 mt-3 items-center justify-center">
<h1 class="text-3xl font-bold">"Applicants Page"</h1>
<p>"This page is under construction! It will show all the students that are eligible for your scholarship(s)."</p>
<p>"Come back soon!"</p>
</div>
}
}
+228
View File
@@ -0,0 +1,228 @@
#[cfg(feature = "ssr")]
use crate::utils::server::{create_dynamo_client, into_attr_map};
use std::collections::HashMap;
use leptos::logging::log;
use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated};
use crate::common::ValueType;
use crate::components::{Banner, Loading, OutlinedTextField, Panel, Row, TextFieldType, Toast, ToastContext, ToastList, ValidatedForm};
use crate::pages::UnauthenticatedPage;
use crate::utils::get_user_claims;
#[cfg(feature = "ssr")]
static PROVIDER_CONTACT_TABLE: &str = "leptos-provider-contacts";
#[server]
async fn get_provider_contact(id: String) -> Result<HashMap<String, ValueType>, ServerFnError> {
use aws_sdk_dynamodb::types::AttributeValue;
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
let client = create_dynamo_client().await;
log!("Getting contact info for provider {}", id);
match client
.get_item()
.table_name(PROVIDER_CONTACT_TABLE)
.key("subject", AttributeValue::S(id))
.send()
.await
{
Ok(output) => {
let Some(item) = output.item else {
return Ok(HashMap::new());
};
let map = item.iter().map(|(key, val)| {
let val_type = ValueType::from(val);
(key.clone(), val_type)
}).collect::<HashMap<String, ValueType>>();
Ok(map)
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred.");
Err(ServerFnError::new(msg))
}
}
}
#[server]
async fn put_provider_contact(id: String, contact_info: HashMap<String, ValueType>) -> Result<(), ServerFnError> {
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
let client = create_dynamo_client().await;
let mut item = into_attr_map(contact_info);
item.insert("subject".to_owned(), aws_sdk_dynamodb::types::AttributeValue::S(id));
match client
.put_item()
.table_name(PROVIDER_CONTACT_TABLE)
.set_item(Some(item))
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred.");
Err(ServerFnError::new(msg))
}
}
}
#[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="student@region15.org"
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>
}
}
+59 -34
View File
@@ -1,42 +1,67 @@
use crate::components::{Banner, DashboardButton}; use crate::components::{Banner, DashboardButton, DateList, Loading};
use leptos::prelude::*; use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated};
use crate::pages::api::get_important_dates;
use crate::pages::UnauthenticatedPage;
#[component] #[component]
pub fn ProviderPortal() -> impl IntoView { pub fn ProviderPortal() -> impl IntoView {
view! { let dates_resource = OnceResource::new(async move {
<Banner get_important_dates().await
title="Provider Dashboard" });
logo="PHS_Stacked_Acronym.png"
path="/"
/>
<div class="mx-auto px-6">
<div class="container mx-auto px-6 py-8 space-y-8">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<section class="space-y-4">
<DashboardButton
title="Profile"
description="Edit user profile"
icon="/Person_Black.png"
path="/"
/>
<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">
</section> let dates_list = RwSignal::new(Vec::new());
Effect::new(move || {
if let Some(Ok(mut dates)) = dates_resource.get() {
dates.sort_by_key(|info| info.date.get_status());
dates_list.set(dates);
}
});
view! {
<Banner title="Provider Dashboard" logo="PHS_Stacked_Acronym.png" />
<AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage>
<div class="mx-auto px-6">
<div class="container mx-auto px-6 py-8 space-y-8">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<section class="space-y-4">
<DashboardButton
title="Profile"
description="Edit user profile"
icon="/Person_Black.png"
path="/providers/profile"
/>
<DashboardButton
title="Create Scholarship"
description="Navigate to Scholarship Creation Page"
icon="/Create_Black.png"
path="/providers/scholarships"
/>
<DashboardButton
title="Applicants"
description="View Scholarship Applicants"
icon="/Form_Black.png"
path="/providers/applicants"
/>
</section>
<section class="space-y-4">
<div class="rounded-lg shadow-lg/25 overflow-hidden">
<div class="bg-red-900 text-white px-4 py-3 shadow-lg">
<h3 class="text-white font-bold">Important Dates</h3>
</div>
<div class="p-2">
<div class="flex flex-col p-3 gap-3">
<DateList dates=dates_list />
</div>
</div>
</div>
</section>
</div>
</div>
</div> </div>
</div> </Authenticated>
</div> </AuthLoaded>
} }
} }
+683 -224
View File
@@ -1,261 +1,720 @@
use super::UnauthenticatedPage; use std::collections::HashMap;
use super::api::{ use leptos::either::Either;
CreateScholarshipInfo, CreateTestComparisons, get_comparison_info, get_scholarship_info, use leptos::html::Dialog;
};
use crate::common::ComparisonData;
use crate::common::{ExpandableInfo, ScholarshipFormParams};
use crate::components::{
ActionButton, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row,
};
use leptos::logging::log; use leptos::logging::log;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated}; use leptos_oidc::{AuthLoaded, Authenticated};
use leptos_router::hooks::use_params; use leptos_router::hooks::{use_navigate, use_params};
use traits::{AsReactive, ReactiveCapture}; 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 /// # 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 /// 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 /// track a single scholarship (for now) and so selection of what scholarship will be edited
/// must be handled *before* navigation to this page. /// 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] #[component]
pub fn ScholarshipInfoPage() -> impl IntoView { 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 url_params = use_params::<ScholarshipFormParams>();
let scholarship_id = move || { let scholarship_id = Memo::new(move |_| {
url_params url_params.read()
.read()
.as_ref() .as_ref()
.ok() .ok()
.and_then(|params| params.id.clone()) .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 // Remember that we only want to show the page after we've got authentication information
// and if there's data for us to use. // and if there's data for us to use.
view! { view! {
<AuthLoaded fallback=Loading> <Banner title="R15 Scholarships" logo="/PHS_Stacked_Acronym.png" path="/providers" />
<Authenticated unauthenticated=UnauthenticatedPage> <ToastList>
<Suspense fallback=Loading> <AuthLoaded fallback=Loading>
<Row> <Authenticated unauthenticated=UnauthenticatedPage>
<div class="flex flex-row align-top items-start">
<div class="flex flex-col flex-1" /> <div class="flex flex-col flex-1" />
{move || Suspend::new(async move { <ScholarshipList refresh_token=list_refresh on_delete=on_delete />
let response = get_scholarship_action.get(); <Transition fallback=Loading>
let comparisons = get_comparison_info.get(); <ScholarshipForm
scholarship_id=scholarship_id
response on_submit=move || {
.zip(comparisons) list_refresh.update(|n| *n += 1);
.map(|(response, comparison_list)| view! { }
<ScholarshipForm />
response=response </Transition>
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>
<div class="flex flex-col flex-1" /> <div class="flex flex-col flex-1" />
</Row> </div>
</Suspense> </Authenticated>
</Authenticated> </AuthLoaded>
</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] #[component]
fn ScholarshipForm( fn ScholarshipList(
response: ExpandableInfo, /// A signal that refreshes the list when changed. Most times, we don't need to worry about the
comparison_list: Vec<ComparisonData>, /// user reloading our page 2 billion times.
submit_action: ServerAction<CreateScholarshipInfo>, #[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 { ) -> impl IntoView {
let reactive_info = response.as_reactive(); // List state
let elements_disabled = RwSignal::new(false); let pending_delete = RwSignal::new(None::<ExpandableInfo>);
let result_msg = Signal::derive(move || { let delete_dialog_ref = NodeRef::<Dialog>::new();
if submit_action.pending().get() {
elements_disabled.set(true); // Get scholarship provider information from authentication. This element will not display
"Sending request...".to_string() // unless that information is available.
} else { let user_claims = get_user_claims();
elements_disabled.set(false); let provider_id = Memo::new(move |_|
if let Some(result) = submit_action.value().get() { user_claims.get()
match result { .as_ref()
Ok(()) => "Request sent successfully".to_string(), .map(|info| info.claims.subject.clone())
Err(err) => { );
format!("An error occurred: {:?}", err)
} // Register scholarship server resource
} let scholarships = Resource::new(
move || {
(refresh_token.get(), provider_id.get())
},
|(_, provider_id)| async move {
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 { } else {
"".to_string() dialog.close();
} }
} }
}); });
// We'll collect the scholarship's name, num_awards, amount_per_award, total_awards, let modal_on_click_continue = move |_| {
// fafsa_required, award_to, transcript_required, recipient_selection, essay_requirement (and prompt) let subject = pending_delete
// award_night_remarks .get()
.map(|s| s.subject.clone())
.unwrap_or_default();
log!("Deleting scholarship with subject {}", subject);
delete_action
.dispatch(DeleteProviderScholarship {
scholarship_id: subject,
provider_id: provider_id.get().unwrap_or_default(),
});
};
view! {
<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(
/// 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 {
//#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 {
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") {
log!("Found requirements map: {:?}", map);
map.clone()
} else {
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(()))
}
}
});
let mut toasts = expect_context::<ToastContext>();
let on_submit = move |_| {
let mut info = ExpandableInfo::new(scholarship_id.get().unwrap_or_default());
info.data = form_data.get();
info.data.insert("requirements".to_string(), ValueType::Map(Some(chips_data.get())));
log!("Map values: {:?}", info.data);
submit_action.dispatch(CreateScholarshipInfo {
info
});
};
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.toast(toast);
});
},
false
);
//#endregion
view! { view! {
<Panel> <Panel>
<Row> <Show
<h1 class="text-3xl font-bold"> when=move || scholarship_id.get().is_some()
"Scholarship Info Form (test)" fallback=|| {
</h1> view! { <p>"Choose a scholarship from the right, or create a new one."</p> }
</Row> }
<Row> >
<OutlinedTextField <Suspense fallback=Loading>
label="Scholarship Name" {move || {
placeholder="Example Scholarship Name" scholarship_info
disabled=elements_disabled .get()
data_member="name" .map(|res_scholarship| {
data_map=reactive_info.data match res_scholarship {
/> Ok(_) => {
</Row> Either::Left(
<Row> view! {
<OutlinedTextField <ValidatedForm
label="Amount per award: (if multiple, enter the highest value)" on_submit=Callback::new(on_submit)
placeholder="Enter an amount..." title="Scholarship Info Form"
disabled=elements_disabled description="Create or edit a scholarship. Input all information and click Submit."
data_member="amount_per_award" >
data_map=reactive_info.data <Row>
input_type="number" <OutlinedTextField
/> label="Scholarship Name"
</Row> placeholder="Example Scholarship Name"
<Row> disabled=elements_disabled
<OutlinedTextField data_member="name"
label="Total number of awards:" data_map=form_data
placeholder="Enter an amount..." />
disabled=elements_disabled </Row>
data_member="num_awards" <Row>
data_map=reactive_info.data <OutlinedTextField
input_type="number" label="Amount per award: (if multiple, enter the highest value)"
/> placeholder="Enter an amount..."
</Row> disabled=elements_disabled
<Row> data_member="amount_per_award"
<OutlinedTextField data_map=form_data
label="Total amount of all awards:" input_type=TextFieldType::Number
placeholder="Enter an amount..." />
disabled=elements_disabled </Row>
data_member="total_awards" <Row>
data_map=reactive_info.data <OutlinedTextField
input_type="number" label="Total number of awards:"
/> placeholder="Enter an amount..."
</Row> disabled=elements_disabled
<Row> data_member="num_awards"
<RadioList data_map=form_data
data_member="fafsa_required" input_type=TextFieldType::Number
data_map=reactive_info.data />
items=vec!["Yes".to_string(), "No".to_string()] </Row>
disabled=elements_disabled <Row>
label="Do you require student financial information?" <OutlinedTextField
/> label="Total amount of all award(s): (estimates are acceptable)"
</Row> placeholder="Enter an amount..."
<Row> disabled=elements_disabled
<RadioList data_member="total_awards"
data_member="transcript_required" data_map=form_data
data_map=reactive_info.data input_type=TextFieldType::Number
items=vec!["Yes".to_string(), "No".to_string()] />
disabled=elements_disabled </Row>
label="Do you require a student transcript?" <Row>
/> <RadioList
</Row> data_member="fafsa_required"
<Row> data_map=form_data
<RadioList items=vec!["Yes".to_string(), "No".to_string()]
data_member="award_to" disabled=elements_disabled
data_map=reactive_info.data label="Do you require student FAFSA information?"
items=vec!["School".to_string(), "Student".to_string()] />
disabled=elements_disabled </Row>
label="Will the award be made to the school or the student?" <Row>
/> <RadioList
</Row> data_member="transcript_required"
<Row> data_map=form_data
<RadioList items=vec!["Yes".to_string(), "No".to_string()]
data_member="essay_required" disabled=elements_disabled
data_map=reactive_info.data label="Do you require a student transcript?"
items=vec!["Yes".to_string(), "No".to_string()] />
disabled=elements_disabled </Row>
label="Do you require a student essay?" <Row>
/> <RadioList
</Row> data_member="award_to"
<Row> data_map=form_data
<RadioList items=vec!["University".to_string(), "Student".to_string()]
data_member="essay_prompt" disabled=elements_disabled
data_map=reactive_info.data label="Will the award be made to the school or the student?"
items=vec!["Test 1", "Test 2", "Test 3"].iter().map(|s| s.to_string()).collect() />
disabled=elements_disabled </Row>
label="If so, select a prompt from the list below." <Row>
/> <RadioList
</Row> data_member="essay_prompt"
<Row> data_map=form_data
<ChipsList items=vec![
label="Scholarship Requirements" "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.",
data_member="requirements" "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?",
data_map=reactive_info.data "How has participation in sports been beneficial to your personal, academic, and emotional growth?",
values=comparison_list ]
.iter() .iter()
.cloned() .map(|prompt| prompt.to_string())
.map(|comp| comp.id) .collect()
.collect() disabled=elements_disabled
displayed_text=comparison_list label="Select an essay prompt, or leave blank if none is required:"
.iter() other_prompt="Custom prompt..."
.cloned() />
.map(|comp| comp.display_text) </Row>
.collect() <Header
/> title="Eligibility Requirements"
</Row> description="Choose any requirements that this scholarship may have. If there are no extra requirements,
<Row> leave all options blank, however please note that this will apply all students to your scholarship."
<ActionButton />
disabled=elements_disabled <Row>
on:click=move |_| { <ChipsList
let captured = reactive_info.capture(); label="GPA Requirements"
log!("Map values: {:?}", captured); data_member="gpa"
submit_action.dispatch(CreateScholarshipInfo { data_map=chips_data
info: captured values=gpa_ids
}); displayed_text=gpa_text
} allows_multiple=false
>"Submit"</ActionButton> />
</Row> </Row>
<Row> <Row>
<p>{result_msg}</p> <ChipsList
</Row> label="Sports Participation"
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> </Panel>
} }.into_any()
} }
+69 -21
View File
@@ -1,28 +1,76 @@
use crate::components::DashboardButton; use chrono::DateTime;
use crate::components::ActionButton;
use leptos::prelude::*; use leptos::prelude::*;
use crate::common::{DateInfo, DateRange};
use crate::pages::api::{CreateDates, CreateTestComparisons};
fn get_important_dates() -> Vec<DateInfo> {
vec! [
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Provider Forms Open".to_string(),
date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-01-20T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-03-03T23:59:00-05:00").unwrap()
),
description: "".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Student Forms Open".to_string(),
date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-03-03T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-03-27T14:05:00-05:00").unwrap()
),
description: "".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Scholarship Decision Window".to_string(),
date: DateRange::Range(
DateTime::parse_from_rfc3339("2026-04-10T07:15:00-05:00").unwrap(),
DateTime::parse_from_rfc3339("2026-05-01T23:59:00-05:00").unwrap()
),
description: "Start date may change if available sooner.".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "PHS Scholarship Committee".to_string(),
date: DateRange::Single(
DateTime::parse_from_rfc3339("2026-05-18T00:00:00-05:00").unwrap(),
),
description: "Time TBD".to_string(),
},
DateInfo {
id: uuid::Uuid::new_v4().to_string(),
title: "Scholarship and Awards Night".to_string(),
date: DateRange::Single(
DateTime::parse_from_rfc3339("2026-06-09T00:00:00-05:00").unwrap(),
),
description: "Time TBD".to_string(),
},
]
}
#[component] #[component]
pub fn TestPage() -> impl IntoView { pub fn TestPage() -> impl IntoView {
let create_comparisons = ServerAction::<CreateTestComparisons>::new();
let create_dates = ServerAction::<CreateDates>::new();
let on_click_comparisons = move |_| {
create_comparisons.dispatch(CreateTestComparisons{});
};
let on_click_dates = move |_| {
create_dates.dispatch(CreateDates{dates: get_important_dates()});
};
view! { view! {
<div class="p-6 space-y-4 max-w-1/3"> <div class="mx-auto px-6">
<DashboardButton <div class="flex flex-col gap-4">
title="Home Page" <ActionButton on:click=on_click_comparisons>"Create Comparisons"</ActionButton>
description="Navigate to Home Page" <ActionButton on:click=on_click_dates>"Create Dates"</ActionButton>
icon="Book.png" </div>
path="/"
/>
<DashboardButton
title="Home Page"
description="Navigate to Home Page"
icon="Calendar.png"
path="/"
/>
<DashboardButton
title="Home Page"
description="Navigate to Home Page"
icon="Edit.png"
path="/"
/>
</div> </div>
} }.into_any()
} }
+23 -15
View File
@@ -2,35 +2,43 @@ use crate::components::ActionButton;
use leptos::logging::log; use leptos::logging::log;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_oidc::AuthSignal; use leptos_oidc::AuthSignal;
use leptos_router::hooks::{use_navigate, use_location};
#[component] #[component]
pub fn UnauthenticatedPage() -> impl IntoView { pub fn UnauthenticatedPage() -> impl IntoView {
let auth = use_context::<AuthSignal>().expect("AuthSignal not present in LoginLink"); let auth = use_context::<AuthSignal>().expect("AuthSignal not present in LoginLink");
let login_url = move || { let login_url = Memo::new(move |_| {
auth.with(|auth| { auth.with(|auth| {
auth.unauthenticated() auth.unauthenticated()
.map(|unauthenticated| unauthenticated.login_url()) .map(|unauthenticated| unauthenticated.login_url())
}) })
});
Effect::new(move || log!("Generated login url: {:?}", login_url.get()));
let navigate = use_navigate();
let location = use_location();
let on_click = move |_| {
let current_path = location.pathname.get();
let storage_opt = window().session_storage().ok().flatten();
if let Some(session_storage) = storage_opt {
let _ = session_storage.set_item("post_login_path", &current_path);
}
if let Some(url) = login_url.get() {
navigate(&url, Default::default())
}
}; };
Effect::new(move || log!("Generated login url: {:?}", login_url())); view! {
let navigate = leptos_router::hooks::use_navigate();
view! (
<div class="flex h-screen justify-center items-center pb-50"> <div class="flex h-screen justify-center items-center pb-50">
<div class="bg-white p-8 border rounded-lg shadow-lg"> <div class="bg-white p-8 border rounded-lg shadow-lg">
<h3> "You're not signed in. Please sign in with your Region 15 email." </h3> <h3>"You're not signed in. Please sign in with your Region 15 email."</h3>
<div class="flex pt-4 justify-center items-center"> <div class="flex pt-4 justify-center items-center">
<ActionButton on:click=move |_| { <ActionButton on:click=on_click>"Sign in"</ActionButton>
if let Some(url) = login_url() {
navigate(&url, Default::default())
}
}>
"Sign in"
</ActionButton>
</div> </div>
</div> </div>
</div> </div>
) }
} }
-19
View File
@@ -1,19 +0,0 @@
/// # Server Utilities
///
/// These utility functions can only be used from the server. They are created to use server-side
/// dependencies like the AWS SDK. Use of these functions will crash the front end, as these
/// dependencies cannot be compiled for WASM (nor should they be).
#[cfg(feature = "ssr")]
pub mod server_utils {
use aws_config::SdkConfig;
/// Creates an [`SdkConfig`] struct for use with AWS SDK structs.
pub async fn create_aws_config() -> SdkConfig {
aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await
}
/// Creates a DynamoDB client.
pub async fn create_dynamo_client() -> aws_sdk_dynamodb::Client {
aws_sdk_dynamodb::Client::new(&create_aws_config().await)
}
}
+33
View File
@@ -0,0 +1,33 @@
use leptos::prelude::*;
use leptos_oidc::{Algorithm, AuthSignal, TokenData};
use crate::common::UserClaims;
pub fn use_origin() -> String {
#[cfg(target_arch = "wasm32")] {
use leptos::web_sys;
// Just get the current URL origin.
web_sys::window()
.unwrap()
.location()
.origin()
.unwrap_or("http://localhost:3000/".to_string())
}
#[cfg(not(target_arch = "wasm32"))] {
// Read the expected origin out of an environment variable.
std::env::var("LP_SITE_ORIGIN").unwrap_or("http://localhost:3000/".to_string())
}
}
/// Gets the current user claims. This function should only be used in an area that has
/// access to an AuthSignal, or it will result in a total failure.
pub fn get_user_claims() -> Signal<Option<TokenData<UserClaims>>> {
let auth = use_context::<AuthSignal>().expect("Couldn't find AuthSignal.");
Signal::derive(move || {
auth.with(|auth| {
auth.authenticated().and_then(|data| {
data.decoded_access_token::<UserClaims>(Algorithm::RS256, &["account"])
})
})
})
}
+4 -22
View File
@@ -1,23 +1,5 @@
use crate::common::{ExpandableInfo, ValueType}; #[cfg(feature = "ssr")]
use std::ops::ControlFlow; pub mod server;
pub fn get_number_list(student_info: &ExpandableInfo, member: &String) -> Result<Vec<f64>, String> { mod common;
match student_info.data.get(member) { pub use common::*;
Some(ValueType::List(Some(list))) => {
let temp = list.iter().try_fold(vec![], |mut list_acc, item| {
if let ValueType::Number(Some(number)) = item {
list_acc.push(number.parse::<f64>().unwrap());
ControlFlow::Continue(list_acc)
} else {
ControlFlow::Break(list_acc)
}
});
match temp {
ControlFlow::Continue(list) => Ok(list),
ControlFlow::Break(_) => return Err("List contains non-number values".to_owned()),
}
}
_ => return Err("Couldn't find the list".to_owned()),
}
}
+17
View File
@@ -0,0 +1,17 @@
use std::collections::HashMap;
use aws_config::SdkConfig;
use crate::common::ValueType;
/// Creates an [`SdkConfig`] struct for use with AWS SDK structs.
pub async fn create_aws_config() -> SdkConfig {
aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await
}
/// Creates a DynamoDB client.
pub async fn create_dynamo_client() -> aws_sdk_dynamodb::Client {
aws_sdk_dynamodb::Client::new(&create_aws_config().await)
}
pub fn into_attr_map(map: HashMap<String, ValueType>) -> HashMap<String, aws_sdk_dynamodb::types::AttributeValue> {
map.into_iter().map(|(k, v)| (k, v.into())).collect()
}