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
This commit is contained in:
commit
26ee1cf7b4
50 files changed
+3428
-2237
No files matched your search
+10
-1
@@ -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
+614
-753
File diff suppressed because it is too large.
Load diff
+4
-1
@@ -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,14 @@ uuid = { version = "1.17.0", features = ["serde", "v4", "js"] }
|
|||||||
leptos_oidc = "0.9.0"
|
leptos_oidc = "0.9.0"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
chrono = "0.4.42"
|
chrono = "0.4.42"
|
||||||
|
regex = "1.11.2"
|
||||||
|
leptos_animate = "0.1.9"
|
||||||
|
|
||||||
macros = { path = "./macros" }
|
macros = { path = "./macros" }
|
||||||
traits = { path = "./traits" }
|
traits = { path = "./traits" }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
default = ["ssr"]
|
||||||
hydrate = [
|
hydrate = [
|
||||||
"leptos/hydrate",
|
"leptos/hydrate",
|
||||||
"dep:console_error_panic_hook",
|
"dep:console_error_panic_hook",
|
||||||
|
|||||||
+76
-126
@@ -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.
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
target
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "signup-lambda"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
|
||||||
|
lambda_runtime = "1.0.1"
|
||||||
|
aws-sdk-cognitoidentityprovider = "1.105.0"
|
||||||
|
serde = "1"
|
||||||
|
tokio = { version = "1", features = ["macros"] }
|
||||||
|
aws-config = "1.8.12"
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Introduction
|
||||||
|
|
||||||
|
signup-lambda is a Rust project that implements an AWS Lambda function in Rust.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Rust](https://www.rust-lang.org/tools/install)
|
||||||
|
- [Cargo Lambda](https://www.cargo-lambda.info/guide/installation.html)
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To build the project for production, run `cargo lambda build --release`. Remove the `--release` flag to build for development.
|
||||||
|
|
||||||
|
Read more about building your lambda function in [the Cargo Lambda documentation](https://www.cargo-lambda.info/commands/build.html).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
You can run regular Rust unit tests with `cargo test`.
|
||||||
|
|
||||||
|
If you want to run integration tests locally, you can use the `cargo lambda watch` and `cargo lambda invoke` commands to do it.
|
||||||
|
|
||||||
|
First, run `cargo lambda watch` to start a local server. When you make changes to the code, the server will automatically restart.
|
||||||
|
|
||||||
|
Second, you'll need a way to pass the event data to the lambda function.
|
||||||
|
|
||||||
|
You can use the existent [event payloads](https://github.com/awslabs/aws-lambda-rust-runtime/tree/main/lambda-events/src/fixtures) in the Rust Runtime repository if your lambda function is using one of the supported event types.
|
||||||
|
|
||||||
|
You can use those examples directly with the `--data-example` flag, where the value is the name of the file in the [lambda-events](https://github.com/awslabs/aws-lambda-rust-runtime/tree/main/lambda-events/src/fixtures) repository without the `example_` prefix and the `.json` extension.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo lambda invoke --data-example apigw-request
|
||||||
|
```
|
||||||
|
|
||||||
|
For generic events, where you define the event data structure, you can create a JSON file with the data you want to test with. For example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "test"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, run `cargo lambda invoke --data-file ./data.json` to invoke the function with the data in `data.json`.
|
||||||
|
|
||||||
|
|
||||||
|
Read more about running the local server in [the Cargo Lambda documentation for the `watch` command](https://www.cargo-lambda.info/commands/watch.html).
|
||||||
|
Read more about invoking the function in [the Cargo Lambda documentation for the `invoke` command](https://www.cargo-lambda.info/commands/invoke.html).
|
||||||
|
|
||||||
|
## Deploying
|
||||||
|
|
||||||
|
To deploy the project, run `cargo lambda deploy`. This will create an IAM role and a Lambda function in your AWS account.
|
||||||
|
|
||||||
|
Read more about deploying your lambda function in [the Cargo Lambda documentation](https://www.cargo-lambda.info/commands/deploy.html).
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
use lambda_runtime::LambdaEvent;
|
||||||
|
use aws_sdk_cognitoidentityprovider::Client;
|
||||||
|
use aws_config::BehaviorVersion;
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use aws_sdk_cognitoidentityprovider::error::ProvideErrorMetadata;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct PostConfirmationEvent {
|
||||||
|
version: String,
|
||||||
|
trigger_source: String,
|
||||||
|
region: String,
|
||||||
|
user_pool_id: String,
|
||||||
|
user_name: String,
|
||||||
|
caller_context: CallerContext,
|
||||||
|
request: Request,
|
||||||
|
response: Response
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct CallerContext {
|
||||||
|
client_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct Request {
|
||||||
|
user_attributes: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct Response {}
|
||||||
|
|
||||||
|
fn get_error_message(err: impl ProvideErrorMetadata) -> String {
|
||||||
|
err.message().unwrap_or("An unknown error occurred").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_user_to_group(provider: Client, user_id: String, group_name: impl Into<String>) -> Result<(), String> {
|
||||||
|
let group_name = group_name.into();
|
||||||
|
println!("Adding user {} to group {}", user_id, group_name);
|
||||||
|
match provider
|
||||||
|
.admin_add_user_to_group()
|
||||||
|
.user_pool_id("us-east-1_Lfjuy5zaM")
|
||||||
|
.username(user_id)
|
||||||
|
.group_name(group_name)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(err) => Err(get_error_message(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn handler(event: LambdaEvent<PostConfirmationEvent>) -> Result<PostConfirmationEvent, String> {
|
||||||
|
println!("Found event: {:?}", event.payload);
|
||||||
|
|
||||||
|
let user_id = event.payload.request.user_attributes.get("sub").unwrap().to_string();
|
||||||
|
let config = aws_config::load_defaults(BehaviorVersion::latest()).await;
|
||||||
|
let client = Client::new(&config);
|
||||||
|
|
||||||
|
// Check the client ID of the request to assign the user to the correct location.
|
||||||
|
let res = match event.payload.caller_context.client_id.as_str() {
|
||||||
|
"10jr2h3vtpu9n7gj46pvg5qo2q" => add_user_to_group(client, user_id, "ScholarshipStudents").await,
|
||||||
|
"56c2bqvl021rv8d5mq36blt7jv" => add_user_to_group(client, user_id, "ScholarshipProviders").await,
|
||||||
|
_ => Err("Invalid client ID, denying all user access.".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if res.is_ok() {
|
||||||
|
Ok(event.payload)
|
||||||
|
} else {
|
||||||
|
Err(res.unwrap_err())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
use lambda_runtime::{run, service_fn, tracing, Error};
|
||||||
|
mod handler;
|
||||||
|
|
||||||
|
use handler::handler as function_handler;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Error> {
|
||||||
|
tracing::init_default_subscriber();
|
||||||
|
|
||||||
|
run(service_fn(function_handler)).await
|
||||||
|
}
|
||||||
+27
-31
@@ -1,15 +1,9 @@
|
|||||||
use crate::pages::{
|
use crate::pages::{AboutPage, HomePage, ProviderPortal, ComparisonTestPage, ScholarshipInfoPage, TestPage, ProviderContactPage, LoanerShell, LoanerFallback, LoanerBorrowForm, LoanerReturnForm};
|
||||||
AboutPage, ComparisonTestPage, HomePage, LoanerPage, ProviderPortal, ScholarshipInfoPage,
|
|
||||||
TestPage,
|
|
||||||
};
|
|
||||||
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::{Auth, AuthParameters, AuthSignal, Challenge};
|
||||||
use leptos_router::{
|
use leptos_router::{components::{Route, Router, Routes, ParentRoute}, path};
|
||||||
components::{ParentRoute, Route, Router, Routes},
|
use crate::components::ToastList;
|
||||||
path,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
@@ -50,8 +44,7 @@ pub fn App() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn use_origin() -> String {
|
fn use_origin() -> String {
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")] {
|
||||||
{
|
|
||||||
use leptos::web_sys;
|
use leptos::web_sys;
|
||||||
// Just get the current URL origin.
|
// Just get the current URL origin.
|
||||||
web_sys::window()
|
web_sys::window()
|
||||||
@@ -60,9 +53,8 @@ fn use_origin() -> String {
|
|||||||
.origin()
|
.origin()
|
||||||
.unwrap_or("http://localhost:3000/".to_string())
|
.unwrap_or("http://localhost:3000/".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))] {
|
||||||
{
|
|
||||||
// Read the expected origin out of an environment variable.
|
// Read the expected origin out of an environment variable.
|
||||||
std::env::var("LP_SITE_ORIGIN").unwrap_or("http://localhost:3000/".to_string())
|
std::env::var("LP_SITE_ORIGIN").unwrap_or("http://localhost:3000/".to_string())
|
||||||
}
|
}
|
||||||
@@ -90,7 +82,7 @@ pub fn AppWithRoutes() -> impl IntoView {
|
|||||||
|
|
||||||
let auth = Auth::signal();
|
let auth = Auth::signal();
|
||||||
provide_context(auth); // allows use of this signal in lower areas of the tree without
|
provide_context(auth); // allows use of this signal in lower areas of the tree without
|
||||||
// explicitly passing it through the html tree
|
// explicitly passing it through the html tree
|
||||||
|
|
||||||
let _ = Auth::init(parameters);
|
let _ = Auth::init(parameters);
|
||||||
|
|
||||||
@@ -106,22 +98,26 @@ pub fn AppWithRoutes() -> impl IntoView {
|
|||||||
<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/>
|
<Route path=path!("") view=HomePage/>
|
||||||
<Route path=path!("/test_page") view=TestPage/>
|
<Route path=path!("/about") view=AboutPage/>
|
||||||
<Route path=path!("/comparison") view=ComparisonTestPage />
|
<Route path=path!("/test_page") view=TestPage/>
|
||||||
<Route path=path!("/providers") view=ProviderPortal/>
|
<Route path=path!("/comparison") view=ComparisonTestPage />
|
||||||
<ParentRoute path=path!("/providers/scholarships") view=ScholarshipInfoPage>
|
<Route path=path!("/providers") view=ProviderPortal/>
|
||||||
<Route path=path!(":id") view=ScholarshipInfoPage/>
|
<Route path=path!("/providers/profile") view=ProviderContactPage />
|
||||||
<Route path=path!("") view=ScholarshipInfoPage/>
|
<ParentRoute path=path!("/providers/scholarships") view=ScholarshipInfoPage>
|
||||||
</ParentRoute>
|
<Route path=path!(":id") view=ScholarshipInfoPage/>
|
||||||
<ParentRoute path=path!("loaners") view=LoanerPage>
|
<Route path=path!("") view=ScholarshipInfoPage/>
|
||||||
<Route path=path!("") view=LoanerPage />
|
</ParentRoute>
|
||||||
<Route path=path!(":form_name") view=LoanerPage />
|
<ParentRoute path=path!("loaners") view=LoanerShell>
|
||||||
</ParentRoute>
|
<Route path=path!("") view=LoanerFallback />
|
||||||
</Routes>
|
<Route path=path!("borrowing") view=LoanerBorrowForm />
|
||||||
|
<Route path=path!("returning") view=LoanerReturnForm />
|
||||||
|
</ParentRoute>
|
||||||
|
</Routes>
|
||||||
|
</ToastList>
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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::*};
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
+34
-37
@@ -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
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ mod form_schema;
|
|||||||
mod submit_status;
|
mod submit_status;
|
||||||
mod user;
|
mod user;
|
||||||
mod value_type;
|
mod value_type;
|
||||||
|
pub mod animations;
|
||||||
|
|
||||||
pub use self::{
|
pub use self::{
|
||||||
comparison::*, date_info::*, expandable_info::*, form_params::*, form_schema::*,
|
comparison::*, date_info::*, expandable_info::*, form_params::*, form_schema::*,
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ pub enum SubmitStatus {
|
|||||||
Idle,
|
Idle,
|
||||||
Sending,
|
Sending,
|
||||||
Success,
|
Success,
|
||||||
Error(String),
|
Error(()),
|
||||||
}
|
}
|
||||||
@@ -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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,14 @@ use leptos::prelude::*;
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ActionButton(
|
pub fn ActionButton(
|
||||||
#[prop(default = String::from("button"))] button_type: String,
|
#[prop(default = String::from("button"), into)] button_type: String,
|
||||||
#[prop(optional, into)] disabled: Signal<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 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()
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
use crate::common::ValueType;
|
|
||||||
use crate::components::utils::create_unique_id;
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Checkbox(
|
|
||||||
#[prop()] checked: Signal<bool>,
|
|
||||||
#[prop(into)] on_change: Callback<(), ()>,
|
|
||||||
#[prop(into)] value: String,
|
|
||||||
#[prop(into)] name: String,
|
|
||||||
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let id = create_unique_id(&name, &value);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<label for=id class="flex items-center">
|
|
||||||
<input
|
|
||||||
// Using the "peer" class links this input with the div "peer" at the same level.
|
|
||||||
// By using the "peer-checked" selector this div can toggle states based on this
|
|
||||||
// input's state (checked/unchecked)
|
|
||||||
class="
|
|
||||||
relative peer shrink-0
|
|
||||||
hidden"
|
|
||||||
type="checkbox"
|
|
||||||
id=id.clone()
|
|
||||||
name=name.clone()
|
|
||||||
on:change=move |_| on_change.run(())
|
|
||||||
prop:checked=checked
|
|
||||||
disabled=disabled
|
|
||||||
/>
|
|
||||||
// This div displays the box for the checkbox, and applies some styling to the child
|
|
||||||
// span element.
|
|
||||||
<div class="relative inline-block h-5 w-5 m-1 mr-2 rounded-sm border border-gray-300 bg-white
|
|
||||||
transition-bg duration-150 peer-checked:bg-red-700 peer-checked:border-red-700
|
|
||||||
[&>span]:opacity-0 peer-checked:[&>span]:opacity-100
|
|
||||||
peer-disabled:bg-gray-600/33 peer-disabled:border-gray-600
|
|
||||||
peer-disabled:peer-checked:bg-gray-600">
|
|
||||||
// This span is the check icon. It is centered within the div and has a border on the bottom
|
|
||||||
// and right sides, which is then rotated to appear like a checkmark.
|
|
||||||
<span class="absolute left-1/2 top-1/2 block h-3 w-1.5 -translate-x-1/2 -translate-y-1/2
|
|
||||||
rotate-45 transform border-b-3 border-r-3
|
|
||||||
border-white transition-opacity duration-150"></span>
|
|
||||||
</div>
|
|
||||||
<span>{value.clone()}</span>
|
|
||||||
</label>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn CheckboxList(
|
|
||||||
#[prop(optional)] selected: RwSignal<Vec<String>>,
|
|
||||||
#[prop(into)] data_member: String,
|
|
||||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
|
||||||
#[prop()] items: Vec<String>,
|
|
||||||
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
|
|
||||||
#[prop(optional, into)] label: String,
|
|
||||||
) -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<div class="m-1.5 mt-0 mb-0">
|
|
||||||
<span class="font-bold">{label}</span>
|
|
||||||
{items
|
|
||||||
.into_iter()
|
|
||||||
.map(|item| {
|
|
||||||
let checked_signal = Signal::derive({
|
|
||||||
let item_name = item.clone();
|
|
||||||
let data_member = data_member.clone();
|
|
||||||
move || {
|
|
||||||
data_map.get() // HashMap
|
|
||||||
.get(&data_member) // Option<ValueType>
|
|
||||||
.unwrap_or(&ValueType::List(None)) // ValueType
|
|
||||||
.as_list() // Result<Option<Vec<ValueType>>, ValueType>
|
|
||||||
.unwrap_or(Some(vec!())) // Option<Vec<ValueType>>
|
|
||||||
.unwrap_or(vec!()) // Vec<ValueType>
|
|
||||||
.contains(&ValueType::String(Some(item_name.clone()))) // bool
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let on_change = {
|
|
||||||
let item_name = item.clone();
|
|
||||||
let data_member = data_member.clone();
|
|
||||||
move || {
|
|
||||||
let result = ValueType::String(Some(item_name.clone()));
|
|
||||||
data_map.update(|map| {
|
|
||||||
// Attempt to get the value type from the hash map.
|
|
||||||
match map.get_mut(&data_member) {
|
|
||||||
Some(value_type) => {
|
|
||||||
// Attempt to get the value as a list.
|
|
||||||
match value_type.as_list().unwrap_or(None) {
|
|
||||||
Some(mut list) => {
|
|
||||||
// Attempt to find the selected value in the selected list.
|
|
||||||
// If it's present, remove it, or vice versa.
|
|
||||||
match list.iter().position(|val| *val == result) {
|
|
||||||
Some(index) => { list.remove(index); },
|
|
||||||
None => { list.push(result); }
|
|
||||||
};
|
|
||||||
// Update the existing value_type entry in the hash map.
|
|
||||||
*value_type = ValueType::List(Some(list));
|
|
||||||
},
|
|
||||||
// Insert a new value_type entry.
|
|
||||||
None => { *value_type = ValueType::List(Some(vec![result])); }
|
|
||||||
};
|
|
||||||
},
|
|
||||||
// Insert a new value_type entry.
|
|
||||||
None => { map.insert(data_member.clone(), ValueType::List(Some(vec![result]))); }
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<Checkbox
|
|
||||||
checked=checked_signal
|
|
||||||
on_change=on_change
|
|
||||||
// The actual selected values are tracked by this element, not by the checkboxes themselves.
|
|
||||||
value=item.clone()
|
|
||||||
name=data_member.clone()
|
|
||||||
disabled=disabled
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
use crate::common::ValueType;
|
|
||||||
use crate::components::utils::create_unique_id;
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
/// # Chip Component
|
|
||||||
///
|
|
||||||
/// This component defines a single chip for use in the `ChipsList` component. It functions almost
|
|
||||||
/// identically to the `Checkbox` component. It should only be created from within its parent
|
|
||||||
/// `CheckboxList` component.
|
|
||||||
///
|
|
||||||
/// Example usage:
|
|
||||||
/// ```
|
|
||||||
/// view! {
|
|
||||||
/// <Chip
|
|
||||||
/// value="Some Value"
|
|
||||||
/// name="list_name"
|
|
||||||
/// on_change=move || { println!("Some work here.") }
|
|
||||||
/// checked=RwSignal::new(false)
|
|
||||||
/// disabled=RwSignal::new(false)
|
|
||||||
/// />
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
#[component]
|
|
||||||
pub fn Chip(
|
|
||||||
#[prop(into)] display_text: String,
|
|
||||||
#[prop(into)] value: String,
|
|
||||||
#[prop(into)] name: String,
|
|
||||||
#[prop(into)] on_change: Callback<(), ()>,
|
|
||||||
#[prop()] checked: Signal<bool>,
|
|
||||||
#[prop(optional)] disabled: RwSignal<bool>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
// Generate a unique id - ensure that the value doesn't contain spaces.
|
|
||||||
// Without this id, checkbox/radio inputs can interfere with each other.
|
|
||||||
let id = create_unique_id(&name, &value);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<label for=id>
|
|
||||||
<input
|
|
||||||
r#type="checkbox"
|
|
||||||
class="relative peer shrink-0 hidden"
|
|
||||||
name=name.clone()
|
|
||||||
id=id.clone()
|
|
||||||
prop:checked=checked
|
|
||||||
on:change=move |_| on_change.run(())
|
|
||||||
disabled=disabled />
|
|
||||||
<div class="rounded-full p-2.5
|
|
||||||
cursor-pointer text-base
|
|
||||||
bg-transparent border-1 border-gray-300
|
|
||||||
shadow-md/50 hover:shadow-md/70
|
|
||||||
peer-checked:bg-red-700 peer-checked:border-red-700 peer-checked:text-white
|
|
||||||
peer-disabled:peer-checked:bg-gray-700
|
|
||||||
peer-disabled:peer-checked:border-gray-700
|
|
||||||
peer-disabled:cursor-default
|
|
||||||
transition-all duration-150">
|
|
||||||
<span>{display_text.clone()}</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// # Chips List Component
|
|
||||||
///
|
|
||||||
/// This defines a list of `Chip` components. The list of values is created based on the data from the
|
|
||||||
/// `data_map` prop, which is found using the given `data_member`.
|
|
||||||
///
|
|
||||||
/// Any values selected from the list are stored within the `data_map` immediately as part of a callback
|
|
||||||
/// passed to the `on_change` prop for each individual `Chip` component.
|
|
||||||
#[component]
|
|
||||||
pub fn ChipsList(
|
|
||||||
#[prop(into)] data_member: String,
|
|
||||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
|
||||||
#[prop(into)] displayed_text: Signal<Vec<String>>,
|
|
||||||
#[prop(into)] values: Signal<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">
|
|
||||||
{move || { displayed_text.get().into_iter().zip(values.get())
|
|
||||||
.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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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="m-1.5 mb-5">
|
||||||
|
<div class="text-3xl font-bold">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<Show when=move || show_description.get()>
|
||||||
|
<div>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
mod multi;
|
||||||
|
mod single;
|
||||||
|
|
||||||
|
pub use {
|
||||||
|
multi::*, single::*
|
||||||
|
};
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
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 || {
|
||||||
|
// Update the data list by checking if the value is present in it. If so,
|
||||||
|
// remove it. If not, add it.
|
||||||
|
controller.selected_list.update(|list| {
|
||||||
|
let item = item.get();
|
||||||
|
if list.contains(&item) {
|
||||||
|
list.retain(|val| *val != item);
|
||||||
|
} else {
|
||||||
|
list.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
controller.dirty.set(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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="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 || {
|
||||||
|
// Update the data list by checking if the value is present in it. If so,
|
||||||
|
// remove it. If not, add it.
|
||||||
|
let value = value.get();
|
||||||
|
let list = controller.selected_list.get();
|
||||||
|
|
||||||
|
if list.contains(&value) {
|
||||||
|
allows_multiple.get()
|
||||||
|
.then(|| controller.selected_list.update(|list| list.retain(|v| v != &value)))
|
||||||
|
.or_else(|| Some(controller.selected_list.set(Vec::new())));
|
||||||
|
} else {
|
||||||
|
allows_multiple.get()
|
||||||
|
.then(|| controller.selected_list.update(|list| list.push(value.clone())))
|
||||||
|
.or_else(|| Some(controller.selected_list.set(vec![value.clone()])));
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.dirty.set(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Chip
|
||||||
|
checked=checked
|
||||||
|
on_change=on_change
|
||||||
|
value=value
|
||||||
|
display_text=text
|
||||||
|
name=data_member
|
||||||
|
disabled=disabled
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect_view()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when=move || controller.show_errors.get()>
|
||||||
|
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
|
||||||
|
{move || {
|
||||||
|
match controller.error.get() {
|
||||||
|
ValidationState::Invalid(msg) => msg,
|
||||||
|
_ => "There is no error - should not see this message.".to_string()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
mod utils;
|
||||||
|
mod checkbox;
|
||||||
|
mod chips;
|
||||||
|
|
||||||
|
pub use checkbox::*;
|
||||||
|
pub use chips::*;
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use crate::common::ValueType;
|
||||||
|
use crate::components::{use_validation_context, InputState, ValidationState};
|
||||||
|
|
||||||
|
pub struct ListController {
|
||||||
|
pub error: Signal<ValidationState>,
|
||||||
|
pub dirty: RwSignal<bool>,
|
||||||
|
pub show_errors: Signal<bool>,
|
||||||
|
pub selected_list: RwSignal<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_type_to_vec(value_type: &ValueType) -> Vec<String> {
|
||||||
|
let ValueType::List(Some(list)) = value_type else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
list.iter().map(|val| {
|
||||||
|
val.as_string()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}).collect::<Vec<String>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn use_selectable_list(
|
||||||
|
data_member: Signal<String>,
|
||||||
|
data_map: RwSignal<HashMap<String, ValueType>>,
|
||||||
|
required: Signal<bool>
|
||||||
|
) -> ListController {
|
||||||
|
//#region Central List Logic
|
||||||
|
|
||||||
|
// "Raw" selected values - just strings, no ValueTyping
|
||||||
|
let selected_list = RwSignal::new(Vec::new());
|
||||||
|
let hydrated = RwSignal::new(false);
|
||||||
|
let dirty = RwSignal::new(false);
|
||||||
|
|
||||||
|
// Hydration effect - runs only once, to hydrate data.
|
||||||
|
Effect::new(move || {
|
||||||
|
if hydrated.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// React to data_map changes
|
||||||
|
let map = data_map.get();
|
||||||
|
let key = data_member.get();
|
||||||
|
|
||||||
|
let list = map
|
||||||
|
.get(&key)
|
||||||
|
.unwrap_or(&ValueType::List(None));
|
||||||
|
|
||||||
|
selected_list.set(value_type_to_vec(list));
|
||||||
|
|
||||||
|
hydrated.set(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Effect::new({
|
||||||
|
move || {
|
||||||
|
// Convert selected strings into ValueTypes
|
||||||
|
let typed_list = selected_list.with(|list|
|
||||||
|
list.iter().cloned().map(|val|
|
||||||
|
ValueType::String(Some(val))
|
||||||
|
).collect::<Vec<ValueType>>()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the map
|
||||||
|
data_map.update(|map| {
|
||||||
|
map.insert(data_member.get(), ValueType::List(Some(typed_list)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
//#region Form Validation
|
||||||
|
|
||||||
|
let validation_context = use_validation_context()
|
||||||
|
.expect("FormValidationRegistry was not found");
|
||||||
|
|
||||||
|
let error = Signal::derive(move || {
|
||||||
|
if required.get() && selected_list.get().len() <= 0 {
|
||||||
|
ValidationState::Invalid("This field is required.".to_string())
|
||||||
|
} else {
|
||||||
|
ValidationState::Valid
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let show_errors = Signal::derive(move || dirty.get() && matches!(error.get(), ValidationState::Invalid(_)));
|
||||||
|
|
||||||
|
let validator = RwSignal::new(InputState::new(
|
||||||
|
data_member.get(),
|
||||||
|
error.clone(),
|
||||||
|
dirty.clone()
|
||||||
|
));
|
||||||
|
|
||||||
|
validation_context.validators.update(|list| list.push(validator.clone()));
|
||||||
|
|
||||||
|
on_cleanup(move || {
|
||||||
|
validation_context.validators.update(|list| {
|
||||||
|
list.retain(|v| *v.get_untracked().input_name != *validator.get_untracked().input_name)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
ListController {
|
||||||
|
error,
|
||||||
|
dirty,
|
||||||
|
show_errors,
|
||||||
|
selected_list,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod radio;
|
||||||
|
pub mod select;
|
||||||
|
|
||||||
|
pub use radio::*;
|
||||||
|
pub use select::*;
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
use crate::common::ValueType;
|
||||||
|
use crate::components::utils::create_unique_id;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
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-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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
//#region Value Logic
|
||||||
|
|
||||||
|
// Store a single signal that is the currently selected value. We can only have one selected
|
||||||
|
// value, so there's no list needed. The default selected value is whatever the map has selected
|
||||||
|
// already, or nothing.
|
||||||
|
let default_value = match data_map.get_untracked().get(&data_member.get_untracked()) {
|
||||||
|
Some(ValueType::String(Some(value))) => value.clone(),
|
||||||
|
_ => String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let selected_value = RwSignal::new(default_value);
|
||||||
|
|
||||||
|
// When the selected value changes, update the data map at the correct location
|
||||||
|
Effect::new(move || {
|
||||||
|
data_map.update(|map| {
|
||||||
|
map.insert(data_member.get(), ValueType::String(Some(selected_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)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
//#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()
|
||||||
|
});
|
||||||
|
|
||||||
|
let on_change = move || {
|
||||||
|
// on_change only runs when the radio button is checked, not when unchecked.
|
||||||
|
// Change the selected value to the new one.
|
||||||
|
selected_value.set(item.get());
|
||||||
|
error.set(validate(selected_value.get_untracked(), required.get_untracked()));
|
||||||
|
dirty.set(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
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<_>>()}
|
||||||
|
</div>
|
||||||
|
<Show when=move || show_errors.get()>
|
||||||
|
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
|
||||||
|
{move || {
|
||||||
|
match error.get() {
|
||||||
|
ValidationState::Invalid(msg) => msg,
|
||||||
|
_ => "There is no error - should not see this message.".to_string()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
mod action_button;
|
mod action_button;
|
||||||
mod banner;
|
mod banner;
|
||||||
mod checkbox;
|
|
||||||
mod chips;
|
|
||||||
mod dashboard_button;
|
mod dashboard_button;
|
||||||
mod date;
|
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 use self::{
|
pub use self::{
|
||||||
action_button::*, banner::*, checkbox::*, chips::*, dashboard_button::*, date::*, 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::*
|
||||||
};
|
};
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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(optional, into)] disabled: Signal<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(optional, into)] disabled: Signal<bool>,
|
|
||||||
#[prop(optional, into)] label: String,
|
|
||||||
) -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<div class="m-1.5 mt-0 mb-0">
|
|
||||||
<span class="font-bold">{label}</span>
|
|
||||||
{items
|
|
||||||
.into_iter()
|
|
||||||
.map(|item| {
|
|
||||||
let checked_signal = Signal::derive({
|
|
||||||
let item_name = item.clone();
|
|
||||||
let data_member = data_member.clone();
|
|
||||||
move || {
|
|
||||||
// Check if the value is contained in the data map's fields
|
|
||||||
let map = data_map.get();
|
|
||||||
let value = map.get(&data_member);
|
|
||||||
let mut result = false;
|
|
||||||
if let Some(val) = value {
|
|
||||||
if val.is_string() {
|
|
||||||
result = val.as_string().unwrap().unwrap() == item_name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let on_change = {
|
|
||||||
let item_name = item.clone();
|
|
||||||
let data_member = data_member.clone();
|
|
||||||
move || {
|
|
||||||
// Update the hash map
|
|
||||||
data_map.update(|map| {
|
|
||||||
if let Some(val) = map.get_mut(&data_member) {
|
|
||||||
*val = ValueType::String(Some(item_name.clone()));
|
|
||||||
} else {
|
|
||||||
map.insert(data_member.clone(), ValueType::String(Some(item_name.clone())));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<Radio
|
|
||||||
checked=checked_signal
|
|
||||||
on_change=on_change
|
|
||||||
// The actual selected values are tracked by this element, not by the checkboxes themselves.
|
|
||||||
value=item.clone()
|
|
||||||
name=data_member.clone()
|
|
||||||
disabled=disabled
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
use crate::common::ValueType;
|
|
||||||
use leptos::ev::{Event, Targeted};
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use leptos::web_sys::HtmlSelectElement;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
/// A custom-styled select input.
|
|
||||||
///
|
|
||||||
/// Takes a `Vec<String>` as the set of inputs. Only one value may be selected, which is indicated
|
|
||||||
/// by the `value` prop of the component.
|
|
||||||
#[component]
|
|
||||||
pub fn Select(
|
|
||||||
#[prop()] value_list: Vec<String>,
|
|
||||||
#[prop(optional)] value: RwSignal<String>,
|
|
||||||
#[prop(into)] data_member: String,
|
|
||||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
|
||||||
#[prop(optional, into)] label: String,
|
|
||||||
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let on_change = {
|
|
||||||
let data_member = data_member.clone();
|
|
||||||
move |e: Targeted<Event, HtmlSelectElement>| {
|
|
||||||
let target_value = e.target().value();
|
|
||||||
data_map.update(|map| {
|
|
||||||
if let Some(val) = map.get_mut(&data_member) {
|
|
||||||
*val = ValueType::String(Some(target_value));
|
|
||||||
} else {
|
|
||||||
map.insert(data_member.clone(), ValueType::String(Some(target_value)));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<div class="flex flex-1">
|
|
||||||
<label class="flex flex-col flex-1">
|
|
||||||
<span class="ml-1.5 mb-0 font-bold">{label}</span>
|
|
||||||
<select
|
|
||||||
class="relative flex-1 border-2 m-1.5 mt-0 p-1.5 rounded-md
|
|
||||||
transition-border duration-150
|
|
||||||
border-red-700 bg-transparent
|
|
||||||
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
|
|
||||||
on:change:target=on_change
|
|
||||||
disabled = disabled >
|
|
||||||
// This closure handles the display of the options.
|
|
||||||
{move || {
|
|
||||||
value_list.iter().map(
|
|
||||||
move |value| {
|
|
||||||
let display = value.to_owned();
|
|
||||||
view! { <option value={display}>{display.clone()}</option> }
|
|
||||||
}
|
|
||||||
).collect::<Vec<_>>()
|
|
||||||
}}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+132
-59
@@ -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(into, optional)] disabled: Signal<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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
mod toast_list;
|
||||||
|
mod toast;
|
||||||
|
|
||||||
|
pub use toast_list::*;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_animate::animate;
|
||||||
|
use leptos_animate::animations::classes::{In, Out};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Toast(
|
||||||
|
#[prop(into)] on_close: Callback<()>,
|
||||||
|
#[prop(into)] message: Signal<String>,
|
||||||
|
#[prop(into)] title: Signal<String>
|
||||||
|
) -> impl IntoView {
|
||||||
|
set_timeout(
|
||||||
|
move || {
|
||||||
|
on_close.run(())
|
||||||
|
},
|
||||||
|
Duration::from_secs(3)
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class="flex flex-col rounded-md shadow-lg/33 bg-white p-3 m-1.5 mx-auto min-w-lg max-w-lg"
|
||||||
|
use:animate=(
|
||||||
|
In::default()
|
||||||
|
.source("opacity-0 -translate-y-2 scale-90")
|
||||||
|
.active("transition-all duration-150 transform-gpu")
|
||||||
|
.target("opacity-100 translate-y-0 scale-100"),
|
||||||
|
Out::default()
|
||||||
|
.source("opacity-100 translate-y-0 scale-100")
|
||||||
|
.active("transition-all duration-150 transform-gpu")
|
||||||
|
.target("opacity-0 -translate-y-2 scale-90"),
|
||||||
|
)
|
||||||
|
>
|
||||||
|
<h3 class="font-bold text-lg">{title}</h3>
|
||||||
|
<p>{message}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use crate::components::toasts::toast::Toast;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Toast {
|
||||||
|
id: String,
|
||||||
|
header: String,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Toast {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Toast {
|
||||||
|
id: "".to_owned(),
|
||||||
|
header: "".to_owned(),
|
||||||
|
message: "".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn header(mut self, header: impl Into<String>) -> Self {
|
||||||
|
self.header = header.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn msg(mut self, msg: impl Into<String>) -> Self {
|
||||||
|
self.message = msg.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id(mut self, id: impl Into<String>) -> Self {
|
||||||
|
self.id = id.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct ToastContext {
|
||||||
|
messages: RwSignal<Vec<Toast>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToastContext {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
messages: RwSignal::new(vec![]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toast(&mut self, toast: Toast) {
|
||||||
|
self.messages.update(|list| list.push(toast));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Toast List Component
|
||||||
|
/// Displays a list of toasts. They must be queued using the [`ToastContext`].
|
||||||
|
#[component]
|
||||||
|
pub fn ToastList(
|
||||||
|
children: Children,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// This context should be used wherever the application might need to use a toast.
|
||||||
|
// That specific component should also be wrapped in a ToastList component, otherwise
|
||||||
|
// this context won't exist.
|
||||||
|
let context = use_context::<ToastContext>()
|
||||||
|
.unwrap_or_else(provide_toast_context);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
{children()}
|
||||||
|
<div class="fixed top-1 left-1/2 right-1/2 -translate-x-1/2 w-full">
|
||||||
|
<For
|
||||||
|
each=move || context.messages.get()
|
||||||
|
key=|toast| toast.id.clone()
|
||||||
|
let(Toast { id, header, message })
|
||||||
|
>
|
||||||
|
<Toast
|
||||||
|
message=message
|
||||||
|
title=header
|
||||||
|
on_close=move || {
|
||||||
|
context.messages.update(|list|
|
||||||
|
list.retain(|v| v.id != id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provide_toast_context() -> ToastContext {
|
||||||
|
let context = ToastContext::new();
|
||||||
|
provide_context(context);
|
||||||
|
context
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
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 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ pub mod app;
|
|||||||
mod common;
|
mod common;
|
||||||
mod components;
|
mod components;
|
||||||
mod pages;
|
mod pages;
|
||||||
mod utils;
|
|
||||||
|
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
|||||||
+151
-30
@@ -1,19 +1,25 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
use crate::pages::utils::server_utils::create_dynamo_client;
|
use crate::pages::utils::server_utils::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 leptos::prelude::ServerFnError;
|
use leptos::prelude::ServerFnError;
|
||||||
use leptos::server;
|
use leptos::server;
|
||||||
|
|
||||||
|
static COMPARISONS_TABLE: &str = "leptos-comparison-test";
|
||||||
|
|
||||||
/// 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 +190,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",
|
||||||
@@ -198,21 +267,7 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
|
|||||||
"Academic Information",
|
"Academic Information",
|
||||||
"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 +278,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 +306,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 +333,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 +342,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 +351,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 +374,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 +411,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 +429,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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
use aws_sdk_dynamodb::{error::ProvideErrorMetadata, types::AttributeValue};
|
use aws_sdk_dynamodb::{
|
||||||
|
types::AttributeValue,
|
||||||
use crate::common::{ExpandableInfo, ValueType};
|
error::ProvideErrorMetadata,
|
||||||
|
};
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
use leptos::leptos_dom::log;
|
use leptos::leptos_dom::log;
|
||||||
use leptos::prelude::ServerFnError;
|
#[cfg(feature = "ssr")]
|
||||||
use leptos::server;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
use crate::pages::utils::server_utils::create_dynamo_client;
|
use crate::pages::utils::server_utils::create_dynamo_client;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
use crate::common::ValueType;
|
||||||
|
|
||||||
|
use crate::common::ExpandableInfo;
|
||||||
|
|
||||||
|
use leptos::prelude::ServerFnError;
|
||||||
|
use leptos::server;
|
||||||
|
|
||||||
|
static SCHOLARSHIPS_TABLE: &str = "leptos-scholarship-test";
|
||||||
|
|
||||||
#[server(GetScholarshipInfo, endpoint = "/scholarship/info/get")]
|
#[server(GetScholarshipInfo, endpoint = "/scholarship/info/get")]
|
||||||
pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFnError> {
|
pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFnError> {
|
||||||
@@ -19,7 +28,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
|
||||||
@@ -30,9 +39,7 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
|
|||||||
Ok(serde_dynamo::from_item(item)?)
|
Ok(serde_dynamo::from_item(item)?)
|
||||||
} else {
|
} else {
|
||||||
log!("Couldn't find scholarship with ID {:?}", id);
|
log!("Couldn't find scholarship with ID {:?}", id);
|
||||||
Err(ServerFnError::new(
|
Err(ServerFnError::new("Couldn't find scholarship with given ID."))
|
||||||
"Couldn't find scholarship with given ID.",
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -49,14 +56,11 @@ pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerF
|
|||||||
|
|
||||||
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
|
||||||
@@ -75,7 +79,7 @@ pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnE
|
|||||||
log!("Getting all scholarship info");
|
log!("Getting all scholarship info");
|
||||||
match client
|
match client
|
||||||
.scan()
|
.scan()
|
||||||
.table_name("leptos-scholarship-test")
|
.table_name(SCHOLARSHIPS_TABLE)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -85,7 +89,7 @@ pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnE
|
|||||||
} else {
|
} else {
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let msg = err.message().unwrap_or("An unknown error occurred");
|
let msg = err.message().unwrap_or("An unknown error occurred");
|
||||||
Err(ServerFnError::new(msg))
|
Err(ServerFnError::new(msg))
|
||||||
@@ -95,23 +99,15 @@ pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnE
|
|||||||
|
|
||||||
/// Gets all scholarships that are associated with the given scholarship provider's ID.
|
/// Gets all scholarships that are associated with the given scholarship provider's ID.
|
||||||
#[server(GetProviderScholarships, "/providers/scholarships/get")]
|
#[server(GetProviderScholarships, "/providers/scholarships/get")]
|
||||||
pub async fn get_provider_scholarships(
|
pub async fn get_provider_scholarships(provider_id: String) -> Result<Vec<ExpandableInfo>, ServerFnError> {
|
||||||
provider_id: String,
|
|
||||||
) -> Result<Vec<ExpandableInfo>, ServerFnError> {
|
|
||||||
let client = create_dynamo_client().await;
|
let client = create_dynamo_client().await;
|
||||||
|
|
||||||
log!(
|
log!("Getting provider scholarships for provider with ID {:?}", provider_id);
|
||||||
"Getting provider scholarships for provider with ID {:?}",
|
|
||||||
provider_id
|
|
||||||
);
|
|
||||||
|
|
||||||
match client
|
match client
|
||||||
.scan()
|
.scan()
|
||||||
.table_name("leptos-scholarship-test")
|
.table_name(SCHOLARSHIPS_TABLE)
|
||||||
.expression_attribute_values(
|
.expression_attribute_values(":id", serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?)
|
||||||
":id",
|
|
||||||
serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?,
|
|
||||||
)
|
|
||||||
.filter_expression("provider_id = :id")
|
.filter_expression("provider_id = :id")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -132,32 +128,20 @@ pub async fn get_provider_scholarships(
|
|||||||
|
|
||||||
/// Creates a new scholarship with a unique ID, and then returns that ID.
|
/// Creates a new scholarship with a unique ID, and then returns that ID.
|
||||||
#[server(RegisterScholarship, "/providers/scholarships/create")]
|
#[server(RegisterScholarship, "/providers/scholarships/create")]
|
||||||
pub async fn register_scholarship(
|
pub async fn register_scholarship(provider_id: String, scholarship_name: String) -> Result<String, ServerFnError> {
|
||||||
provider_id: String,
|
|
||||||
scholarship_name: String,
|
|
||||||
) -> Result<String, ServerFnError> {
|
|
||||||
let client = create_dynamo_client().await;
|
let client = create_dynamo_client().await;
|
||||||
|
|
||||||
log!(
|
log!("Creating scholarship for provider with ID {:?}", provider_id);
|
||||||
"Creating scholarship for provider with ID {:?}",
|
|
||||||
provider_id
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut current_uuid = "testing".to_string();
|
let mut current_uuid = "testing".to_string();
|
||||||
let mut item = ExpandableInfo::new(current_uuid.clone());
|
let mut item = ExpandableInfo::new(current_uuid.clone());
|
||||||
item.data.insert(
|
item.data.insert("provider_id".to_string(), ValueType::String(Some(provider_id)));
|
||||||
"provider_id".to_string(),
|
item.data.insert("scholarship_name".to_string(), ValueType::String(Some(scholarship_name)));
|
||||||
ValueType::String(Some(provider_id)),
|
|
||||||
);
|
|
||||||
item.data.insert(
|
|
||||||
"scholarship_name".to_string(),
|
|
||||||
ValueType::String(Some(scholarship_name)),
|
|
||||||
);
|
|
||||||
loop {
|
loop {
|
||||||
let ser_item = serde_dynamo::to_item(&item)?;
|
let ser_item = serde_dynamo::to_item(&item)?;
|
||||||
match client
|
match client
|
||||||
.put_item()
|
.put_item()
|
||||||
.table_name("leptos-scholarship-test")
|
.table_name(SCHOLARSHIPS_TABLE)
|
||||||
.set_item(Some(ser_item))
|
.set_item(Some(ser_item))
|
||||||
.condition_expression("attribute_not_exists(subject)")
|
.condition_expression("attribute_not_exists(subject)")
|
||||||
.send()
|
.send()
|
||||||
@@ -176,12 +160,10 @@ pub async fn register_scholarship(
|
|||||||
log!("Current error: {:?}", err);
|
log!("Current error: {:?}", err);
|
||||||
current_uuid = Uuid::new_v4().to_string();
|
current_uuid = Uuid::new_v4().to_string();
|
||||||
item.subject = current_uuid.clone();
|
item.subject = current_uuid.clone();
|
||||||
continue;
|
continue
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Err(ServerFnError::new(
|
return Err(ServerFnError::new(err.message().unwrap_or("An unknown error occurred")));
|
||||||
err.message().unwrap_or("An unknown error occurred"),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,33 +173,25 @@ pub async fn register_scholarship(
|
|||||||
|
|
||||||
/// Deletes a provider's scholarship given their provider ID and scholarship ID.
|
/// Deletes a provider's scholarship given their provider ID and scholarship ID.
|
||||||
#[server(DeleteProviderScholarship, "/providers/scholarships/delete")]
|
#[server(DeleteProviderScholarship, "/providers/scholarships/delete")]
|
||||||
pub async fn delete_provider_scholarship(
|
pub async fn delete_provider_scholarship(provider_id: String, scholarship_id: String) -> Result<(), ServerFnError> {
|
||||||
provider_id: String,
|
|
||||||
scholarship_id: String,
|
|
||||||
) -> Result<(), ServerFnError> {
|
|
||||||
let client = create_dynamo_client().await;
|
let client = create_dynamo_client().await;
|
||||||
|
|
||||||
log!(
|
log!("Deleting scholarship with ID {:?} for provider with ID {:?}", scholarship_id, provider_id);
|
||||||
"Deleting scholarship with ID {:?} for provider with ID {:?}",
|
|
||||||
scholarship_id,
|
|
||||||
provider_id
|
|
||||||
);
|
|
||||||
|
|
||||||
// When we delete a scholarship, we need to ensure that the provider's ID matches the scholarship,
|
// When we delete a scholarship, we need to ensure that the provider's ID matches the scholarship,
|
||||||
// otherwise everyone can delete anyone else's scholarships.
|
// otherwise everyone can delete anyone else's scholarships.
|
||||||
match client
|
match client
|
||||||
.delete_item()
|
.delete_item()
|
||||||
.table_name("leptos-scholarship-test")
|
.table_name(SCHOLARSHIPS_TABLE)
|
||||||
.key("subject", AttributeValue::S(scholarship_id))
|
.key("subject", AttributeValue::S(scholarship_id))
|
||||||
.expression_attribute_values(
|
.expression_attribute_values(":provider_id", serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?)
|
||||||
":provider_id",
|
|
||||||
serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?,
|
|
||||||
)
|
|
||||||
.condition_expression("provider_id = :provider_id")
|
.condition_expression("provider_id = :provider_id")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let msg = err.message().unwrap_or("An unknown error occurred");
|
let msg = err.message().unwrap_or("An unknown error occurred");
|
||||||
Err(ServerFnError::new(msg))
|
Err(ServerFnError::new(msg))
|
||||||
|
|||||||
+232
-205
@@ -1,30 +1,31 @@
|
|||||||
// 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::{ComparisonData, ExpandableInfo};
|
||||||
use crate::components::{
|
|
||||||
ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row,
|
|
||||||
Select,
|
|
||||||
};
|
|
||||||
use crate::input;
|
|
||||||
use crate::pages::UnauthenticatedPage;
|
use crate::pages::UnauthenticatedPage;
|
||||||
use crate::pages::utils::get_user_claims;
|
use crate::pages::utils::get_user_claims;
|
||||||
use base64::Engine;
|
use crate::components::{
|
||||||
|
ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row,
|
||||||
|
Select, TextFieldType, ValidatedForm
|
||||||
|
};
|
||||||
|
use crate::input;
|
||||||
use leptos::leptos_dom::logging::console_log;
|
use leptos::leptos_dom::logging::console_log;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
|
||||||
use leptos::wasm_bindgen::JsCast;
|
|
||||||
use leptos::web_sys::HtmlAnchorElement;
|
|
||||||
use leptos_oidc::{AuthLoaded, Authenticated};
|
use leptos_oidc::{AuthLoaded, Authenticated};
|
||||||
use traits::{AsReactive, ReactiveCapture};
|
use base64::Engine;
|
||||||
|
use leptos::logging::log;
|
||||||
|
use leptos::task::spawn_local;
|
||||||
|
use leptos::web_sys::HtmlAnchorElement;
|
||||||
|
use leptos::wasm_bindgen::JsCast;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use crate::pages::api::get_comparisons_categorized;
|
||||||
|
|
||||||
/// # Get Student Info
|
/// # Get Student Info
|
||||||
/// Gets a student's information given their `subject`.
|
/// Gets a student's information given their `subject`.
|
||||||
@@ -144,24 +145,96 @@ 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 user_claims = get_user_claims();
|
||||||
|
let user_subject = Memo::new(move |_| {
|
||||||
|
user_claims.get()
|
||||||
|
.map(|claim| claim.claims.subject.clone())
|
||||||
|
});
|
||||||
|
|
||||||
// Note that the value passed in MUST be equatable.
|
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
|
||||||
|
|
||||||
|
let comparison_resource = Resource::new(
|
||||||
|
move || user_subject.get(),
|
||||||
|
async move |_| get_comparisons_categorized().await
|
||||||
|
);
|
||||||
|
|
||||||
|
let comparison_info = RwSignal::new(HashMap::<String, Vec<ComparisonData>>::new());
|
||||||
|
|
||||||
|
Effect::new(move || {
|
||||||
|
match comparison_resource.get() {
|
||||||
|
Some(Ok(comparisons)) => {
|
||||||
|
log!("Found comparison info: {:?}", comparisons);
|
||||||
|
comparison_info.set(comparisons);
|
||||||
|
}
|
||||||
|
Some(Err(err)) => {
|
||||||
|
log!("Error while getting comparisons: {:?}", err);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
log!("An unknown error occurred.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<AuthLoaded fallback=Loading>
|
<AuthLoaded fallback=Loading>
|
||||||
<Authenticated unauthenticated=UnauthenticatedPage>
|
<Authenticated unauthenticated=UnauthenticatedPage>
|
||||||
@@ -170,195 +243,149 @@ 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
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
// Select { data_member, label, items }
|
<CheckboxList
|
||||||
<Select
|
label="Favorite Candies:"
|
||||||
label="Gender:"
|
items=vec!["Twizzlers", "Reese's", "Starburst"]
|
||||||
value_list=vec!["Male", "Female", "Prefer not to answer"]
|
.into_iter()
|
||||||
.into_iter()
|
.map(|s| s.into())
|
||||||
.map(|s| s.to_owned())
|
.collect()
|
||||||
.collect()
|
disabled=elements_disabled
|
||||||
disabled=elements_disabled
|
data_member="favorite_candies"
|
||||||
data_member="gender"
|
data_map=form_data
|
||||||
data_map=expandable_react.data
|
required=true
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<MultiEntry
|
<Select
|
||||||
data_map=expandable_react.data
|
label="Gender:"
|
||||||
data_member="community_involvement"
|
value_list=vec!["Male", "Female", "Prefer not to answer"]
|
||||||
schema=vec![
|
.into_iter()
|
||||||
input!(Text, "service_name", "Activity Name:", "Some service activity..."),
|
.map(|s| s.to_owned())
|
||||||
input!(Number, "service_hours", "Total Service Hours", "20")
|
.collect()
|
||||||
]
|
disabled=elements_disabled
|
||||||
/>
|
data_member="gender"
|
||||||
</Row>
|
data_map=form_data
|
||||||
<Row>
|
required=true
|
||||||
<ActionButton
|
/>
|
||||||
on:click=move |_| {
|
</Row>
|
||||||
let captured_map = expandable_react.capture();
|
<Row>
|
||||||
console_log(
|
<MultiEntry
|
||||||
format!("Map values: {:?}", captured_map).as_str(),
|
data_map=form_data
|
||||||
);
|
data_member="community_involvement"
|
||||||
submit_action
|
schema=vec![
|
||||||
.dispatch(CreateSampleSubmission {
|
input!(Text, "service_name", "Activity Name:", true, "Some service activity..."),
|
||||||
student_info: captured_map,
|
input!(Number, "service_hours", "Total Service Hours", true, "20")
|
||||||
});
|
]
|
||||||
}
|
/>
|
||||||
disabled=elements_disabled
|
</Row>
|
||||||
>
|
</ValidatedForm>
|
||||||
"Submit"
|
|
||||||
</ActionButton>
|
|
||||||
</Row>
|
|
||||||
<Row>
|
|
||||||
<ActionButton on:click=move |_| {
|
|
||||||
console_log("Attempting to get PDF from server endpoint");
|
|
||||||
spawn_local(async move {
|
|
||||||
let result = create_example_pdf().await;
|
|
||||||
if let Ok(bytes) = result {
|
|
||||||
let base64 = base64::engine::general_purpose::STANDARD
|
|
||||||
.encode(bytes);
|
|
||||||
let data_url = format!(
|
|
||||||
"data:application/pdf;base64,{}",
|
|
||||||
base64,
|
|
||||||
);
|
|
||||||
console_log("Found document, opening in new tab...");
|
|
||||||
let link = document()
|
|
||||||
.create_element("a")
|
|
||||||
.unwrap()
|
|
||||||
.dyn_into::<HtmlAnchorElement>()
|
|
||||||
.unwrap();
|
|
||||||
link.set_href(&*data_url);
|
|
||||||
link.set_target("_blank");
|
|
||||||
link.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}>"Get PDF"</ActionButton>
|
|
||||||
</Row>
|
|
||||||
<Row>
|
|
||||||
<p>{result_msg}</p>
|
|
||||||
</Row>
|
|
||||||
</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()
|
||||||
}
|
}
|
||||||
+149
-96
@@ -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,12 +210,6 @@ 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! {
|
||||||
@@ -234,94 +235,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>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,12 +391,19 @@ pub fn LoanerReturnForm() -> impl IntoView {
|
|||||||
|
|
||||||
// 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() {
|
||||||
// Return to the main page.
|
// Return to the main page.
|
||||||
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())
|
||||||
|
.header("Check in successful!")
|
||||||
|
.msg("Don't forget to take your collateral.")
|
||||||
|
);
|
||||||
navigate("/loaners", Default::default());
|
navigate("/loaners", Default::default());
|
||||||
check_in_action.clear();
|
check_in_action.clear();
|
||||||
}
|
}
|
||||||
@@ -381,6 +429,7 @@ 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>
|
||||||
@@ -396,12 +445,16 @@ pub fn LoanerReturnForm() -> impl IntoView {
|
|||||||
<ActionButton on:click=close_dialog disabled=dialog_disabled>"Close"</ActionButton>
|
<ActionButton on:click=close_dialog disabled=dialog_disabled>"Close"</ActionButton>
|
||||||
<ActionButton on:click=dialog_check_in disabled=dialog_disabled>"Check In"</ActionButton>
|
<ActionButton on:click=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
|
||||||
<p class="text-lg">"Please select your name from the list."</p>
|
class="flex flex-col gap-2 m-2"
|
||||||
</div>
|
use:animate=pop_in_out()
|
||||||
<Suspense fallback=move || "Loading...".into_view()>
|
>
|
||||||
<div class="flex flex-col gap-2 m-2">
|
<div class="flex flex-col justify-center gap-2 mb-3 py-3 px-2">
|
||||||
|
<h2 class="text-2xl font-bold">"Student List"</h2>
|
||||||
|
<p class="text-lg">"Please select your name from the list."</p>
|
||||||
|
</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) => {
|
||||||
@@ -443,7 +496,7 @@ pub fn LoanerReturnForm() -> impl IntoView {
|
|||||||
None => Either::Right("No loaners found.".into_view())
|
None => Either::Right("No loaners found.".into_view())
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
</div>
|
</Suspense>
|
||||||
</Suspense>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-1
@@ -8,8 +8,9 @@ mod scholarship_info;
|
|||||||
mod test_page;
|
mod test_page;
|
||||||
mod unauthenticated_page;
|
mod unauthenticated_page;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
mod provider_contact;
|
||||||
|
|
||||||
pub use self::{
|
pub use self::{
|
||||||
about_page::*, comparison_test::*, home_page::*, loaner_page::*, provider_portal::*,
|
about_page::*, comparison_test::*, home_page::*, loaner_page::*, provider_portal::*,
|
||||||
scholarship_info::*, test_page::*, unauthenticated_page::*,
|
test_page::*, unauthenticated_page::*, scholarship_info::*, provider_contact::*
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
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::pages::utils::get_user_claims;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
use crate::pages::utils::server_utils::create_dynamo_client;
|
||||||
|
|
||||||
|
#[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;
|
||||||
|
use crate::pages::utils::server_utils::into_attr_map;
|
||||||
|
|
||||||
|
let client = create_dynamo_client().await;
|
||||||
|
|
||||||
|
let mut item = into_attr_map(contact_info);
|
||||||
|
item.insert("subject".to_owned(), aws_sdk_dynamodb::types::AttributeValue::S(id));
|
||||||
|
|
||||||
|
match client
|
||||||
|
.put_item()
|
||||||
|
.table_name(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::new(move || {
|
||||||
|
submit_action.value().get().is_some()
|
||||||
|
.then(|| {
|
||||||
|
toasts_context.toast(
|
||||||
|
Toast::new()
|
||||||
|
.id(uuid::Uuid::new_v4())
|
||||||
|
.header("Submission Successful")
|
||||||
|
.msg("You can go back or continue editing your responses.")
|
||||||
|
);
|
||||||
|
|
||||||
|
submit_action.clear();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Display contact form.
|
||||||
|
view! {
|
||||||
|
<Banner title="R15 Scholarships" logo="/PHS_Stacked_Acronym.png" path="/" />
|
||||||
|
<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>
|
||||||
|
<Row>
|
||||||
|
<OutlinedTextField
|
||||||
|
label="Testing Number"
|
||||||
|
placeholder="Just put a number"
|
||||||
|
disabled=elements_disabled
|
||||||
|
data_member="testing_number"
|
||||||
|
data_map=contact_info
|
||||||
|
input_type=TextFieldType::Number
|
||||||
|
required=true
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</ValidatedForm>
|
||||||
|
</Panel>
|
||||||
|
<div class="flex flex-col flex-1" />
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
</Authenticated>
|
||||||
|
</AuthLoaded>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ pub fn ProviderPortal() -> impl IntoView {
|
|||||||
title="Profile"
|
title="Profile"
|
||||||
description="Edit user profile"
|
description="Edit user profile"
|
||||||
icon="/Person_Black.png"
|
icon="/Person_Black.png"
|
||||||
path="/"
|
path="/providers/profile"
|
||||||
/>
|
/>
|
||||||
<DashboardButton
|
<DashboardButton
|
||||||
title="Create Scholarship"
|
title="Create Scholarship"
|
||||||
|
|||||||
+410
-308
@@ -1,24 +1,19 @@
|
|||||||
use super::UnauthenticatedPage;
|
use std::collections::HashMap;
|
||||||
use super::api::{
|
|
||||||
CreateScholarshipInfo, CreateTestComparisons, DeleteProviderScholarship, RegisterScholarship,
|
|
||||||
get_comparison_info, get_provider_scholarships, get_scholarship_info,
|
|
||||||
};
|
|
||||||
use crate::common::ComparisonData;
|
|
||||||
use crate::common::{ExpandableInfo, ScholarshipFormParams, SubmitStatus, ValueType};
|
|
||||||
use crate::components::{
|
|
||||||
ActionButton, Banner, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row,
|
|
||||||
};
|
|
||||||
use crate::pages::utils::get_user_claims;
|
|
||||||
use leptos::either::Either;
|
use leptos::either::Either;
|
||||||
use leptos::html::Dialog;
|
use leptos::html::Dialog;
|
||||||
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_navigate, use_params};
|
use leptos_router::hooks::{use_navigate, use_params};
|
||||||
use traits::{AsReactive, ReactiveCapture};
|
use crate::common::{ExpandableInfo, ScholarshipFormParams, SubmitStatus, ValueType};
|
||||||
|
use crate::components::{ActionButton, Banner, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row, TextFieldType, Toast, ToastContext, ToastList, ValidatedForm};
|
||||||
|
use super::UnauthenticatedPage;
|
||||||
|
use crate::pages::utils::get_user_claims;
|
||||||
|
use super::api::{get_provider_scholarships, get_scholarship_info, CreateScholarshipInfo, RegisterScholarship, DeleteProviderScholarship, get_comparisons_categorized, CreateTestComparisons};
|
||||||
|
|
||||||
|
|
||||||
/// # 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.
|
||||||
@@ -29,65 +24,59 @@ pub fn ScholarshipInfoPage() -> impl IntoView {
|
|||||||
|
|
||||||
let url_params = use_params::<ScholarshipFormParams>();
|
let url_params = use_params::<ScholarshipFormParams>();
|
||||||
let scholarship_id = Memo::new(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())
|
||||||
});
|
});
|
||||||
|
|
||||||
let create_test_comps = ServerAction::<CreateTestComparisons>::new();
|
// let create_test_comps = ServerAction::<CreateTestComparisons>::new();
|
||||||
|
|
||||||
let on_delete = move || {
|
let on_delete = move || {
|
||||||
let navigate = use_navigate();
|
let navigate = use_navigate();
|
||||||
navigate("/providers/scholarships", Default::default());
|
navigate("/providers/scholarships", Default::default());
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remember that we only want to show the page after we've got authentication information
|
// Remember that we only want to show the page after we've got authentication information
|
||||||
// and if there's data for us to use.
|
// and if there's data for us to use.
|
||||||
view! {
|
view! {
|
||||||
<Banner
|
<Banner title="R15 Scholarships" logo="/PHS_Stacked_Acronym.png" path="/" />
|
||||||
title="R15 Scholarships"
|
<ToastList>
|
||||||
logo="/PHS_Stacked_Acronym.png"
|
<AuthLoaded fallback=Loading>
|
||||||
path="/"
|
<Authenticated unauthenticated=UnauthenticatedPage>
|
||||||
/>
|
<div class="flex flex-row align-top items-start">
|
||||||
<AuthLoaded fallback=Loading>
|
<div class="flex flex-col flex-1" />
|
||||||
<Authenticated unauthenticated=UnauthenticatedPage>
|
<ScholarshipList refresh_token=list_refresh on_delete=on_delete />
|
||||||
<div class="flex flex-row align-top items-start">
|
<Transition fallback=Loading>
|
||||||
<div class="flex flex-col flex-1" />
|
<ScholarshipForm
|
||||||
<ScholarshipList
|
scholarship_id=scholarship_id
|
||||||
refresh_token=list_refresh
|
on_submit=move || {
|
||||||
on_delete=on_delete
|
list_refresh.update(|n| *n += 1);
|
||||||
/>
|
}
|
||||||
<Transition fallback=Loading>
|
/>
|
||||||
<ScholarshipForm
|
</Transition>
|
||||||
scholarship_id=scholarship_id
|
<div class="flex flex-col flex-1" />
|
||||||
on_submit=move || {
|
</div>
|
||||||
list_refresh.update(|n| *n += 1);
|
</Authenticated>
|
||||||
}
|
</AuthLoaded>
|
||||||
/>
|
</ToastList>
|
||||||
</Transition>
|
|
||||||
<div class="flex flex-col flex-1" />
|
|
||||||
</div>
|
|
||||||
</Authenticated>
|
|
||||||
</AuthLoaded>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// # Scholarship List Component
|
/// # Scholarship List Component
|
||||||
///
|
///
|
||||||
/// This component should always be wrapped in an Authenticated component. Assuming that a provider
|
/// 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
|
/// 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.
|
/// will display them in a list, giving them the option to edit, delete, or create new scholarships.
|
||||||
///
|
///
|
||||||
/// Example usage:
|
/// Example usage:
|
||||||
/// ```
|
/// ```
|
||||||
/// let refresh = RwSignal::new(0);
|
/// let refresh = RwSignal::new(0);
|
||||||
///
|
///
|
||||||
/// view! {
|
/// view! {
|
||||||
/// <AuthLoaded>
|
/// <AuthLoaded>
|
||||||
/// <Authenticated>
|
/// <Authenticated>
|
||||||
/// <ScholarshipList
|
/// <ScholarshipList
|
||||||
/// refresh_token=refresh
|
/// refresh_token=refresh
|
||||||
/// on_delete=move || log!("This is run when an item is deleted on the server.")
|
/// on_delete=move || log!("This is run when an item is deleted on the server.")
|
||||||
/// />
|
/// />
|
||||||
@@ -99,11 +88,9 @@ pub fn ScholarshipInfoPage() -> impl IntoView {
|
|||||||
fn ScholarshipList(
|
fn ScholarshipList(
|
||||||
/// A signal that refreshes the list when changed. Most times, we don't need to worry about the
|
/// A signal that refreshes the list when changed. Most times, we don't need to worry about the
|
||||||
/// user reloading our page 2 billion times.
|
/// user reloading our page 2 billion times.
|
||||||
#[prop()]
|
#[prop()] refresh_token: RwSignal<i32>,
|
||||||
refresh_token: RwSignal<i32>,
|
|
||||||
/// A callback that is run on successful server-side deletion of an item.
|
/// A callback that is run on successful server-side deletion of an item.
|
||||||
#[prop(into)]
|
#[prop(into)] on_delete: Callback<()>
|
||||||
on_delete: Callback<()>,
|
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
// List state
|
// List state
|
||||||
let pending_delete = RwSignal::new(None::<ExpandableInfo>);
|
let pending_delete = RwSignal::new(None::<ExpandableInfo>);
|
||||||
@@ -112,43 +99,44 @@ fn ScholarshipList(
|
|||||||
// Get scholarship provider information from authentication. This element will not display
|
// Get scholarship provider information from authentication. This element will not display
|
||||||
// unless that information is available.
|
// unless that information is available.
|
||||||
let user_claims = get_user_claims();
|
let user_claims = get_user_claims();
|
||||||
let provider_id = Memo::new(move |_| {
|
let provider_id = Memo::new(move |_|
|
||||||
user_claims
|
user_claims.get()
|
||||||
.get()
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|info| info.claims.subject.clone())
|
.map(|info| info.claims.subject.clone())
|
||||||
});
|
);
|
||||||
|
|
||||||
// Register scholarship server resource
|
// Register scholarship server resource
|
||||||
let scholarships = Resource::new(
|
let scholarships = Resource::new(
|
||||||
move || (refresh_token.get(), provider_id.get()),
|
move || {
|
||||||
|
(refresh_token.get(), provider_id.get())
|
||||||
|
},
|
||||||
|(_, provider_id)| async move {
|
|(_, provider_id)| async move {
|
||||||
log!("Fetching from scholarship API.");
|
log!("Fetching from scholarship API.");
|
||||||
let Some(provider_id) = provider_id else {
|
let Some(provider_id) = provider_id else {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get the scholarships
|
// Get the scholarships
|
||||||
get_provider_scholarships(provider_id).await
|
get_provider_scholarships(provider_id).await
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Register scholarship creation/deletion actions
|
// Register scholarship creation/deletion actions
|
||||||
let create_action = ServerAction::<RegisterScholarship>::new();
|
let create_action = ServerAction::<RegisterScholarship>::new();
|
||||||
let delete_action = ServerAction::<DeleteProviderScholarship>::new();
|
let delete_action = ServerAction::<DeleteProviderScholarship>::new();
|
||||||
|
|
||||||
// Button handlers
|
// Button handlers
|
||||||
let create_on_click = move |_| {
|
let create_on_click = move |_| {
|
||||||
create_action.dispatch(RegisterScholarship {
|
create_action.dispatch(RegisterScholarship {
|
||||||
provider_id: provider_id.get().unwrap(),
|
provider_id: provider_id.get().unwrap(),
|
||||||
scholarship_name: "Testing Scholarship".to_string(),
|
scholarship_name: "Testing Scholarship".to_string()
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_item_delete = move |s| {
|
let on_item_delete = move |s| {
|
||||||
pending_delete.set(Some(s));
|
pending_delete.set(Some(s));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register scholarship creation effect
|
// Register scholarship creation effect
|
||||||
Effect::new(move || {
|
Effect::new(move || {
|
||||||
// We want to wait for the action to complete first, but then find whether the request
|
// We want to wait for the action to complete first, but then find whether the request
|
||||||
@@ -179,47 +167,50 @@ fn ScholarshipList(
|
|||||||
if pending_delete.get().is_some() {
|
if pending_delete.get().is_some() {
|
||||||
// Find the modal dialog and show it. Ask for the user's confirmation. Afterward,
|
// Find the modal dialog and show it. Ask for the user's confirmation. Afterward,
|
||||||
// take the correct action.
|
// take the correct action.
|
||||||
dialog
|
dialog.show_modal()
|
||||||
.show_modal()
|
|
||||||
.expect("Couldn't show dialog. Is it already open?");
|
.expect("Couldn't show dialog. Is it already open?");
|
||||||
} else {
|
} else {
|
||||||
dialog.close();
|
dialog.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<dialog
|
<dialog node_ref=delete_dialog_ref class="m-auto p-2">
|
||||||
node_ref=delete_dialog_ref
|
|
||||||
class="m-auto p-2"
|
|
||||||
>
|
|
||||||
<h2 class="text-2xl font-bold">"Confirm Deletion"</h2>
|
<h2 class="text-2xl font-bold">"Confirm Deletion"</h2>
|
||||||
<p>
|
<p>
|
||||||
"Are you sure you want to delete "
|
"Are you sure you want to delete "
|
||||||
{move || {
|
{move || {
|
||||||
pending_delete.get().map(|s| {
|
pending_delete
|
||||||
s.data.get("name").unwrap_or(&ValueType::String(None))
|
.get()
|
||||||
.as_string()
|
.map(|s| {
|
||||||
.unwrap_or_default()
|
s.data
|
||||||
.unwrap_or("<no name found>".to_string())
|
.get("name")
|
||||||
})
|
.unwrap_or(&ValueType::String(None))
|
||||||
}}
|
.as_string()
|
||||||
"?"
|
.unwrap_or_default()
|
||||||
|
.unwrap_or("<no name found>".to_string())
|
||||||
|
})
|
||||||
|
}} "?"
|
||||||
</p>
|
</p>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
on:click=move |_| {
|
on:click=move |_| {
|
||||||
let subject = pending_delete.get().map(|s| s.subject.clone()).unwrap_or_default();
|
let subject = pending_delete
|
||||||
|
.get()
|
||||||
|
.map(|s| s.subject.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
log!("Deleting scholarship with subject {}", subject);
|
log!("Deleting scholarship with subject {}", subject);
|
||||||
delete_action.dispatch(DeleteProviderScholarship {
|
delete_action
|
||||||
scholarship_id: subject,
|
.dispatch(DeleteProviderScholarship {
|
||||||
provider_id: provider_id.get().unwrap_or_default()
|
scholarship_id: subject,
|
||||||
});
|
provider_id: provider_id.get().unwrap_or_default(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
disabled=modal_disabled
|
disabled=modal_disabled
|
||||||
>"Confirm"</ActionButton>
|
>
|
||||||
<ActionButton
|
"Confirm"
|
||||||
on:click=move |_| pending_delete.set(None)
|
</ActionButton>
|
||||||
>"Cancel"</ActionButton>
|
<ActionButton on:click=move |_| pending_delete.set(None)>"Cancel"</ActionButton>
|
||||||
</dialog>
|
</dialog>
|
||||||
<div class="w-75">
|
<div class="w-75">
|
||||||
<Panel>
|
<Panel>
|
||||||
@@ -229,31 +220,42 @@ fn ScholarshipList(
|
|||||||
<Transition fallback=Loading>
|
<Transition fallback=Loading>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{move || {
|
{move || {
|
||||||
scholarships.get().map(|result| {
|
scholarships
|
||||||
match result {
|
.get()
|
||||||
Ok(list) => {
|
.map(|result| {
|
||||||
let views = list.iter().map(|entry| {
|
match result {
|
||||||
view! {
|
Ok(list) => {
|
||||||
<ScholarshipListEntry scholarship=entry.clone()
|
let views = list
|
||||||
on_delete=Callback::new(on_item_delete)/>
|
.iter()
|
||||||
}
|
.map(|entry| {
|
||||||
});
|
view! {
|
||||||
Either::Left(views.collect_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>
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
})
|
||||||
Either::Right(view! {
|
.collect_view()
|
||||||
<div>
|
|
||||||
<p>"Failed to load scholarship list: "{err.to_string()}</p>
|
|
||||||
</div>
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).collect_view()
|
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="p-2 bg-red-800 rounded-md text-center text-white hover:bg-red-900 transition-all"
|
<div
|
||||||
on:click=create_on_click>
|
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"
|
"Create New"
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,9 +266,9 @@ fn ScholarshipList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// # Scholarship List Entry Component
|
/// # Scholarship List Entry Component
|
||||||
///
|
///
|
||||||
/// Displays a single scholarship within the [`ScholarshipList`] component.
|
/// Displays a single scholarship within the [`ScholarshipList`] component.
|
||||||
///
|
///
|
||||||
/// Example usage (within the [`ScholarshipList`]):
|
/// Example usage (within the [`ScholarshipList`]):
|
||||||
/// ```
|
/// ```
|
||||||
/// view! {
|
/// view! {
|
||||||
@@ -280,26 +282,24 @@ fn ScholarshipList(
|
|||||||
fn ScholarshipListEntry(
|
fn ScholarshipListEntry(
|
||||||
/// The scholarship information. This element will attempt to get the name of the scholarship
|
/// The scholarship information. This element will attempt to get the name of the scholarship
|
||||||
/// using its `name` field, and also utilizes the `subject` field.
|
/// using its `name` field, and also utilizes the `subject` field.
|
||||||
#[prop()]
|
#[prop()] scholarship: ExpandableInfo,
|
||||||
scholarship: ExpandableInfo,
|
|
||||||
/// A [`Callback<ExpandableInfo>`] that runs upon successful deletion of the scholarship.
|
/// A [`Callback<ExpandableInfo>`] that runs upon successful deletion of the scholarship.
|
||||||
#[prop(into)]
|
#[prop(into)] on_delete: Callback<ExpandableInfo, ()>,
|
||||||
on_delete: Callback<ExpandableInfo, ()>,
|
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let navigate = use_navigate();
|
let navigate = use_navigate();
|
||||||
let user_claims = get_user_claims();
|
let user_claims = get_user_claims();
|
||||||
let user_id = Memo::new(move |_| user_claims.get().map(|info| info.claims.subject.clone()));
|
let _user_id = Memo::new(move |_| {
|
||||||
|
user_claims.get()
|
||||||
// Get the values that we need out of the info. We just need the subject and the name of
|
.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.
|
// the scholarship.
|
||||||
let name = scholarship
|
let name = scholarship.data.get("name")
|
||||||
.data
|
|
||||||
.get("name")
|
|
||||||
.unwrap_or(&ValueType::String(None))
|
.unwrap_or(&ValueType::String(None))
|
||||||
.as_string()
|
.as_string().unwrap_or_default()
|
||||||
.unwrap_or_default()
|
|
||||||
.unwrap_or("<no name found>".to_string());
|
.unwrap_or("<no name found>".to_string());
|
||||||
|
|
||||||
// Button click handlers
|
// Button click handlers
|
||||||
let edit_click = {
|
let edit_click = {
|
||||||
let subject = scholarship.subject.clone();
|
let subject = scholarship.subject.clone();
|
||||||
@@ -308,20 +308,22 @@ fn ScholarshipListEntry(
|
|||||||
navigate(path.as_str(), Default::default());
|
navigate(path.as_str(), Default::default());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
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="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">
|
<div class="p-2 text-center">
|
||||||
<span>{name}</span>
|
<span>{name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row">
|
<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
|
<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"
|
hover:bg-red-900 cursor-pointer transition-all"
|
||||||
on:click=edit_click
|
on:click=edit_click
|
||||||
>
|
>
|
||||||
"Edit"
|
"Edit"
|
||||||
</div>
|
</div>
|
||||||
<div class="p-2 flex-1 bg-red-800 border-l border-white rounded-br-md text-white text-center
|
<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"
|
hover:bg-red-900 cursor-pointer transition-all"
|
||||||
on:click=move |_| on_delete.run(scholarship.clone())
|
on:click=move |_| on_delete.run(scholarship.clone())
|
||||||
>
|
>
|
||||||
@@ -354,54 +356,48 @@ fn ScholarshipListEntry(
|
|||||||
fn ScholarshipForm(
|
fn ScholarshipForm(
|
||||||
/// The ID of the scholarship to edit. The form will handle getting all information about the
|
/// The ID of the scholarship to edit. The form will handle getting all information about the
|
||||||
/// scholarship and submitting new information.
|
/// scholarship and submitting new information.
|
||||||
#[prop(into)]
|
#[prop(into)] scholarship_id: Signal<Option<String>>,
|
||||||
scholarship_id: Signal<Option<String>>,
|
|
||||||
/// A callback that is run when the form successfully submits new data. In the event that the
|
/// A callback that is run when the form successfully submits new data. In the event that the
|
||||||
/// submission fails, this function is not run.
|
/// submission fails, this function is not run.
|
||||||
#[prop(into)]
|
#[prop(into)] on_submit: Callback<()>
|
||||||
on_submit: Callback<()>,
|
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let submit_status = RwSignal::new(SubmitStatus::Idle);
|
|
||||||
let elements_disabled = Signal::derive(move || match submit_status.get() {
|
|
||||||
SubmitStatus::Sending => true,
|
|
||||||
_ => false,
|
|
||||||
});
|
|
||||||
let result_msg = Signal::derive(move || match submit_status.get() {
|
|
||||||
SubmitStatus::Idle => "".into(),
|
|
||||||
SubmitStatus::Sending => "Sending submission...".into(),
|
|
||||||
SubmitStatus::Success => "Success".into(),
|
|
||||||
SubmitStatus::Error(msg) => format!("Error: {}", msg),
|
|
||||||
});
|
|
||||||
|
|
||||||
let submit_action = ServerAction::<CreateScholarshipInfo>::new();
|
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 || {
|
Effect::new(move || {
|
||||||
if submit_action.pending().get() {
|
if submit_action.pending().get() {
|
||||||
submit_status.set(SubmitStatus::Sending);
|
submit_status.set(SubmitStatus::Sending);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(result) = submit_action.value().get() {
|
if let Some(result) = submit_action.value().get() {
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
submit_status.set(SubmitStatus::Success);
|
submit_status.set(SubmitStatus::Success);
|
||||||
on_submit.run(());
|
on_submit.run(());
|
||||||
}
|
},
|
||||||
Err(err) => submit_status.set(SubmitStatus::Error(err.to_string())),
|
Err(_err) => submit_status.set(SubmitStatus::Error(()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let comparison_list: Resource<Vec<ComparisonData>> = Resource::new(
|
let comparison_lists = Resource::new(
|
||||||
move || scholarship_id.get(), // There's no input to this function.
|
move || scholarship_id.get().is_some(),
|
||||||
async move |_| {
|
async move |_| {
|
||||||
get_comparison_info().await.unwrap_or_else(|e| {
|
get_comparisons_categorized().await
|
||||||
log!("Failed to get comparison info: {:?}", e);
|
}
|
||||||
Vec::new()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Effect::new(move || {
|
||||||
|
if let Some(Ok(map)) = comparison_lists.get() {
|
||||||
|
log!("Available categories: {:?}", map.keys());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let scholarship_info = Resource::new(
|
let scholarship_info = Resource::new(
|
||||||
move || scholarship_id.get(),
|
move || scholarship_id.get(),
|
||||||
async move |opt_id| {
|
async move |opt_id| {
|
||||||
@@ -410,175 +406,281 @@ fn ScholarshipForm(
|
|||||||
} else {
|
} else {
|
||||||
Err(ServerFnError::new("Cannot get from server with no ID."))
|
Err(ServerFnError::new("Cannot get from server with no ID."))
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
let comparison_ids = Memo::new(move |_| {
|
let form_data = RwSignal::new(HashMap::new());
|
||||||
comparison_list
|
let chips_data = RwSignal::new(HashMap::new());
|
||||||
.get()
|
|
||||||
.map(|list| {
|
Effect::new(move || {
|
||||||
list.iter()
|
if let Some(Ok(scholarship)) = scholarship_info.get() {
|
||||||
.map(|comp| comp.clone().id)
|
// Get the default chips data.
|
||||||
.collect::<Vec<String>>()
|
let chips_default = if let Some(ValueType::Map(Some(map))) = scholarship.data.get("requirements") {
|
||||||
})
|
log!("Found requirements map: {:?}", map);
|
||||||
.unwrap_or_default()
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let comparison_text = Memo::new(move |_| {
|
let get_comp_category = move |category: &str| {
|
||||||
comparison_list
|
if let Some(Ok(map)) = comparison_lists.get() {
|
||||||
.get()
|
map.get(category).unwrap_or(&Vec::new()).clone()
|
||||||
.map(|list| {
|
} else {
|
||||||
list.iter()
|
Vec::new()
|
||||||
.map(|comp| comp.clone().display_text)
|
}
|
||||||
.collect::<Vec<String>>()
|
};
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
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"));
|
||||||
|
|
||||||
|
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::new(move || {
|
||||||
|
submit_action.value().get().is_some()
|
||||||
|
.then(|| {
|
||||||
|
log!("Displaying toasts!");
|
||||||
|
toasts.toast(
|
||||||
|
Toast::new()
|
||||||
|
.id(uuid::Uuid::new_v4())
|
||||||
|
.msg("Succesfully submitted.")
|
||||||
|
);
|
||||||
|
|
||||||
|
submit_action.clear();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// We'll collect the scholarship's name, num_awards, amount_per_award, total_awards,
|
let create_comps = ServerAction::<CreateTestComparisons>::new();
|
||||||
// fafsa_required, award_to, transcript_required, recipient_selection, essay_requirement (and prompt)
|
|
||||||
// award_night_remarks
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Panel>
|
<Panel>
|
||||||
<Show
|
<Show
|
||||||
when=move || scholarship_id.get().is_some()
|
when=move || scholarship_id.get().is_some()
|
||||||
fallback=|| view! { <p>"Choose a scholarship from the right, or create a new one."</p> }
|
fallback=|| {
|
||||||
|
view! { <p>"Choose a scholarship from the right, or create a new one."</p> }
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Suspense fallback=Loading>
|
<Suspense fallback=Loading>
|
||||||
{move || {
|
<ValidatedForm
|
||||||
scholarship_info.get()
|
on_submit=Callback::new(on_submit)
|
||||||
.map(|res_scholarship| {
|
title="Scholarship Info Form"
|
||||||
match res_scholarship {
|
description="Create or edit a scholarship. Input all information and click Submit."
|
||||||
Ok(scholarship) => {
|
>
|
||||||
let reactive_info = scholarship.as_reactive();
|
{move || {
|
||||||
|
scholarship_info
|
||||||
Either::Left(view! {
|
.get()
|
||||||
<Row>
|
.map(|res_scholarship| {
|
||||||
<h1 class="text-3xl font-bold">
|
match res_scholarship {
|
||||||
"Scholarship Info Form (test)"
|
Ok(_) => {
|
||||||
</h1>
|
Either::Left(
|
||||||
</Row>
|
view! {
|
||||||
<Row>
|
<Row>
|
||||||
<OutlinedTextField
|
<OutlinedTextField
|
||||||
label="Scholarship Name"
|
label="Scholarship Name"
|
||||||
placeholder="Example Scholarship Name"
|
placeholder="Example Scholarship Name"
|
||||||
disabled=elements_disabled
|
disabled=elements_disabled
|
||||||
data_member="name"
|
data_member="name"
|
||||||
data_map=reactive_info.data
|
data_map=form_data
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<OutlinedTextField
|
<OutlinedTextField
|
||||||
label="Amount per award: (if multiple, enter the highest value)"
|
label="Amount per award: (if multiple, enter the highest value)"
|
||||||
placeholder="Enter an amount..."
|
placeholder="Enter an amount..."
|
||||||
disabled=elements_disabled
|
disabled=elements_disabled
|
||||||
data_member="amount_per_award"
|
data_member="amount_per_award"
|
||||||
data_map=reactive_info.data
|
data_map=form_data
|
||||||
input_type="number"
|
input_type=TextFieldType::Number
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<OutlinedTextField
|
<OutlinedTextField
|
||||||
label="Total number of awards:"
|
label="Total number of awards:"
|
||||||
placeholder="Enter an amount..."
|
placeholder="Enter an amount..."
|
||||||
disabled=elements_disabled
|
disabled=elements_disabled
|
||||||
data_member="num_awards"
|
data_member="num_awards"
|
||||||
data_map=reactive_info.data
|
data_map=form_data
|
||||||
input_type="number"
|
input_type=TextFieldType::Number
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<OutlinedTextField
|
<OutlinedTextField
|
||||||
label="Total amount of all awards:"
|
label="Total amount of all award(s): (estimates are acceptable)"
|
||||||
placeholder="Enter an amount..."
|
placeholder="Enter an amount..."
|
||||||
disabled=elements_disabled
|
disabled=elements_disabled
|
||||||
data_member="total_awards"
|
data_member="total_awards"
|
||||||
data_map=reactive_info.data
|
data_map=form_data
|
||||||
input_type="number"
|
input_type=TextFieldType::Number
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<RadioList
|
<RadioList
|
||||||
data_member="fafsa_required"
|
data_member="fafsa_required"
|
||||||
data_map=reactive_info.data
|
data_map=form_data
|
||||||
items=vec!["Yes".to_string(), "No".to_string()]
|
items=vec!["Yes".to_string(), "No".to_string()]
|
||||||
disabled=elements_disabled
|
disabled=elements_disabled
|
||||||
label="Do you require student financial information?"
|
label="Do you require student FAFSA information?"
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<RadioList
|
<RadioList
|
||||||
data_member="transcript_required"
|
data_member="transcript_required"
|
||||||
data_map=reactive_info.data
|
data_map=form_data
|
||||||
items=vec!["Yes".to_string(), "No".to_string()]
|
items=vec!["Yes".to_string(), "No".to_string()]
|
||||||
disabled=elements_disabled
|
disabled=elements_disabled
|
||||||
label="Do you require a student transcript?"
|
label="Do you require a student transcript?"
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<RadioList
|
<RadioList
|
||||||
data_member="award_to"
|
data_member="award_to"
|
||||||
data_map=reactive_info.data
|
data_map=form_data
|
||||||
items=vec!["School".to_string(), "Student".to_string()]
|
items=vec!["School".to_string(), "Student".to_string()]
|
||||||
disabled=elements_disabled
|
disabled=elements_disabled
|
||||||
label="Will the award be made to the school or the student?"
|
label="Will the award be made to the school or the student?"
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<RadioList
|
<RadioList
|
||||||
data_member="essay_required"
|
data_member="essay_required"
|
||||||
data_map=reactive_info.data
|
data_map=form_data
|
||||||
items=vec!["Yes".to_string(), "No".to_string()]
|
items=vec!["Yes".to_string(), "No".to_string()]
|
||||||
disabled=elements_disabled
|
disabled=elements_disabled
|
||||||
label="Do you require a student essay?"
|
label="Do you require a student essay?"
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<RadioList
|
<RadioList
|
||||||
data_member="essay_prompt"
|
data_member="essay_prompt"
|
||||||
data_map=reactive_info.data
|
data_map=form_data
|
||||||
items=vec!["Test 1", "Test 2", "Test 3"].iter().map(|s| s.to_string()).collect()
|
items=vec!["Test 1", "Test 2", "Test 3"]
|
||||||
disabled=elements_disabled
|
.iter()
|
||||||
label="If so, select a prompt from the list below."
|
.map(|s| s.to_string())
|
||||||
/>
|
.collect()
|
||||||
</Row>
|
disabled=elements_disabled
|
||||||
<Row>
|
label="If so, select a prompt from the list below."
|
||||||
<ChipsList
|
/>
|
||||||
label="Scholarship Requirements"
|
</Row>
|
||||||
data_member="requirements"
|
<Row>
|
||||||
data_map=reactive_info.data
|
<ChipsList
|
||||||
values=comparison_ids
|
label="GPA Requirements"
|
||||||
displayed_text=comparison_text
|
data_member="gpa"
|
||||||
/>
|
data_map=chips_data
|
||||||
</Row>
|
values=gpa_ids
|
||||||
<Row>
|
displayed_text=gpa_text
|
||||||
<ActionButton
|
/>
|
||||||
disabled=elements_disabled
|
</Row>
|
||||||
on:click=move |_| {
|
<Row>
|
||||||
let captured = reactive_info.capture();
|
<ChipsList
|
||||||
log!("Map values: {:?}", captured);
|
label="Sports Participation"
|
||||||
submit_action.dispatch(CreateScholarshipInfo {
|
data_member="sports_participation"
|
||||||
info: captured.clone()
|
data_map=chips_data
|
||||||
});
|
values=sports_ids
|
||||||
}
|
displayed_text=sports_text
|
||||||
>"Submit"</ActionButton>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<p>{result_msg}</p>
|
<ChipsList
|
||||||
</Row>
|
label="Community Service"
|
||||||
})
|
data_member="community_service"
|
||||||
|
data_map=chips_data
|
||||||
|
values=comm_ids
|
||||||
|
displayed_text=comm_text
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<ChipsList
|
||||||
|
label="Residency"
|
||||||
|
data_member="residency"
|
||||||
|
data_map=chips_data
|
||||||
|
values=res_ids
|
||||||
|
displayed_text=res_text
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
Either::Right(
|
||||||
|
view! {
|
||||||
|
<p>"Error while getting scholarship: "{err.to_string()}</p>
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
})
|
||||||
Either::Right(view! {
|
.collect_view()
|
||||||
<p>"Error while getting scholarship: "{err.to_string()}</p>
|
}}
|
||||||
})
|
</ValidatedForm>
|
||||||
}
|
<ActionButton on:click=move |_| {
|
||||||
}
|
create_comps.dispatch(CreateTestComparisons {});
|
||||||
}).collect_view()
|
}>"Create Comparisons"</ActionButton>
|
||||||
}}
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Show>
|
</Show>
|
||||||
</Panel>
|
</Panel>
|
||||||
}
|
}.into_any()
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,9 @@ use leptos_oidc::{Algorithm, AuthSignal, TokenData};
|
|||||||
/// dependencies cannot be compiled for WASM (nor should they be).
|
/// dependencies cannot be compiled for WASM (nor should they be).
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod server_utils {
|
pub mod server_utils {
|
||||||
|
use std::collections::HashMap;
|
||||||
use aws_config::SdkConfig;
|
use aws_config::SdkConfig;
|
||||||
|
use crate::common::ValueType;
|
||||||
|
|
||||||
/// Creates an [`SdkConfig`] struct for use with AWS SDK structs.
|
/// Creates an [`SdkConfig`] struct for use with AWS SDK structs.
|
||||||
pub async fn create_aws_config() -> SdkConfig {
|
pub async fn create_aws_config() -> SdkConfig {
|
||||||
@@ -20,6 +22,10 @@ pub mod server_utils {
|
|||||||
pub async fn create_dynamo_client() -> aws_sdk_dynamodb::Client {
|
pub async fn create_dynamo_client() -> aws_sdk_dynamodb::Client {
|
||||||
aws_sdk_dynamodb::Client::new(&create_aws_config().await)
|
aws_sdk_dynamodb::Client::new(&create_aws_config().await)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn into_attr_map(map: HashMap<String, ValueType>) -> HashMap<String, aws_sdk_dynamodb::types::AttributeValue> {
|
||||||
|
map.into_iter().map(|(k, v)| (k, v.into())).collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets the current user claims. This function should only be used in an area that has
|
/// Gets the current user claims. This function should only be used in an area that has
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
use crate::common::{ExpandableInfo, ValueType};
|
|
||||||
use std::ops::ControlFlow;
|
|
||||||
|
|
||||||
pub fn get_number_list(student_info: &ExpandableInfo, member: &String) -> Result<Vec<f64>, String> {
|
|
||||||
match student_info.data.get(member) {
|
|
||||||
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()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user