Implement error handling #41
No files matched your search
Generated
+1
@@ -2947,6 +2947,7 @@ dependencies = [
|
||||
"leptos_oidc",
|
||||
"leptos_router",
|
||||
"macros",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_dynamo",
|
||||
"tokio",
|
||||
|
||||
@@ -23,6 +23,7 @@ uuid = { version = "1.17.0", features = ["serde", "v4", "js"] }
|
||||
leptos_oidc = "0.9.0"
|
||||
base64 = "0.22.1"
|
||||
chrono = "0.4.42"
|
||||
regex = "1.11.2"
|
||||
|
||||
macros = { path = "./macros" }
|
||||
traits = { path = "./traits" }
|
||||
|
||||
+76
-126
@@ -1,136 +1,86 @@
|
||||
Forms should be built for this application with a series of JSON-type files.
|
||||
Each form will have a series of sections, and each section will have a series of
|
||||
questions. Each question has a type and a label - question styles may come later.
|
||||
It may be possible for us to call out specific question types without needing
|
||||
to parse the types separately, but that feels not-really-worth-it.
|
||||
To track errors and force form input validation, we're going to create a custom `ValidatedForm` component.
|
||||
|
||||
Example:
|
||||
This component will do two things:
|
||||
- Provide a submit button (that is disabled if the form is invalid, still thinking about that)
|
||||
- Define a validation method that will be called on submit that's updated on a per-input basis
|
||||
(this likely looks like a `Vec<RwSignal<ValidationState>>`)
|
||||
|
||||
```
|
||||
{
|
||||
"SectionDefinitions": [
|
||||
"SectionTitle": [
|
||||
"text": {
|
||||
"label": "First name question!",
|
||||
"placeholder": "John",
|
||||
},
|
||||
"text": {
|
||||
"label": "Last name question!",
|
||||
"placeholder": "Smith",
|
||||
}
|
||||
],
|
||||
"Section Two": [
|
||||
"text": {
|
||||
"label": "Section two testing question",
|
||||
"placeholder": "Some example here",
|
||||
},
|
||||
"select": {
|
||||
"label": "This is a dropdown element!",
|
||||
"placeholder": "option1",
|
||||
"possibleValues": [
|
||||
"option1": "something",
|
||||
"option2": "another something",
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
```
|
||||
In order for this to work, the form will `provide_context` to the `Vec`, and then it will check this
|
||||
vector on submit. It doesn't worry about the states inbetween, only during the submission logic.
|
||||
|
||||
...and so on. Here's a likely set of structures to use:
|
||||
The inputs start off with a default state, which will be defined by that input's validation method.
|
||||
This means that every input will run the validation method on mount and on each change. The first time the
|
||||
function is called should be during mount, which will set the default state. This does mean that any inputs
|
||||
that are marked as required will always be invalid on mount, and so we need to determine when these
|
||||
error messages should be shown and updated.
|
||||
|
||||
```
|
||||
struct FormDefinitionDoc {
|
||||
Vec<Section> section_defs;
|
||||
// and any extra values that should be applied
|
||||
// across the entire form, like settings.
|
||||
I think they should be shown in these situations:
|
||||
- Text inputs: on blur - we don't want to scream at users as they type.
|
||||
- Selectable inputs: on change and form submit - only show the message if the value is invalid, and not at mount.
|
||||
|
||||
To check values for the selectable inputs at submit time, we'll probably need to provide a refresh method
|
||||
that can be called on submit. This method must somehow be callable from the outside of the component and
|
||||
will show the error messages. We'd likely store all this in a struct of some kind, which could contain an
|
||||
`error` and a `show_message` signal.
|
||||
|
||||
We already know that we can send any `Signal` to another component, so we can just send the `show_message`
|
||||
signal to the parent component without any issues. We'll just use that signal to show the error messages
|
||||
in the component as well. Usually we don't want to edit too many signals outside their local context,
|
||||
but this is a special case – documentation is therefore incredibly important! Remember that we may also
|
||||
edit the `show_message` signal within its local context as well, so we need to make sure there are no cyclical
|
||||
dependencies. Alternatively, we could not edit the `show_message` signal at all locally and just use it
|
||||
to trigger side effects that show messages. Have to think about this more before implementation.
|
||||
|
||||
Here's a rough sketch of it all put together:
|
||||
```rust
|
||||
enum ValidationState {
|
||||
/// A valid input!
|
||||
Valid,
|
||||
/// An invalid input with an error message.
|
||||
Invalid(String)
|
||||
}
|
||||
|
||||
struct Section {
|
||||
Vec<QuestionComponent> questions;
|
||||
// each section may have a series of settings including how to submit,
|
||||
// when to submit, and where to submit to at the very least.
|
||||
// We'll store this in a Vec<RwSignal<InputState>>
|
||||
struct InputState {
|
||||
/// Determines if there is an error and provides an error message.
|
||||
error: Signal<ValidationState>,
|
||||
/// Determines if the form has requested to show error messages.
|
||||
show_message: RwSignal<bool>
|
||||
}
|
||||
|
||||
struct QuestionComponent {
|
||||
label: String,
|
||||
placeholder: String,
|
||||
possible_values: HashMap<str, str>,
|
||||
// there are definitely values that i'll find while creating this structure.
|
||||
// Define a new type for ease of use.
|
||||
type ValidationVec = Vec<RwSignal<InputState>>;
|
||||
|
||||
// Parent form component
|
||||
#[component]
|
||||
fn Form() -> impl IntoView {
|
||||
let validation_states: ValidationVec = Vec::new();
|
||||
|
||||
provide_context(validation_states);
|
||||
|
||||
/* All other form logic */
|
||||
}
|
||||
|
||||
fn validation_function(input: String) -> ValidationState {
|
||||
// Validation logic is specific to each input type.
|
||||
}
|
||||
|
||||
// Child component
|
||||
#[component]
|
||||
fn AnyInput() -> impl IntoView {
|
||||
let validation_states = use_context::<ValidationVec>().expect("Couldn't find form validation context");
|
||||
let current_input = RwSignal::new(String::new()); // Obviously, this would have a default value.
|
||||
let error_signal = RwSignal::new(validation_function(current_input.get()));
|
||||
let show_errors = RwSignal::new(false); // Don't show them on mount!
|
||||
|
||||
// See below for InputState definition
|
||||
let error_state = InputState {
|
||||
error: error_signal.clone().into(),
|
||||
show_message: show_errors.clone()
|
||||
};
|
||||
|
||||
validation_states.push(RwSignal::new())
|
||||
|
||||
/* All other input logic */
|
||||
}
|
||||
```
|
||||
|
||||
Types of questions that are required, starting with the providers.
|
||||
|
||||
Providers must be able to:
|
||||
|
||||
- Log into the website using a username and password (handled by AWS)
|
||||
- Create and manage multiple scholarships.
|
||||
- Input scholarship information for each scholarship.
|
||||
- View students that have applied.
|
||||
- View student application information (should just be labels??)
|
||||
- Buttons are a given. Duh.
|
||||
|
||||
Students must be able to:
|
||||
|
||||
- Log in using their Region 15 email (handled by AWS)
|
||||
- Create and manage their general application.
|
||||
- Create and manage applications specific to a single scholarship.
|
||||
- View their acceptance status (need more confirmation)
|
||||
|
||||
Administrators must be able to:
|
||||
|
||||
- Log in using their Region 15 email (handled by AWS)
|
||||
- Manage student scholarship applications.
|
||||
- Manage scholarship provider account information.
|
||||
- Create/manage scholarship information.
|
||||
- Download student applications if needed.
|
||||
- Statistics would be nice, but are likely a future thing.
|
||||
|
||||
Right off the bad, I think I can see a few types of buttons and text inputs,
|
||||
along with dropdowns, checkboxes, our custom `MultiEntry` element from last
|
||||
time. That means no new options, but it does mean that the `FormDefinition`
|
||||
needs to be able to keep track of all this information.
|
||||
|
||||
These are some example `QuestionComponent` objects:
|
||||
|
||||
Text fields:
|
||||
|
||||
```
|
||||
{
|
||||
question_type: "TEXT" | "EMAIL" | "NUMBER",
|
||||
label: "The question label",
|
||||
name: "input_name[jsdoc]",
|
||||
placeholder: "An example input"
|
||||
}
|
||||
```
|
||||
|
||||
Dropdowns/checkboxes/radios:
|
||||
|
||||
```
|
||||
{
|
||||
question_type: "DROPDOWN" | "CHECKBOX" | "RADIO",
|
||||
label: "The question label",
|
||||
name: "input_name[jsdoc]",
|
||||
placeholder: "The pre-selected option value",
|
||||
options: [
|
||||
"optionValue": "Option Text"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`MultiEntry`:
|
||||
|
||||
```
|
||||
{
|
||||
question_type: "MULTI",
|
||||
label: "The question label",
|
||||
name: "input_name[jsdoc]"
|
||||
}
|
||||
```
|
||||
|
||||
The real question here: how can we store this information in a database? And
|
||||
this means specifically within DynamoDB, because this is not an easy structure
|
||||
to store in there.
|
||||
That's going to be the hardest apart and I'll likely involve the AI in that
|
||||
planning.
|
||||
@@ -23,6 +23,7 @@ pub struct ComparisonData {
|
||||
}
|
||||
|
||||
impl ComparisonData {
|
||||
#[allow(unused)]
|
||||
pub fn new(
|
||||
id: impl Into<String>,
|
||||
member: impl Into<String>,
|
||||
|
||||
@@ -3,7 +3,6 @@ mod lists;
|
||||
mod number;
|
||||
mod text;
|
||||
mod traits;
|
||||
mod utils;
|
||||
|
||||
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::components::{CheckboxList, OutlinedTextField, RadioList, Row, Select};
|
||||
use leptos::either::EitherOf5;
|
||||
use leptos::prelude::RwSignal;
|
||||
use leptos::{IntoView, view};
|
||||
use crate::components::{CheckboxList, OutlinedTextField, RadioList, Row, Select, TextFieldType};
|
||||
use leptos::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -11,85 +9,82 @@ use std::collections::HashMap;
|
||||
pub enum InputType {
|
||||
/// Represents a text input. Requires a `String` key, `String` label, and `String` placeholder.
|
||||
/// The entered value will be returned as a single `String` value.
|
||||
Text(String, String, String),
|
||||
Text(String, String, String, bool),
|
||||
/// Represents a number input. Requires a `String` key, `String` label, and a `String` placeholder.
|
||||
/// The entered value will be returned as a `String`, and will be parseable into any number type
|
||||
/// (i.e. an `i32`, `i64`, etc)
|
||||
Number(String, String, String),
|
||||
Number(String, String, String, bool),
|
||||
/// Represents a radio input. Requires a `String` key, `String` label, and a series of `String`
|
||||
/// options. The selected value will be returned as a `String`.
|
||||
Radio(String, String, Vec<String>),
|
||||
Radio(String, String, Vec<String>, bool),
|
||||
/// Represents a checkbox input. Requires a `String` key, `String` label, and a series of `String`
|
||||
/// options. The selected values will be returned as a `Vec<String>`.
|
||||
Checkbox(String, String, Vec<String>),
|
||||
Checkbox(String, String, Vec<String>, bool),
|
||||
/// Represents a dropdown input. Requires a `String` key, `String` label, and a series
|
||||
/// of `String` options. The selected value will be returned as a `String`.
|
||||
Select(String, String, Vec<String>),
|
||||
Select(String, String, Vec<String>, bool),
|
||||
}
|
||||
|
||||
impl InputType {
|
||||
pub fn into_view(self, data_map: RwSignal<HashMap<String, ValueType>>) -> impl IntoView {
|
||||
view! {
|
||||
{match self {
|
||||
InputType::Text(member, label, placeholder) =>
|
||||
EitherOf5::A(view! {
|
||||
InputType::Text(member, label, placeholder, required) =>
|
||||
view! {
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label = label.clone()
|
||||
placeholder = placeholder.clone()
|
||||
data_member = member.clone()
|
||||
data_map = data_map
|
||||
required = required
|
||||
/>
|
||||
</Row>
|
||||
}),
|
||||
InputType::Number(member, label, placeholder) =>
|
||||
EitherOf5::B(view! {
|
||||
}.into_any(),
|
||||
InputType::Number(member, label, placeholder, required) =>
|
||||
view! {
|
||||
<OutlinedTextField
|
||||
label = label.clone()
|
||||
placeholder = placeholder.clone()
|
||||
data_member = member.clone()
|
||||
data_map = data_map
|
||||
input_type = "number"
|
||||
input_type = TextFieldType::Number
|
||||
required = required
|
||||
/>
|
||||
}),
|
||||
InputType::Checkbox(member, label, options) =>
|
||||
EitherOf5::C(view! {
|
||||
}.into_any(),
|
||||
InputType::Checkbox(member, label, options, required) =>
|
||||
view! {
|
||||
<CheckboxList
|
||||
label = label.clone()
|
||||
items = options.clone()
|
||||
data_member = member.clone()
|
||||
data_map = data_map
|
||||
required = required
|
||||
/>
|
||||
}),
|
||||
InputType::Radio(member, label, options) =>
|
||||
EitherOf5::D(view! {
|
||||
}.into_any(),
|
||||
InputType::Radio(member, label, options, required) =>
|
||||
view! {
|
||||
<RadioList
|
||||
label = label.clone()
|
||||
data_member = member.clone()
|
||||
data_map = data_map
|
||||
items = options.clone()
|
||||
required = required
|
||||
/>
|
||||
}),
|
||||
InputType::Select(member, label, options) =>
|
||||
EitherOf5::E(view! {
|
||||
}.into_any(),
|
||||
InputType::Select(member, label, options, required) =>
|
||||
view! {
|
||||
<Select
|
||||
label = label.clone()
|
||||
value_list = options.clone()
|
||||
data_member = member.clone()
|
||||
data_map = data_map
|
||||
required = required
|
||||
/>
|
||||
})
|
||||
}.into_any()
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_view_rows(self, data_map: RwSignal<HashMap<String, ValueType>>) -> impl IntoView {
|
||||
view! {
|
||||
<Row>
|
||||
{self.into_view(data_map)}
|
||||
</Row>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shorthand macro for creating `InputType` enums.
|
||||
@@ -104,19 +99,21 @@ impl InputType {
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! input {
|
||||
($variant:ident, $key:expr, $label:expr, [$($option:expr),* $(,)?]) => {
|
||||
($variant:ident, $key:expr, $label:expr, $required:expr, [$($option:expr),* $(,)?]) => {
|
||||
$crate::common::InputType::$variant(
|
||||
$key.to_string(),
|
||||
$label.to_string(),
|
||||
vec![$($option.to_string()), *]
|
||||
vec![$($option.to_string()), *],
|
||||
$required
|
||||
)
|
||||
};
|
||||
|
||||
($variant:ident, $key:expr, $label:expr, $placeholder:expr) => {
|
||||
($variant:ident, $key:expr, $label:expr, $required:expr, $placeholder:expr) => {
|
||||
$crate::common::InputType::$variant(
|
||||
$key.to_string(),
|
||||
$label.to_string(),
|
||||
$placeholder.to_string()
|
||||
$placeholder.to_string(),
|
||||
$required
|
||||
)
|
||||
};
|
||||
}
|
||||
@@ -3,5 +3,5 @@ pub enum SubmitStatus {
|
||||
Idle,
|
||||
Sending,
|
||||
Success,
|
||||
Error(String),
|
||||
Error(()),
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
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>,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
|
||||
@@ -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,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: String,
|
||||
/// A [`Signal`] indicating whether the checkbox is disabled.
|
||||
#[prop(into)] disabled: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
let id = create_unique_id(&name, &value.get_untracked());
|
||||
|
||||
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: 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.clone()
|
||||
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,139 @@
|
||||
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: String,
|
||||
#[prop(into)] on_change: Callback<(), ()>,
|
||||
#[prop()] checked: Signal<bool>,
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
// Generate a unique id - ensure that the value doesn't contain spaces.
|
||||
// Without this id, checkbox/radio inputs can interfere with each other.
|
||||
let id = create_unique_id(&name, &value.get_untracked());
|
||||
|
||||
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(optional, into)] disabled: Signal<bool>,
|
||||
#[prop(optional, into)] label: String,
|
||||
#[prop(optional, into)] required: 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>
|
||||
{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.
|
||||
controller.selected_list.update(|list| {
|
||||
let value = value.get();
|
||||
if list.contains(&value) {
|
||||
list.retain(|val| *val != value);
|
||||
} else {
|
||||
list.push(value.clone());
|
||||
}
|
||||
});
|
||||
controller.dirty.set(true);
|
||||
};
|
||||
|
||||
view! {
|
||||
<Chip
|
||||
checked=checked
|
||||
on_change=on_change
|
||||
value=value
|
||||
display_text=text
|
||||
name=data_member.clone()
|
||||
disabled=disabled
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect_view()}
|
||||
</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,91 @@
|
||||
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: String,
|
||||
data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
required: Signal<bool>
|
||||
) -> ListController {
|
||||
//#region Central List Logic
|
||||
|
||||
// Get default values from the data map. This will only run once, when the input is created
|
||||
// and mounted to the DOM.
|
||||
let temp_map = data_map.get_untracked();
|
||||
let origin_value_type = temp_map.get(&data_member)
|
||||
.unwrap_or(&ValueType::List(None));
|
||||
let origin_list = value_type_to_vec(origin_value_type);
|
||||
|
||||
// "Raw" selected values - just strings, no ValueTyping
|
||||
let selected_list = RwSignal::new(origin_list);
|
||||
|
||||
Effect::new({
|
||||
let data_member = data_member.clone();
|
||||
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.clone(), 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 dirty = RwSignal::new(false);
|
||||
let show_errors = Signal::derive(move || dirty.get() && matches!(error.get(), ValidationState::Invalid(_)));
|
||||
|
||||
let validator = RwSignal::new(InputState::new(
|
||||
data_member.clone(),
|
||||
error.clone(),
|
||||
dirty.clone()
|
||||
));
|
||||
|
||||
validation_context.validators.update(|list| list.push(validator.clone()));
|
||||
|
||||
//#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,158 @@
|
||||
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 = InputState::new(
|
||||
data_member.get_untracked(),
|
||||
error.clone(),
|
||||
dirty.clone()
|
||||
);
|
||||
|
||||
validation_context.validators.update(|list| list.push(RwSignal::new(input_state)));
|
||||
|
||||
//#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,126 @@
|
||||
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 = InputState::new(data_member.get(), error.clone(), dirty.clone());
|
||||
|
||||
let validation_context = use_validation_context()
|
||||
.expect("Could not find FormValidationRegistry context");
|
||||
|
||||
validation_context.validators.update(|list| list.push(RwSignal::new(input_state)));
|
||||
|
||||
//#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=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,18 +1,17 @@
|
||||
mod action_button;
|
||||
mod banner;
|
||||
mod checkbox;
|
||||
mod chips;
|
||||
mod dashboard_button;
|
||||
mod loading;
|
||||
mod multi_entry;
|
||||
mod panel;
|
||||
mod radio;
|
||||
mod row;
|
||||
mod select;
|
||||
mod text_field;
|
||||
mod utils;
|
||||
mod validated_form;
|
||||
mod lists;
|
||||
|
||||
pub use self::{
|
||||
action_button::*, banner::*, checkbox::*, chips::*, dashboard_button::*, loading::*,
|
||||
multi_entry::*, panel::*, radio::*, row::*, select::*, text_field::*,
|
||||
action_button::*, banner::*, dashboard_button::*, lists::*,
|
||||
loading::*, multi_entry::*, panel::*, row::*, text_field::*,
|
||||
validated_form::*
|
||||
};
|
||||
@@ -73,9 +73,6 @@ pub fn MultiEntry(
|
||||
/// component's `data_member` input.
|
||||
#[prop()]
|
||||
data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
/// Currently unused.
|
||||
#[prop(optional, into)]
|
||||
name_member: String,
|
||||
/// The type and order of the inputs for each `Entry` component within this `MultiEntry` component.
|
||||
#[prop(optional)]
|
||||
schema: Vec<InputType>,
|
||||
@@ -132,7 +129,6 @@ pub fn MultiEntry(
|
||||
view! {
|
||||
<Entry
|
||||
data_map = map_signal
|
||||
name_member = name_member.clone()
|
||||
schema = schema.clone()
|
||||
/>
|
||||
}
|
||||
@@ -186,14 +182,6 @@ fn Entry(
|
||||
/// data members those inputs modify.
|
||||
#[prop()]
|
||||
schema: Vec<InputType>,
|
||||
/// The display name member of this entry. Determines where the component should search for the
|
||||
/// name of the entry within the `data_map`.
|
||||
#[prop(into)]
|
||||
name_member: String,
|
||||
/// The info member of this entry. Determines where the component should search for the extra
|
||||
/// information of this entry within the `data_map`.
|
||||
#[prop(optional, into)]
|
||||
info_member: String,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<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>
|
||||
}
|
||||
}
|
||||
+114
-51
@@ -1,69 +1,125 @@
|
||||
use crate::common::ValueType;
|
||||
use leptos::leptos_dom::logging::console_log;
|
||||
use leptos::prelude::*;
|
||||
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 only Region 15 email addresses.
|
||||
/// TODO Accept more than just Region 15 addresses.
|
||||
Email
|
||||
}
|
||||
|
||||
/// 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 => validate_email(value),
|
||||
TextFieldType::Number => validate_number(value),
|
||||
TextFieldType::Text => ValidationState::Valid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates an email address.
|
||||
fn validate_email(input: &str) -> ValidationState {
|
||||
// TODO Accept more than just Region 15 addresses.
|
||||
let pattern = regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@region15\.org$").expect("Invalid regex pattern");
|
||||
|
||||
if pattern.is_match(input) {
|
||||
ValidationState::Valid
|
||||
} else {
|
||||
ValidationState::Invalid("Please provide a valid Region 15 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]
|
||||
pub fn OutlinedTextField(
|
||||
#[prop(optional, into)] placeholder: String,
|
||||
// #[prop()] value: Subfield<TStore, TInner, ValueType>,
|
||||
#[prop(into)] data_member: String, // should this be an RwSignal??
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
#[prop(optional)] value: RwSignal<ValueType>,
|
||||
#[prop(default = "text".to_owned(), into)] input_type: String,
|
||||
#[prop(optional)] input_type: TextFieldType,
|
||||
#[prop(into, optional)] disabled: Signal<bool>,
|
||||
#[prop(optional)] error: RwSignal<bool>,
|
||||
#[prop(optional, into)] name: String,
|
||||
#[prop(optional, into)] label: String,
|
||||
#[prop(optional, into)] error_text: RwSignal<String>,
|
||||
#[prop(optional, into)] required: Signal<bool>
|
||||
) -> impl IntoView {
|
||||
let input_ref = NodeRef::<Input>::new();
|
||||
|
||||
// Register this input's validation signal.
|
||||
let validator_context = use_validation_context()
|
||||
.expect("FormValidSignal was not found");
|
||||
|
||||
let raw_value = RwSignal::new(data_map.get_untracked()
|
||||
.get(&data_member)
|
||||
.unwrap_or(&ValueType::String(None))
|
||||
.to_string());
|
||||
|
||||
let dirty = RwSignal::new(false);
|
||||
let error = RwSignal::new(validate(required.get_untracked(), &raw_value.get_untracked(), &input_type));
|
||||
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 = match input_type {
|
||||
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
|
||||
// valid types, and parses them accordingly.
|
||||
let on_input = {
|
||||
let input_type = input_type.clone();
|
||||
let data_member = data_member.clone();
|
||||
move |e| {
|
||||
let to_parse = event_target_value(&e);
|
||||
match input_type.as_str() {
|
||||
"text" => {
|
||||
// String parse function. It will always parse, but make sure the error
|
||||
// state is set properly anyway.
|
||||
error.set(false);
|
||||
value.set(ValueType::String(Some(to_parse.clone())));
|
||||
data_map.update(|map| {
|
||||
map.insert(data_member.clone(), ValueType::String(Some(to_parse)));
|
||||
});
|
||||
}
|
||||
"number" => {
|
||||
// Define number parse function.
|
||||
// If the number parses, Some(num), else None, but we want to keep the value that's
|
||||
// in the text field at this point, just set the error.
|
||||
console_log(format!("Parsing {to_parse} to a number").as_str());
|
||||
match to_parse
|
||||
.parse::<i32>()
|
||||
.map(|n| ValueType::Number(Some(n.to_string())))
|
||||
{
|
||||
Ok(value_type) => {
|
||||
error.set(false);
|
||||
value.set(value_type.clone());
|
||||
data_map.update(|map| {
|
||||
map.insert(data_member.clone(), value_type);
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
error.set(true);
|
||||
error_text.set("Value is not a valid number.".to_owned());
|
||||
}
|
||||
raw_value.set(to_parse.clone());
|
||||
error.set(validate(required.get(), &to_parse, &input_type));
|
||||
let into_map = match input_type {
|
||||
TextFieldType::Number => ValueType::Number(Some(to_parse)),
|
||||
_ => ValueType::String(Some(to_parse))
|
||||
};
|
||||
}
|
||||
_ => {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Edit the data map. Note that this should be uncoupled in the future - we don't want
|
||||
// to update the map inside the component definition, but inside the page/form that
|
||||
// created this input.
|
||||
data_map.update(|map| {
|
||||
map.insert(data_member.clone(), into_map);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -72,11 +128,12 @@ pub fn OutlinedTextField(
|
||||
<label class="flex flex-col flex-1">
|
||||
<span class="block ml-1.5 mb-0 font-bold">{label}</span>
|
||||
<input
|
||||
node_ref=input_ref
|
||||
class="border-2 m-1.5 p-1.5 mt-0 rounded-md bg-transparent relative flex-1
|
||||
transition-all duration-150
|
||||
border-red-700 bg-transparent
|
||||
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
|
||||
r#type={input_type}
|
||||
r#type={str_type}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
prop:name=name
|
||||
@@ -86,12 +143,18 @@ pub fn OutlinedTextField(
|
||||
.unwrap_or(&ValueType::String(None)) // ValueType
|
||||
.to_string()
|
||||
on:input=on_input
|
||||
on:blur=on_blur
|
||||
/>
|
||||
<div
|
||||
class="text-red-600 text-sm mr-1.5 ml-1.5"
|
||||
class=(["hidden"], move || !error.get())>
|
||||
{error_text}
|
||||
<Show when=move || show_errors.get()>
|
||||
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
|
||||
{move || {
|
||||
match error.get() {
|
||||
ValidationState::Invalid(msg) => msg,
|
||||
_ => "There is no error - should not see this message.".to_string()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</Show>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
use leptos::logging::log;
|
||||
use leptos::prelude::*;
|
||||
use leptos::web_sys::SubmitEvent;
|
||||
use crate::components::ActionButton;
|
||||
|
||||
/// 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 */})>
|
||||
/// {/* 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<()>,
|
||||
) -> 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>
|
||||
{children()}
|
||||
<ActionButton
|
||||
on:click=move |_| {
|
||||
log!("{:?}", can_submit.get());
|
||||
}
|
||||
button_type="submit"
|
||||
>
|
||||
"Submit form (but actually)"
|
||||
</ActionButton>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ pub mod app;
|
||||
mod common;
|
||||
mod components;
|
||||
mod pages;
|
||||
mod utils;
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||
|
||||
@@ -8,12 +8,12 @@ use crate::common::{
|
||||
ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison,
|
||||
TextComparison, TextListComparison, ValueType,
|
||||
};
|
||||
use leptos::leptos_dom::log;
|
||||
use leptos::prelude::ServerFnError;
|
||||
use leptos::server;
|
||||
|
||||
/// Helper function to create all sports comparisons. Only used to create initial
|
||||
/// comparison data, and should be removed upon completion of a comparison editor page.
|
||||
#[allow(unused)]
|
||||
fn create_sports_comparisons() -> Vec<ComparisonData> {
|
||||
vec![
|
||||
ComparisonData::new(
|
||||
@@ -184,12 +184,8 @@ fn create_sports_comparisons() -> Vec<ComparisonData> {
|
||||
]
|
||||
}
|
||||
|
||||
#[server(CreateTestComparisons, endpoint = "/comparisons/create-test")]
|
||||
pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
log!("Creating test comparisons");
|
||||
|
||||
#[allow(unused)]
|
||||
fn make_comp_list() -> Vec<ComparisonData> {
|
||||
let test_comp = ComparisonData::new(
|
||||
"math_sat_comp",
|
||||
"math_sat",
|
||||
@@ -304,6 +300,19 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
|
||||
comp_list.append(&mut sports_comps);
|
||||
comp_list.append(&mut major_comps);
|
||||
|
||||
comp_list
|
||||
}
|
||||
|
||||
#[server(CreateTestComparisons, endpoint = "/comparisons/create-test")]
|
||||
pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
|
||||
use leptos::logging::log;
|
||||
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
log!("Creating test comparisons");
|
||||
|
||||
let comp_list = make_comp_list();
|
||||
|
||||
for comparison in comp_list {
|
||||
if let Err(err) = client
|
||||
.put_item()
|
||||
@@ -322,6 +331,8 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
|
||||
|
||||
#[server]
|
||||
pub async fn get_comparison_info() -> Result<Vec<ComparisonData>, ServerFnError> {
|
||||
use leptos::logging::log;
|
||||
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
log!("Getting all comparisons from the database");
|
||||
|
||||
@@ -3,15 +3,19 @@ use aws_sdk_dynamodb::{
|
||||
types::AttributeValue,
|
||||
error::ProvideErrorMetadata,
|
||||
};
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use leptos::leptos_dom::log;
|
||||
use leptos::prelude::ServerFnError;
|
||||
use leptos::server;
|
||||
#[cfg(feature = "ssr")]
|
||||
use uuid::Uuid;
|
||||
use crate::common::{ExpandableInfo, ValueType};
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
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;
|
||||
|
||||
#[server(GetScholarshipInfo, endpoint = "/scholarship/info/get")]
|
||||
pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFnError> {
|
||||
|
||||
+177
-182
@@ -1,7 +1,6 @@
|
||||
// Server dependencies
|
||||
#[cfg(feature = "ssr")]
|
||||
use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue};
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use serde_dynamo::{from_item, to_item};
|
||||
#[cfg(feature = "ssr")]
|
||||
@@ -9,22 +8,20 @@ use std::process::{Command, Stdio};
|
||||
#[cfg(feature = "ssr")]
|
||||
use std::io::Write;
|
||||
|
||||
use crate::common::{ExpandableInfo, UserClaims};
|
||||
use crate::common::ExpandableInfo;
|
||||
use crate::pages::UnauthenticatedPage;
|
||||
use crate::components::{
|
||||
ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row,
|
||||
Select,
|
||||
};
|
||||
use crate::pages::utils::get_user_claims;
|
||||
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::prelude::*;
|
||||
use leptos_oidc::{AuthLoaded, Authenticated};
|
||||
use base64::Engine;
|
||||
use leptos::logging::log;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos::web_sys::HtmlAnchorElement;
|
||||
use leptos::wasm_bindgen::JsCast;
|
||||
use traits::{AsReactive, ReactiveCapture};
|
||||
use crate::pages::utils::get_user_claims;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// # Get Student Info
|
||||
/// Gets a student's information given their `subject`.
|
||||
@@ -144,187 +141,50 @@ pub async fn create_example_pdf() -> Result<Vec<u8>, ServerFnError> {
|
||||
/// The main home page component. Contains a simple contact form.
|
||||
#[component]
|
||||
pub fn HomePage() -> impl IntoView {
|
||||
// Creates a reactive value to update the button
|
||||
//#region Server Resources
|
||||
let user_claims = get_user_claims();
|
||||
let user_subject = Memo::new(move |_| {
|
||||
user_claims.get()
|
||||
.map(|claim| claim.claims.subject.clone())
|
||||
});
|
||||
|
||||
// Note that the value passed in MUST be equatable.
|
||||
// We get/unwrap the value repeatedly until we get a simple string value, then clone it so that
|
||||
// we don't lose access to it in the future, should we need it again.
|
||||
let server_resource: Resource<ExpandableInfo> = Resource::new(
|
||||
move || user_claims.get().map(|claim| claim.claims.subject.clone()),
|
||||
async |opt_username| match opt_username {
|
||||
Some(subject) => get_submission(subject.clone()).await.unwrap_or_else(|e| {
|
||||
console_log(e.to_string().as_str());
|
||||
ExpandableInfo::new(subject)
|
||||
}),
|
||||
None => ExpandableInfo::new("".to_owned()),
|
||||
},
|
||||
let form_data = RwSignal::new(HashMap::new());
|
||||
|
||||
let server_resource = Resource::new(
|
||||
move || user_subject.get(),
|
||||
async move |subject| get_submission(subject.unwrap_or_default()).await
|
||||
);
|
||||
let submit_action = ServerAction::<CreateSampleSubmission>::new();
|
||||
|
||||
view! {
|
||||
<AuthLoaded fallback=Loading>
|
||||
<Authenticated unauthenticated=UnauthenticatedPage>
|
||||
// Replace this fallback with a real loading screen.
|
||||
<Suspense fallback=Loading>
|
||||
{move || {
|
||||
server_resource
|
||||
.get()
|
||||
.map(|submission| {
|
||||
let expandable_react = submission.as_reactive();
|
||||
let elements_disabled = RwSignal::new(false);
|
||||
let result_msg = Signal::derive(move || {
|
||||
if submit_action.pending().get() {
|
||||
elements_disabled.set(true);
|
||||
"Sending request...".to_owned()
|
||||
} else {
|
||||
elements_disabled.set(false);
|
||||
if let Some(result) = submit_action.value().get() {
|
||||
match result {
|
||||
Ok(_) => "Request sent successfully.".to_owned(),
|
||||
Err(e) => format!("Failed to send request: {:?}", e),
|
||||
Effect::new(move || {
|
||||
match server_resource.get() {
|
||||
Some(Ok(submission)) => {
|
||||
form_data.set(submission.data);
|
||||
}
|
||||
} else {
|
||||
"".to_owned()
|
||||
Some(Err(err)) => {
|
||||
log!("Error while getting information: {:?}", err);
|
||||
}
|
||||
_ => {
|
||||
log!("An unknown error occurred.");
|
||||
}
|
||||
}
|
||||
});
|
||||
view! {
|
||||
<Row>
|
||||
<div class="flex flex-col flex-1" />
|
||||
<Panel>
|
||||
<Row>
|
||||
<h1 class="text-3xl font-bold">
|
||||
"Region 15 Scholarship Application (DEMO)"
|
||||
</h1>
|
||||
</Row>
|
||||
<Row>
|
||||
<p class="text-sm">
|
||||
"This is a very simple demonstration of the scholarship application written in Rust."
|
||||
</p>
|
||||
</Row>
|
||||
<Row>
|
||||
// Text { data_member, label, placeholder }
|
||||
<OutlinedTextField
|
||||
label="First Name:"
|
||||
placeholder="John"
|
||||
disabled=elements_disabled
|
||||
data_member="first_name"
|
||||
data_map=expandable_react.data
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Last Name:"
|
||||
placeholder="Smith"
|
||||
disabled=elements_disabled
|
||||
data_member="last_name"
|
||||
data_map=expandable_react.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Contact Email:"
|
||||
placeholder="student@region15.org"
|
||||
disabled=elements_disabled
|
||||
data_member="contact_email"
|
||||
data_map=expandable_react.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Phone Number:"
|
||||
placeholder="123-456-7890"
|
||||
disabled=elements_disabled
|
||||
data_member="phone_number"
|
||||
data_map=expandable_react.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Street Address:"
|
||||
placeholder="123 Fake Street"
|
||||
disabled=elements_disabled
|
||||
data_member="address"
|
||||
data_map=expandable_react.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Highest Math SAT Score:"
|
||||
placeholder="600"
|
||||
disabled=elements_disabled
|
||||
data_member="math_sat"
|
||||
data_map=expandable_react.data
|
||||
input_type="number"
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
// Radio { data_member, label, items }
|
||||
<RadioList
|
||||
label="Town:"
|
||||
items=vec!["Southbury", "Middlebury"]
|
||||
.into_iter()
|
||||
.map(|s| s.into())
|
||||
.collect()
|
||||
disabled=elements_disabled
|
||||
data_member="town"
|
||||
data_map = expandable_react.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
// Checkbox { data_member, label, items }
|
||||
<CheckboxList
|
||||
label="Favorite Candies:"
|
||||
items=vec!["Twizzlers", "Reese's", "Starburst"]
|
||||
.into_iter()
|
||||
.map(|s| s.into())
|
||||
.collect()
|
||||
disabled=elements_disabled
|
||||
data_member="favorite_candies"
|
||||
data_map=expandable_react.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
// Select { data_member, label, items }
|
||||
<Select
|
||||
label="Gender:"
|
||||
value_list=vec!["Male", "Female", "Prefer not to answer"]
|
||||
.into_iter()
|
||||
.map(|s| s.to_owned())
|
||||
.collect()
|
||||
disabled=elements_disabled
|
||||
data_member="gender"
|
||||
data_map=expandable_react.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<MultiEntry
|
||||
data_map=expandable_react.data
|
||||
data_member="community_involvement"
|
||||
schema=vec![
|
||||
input!(Text, "service_name", "Activity Name:", "Some service activity..."),
|
||||
input!(Number, "service_hours", "Total Service Hours", "20")
|
||||
]
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<ActionButton
|
||||
on:click=move |_| {
|
||||
let captured_map = expandable_react.capture();
|
||||
console_log(
|
||||
format!("Map values: {:?}", captured_map).as_str(),
|
||||
);
|
||||
submit_action
|
||||
.dispatch(CreateSampleSubmission {
|
||||
student_info: captured_map,
|
||||
//#endregion
|
||||
//#region Component State
|
||||
let elements_disabled = Signal::derive(move || {
|
||||
submit_action.pending().get()
|
||||
});
|
||||
}
|
||||
disabled=elements_disabled
|
||||
>
|
||||
"Submit"
|
||||
</ActionButton>
|
||||
</Row>
|
||||
<Row>
|
||||
<ActionButton on:click=move |_| {
|
||||
//#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;
|
||||
@@ -346,19 +206,154 @@ pub fn HomePage() -> impl IntoView {
|
||||
link.click();
|
||||
}
|
||||
});
|
||||
}>"Get PDF"</ActionButton>
|
||||
};
|
||||
//#endregion
|
||||
|
||||
view! {
|
||||
<AuthLoaded fallback=Loading>
|
||||
<Authenticated unauthenticated=UnauthenticatedPage>
|
||||
// Replace this fallback with a real loading screen.
|
||||
<Suspense fallback=Loading>
|
||||
{move || {
|
||||
server_resource
|
||||
.get()
|
||||
.map(|_| {
|
||||
view! {
|
||||
<Row>
|
||||
<div class="flex flex-col flex-1" />
|
||||
<Panel>
|
||||
<ValidatedForm on_submit=Callback::new(on_submit)>
|
||||
<Row>
|
||||
<h1 class="text-3xl font-bold">
|
||||
"Region 15 Scholarship Application (DEMO)"
|
||||
</h1>
|
||||
</Row>
|
||||
<Row>
|
||||
<p>{result_msg}</p>
|
||||
<p class="text-sm">
|
||||
"This is a very simple demonstration of the scholarship application written in Rust."
|
||||
</p>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="First Name:"
|
||||
placeholder="John"
|
||||
disabled=elements_disabled
|
||||
data_member="first_name"
|
||||
data_map=form_data
|
||||
required=true
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Last Name:"
|
||||
placeholder="Smith"
|
||||
disabled=elements_disabled
|
||||
data_member="last_name"
|
||||
data_map=form_data
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Contact Email:"
|
||||
placeholder="student@region15.org"
|
||||
disabled=elements_disabled
|
||||
data_member="contact_email"
|
||||
data_map=form_data
|
||||
input_type=TextFieldType::Email
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Phone Number:"
|
||||
placeholder="123-456-7890"
|
||||
disabled=elements_disabled
|
||||
data_member="phone_number"
|
||||
data_map=form_data
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Street Address:"
|
||||
placeholder="123 Fake Street"
|
||||
disabled=elements_disabled
|
||||
data_member="address"
|
||||
data_map=form_data
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<OutlinedTextField
|
||||
label="Highest Math SAT Score:"
|
||||
placeholder="600"
|
||||
disabled=elements_disabled
|
||||
data_member="math_sat"
|
||||
data_map=form_data
|
||||
input_type=TextFieldType::Number
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<RadioList
|
||||
label="Town:"
|
||||
items=vec!["Southbury", "Middlebury"]
|
||||
.into_iter()
|
||||
.map(|s| s.into())
|
||||
.collect()
|
||||
disabled=elements_disabled
|
||||
data_member="town"
|
||||
data_map=form_data
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<CheckboxList
|
||||
label="Favorite Candies:"
|
||||
items=vec!["Twizzlers", "Reese's", "Starburst"]
|
||||
.into_iter()
|
||||
.map(|s| s.into())
|
||||
.collect()
|
||||
disabled=elements_disabled
|
||||
data_member="favorite_candies"
|
||||
data_map=form_data
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<Select
|
||||
label="Gender:"
|
||||
value_list=vec!["Male", "Female", "Prefer not to answer"]
|
||||
.into_iter()
|
||||
.map(|s| s.to_owned())
|
||||
.collect()
|
||||
disabled=elements_disabled
|
||||
data_member="gender"
|
||||
data_map=form_data
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<MultiEntry
|
||||
data_map=form_data
|
||||
data_member="community_involvement"
|
||||
schema=vec![
|
||||
input!(Text, "service_name", "Activity Name:", true, "Some service activity..."),
|
||||
input!(Number, "service_hours", "Total Service Hours", true, "20")
|
||||
]
|
||||
/>
|
||||
</Row>
|
||||
</ValidatedForm>
|
||||
</Panel>
|
||||
<div class="flex flex-col flex-1" />
|
||||
</Row>
|
||||
}
|
||||
})
|
||||
}}
|
||||
<Row>
|
||||
<ActionButton on:click=pdf_button_click>"Get PDF"</ActionButton>
|
||||
</Row>
|
||||
</Suspense>
|
||||
</Authenticated>
|
||||
</AuthLoaded>
|
||||
}
|
||||
}.into_any()
|
||||
}
|
||||
+29
-27
@@ -4,10 +4,11 @@ use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValu
|
||||
#[cfg(feature = "ssr")]
|
||||
use serde_dynamo::{from_item, to_item};
|
||||
|
||||
use crate::common::{ExpandableInfo, ValueType};
|
||||
use crate::components::{
|
||||
ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select,
|
||||
};
|
||||
#[cfg(feature = "ssr")]
|
||||
use crate::common::ValueType;
|
||||
|
||||
use crate::common::ExpandableInfo;
|
||||
use crate::components::{ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select, TextFieldType, ValidatedForm};
|
||||
use chrono::{FixedOffset, TimeZone};
|
||||
use leptos::Params;
|
||||
use leptos::either::{Either, EitherOf3};
|
||||
@@ -18,7 +19,6 @@ use leptos_router::hooks::{use_navigate, use_params};
|
||||
use leptos_router::params::Params;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use traits::{AsReactive, ReactiveCapture};
|
||||
|
||||
#[derive(Params, PartialEq)]
|
||||
struct LoanerParams {
|
||||
@@ -245,13 +245,21 @@ pub fn LoanerPage() -> impl IntoView {
|
||||
pub fn LoanerBorrowForm() -> impl IntoView {
|
||||
// Register server actions.
|
||||
let create_entry_action = ServerAction::<CreateBorrowEntry>::new();
|
||||
let navigate = use_navigate();
|
||||
|
||||
let temp = ExpandableInfo::new(uuid::Uuid::new_v4().to_string());
|
||||
let temp_reactive = temp.as_reactive();
|
||||
let elements_disabled = RwSignal::new(false);
|
||||
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(),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<ValidatedForm on_submit=Callback::new(on_submit)>
|
||||
<div class="flex flex-col justify-center gap-2 mb-1 py-3 px-2">
|
||||
<h2 class="text-2xl font-bold">"Borrower Information"</h2>
|
||||
<p class="text-lg">"Please fill out the form below."</p>
|
||||
@@ -262,14 +270,16 @@ pub fn LoanerBorrowForm() -> impl IntoView {
|
||||
placeholder="John"
|
||||
disabled=elements_disabled
|
||||
data_member="first_name"
|
||||
data_map=temp_reactive.data
|
||||
data_map=data
|
||||
required=true
|
||||
/>
|
||||
<OutlinedTextField
|
||||
label="Last Name:"
|
||||
placeholder="Smith"
|
||||
disabled=elements_disabled
|
||||
data_member="last_name"
|
||||
data_map=temp_reactive.data
|
||||
data_map=data
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
@@ -278,7 +288,9 @@ pub fn LoanerBorrowForm() -> impl IntoView {
|
||||
placeholder="example@region15.org"
|
||||
disabled=elements_disabled
|
||||
data_member="email"
|
||||
data_map=temp_reactive.data
|
||||
data_map=data
|
||||
input_type=TextFieldType::Email
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
@@ -293,7 +305,8 @@ pub fn LoanerBorrowForm() -> impl IntoView {
|
||||
]
|
||||
disabled=elements_disabled
|
||||
data_member="collateral"
|
||||
data_map=temp_reactive.data
|
||||
data_map=data
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
@@ -306,22 +319,11 @@ pub fn LoanerBorrowForm() -> impl IntoView {
|
||||
]
|
||||
disabled=elements_disabled
|
||||
data_member="loan"
|
||||
data_map=temp_reactive.data
|
||||
data_map=data
|
||||
required=true
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<ActionButton
|
||||
on:click=move |_| {
|
||||
let captured = temp_reactive.capture();
|
||||
log!("Loaner record taken.");
|
||||
log!("{:?}", captured);
|
||||
create_entry_action.dispatch(CreateBorrowEntry {
|
||||
input: captured
|
||||
});
|
||||
navigate("/loaners", Default::default());
|
||||
}
|
||||
>"Submit"</ActionButton>
|
||||
</Row>
|
||||
</ValidatedForm>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use leptos::either::Either;
|
||||
use leptos::html::Dialog;
|
||||
use leptos::logging::log;
|
||||
@@ -5,12 +6,11 @@ use leptos::prelude::*;
|
||||
use leptos_oidc::{AuthLoaded, Authenticated};
|
||||
use leptos_router::hooks::{use_navigate, use_params};
|
||||
use crate::common::{ExpandableInfo, ScholarshipFormParams, SubmitStatus, ValueType};
|
||||
use crate::components::{ActionButton, Banner, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row};
|
||||
use crate::components::{ActionButton, Banner, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row, TextFieldType, ValidatedForm};
|
||||
use super::UnauthenticatedPage;
|
||||
use traits::{AsReactive, ReactiveCapture};
|
||||
use crate::common::ComparisonData;
|
||||
use crate::pages::utils::get_user_claims;
|
||||
use super::api::{get_comparison_info, get_provider_scholarships, get_scholarship_info, CreateScholarshipInfo, CreateTestComparisons, RegisterScholarship, DeleteProviderScholarship};
|
||||
use super::api::{get_comparison_info, get_provider_scholarships, get_scholarship_info, CreateScholarshipInfo, RegisterScholarship, DeleteProviderScholarship};
|
||||
|
||||
|
||||
/// # Scholarship Info Page
|
||||
@@ -31,7 +31,7 @@ pub fn ScholarshipInfoPage() -> impl IntoView {
|
||||
.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 navigate = use_navigate();
|
||||
@@ -279,7 +279,7 @@ fn ScholarshipListEntry(
|
||||
) -> impl IntoView {
|
||||
let navigate = use_navigate();
|
||||
let user_claims = get_user_claims();
|
||||
let user_id = Memo::new(move |_| {
|
||||
let _user_id = Memo::new(move |_| {
|
||||
user_claims.get()
|
||||
.map(|info| info.claims.subject.clone())
|
||||
});
|
||||
@@ -350,24 +350,22 @@ fn ScholarshipForm(
|
||||
/// submission fails, this function is not run.
|
||||
#[prop(into)] on_submit: Callback<()>
|
||||
) -> 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 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_status = RwSignal::new(SubmitStatus::Idle);
|
||||
let elements_disabled = Signal::derive(move || {
|
||||
matches!(submit_action.pending().get(), true)
|
||||
});
|
||||
|
||||
Effect::new(move || {
|
||||
if submit_action.pending().get() {
|
||||
submit_status.set(SubmitStatus::Sending);
|
||||
@@ -380,7 +378,7 @@ fn ScholarshipForm(
|
||||
submit_status.set(SubmitStatus::Success);
|
||||
on_submit.run(());
|
||||
},
|
||||
Err(err) => submit_status.set(SubmitStatus::Error(err.to_string()))
|
||||
Err(_err) => submit_status.set(SubmitStatus::Error(()))
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -407,6 +405,14 @@ fn ScholarshipForm(
|
||||
}
|
||||
);
|
||||
|
||||
let form_data = RwSignal::new(HashMap::new());
|
||||
|
||||
Effect::new(move || {
|
||||
if let Some(Ok(scholarship)) = scholarship_info.get() {
|
||||
form_data.set(scholarship.data);
|
||||
}
|
||||
});
|
||||
|
||||
let comparison_ids = Memo::new(move |_| {
|
||||
comparison_list.get().map(|list| list.iter()
|
||||
.map(|comp| comp.clone().id)
|
||||
@@ -425,6 +431,16 @@ fn ScholarshipForm(
|
||||
// fafsa_required, award_to, transcript_required, recipient_selection, essay_requirement (and prompt)
|
||||
// award_night_remarks
|
||||
|
||||
let on_submit = move |_| {
|
||||
let mut info = ExpandableInfo::new(scholarship_id.get().unwrap_or_default());
|
||||
info.data = form_data.get();
|
||||
|
||||
log!("Map values: {:?}", info.data);
|
||||
submit_action.dispatch(CreateScholarshipInfo {
|
||||
info
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<Panel>
|
||||
<Show
|
||||
@@ -432,13 +448,12 @@ fn ScholarshipForm(
|
||||
fallback=|| view! { <p>"Choose a scholarship from the right, or create a new one."</p> }
|
||||
>
|
||||
<Suspense fallback=Loading>
|
||||
<ValidatedForm on_submit=Callback::new(on_submit)>
|
||||
{move || {
|
||||
scholarship_info.get()
|
||||
.map(|res_scholarship| {
|
||||
match res_scholarship {
|
||||
Ok(scholarship) => {
|
||||
let reactive_info = scholarship.as_reactive();
|
||||
|
||||
Ok(_) => {
|
||||
Either::Left(view! {
|
||||
<Row>
|
||||
<h1 class="text-3xl font-bold">
|
||||
@@ -451,7 +466,7 @@ fn ScholarshipForm(
|
||||
placeholder="Example Scholarship Name"
|
||||
disabled=elements_disabled
|
||||
data_member="name"
|
||||
data_map=reactive_info.data
|
||||
data_map=form_data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
@@ -460,8 +475,8 @@ fn ScholarshipForm(
|
||||
placeholder="Enter an amount..."
|
||||
disabled=elements_disabled
|
||||
data_member="amount_per_award"
|
||||
data_map=reactive_info.data
|
||||
input_type="number"
|
||||
data_map=form_data
|
||||
input_type=TextFieldType::Number
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
@@ -470,8 +485,8 @@ fn ScholarshipForm(
|
||||
placeholder="Enter an amount..."
|
||||
disabled=elements_disabled
|
||||
data_member="num_awards"
|
||||
data_map=reactive_info.data
|
||||
input_type="number"
|
||||
data_map=form_data
|
||||
input_type=TextFieldType::Number
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
@@ -480,14 +495,14 @@ fn ScholarshipForm(
|
||||
placeholder="Enter an amount..."
|
||||
disabled=elements_disabled
|
||||
data_member="total_awards"
|
||||
data_map=reactive_info.data
|
||||
input_type="number"
|
||||
data_map=form_data
|
||||
input_type=TextFieldType::Number
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<RadioList
|
||||
data_member="fafsa_required"
|
||||
data_map=reactive_info.data
|
||||
data_map=form_data
|
||||
items=vec!["Yes".to_string(), "No".to_string()]
|
||||
disabled=elements_disabled
|
||||
label="Do you require student financial information?"
|
||||
@@ -496,7 +511,7 @@ fn ScholarshipForm(
|
||||
<Row>
|
||||
<RadioList
|
||||
data_member="transcript_required"
|
||||
data_map=reactive_info.data
|
||||
data_map=form_data
|
||||
items=vec!["Yes".to_string(), "No".to_string()]
|
||||
disabled=elements_disabled
|
||||
label="Do you require a student transcript?"
|
||||
@@ -505,7 +520,7 @@ fn ScholarshipForm(
|
||||
<Row>
|
||||
<RadioList
|
||||
data_member="award_to"
|
||||
data_map=reactive_info.data
|
||||
data_map=form_data
|
||||
items=vec!["School".to_string(), "Student".to_string()]
|
||||
disabled=elements_disabled
|
||||
label="Will the award be made to the school or the student?"
|
||||
@@ -514,7 +529,7 @@ fn ScholarshipForm(
|
||||
<Row>
|
||||
<RadioList
|
||||
data_member="essay_required"
|
||||
data_map=reactive_info.data
|
||||
data_map=form_data
|
||||
items=vec!["Yes".to_string(), "No".to_string()]
|
||||
disabled=elements_disabled
|
||||
label="Do you require a student essay?"
|
||||
@@ -523,7 +538,7 @@ fn ScholarshipForm(
|
||||
<Row>
|
||||
<RadioList
|
||||
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()
|
||||
disabled=elements_disabled
|
||||
label="If so, select a prompt from the list below."
|
||||
@@ -533,26 +548,11 @@ fn ScholarshipForm(
|
||||
<ChipsList
|
||||
label="Scholarship Requirements"
|
||||
data_member="requirements"
|
||||
data_map=reactive_info.data
|
||||
data_map=form_data
|
||||
values=comparison_ids
|
||||
displayed_text=comparison_text
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<ActionButton
|
||||
disabled=elements_disabled
|
||||
on:click=move |_| {
|
||||
let captured = reactive_info.capture();
|
||||
log!("Map values: {:?}", captured);
|
||||
submit_action.dispatch(CreateScholarshipInfo {
|
||||
info: captured.clone()
|
||||
});
|
||||
}
|
||||
>"Submit"</ActionButton>
|
||||
</Row>
|
||||
<Row>
|
||||
<p>{result_msg}</p>
|
||||
</Row>
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -563,8 +563,9 @@ fn ScholarshipForm(
|
||||
}
|
||||
}).collect_view()
|
||||
}}
|
||||
</ValidatedForm>
|
||||
</Suspense>
|
||||
</Show>
|
||||
</Panel>
|
||||
}
|
||||
}.into_any()
|
||||
}
|
||||
@@ -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