Merge pull request #41 from R15-Software-Dev/feat/error-handling

Implement error handling
This commit was merged in pull request #41.
This commit is contained in:
Lucas Vas authored and GitHub committed 2026-01-06 14:06:06 -05:00
commit b2a361a02f
32 files changed
+1562 -1175

No files matched your search

Generated
+1
View File
@@ -2947,6 +2947,7 @@ dependencies = [
"leptos_oidc", "leptos_oidc",
"leptos_router", "leptos_router",
"macros", "macros",
"regex",
"serde", "serde",
"serde_dynamo", "serde_dynamo",
"tokio", "tokio",
+1
View File
@@ -23,6 +23,7 @@ uuid = { version = "1.17.0", features = ["serde", "v4", "js"] }
leptos_oidc = "0.9.0" leptos_oidc = "0.9.0"
base64 = "0.22.1" base64 = "0.22.1"
chrono = "0.4.42" chrono = "0.4.42"
regex = "1.11.2"
macros = { path = "./macros" } macros = { path = "./macros" }
traits = { path = "./traits" } traits = { path = "./traits" }
+76 -126
View File
@@ -1,136 +1,86 @@
Forms should be built for this application with a series of JSON-type files. To track errors and force form input validation, we're going to create a custom `ValidatedForm` component.
Each form will have a series of sections, and each section will have a series of
questions. Each question has a type and a label - question styles may come later.
It may be possible for us to call out specific question types without needing
to parse the types separately, but that feels not-really-worth-it.
Example: This component will do two things:
- Provide a submit button (that is disabled if the form is invalid, still thinking about that)
- Define a validation method that will be called on submit that's updated on a per-input basis
(this likely looks like a `Vec<RwSignal<ValidationState>>`)
``` In order for this to work, the form will `provide_context` to the `Vec`, and then it will check this
{ vector on submit. It doesn't worry about the states inbetween, only during the submission logic.
"SectionDefinitions": [
"SectionTitle": [
"text": {
"label": "First name question!",
"placeholder": "John",
},
"text": {
"label": "Last name question!",
"placeholder": "Smith",
}
],
"Section Two": [
"text": {
"label": "Section two testing question",
"placeholder": "Some example here",
},
"select": {
"label": "This is a dropdown element!",
"placeholder": "option1",
"possibleValues": [
"option1": "something",
"option2": "another something",
]
}
]
]
}
```
...and so on. Here's a likely set of structures to use: The inputs start off with a default state, which will be defined by that input's validation method.
This means that every input will run the validation method on mount and on each change. The first time the
function is called should be during mount, which will set the default state. This does mean that any inputs
that are marked as required will always be invalid on mount, and so we need to determine when these
error messages should be shown and updated.
``` I think they should be shown in these situations:
struct FormDefinitionDoc { - Text inputs: on blur - we don't want to scream at users as they type.
Vec<Section> section_defs; - Selectable inputs: on change and form submit - only show the message if the value is invalid, and not at mount.
// and any extra values that should be applied
// across the entire form, like settings. To check values for the selectable inputs at submit time, we'll probably need to provide a refresh method
that can be called on submit. This method must somehow be callable from the outside of the component and
will show the error messages. We'd likely store all this in a struct of some kind, which could contain an
`error` and a `show_message` signal.
We already know that we can send any `Signal` to another component, so we can just send the `show_message`
signal to the parent component without any issues. We'll just use that signal to show the error messages
in the component as well. Usually we don't want to edit too many signals outside their local context,
but this is a special case – documentation is therefore incredibly important! Remember that we may also
edit the `show_message` signal within its local context as well, so we need to make sure there are no cyclical
dependencies. Alternatively, we could not edit the `show_message` signal at all locally and just use it
to trigger side effects that show messages. Have to think about this more before implementation.
Here's a rough sketch of it all put together:
```rust
enum ValidationState {
/// A valid input!
Valid,
/// An invalid input with an error message.
Invalid(String)
} }
struct Section { // We'll store this in a Vec<RwSignal<InputState>>
Vec<QuestionComponent> questions; struct InputState {
// each section may have a series of settings including how to submit, /// Determines if there is an error and provides an error message.
// when to submit, and where to submit to at the very least. error: Signal<ValidationState>,
/// Determines if the form has requested to show error messages.
show_message: RwSignal<bool>
} }
struct QuestionComponent { // Define a new type for ease of use.
label: String, type ValidationVec = Vec<RwSignal<InputState>>;
placeholder: String,
possible_values: HashMap<str, str>, // Parent form component
// there are definitely values that i'll find while creating this structure. #[component]
fn Form() -> impl IntoView {
let validation_states: ValidationVec = Vec::new();
provide_context(validation_states);
/* All other form logic */
}
fn validation_function(input: String) -> ValidationState {
// Validation logic is specific to each input type.
}
// Child component
#[component]
fn AnyInput() -> impl IntoView {
let validation_states = use_context::<ValidationVec>().expect("Couldn't find form validation context");
let current_input = RwSignal::new(String::new()); // Obviously, this would have a default value.
let error_signal = RwSignal::new(validation_function(current_input.get()));
let show_errors = RwSignal::new(false); // Don't show them on mount!
// See below for InputState definition
let error_state = InputState {
error: error_signal.clone().into(),
show_message: show_errors.clone()
};
validation_states.push(RwSignal::new())
/* All other input logic */
} }
``` ```
Types of questions that are required, starting with the providers.
Providers must be able to:
- Log into the website using a username and password (handled by AWS)
- Create and manage multiple scholarships.
- Input scholarship information for each scholarship.
- View students that have applied.
- View student application information (should just be labels??)
- Buttons are a given. Duh.
Students must be able to:
- Log in using their Region 15 email (handled by AWS)
- Create and manage their general application.
- Create and manage applications specific to a single scholarship.
- View their acceptance status (need more confirmation)
Administrators must be able to:
- Log in using their Region 15 email (handled by AWS)
- Manage student scholarship applications.
- Manage scholarship provider account information.
- Create/manage scholarship information.
- Download student applications if needed.
- Statistics would be nice, but are likely a future thing.
Right off the bad, I think I can see a few types of buttons and text inputs,
along with dropdowns, checkboxes, our custom `MultiEntry` element from last
time. That means no new options, but it does mean that the `FormDefinition`
needs to be able to keep track of all this information.
These are some example `QuestionComponent` objects:
Text fields:
```
{
question_type: "TEXT" | "EMAIL" | "NUMBER",
label: "The question label",
name: "input_name[jsdoc]",
placeholder: "An example input"
}
```
Dropdowns/checkboxes/radios:
```
{
question_type: "DROPDOWN" | "CHECKBOX" | "RADIO",
label: "The question label",
name: "input_name[jsdoc]",
placeholder: "The pre-selected option value",
options: [
"optionValue": "Option Text"
]
}
```
`MultiEntry`:
```
{
question_type: "MULTI",
label: "The question label",
name: "input_name[jsdoc]"
}
```
The real question here: how can we store this information in a database? And
this means specifically within DynamoDB, because this is not an easy structure
to store in there.
That's going to be the hardest apart and I'll likely involve the AI in that
planning.
+1
View File
@@ -23,6 +23,7 @@ pub struct ComparisonData {
} }
impl ComparisonData { impl ComparisonData {
#[allow(unused)]
pub fn new( pub fn new(
id: impl Into<String>, id: impl Into<String>,
member: impl Into<String>, member: impl Into<String>,
-1
View File
@@ -3,7 +3,6 @@ mod lists;
mod number; mod number;
mod text; mod text;
mod traits; mod traits;
mod utils;
pub use self::{base::*, lists::*, number::*, text::*, traits::*}; pub use self::{base::*, lists::*, number::*, text::*, traits::*};
-28
View File
@@ -1,28 +0,0 @@
use crate::common::ValueType;
/// This function is a work in progress. It's designed to flatten a list of maps by using the
/// given key and verifier function, however it currently doesn't work and just needs to be
/// planned out a little more.
fn flatten_map_helper<F>(
list: &Vec<ValueType>,
key: &String,
verifier: F,
) -> Result<Vec<ValueType>, String>
where
F: Fn(&ValueType) -> Option<&ValueType>,
{
let mut out = Vec::new();
for item in list {
let map = match item {
ValueType::Map(Some(map)) => map,
_ => return Err("Expected a map".to_owned()),
};
// Get the key, and verify that the value is the correct type. If so, push it.
if let Some(value) = map.get(key).and_then(|v| verifier(v)) {
out.push(value.clone());
}
}
Ok(out)
}
+34 -37
View File
@@ -1,8 +1,6 @@
use crate::common::ValueType; use crate::common::ValueType;
use crate::components::{CheckboxList, OutlinedTextField, RadioList, Row, Select}; use crate::components::{CheckboxList, OutlinedTextField, RadioList, Row, Select, TextFieldType};
use leptos::either::EitherOf5; use leptos::prelude::*;
use leptos::prelude::RwSignal;
use leptos::{IntoView, view};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
@@ -11,85 +9,82 @@ use std::collections::HashMap;
pub enum InputType { pub enum InputType {
/// Represents a text input. Requires a `String` key, `String` label, and `String` placeholder. /// Represents a text input. Requires a `String` key, `String` label, and `String` placeholder.
/// The entered value will be returned as a single `String` value. /// The entered value will be returned as a single `String` value.
Text(String, String, String), Text(String, String, String, bool),
/// Represents a number input. Requires a `String` key, `String` label, and a `String` placeholder. /// Represents a number input. Requires a `String` key, `String` label, and a `String` placeholder.
/// The entered value will be returned as a `String`, and will be parseable into any number type /// The entered value will be returned as a `String`, and will be parseable into any number type
/// (i.e. an `i32`, `i64`, etc) /// (i.e. an `i32`, `i64`, etc)
Number(String, String, String), Number(String, String, String, bool),
/// Represents a radio input. Requires a `String` key, `String` label, and a series of `String` /// Represents a radio input. Requires a `String` key, `String` label, and a series of `String`
/// options. The selected value will be returned as a `String`. /// options. The selected value will be returned as a `String`.
Radio(String, String, Vec<String>), Radio(String, String, Vec<String>, bool),
/// Represents a checkbox input. Requires a `String` key, `String` label, and a series of `String` /// Represents a checkbox input. Requires a `String` key, `String` label, and a series of `String`
/// options. The selected values will be returned as a `Vec<String>`. /// options. The selected values will be returned as a `Vec<String>`.
Checkbox(String, String, Vec<String>), Checkbox(String, String, Vec<String>, bool),
/// Represents a dropdown input. Requires a `String` key, `String` label, and a series /// Represents a dropdown input. Requires a `String` key, `String` label, and a series
/// of `String` options. The selected value will be returned as a `String`. /// of `String` options. The selected value will be returned as a `String`.
Select(String, String, Vec<String>), Select(String, String, Vec<String>, bool),
} }
impl InputType { impl InputType {
pub fn into_view(self, data_map: RwSignal<HashMap<String, ValueType>>) -> impl IntoView { pub fn into_view(self, data_map: RwSignal<HashMap<String, ValueType>>) -> impl IntoView {
view! { view! {
{match self { {match self {
InputType::Text(member, label, placeholder) => InputType::Text(member, label, placeholder, required) =>
EitherOf5::A(view! { view! {
<Row> <Row>
<OutlinedTextField <OutlinedTextField
label = label.clone() label = label.clone()
placeholder = placeholder.clone() placeholder = placeholder.clone()
data_member = member.clone() data_member = member.clone()
data_map = data_map data_map = data_map
required = required
/> />
</Row> </Row>
}), }.into_any(),
InputType::Number(member, label, placeholder) => InputType::Number(member, label, placeholder, required) =>
EitherOf5::B(view! { view! {
<OutlinedTextField <OutlinedTextField
label = label.clone() label = label.clone()
placeholder = placeholder.clone() placeholder = placeholder.clone()
data_member = member.clone() data_member = member.clone()
data_map = data_map data_map = data_map
input_type = "number" input_type = TextFieldType::Number
required = required
/> />
}), }.into_any(),
InputType::Checkbox(member, label, options) => InputType::Checkbox(member, label, options, required) =>
EitherOf5::C(view! { view! {
<CheckboxList <CheckboxList
label = label.clone() label = label.clone()
items = options.clone() items = options.clone()
data_member = member.clone() data_member = member.clone()
data_map = data_map data_map = data_map
required = required
/> />
}), }.into_any(),
InputType::Radio(member, label, options) => InputType::Radio(member, label, options, required) =>
EitherOf5::D(view! { view! {
<RadioList <RadioList
label = label.clone() label = label.clone()
data_member = member.clone() data_member = member.clone()
data_map = data_map data_map = data_map
items = options.clone() items = options.clone()
required = required
/> />
}), }.into_any(),
InputType::Select(member, label, options) => InputType::Select(member, label, options, required) =>
EitherOf5::E(view! { view! {
<Select <Select
label = label.clone() label = label.clone()
value_list = options.clone() value_list = options.clone()
data_member = member.clone() data_member = member.clone()
data_map = data_map data_map = data_map
required = required
/> />
}) }.into_any()
}} }}
} }
} }
pub fn into_view_rows(self, data_map: RwSignal<HashMap<String, ValueType>>) -> impl IntoView {
view! {
<Row>
{self.into_view(data_map)}
</Row>
}
}
} }
/// Shorthand macro for creating `InputType` enums. /// Shorthand macro for creating `InputType` enums.
@@ -104,19 +99,21 @@ impl InputType {
/// ``` /// ```
#[macro_export] #[macro_export]
macro_rules! input { macro_rules! input {
($variant:ident, $key:expr, $label:expr, [$($option:expr),* $(,)?]) => { ($variant:ident, $key:expr, $label:expr, $required:expr, [$($option:expr),* $(,)?]) => {
$crate::common::InputType::$variant( $crate::common::InputType::$variant(
$key.to_string(), $key.to_string(),
$label.to_string(), $label.to_string(),
vec![$($option.to_string()), *] vec![$($option.to_string()), *],
$required
) )
}; };
($variant:ident, $key:expr, $label:expr, $placeholder:expr) => { ($variant:ident, $key:expr, $label:expr, $required:expr, $placeholder:expr) => {
$crate::common::InputType::$variant( $crate::common::InputType::$variant(
$key.to_string(), $key.to_string(),
$label.to_string(), $label.to_string(),
$placeholder.to_string() $placeholder.to_string(),
$required
) )
}; };
} }
+1 -1
View File
@@ -3,5 +3,5 @@ pub enum SubmitStatus {
Idle, Idle,
Sending, Sending,
Success, Success,
Error(String), Error(()),
} }
+1 -1
View File
@@ -2,7 +2,7 @@ use leptos::prelude::*;
#[component] #[component]
pub fn ActionButton( pub fn ActionButton(
#[prop(default = String::from("button"))] button_type: String, #[prop(default = String::from("button"), into)] button_type: String,
#[prop(optional, into)] disabled: Signal<bool>, #[prop(optional, into)] disabled: Signal<bool>,
children: Children, children: Children,
) -> impl IntoView { ) -> impl IntoView {
-125
View File
@@ -1,125 +0,0 @@
use crate::common::ValueType;
use crate::components::utils::create_unique_id;
use leptos::prelude::*;
use std::collections::HashMap;
#[component]
pub fn Checkbox(
#[prop()] checked: Signal<bool>,
#[prop(into)] on_change: Callback<(), ()>,
#[prop(into)] value: String,
#[prop(into)] name: String,
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
) -> impl IntoView {
let id = create_unique_id(&name, &value);
view! {
<label for=id class="flex items-center">
<input
// Using the "peer" class links this input with the div "peer" at the same level.
// By using the "peer-checked" selector this div can toggle states based on this
// input's state (checked/unchecked)
class="
relative peer shrink-0
hidden"
type="checkbox"
id=id.clone()
name=name.clone()
on:change=move |_| on_change.run(())
prop:checked=checked
disabled=disabled
/>
// This div displays the box for the checkbox, and applies some styling to the child
// span element.
<div class="relative inline-block h-5 w-5 m-1 mr-2 rounded-sm border border-gray-300 bg-white
transition-bg duration-150 peer-checked:bg-red-700 peer-checked:border-red-700
[&>span]:opacity-0 peer-checked:[&>span]:opacity-100
peer-disabled:bg-gray-600/33 peer-disabled:border-gray-600
peer-disabled:peer-checked:bg-gray-600">
// This span is the check icon. It is centered within the div and has a border on the bottom
// and right sides, which is then rotated to appear like a checkmark.
<span class="absolute left-1/2 top-1/2 block h-3 w-1.5 -translate-x-1/2 -translate-y-1/2
rotate-45 transform border-b-3 border-r-3
border-white transition-opacity duration-150"></span>
</div>
<span>{value.clone()}</span>
</label>
}
}
#[component]
pub fn CheckboxList(
#[prop(optional)] selected: RwSignal<Vec<String>>,
#[prop(into)] data_member: String,
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop()] items: Vec<String>,
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
#[prop(optional, into)] label: String,
) -> impl IntoView {
view! {
<div class="m-1.5 mt-0 mb-0">
<span class="font-bold">{label}</span>
{items
.into_iter()
.map(|item| {
let checked_signal = Signal::derive({
let item_name = item.clone();
let data_member = data_member.clone();
move || {
data_map.get() // HashMap
.get(&data_member) // Option<ValueType>
.unwrap_or(&ValueType::List(None)) // ValueType
.as_list() // Result<Option<Vec<ValueType>>, ValueType>
.unwrap_or(Some(vec!())) // Option<Vec<ValueType>>
.unwrap_or(vec!()) // Vec<ValueType>
.contains(&ValueType::String(Some(item_name.clone()))) // bool
}
});
let on_change = {
let item_name = item.clone();
let data_member = data_member.clone();
move || {
let result = ValueType::String(Some(item_name.clone()));
data_map.update(|map| {
// Attempt to get the value type from the hash map.
match map.get_mut(&data_member) {
Some(value_type) => {
// Attempt to get the value as a list.
match value_type.as_list().unwrap_or(None) {
Some(mut list) => {
// Attempt to find the selected value in the selected list.
// If it's present, remove it, or vice versa.
match list.iter().position(|val| *val == result) {
Some(index) => { list.remove(index); },
None => { list.push(result); }
};
// Update the existing value_type entry in the hash map.
*value_type = ValueType::List(Some(list));
},
// Insert a new value_type entry.
None => { *value_type = ValueType::List(Some(vec![result])); }
};
},
// Insert a new value_type entry.
None => { map.insert(data_member.clone(), ValueType::List(Some(vec![result]))); }
};
});
}
};
view! {
<Checkbox
checked=checked_signal
on_change=on_change
// The actual selected values are tracked by this element, not by the checkboxes themselves.
value=item.clone()
name=data_member.clone()
disabled=disabled
/>
}
})
.collect::<Vec<_>>()}
</div>
}
}
-147
View File
@@ -1,147 +0,0 @@
use crate::common::ValueType;
use crate::components::utils::create_unique_id;
use leptos::prelude::*;
use std::collections::HashMap;
/// # Chip Component
///
/// This component defines a single chip for use in the `ChipsList` component. It functions almost
/// identically to the `Checkbox` component. It should only be created from within its parent
/// `CheckboxList` component.
///
/// Example usage:
/// ```
/// view! {
/// <Chip
/// value="Some Value"
/// name="list_name"
/// on_change=move || { println!("Some work here.") }
/// checked=RwSignal::new(false)
/// disabled=RwSignal::new(false)
/// />
/// }
/// ```
#[component]
pub fn Chip(
#[prop(into)] display_text: String,
#[prop(into)] value: String,
#[prop(into)] name: String,
#[prop(into)] on_change: Callback<(), ()>,
#[prop()] checked: Signal<bool>,
#[prop(optional)] disabled: RwSignal<bool>,
) -> impl IntoView {
// Generate a unique id - ensure that the value doesn't contain spaces.
// Without this id, checkbox/radio inputs can interfere with each other.
let id = create_unique_id(&name, &value);
view! {
<label for=id>
<input
r#type="checkbox"
class="relative peer shrink-0 hidden"
name=name.clone()
id=id.clone()
prop:checked=checked
on:change=move |_| on_change.run(())
disabled=disabled />
<div class="rounded-full p-2.5
cursor-pointer text-base
bg-transparent border-1 border-gray-300
shadow-md/50 hover:shadow-md/70
peer-checked:bg-red-700 peer-checked:border-red-700 peer-checked:text-white
peer-disabled:peer-checked:bg-gray-700
peer-disabled:peer-checked:border-gray-700
peer-disabled:cursor-default
transition-all duration-150">
<span>{display_text.clone()}</span>
</div>
</label>
}
}
/// # Chips List Component
///
/// This defines a list of `Chip` components. The list of values is created based on the data from the
/// `data_map` prop, which is found using the given `data_member`.
///
/// Any values selected from the list are stored within the `data_map` immediately as part of a callback
/// passed to the `on_change` prop for each individual `Chip` component.
#[component]
pub fn ChipsList(
#[prop(into)] data_member: String,
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop(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>
}
}
+6
View File
@@ -0,0 +1,6 @@
mod multi;
mod single;
pub use {
multi::*, single::*
};
+182
View File
@@ -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
}
+139
View File
@@ -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>
}
}
+6
View File
@@ -0,0 +1,6 @@
mod utils;
mod checkbox;
mod chips;
pub use checkbox::*;
pub use chips::*;
+91
View File
@@ -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,
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod radio;
pub mod select;
pub use radio::*;
pub use select::*;
+158
View File
@@ -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
}
+126
View File
@@ -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>
}
}
+5 -6
View File
@@ -1,18 +1,17 @@
mod action_button; mod action_button;
mod banner; mod banner;
mod checkbox;
mod chips;
mod dashboard_button; mod dashboard_button;
mod loading; mod loading;
mod multi_entry; mod multi_entry;
mod panel; mod panel;
mod radio;
mod row; mod row;
mod select;
mod text_field; mod text_field;
mod utils; mod utils;
mod validated_form;
mod lists;
pub use self::{ pub use self::{
action_button::*, banner::*, checkbox::*, chips::*, dashboard_button::*, loading::*, action_button::*, banner::*, dashboard_button::*, lists::*,
multi_entry::*, panel::*, radio::*, row::*, select::*, text_field::*, loading::*, multi_entry::*, panel::*, row::*, text_field::*,
validated_form::*
}; };
-12
View File
@@ -73,9 +73,6 @@ pub fn MultiEntry(
/// component's `data_member` input. /// component's `data_member` input.
#[prop()] #[prop()]
data_map: RwSignal<HashMap<String, ValueType>>, data_map: RwSignal<HashMap<String, ValueType>>,
/// Currently unused.
#[prop(optional, into)]
name_member: String,
/// The type and order of the inputs for each `Entry` component within this `MultiEntry` component. /// The type and order of the inputs for each `Entry` component within this `MultiEntry` component.
#[prop(optional)] #[prop(optional)]
schema: Vec<InputType>, schema: Vec<InputType>,
@@ -132,7 +129,6 @@ pub fn MultiEntry(
view! { view! {
<Entry <Entry
data_map = map_signal data_map = map_signal
name_member = name_member.clone()
schema = schema.clone() schema = schema.clone()
/> />
} }
@@ -186,14 +182,6 @@ fn Entry(
/// data members those inputs modify. /// data members those inputs modify.
#[prop()] #[prop()]
schema: Vec<InputType>, schema: Vec<InputType>,
/// The display name member of this entry. Determines where the component should search for the
/// name of the entry within the `data_map`.
#[prop(into)]
name_member: String,
/// The info member of this entry. Determines where the component should search for the extra
/// information of this entry within the `data_map`.
#[prop(optional, into)]
info_member: String,
) -> impl IntoView { ) -> impl IntoView {
view! { view! {
<div class="flex flex-col flex-1 p-2 rounded-sm transition-shadow shadow-sm hover:shadow-lg/30"> <div class="flex flex-col flex-1 p-2 rounded-sm transition-shadow shadow-sm hover:shadow-lg/30">
-112
View File
@@ -1,112 +0,0 @@
use crate::common::ValueType;
use crate::components::utils::create_unique_id;
use leptos::prelude::*;
use std::collections::HashMap;
#[component]
pub fn Radio(
#[prop()] checked: Signal<bool>,
#[prop(into)] on_change: Callback<(), ()>,
#[prop(into)] value: String,
#[prop(into)] name: String,
#[prop(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>
}
}
-58
View File
@@ -1,58 +0,0 @@
use crate::common::ValueType;
use leptos::ev::{Event, Targeted};
use leptos::prelude::*;
use leptos::web_sys::HtmlSelectElement;
use std::collections::HashMap;
/// A custom-styled select input.
///
/// Takes a `Vec<String>` as the set of inputs. Only one value may be selected, which is indicated
/// by the `value` prop of the component.
#[component]
pub fn Select(
#[prop()] value_list: Vec<String>,
#[prop(optional)] value: RwSignal<String>,
#[prop(into)] data_member: String,
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop(optional, into)] label: String,
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
) -> impl IntoView {
let on_change = {
let data_member = data_member.clone();
move |e: Targeted<Event, HtmlSelectElement>| {
let target_value = e.target().value();
data_map.update(|map| {
if let Some(val) = map.get_mut(&data_member) {
*val = ValueType::String(Some(target_value));
} else {
map.insert(data_member.clone(), ValueType::String(Some(target_value)));
}
});
}
};
view! {
<div class="flex flex-1">
<label class="flex flex-col flex-1">
<span class="ml-1.5 mb-0 font-bold">{label}</span>
<select
class="relative flex-1 border-2 m-1.5 mt-0 p-1.5 rounded-md
transition-border duration-150
border-red-700 bg-transparent
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
on:change:target=on_change
disabled = disabled >
// This closure handles the display of the options.
{move || {
value_list.iter().map(
move |value| {
let display = value.to_owned();
view! { <option value={display}>{display.clone()}</option> }
}
).collect::<Vec<_>>()
}}
</select>
</label>
</div>
}
}
+116 -53
View File
@@ -1,69 +1,125 @@
use crate::common::ValueType; use crate::common::ValueType;
use leptos::leptos_dom::logging::console_log;
use leptos::prelude::*; use leptos::prelude::*;
use std::collections::HashMap; use std::collections::HashMap;
use leptos::html::Input;
use crate::components::{use_validation_context, InputState};
use crate::components::validated_form::ValidationState;
/// Determines the available types of text fields.
#[derive(Default, Debug, Clone)]
pub enum TextFieldType {
/// A text field that accepts any string.
#[default]
Text,
/// A text field that accepts only numbers.
Number,
/// A text field that accepts 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] #[component]
pub fn OutlinedTextField( pub fn OutlinedTextField(
#[prop(optional, into)] placeholder: String, #[prop(optional, into)] placeholder: String,
// #[prop()] value: Subfield<TStore, TInner, ValueType>,
#[prop(into)] data_member: String, // should this be an RwSignal?? #[prop(into)] data_member: String, // should this be an RwSignal??
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>, #[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop(optional)] value: RwSignal<ValueType>, #[prop(optional)] input_type: TextFieldType,
#[prop(default = "text".to_owned(), into)] input_type: String,
#[prop(into, optional)] disabled: Signal<bool>, #[prop(into, optional)] disabled: Signal<bool>,
#[prop(optional)] error: RwSignal<bool>,
#[prop(optional, into)] name: String, #[prop(optional, into)] name: String,
#[prop(optional, into)] label: String, #[prop(optional, into)] label: String,
#[prop(optional, into)] error_text: RwSignal<String>, #[prop(optional, into)] required: Signal<bool>
) -> impl IntoView { ) -> impl IntoView {
let input_ref = NodeRef::<Input>::new();
// Register this input's validation signal.
let validator_context = use_validation_context()
.expect("FormValidSignal was not found");
let raw_value = 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 // This function parses the input into the correct type. It only accepts numbers or strings as
// valid types, and parses them accordingly. // valid types, and parses them accordingly.
let on_input = { let on_input = {
let input_type = input_type.clone();
let data_member = data_member.clone(); let data_member = data_member.clone();
move |e| { move |e| {
let to_parse = event_target_value(&e); let to_parse = event_target_value(&e);
match input_type.as_str() { raw_value.set(to_parse.clone());
"text" => { error.set(validate(required.get(), &to_parse, &input_type));
// String parse function. It will always parse, but make sure the error let into_map = match input_type {
// state is set properly anyway. TextFieldType::Number => ValueType::Number(Some(to_parse)),
error.set(false); _ => ValueType::String(Some(to_parse))
value.set(ValueType::String(Some(to_parse.clone()))); };
data_map.update(|map| {
map.insert(data_member.clone(), ValueType::String(Some(to_parse))); // 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.
"number" => { data_map.update(|map| {
// Define number parse function. map.insert(data_member.clone(), into_map);
// If the number parses, Some(num), else None, but we want to keep the value that's });
// in the text field at this point, just set the error.
console_log(format!("Parsing {to_parse} to a number").as_str());
match to_parse
.parse::<i32>()
.map(|n| ValueType::Number(Some(n.to_string())))
{
Ok(value_type) => {
error.set(false);
value.set(value_type.clone());
data_map.update(|map| {
map.insert(data_member.clone(), value_type);
});
}
Err(_) => {
error.set(true);
error_text.set("Value is not a valid number.".to_owned());
}
};
}
_ => {
// Create some custom function that reports failure.
let msg = format!("Input {input_type:?} is not a valid type.");
console_log(msg.as_str());
error.set(true);
error_text.set(msg);
}
}
} }
}; };
@@ -72,11 +128,12 @@ pub fn OutlinedTextField(
<label class="flex flex-col flex-1"> <label class="flex flex-col flex-1">
<span class="block ml-1.5 mb-0 font-bold">{label}</span> <span class="block ml-1.5 mb-0 font-bold">{label}</span>
<input <input
node_ref=input_ref
class="border-2 m-1.5 p-1.5 mt-0 rounded-md bg-transparent relative flex-1 class="border-2 m-1.5 p-1.5 mt-0 rounded-md bg-transparent relative flex-1
transition-all duration-150 transition-all duration-150
border-red-700 bg-transparent border-red-700 bg-transparent
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33" disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
r#type={input_type} r#type={str_type}
disabled={disabled} disabled={disabled}
placeholder={placeholder} placeholder={placeholder}
prop:name=name prop:name=name
@@ -86,12 +143,18 @@ pub fn OutlinedTextField(
.unwrap_or(&ValueType::String(None)) // ValueType .unwrap_or(&ValueType::String(None)) // ValueType
.to_string() .to_string()
on:input=on_input on:input=on_input
on:blur=on_blur
/> />
<div <Show when=move || show_errors.get()>
class="text-red-600 text-sm mr-1.5 ml-1.5" <div class="text-red-600 text-sm mr-1.5 ml-1.5">
class=(["hidden"], move || !error.get())> {move || {
{error_text} match error.get() {
</div> ValidationState::Invalid(msg) => msg,
_ => "There is no error - should not see this message.".to_string()
}
}}
</div>
</Show>
</label> </label>
</div> </div>
} }
+156
View File
@@ -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>
}
}
-1
View File
@@ -2,7 +2,6 @@ pub mod app;
mod common; mod common;
mod components; mod components;
mod pages; mod pages;
mod utils;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen] #[wasm_bindgen::prelude::wasm_bindgen]
+18 -7
View File
@@ -8,12 +8,12 @@ use crate::common::{
ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison, ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison,
TextComparison, TextListComparison, ValueType, TextComparison, TextListComparison, ValueType,
}; };
use leptos::leptos_dom::log;
use leptos::prelude::ServerFnError; use leptos::prelude::ServerFnError;
use leptos::server; use leptos::server;
/// Helper function to create all sports comparisons. Only used to create initial /// Helper function to create all sports comparisons. Only used to create initial
/// comparison data, and should be removed upon completion of a comparison editor page. /// comparison data, and should be removed upon completion of a comparison editor page.
#[allow(unused)]
fn create_sports_comparisons() -> Vec<ComparisonData> { fn create_sports_comparisons() -> Vec<ComparisonData> {
vec![ vec![
ComparisonData::new( ComparisonData::new(
@@ -184,12 +184,8 @@ fn create_sports_comparisons() -> Vec<ComparisonData> {
] ]
} }
#[server(CreateTestComparisons, endpoint = "/comparisons/create-test")] #[allow(unused)]
pub async fn create_test_comparisons() -> Result<(), ServerFnError> { fn make_comp_list() -> Vec<ComparisonData> {
let client = create_dynamo_client().await;
log!("Creating test comparisons");
let test_comp = ComparisonData::new( let test_comp = ComparisonData::new(
"math_sat_comp", "math_sat_comp",
"math_sat", "math_sat",
@@ -304,6 +300,19 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
comp_list.append(&mut sports_comps); comp_list.append(&mut sports_comps);
comp_list.append(&mut major_comps); comp_list.append(&mut major_comps);
comp_list
}
#[server(CreateTestComparisons, endpoint = "/comparisons/create-test")]
pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
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 { for comparison in comp_list {
if let Err(err) = client if let Err(err) = client
.put_item() .put_item()
@@ -322,6 +331,8 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
#[server] #[server]
pub async fn get_comparison_info() -> Result<Vec<ComparisonData>, ServerFnError> { pub async fn get_comparison_info() -> Result<Vec<ComparisonData>, ServerFnError> {
use leptos::logging::log;
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
log!("Getting all comparisons from the database"); log!("Getting all comparisons from the database");
+9 -5
View File
@@ -3,15 +3,19 @@ use aws_sdk_dynamodb::{
types::AttributeValue, types::AttributeValue,
error::ProvideErrorMetadata, error::ProvideErrorMetadata,
}; };
#[cfg(feature = "ssr")]
use leptos::leptos_dom::log; use leptos::leptos_dom::log;
use leptos::prelude::ServerFnError; #[cfg(feature = "ssr")]
use leptos::server;
use uuid::Uuid; use uuid::Uuid;
use crate::common::{ExpandableInfo, ValueType};
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use crate::pages::utils::server_utils::create_dynamo_client; use crate::pages::utils::server_utils::create_dynamo_client;
#[cfg(feature = "ssr")]
use crate::common::ValueType;
use crate::common::ExpandableInfo;
use leptos::prelude::ServerFnError;
use leptos::server;
#[server(GetScholarshipInfo, endpoint = "/scholarship/info/get")] #[server(GetScholarshipInfo, endpoint = "/scholarship/info/get")]
pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFnError> { pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFnError> {
+194 -199
View File
@@ -1,7 +1,6 @@
// Server dependencies // Server dependencies
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue}; use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue};
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use serde_dynamo::{from_item, to_item}; use serde_dynamo::{from_item, to_item};
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
@@ -9,22 +8,20 @@ use std::process::{Command, Stdio};
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use std::io::Write; use std::io::Write;
use crate::common::{ExpandableInfo, UserClaims}; use crate::common::ExpandableInfo;
use crate::pages::UnauthenticatedPage; use crate::pages::UnauthenticatedPage;
use crate::components::{ use crate::pages::utils::get_user_claims;
ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row, use crate::components::{ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row, Select, TextFieldType, ValidatedForm};
Select,
};
use crate::input; use crate::input;
use leptos::leptos_dom::logging::console_log; use leptos::leptos_dom::logging::console_log;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated}; use leptos_oidc::{AuthLoaded, Authenticated};
use base64::Engine; use base64::Engine;
use leptos::logging::log;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use leptos::web_sys::HtmlAnchorElement; use leptos::web_sys::HtmlAnchorElement;
use leptos::wasm_bindgen::JsCast; use leptos::wasm_bindgen::JsCast;
use traits::{AsReactive, ReactiveCapture}; use std::collections::HashMap;
use crate::pages::utils::get_user_claims;
/// # Get Student Info /// # Get Student Info
/// Gets a student's information given their `subject`. /// Gets a student's information given their `subject`.
@@ -144,23 +141,73 @@ pub async fn create_example_pdf() -> Result<Vec<u8>, ServerFnError> {
/// The main home page component. Contains a simple contact form. /// The main home page component. Contains a simple contact form.
#[component] #[component]
pub fn HomePage() -> impl IntoView { pub fn HomePage() -> impl IntoView {
// Creates a reactive value to update the button //#region Server Resources
let user_claims = get_user_claims(); let user_claims = get_user_claims();
let user_subject = Memo::new(move |_| {
user_claims.get()
.map(|claim| claim.claims.subject.clone())
});
// Note that the value passed in MUST be equatable. let form_data = RwSignal::new(HashMap::new());
// We get/unwrap the value repeatedly until we get a simple string value, then clone it so that
// we don't lose access to it in the future, should we need it again. let server_resource = Resource::new(
let server_resource: Resource<ExpandableInfo> = Resource::new( move || user_subject.get(),
move || user_claims.get().map(|claim| claim.claims.subject.clone()), async move |subject| get_submission(subject.unwrap_or_default()).await
async |opt_username| match opt_username {
Some(subject) => get_submission(subject.clone()).await.unwrap_or_else(|e| {
console_log(e.to_string().as_str());
ExpandableInfo::new(subject)
}),
None => ExpandableInfo::new("".to_owned()),
},
); );
let submit_action = ServerAction::<CreateSampleSubmission>::new(); let submit_action = ServerAction::<CreateSampleSubmission>::new();
Effect::new(move || {
match server_resource.get() {
Some(Ok(submission)) => {
form_data.set(submission.data);
}
Some(Err(err)) => {
log!("Error while getting information: {:?}", err);
}
_ => {
log!("An unknown error occurred.");
}
}
});
//#endregion
//#region Component State
let elements_disabled = Signal::derive(move || {
submit_action.pending().get()
});
//#endregion
//#region Event logic
let on_submit = move |_| {
let mut exp_info = ExpandableInfo::new(user_subject.get().unwrap_or_default());
exp_info.data = form_data.get();
submit_action.dispatch(CreateSampleSubmission {
student_info: exp_info
});
};
let pdf_button_click = move |_| {
console_log("Attempting to get PDF from server endpoint");
spawn_local(async move {
let result = create_example_pdf().await;
if let Ok(bytes) = result {
let base64 = base64::engine::general_purpose::STANDARD
.encode(bytes);
let data_url = format!(
"data:application/pdf;base64,{}",
base64,
);
console_log("Found document, opening in new tab...");
let link = document()
.create_element("a")
.unwrap()
.dyn_into::<HtmlAnchorElement>()
.unwrap();
link.set_href(&*data_url);
link.set_target("_blank");
link.click();
}
});
};
//#endregion
view! { view! {
<AuthLoaded fallback=Loading> <AuthLoaded fallback=Loading>
@@ -170,195 +217,143 @@ pub fn HomePage() -> impl IntoView {
{move || { {move || {
server_resource server_resource
.get() .get()
.map(|submission| { .map(|_| {
let expandable_react = submission.as_reactive();
let elements_disabled = RwSignal::new(false);
let result_msg = Signal::derive(move || {
if submit_action.pending().get() {
elements_disabled.set(true);
"Sending request...".to_owned()
} else {
elements_disabled.set(false);
if let Some(result) = submit_action.value().get() {
match result {
Ok(_) => "Request sent successfully.".to_owned(),
Err(e) => format!("Failed to send request: {:?}", e),
}
} else {
"".to_owned()
}
}
});
view! { view! {
<Row> <Row>
<div class="flex flex-col flex-1" /> <div class="flex flex-col flex-1" />
<Panel> <Panel>
<Row> <ValidatedForm on_submit=Callback::new(on_submit)>
<h1 class="text-3xl font-bold"> <Row>
"Region 15 Scholarship Application (DEMO)" <h1 class="text-3xl font-bold">
</h1> "Region 15 Scholarship Application (DEMO)"
</Row> </h1>
<Row> </Row>
<p class="text-sm"> <Row>
"This is a very simple demonstration of the scholarship application written in Rust." <p class="text-sm">
</p> "This is a very simple demonstration of the scholarship application written in Rust."
</Row> </p>
<Row> </Row>
// Text { data_member, label, placeholder } <Row>
<OutlinedTextField <OutlinedTextField
label="First Name:" label="First Name:"
placeholder="John" placeholder="John"
disabled=elements_disabled disabled=elements_disabled
data_member="first_name" data_member="first_name"
data_map=expandable_react.data data_map=form_data
/> required=true
<OutlinedTextField />
label="Last Name:" <OutlinedTextField
placeholder="Smith" label="Last Name:"
disabled=elements_disabled placeholder="Smith"
data_member="last_name" disabled=elements_disabled
data_map=expandable_react.data data_member="last_name"
/> data_map=form_data
</Row> required=true
<Row> />
<OutlinedTextField </Row>
label="Contact Email:" <Row>
placeholder="student@region15.org" <OutlinedTextField
disabled=elements_disabled label="Contact Email:"
data_member="contact_email" placeholder="student@region15.org"
data_map=expandable_react.data disabled=elements_disabled
/> data_member="contact_email"
</Row> data_map=form_data
<Row> input_type=TextFieldType::Email
<OutlinedTextField required=true
label="Phone Number:" />
placeholder="123-456-7890" </Row>
disabled=elements_disabled <Row>
data_member="phone_number" <OutlinedTextField
data_map=expandable_react.data label="Phone Number:"
/> placeholder="123-456-7890"
</Row> disabled=elements_disabled
<Row> data_member="phone_number"
<OutlinedTextField data_map=form_data
label="Street Address:" required=true
placeholder="123 Fake Street" />
disabled=elements_disabled </Row>
data_member="address" <Row>
data_map=expandable_react.data <OutlinedTextField
/> label="Street Address:"
</Row> placeholder="123 Fake Street"
<Row> disabled=elements_disabled
<OutlinedTextField data_member="address"
label="Highest Math SAT Score:" data_map=form_data
placeholder="600" required=true
disabled=elements_disabled />
data_member="math_sat" </Row>
data_map=expandable_react.data <Row>
input_type="number" <OutlinedTextField
/> label="Highest Math SAT Score:"
</Row> placeholder="600"
<Row> disabled=elements_disabled
// Radio { data_member, label, items } data_member="math_sat"
<RadioList data_map=form_data
label="Town:" input_type=TextFieldType::Number
items=vec!["Southbury", "Middlebury"] required=true
.into_iter() />
.map(|s| s.into()) </Row>
.collect() <Row>
disabled=elements_disabled <RadioList
data_member="town" label="Town:"
data_map = expandable_react.data items=vec!["Southbury", "Middlebury"]
/> .into_iter()
</Row> .map(|s| s.into())
<Row> .collect()
// Checkbox { data_member, label, items } disabled=elements_disabled
<CheckboxList data_member="town"
label="Favorite Candies:" data_map=form_data
items=vec!["Twizzlers", "Reese's", "Starburst"] required=true
.into_iter() />
.map(|s| s.into()) </Row>
.collect() <Row>
disabled=elements_disabled <CheckboxList
data_member="favorite_candies" label="Favorite Candies:"
data_map=expandable_react.data items=vec!["Twizzlers", "Reese's", "Starburst"]
/> .into_iter()
</Row> .map(|s| s.into())
<Row> .collect()
// Select { data_member, label, items } disabled=elements_disabled
<Select data_member="favorite_candies"
label="Gender:" data_map=form_data
value_list=vec!["Male", "Female", "Prefer not to answer"] required=true
.into_iter() />
.map(|s| s.to_owned()) </Row>
.collect() <Row>
disabled=elements_disabled <Select
data_member="gender" label="Gender:"
data_map=expandable_react.data value_list=vec!["Male", "Female", "Prefer not to answer"]
/> .into_iter()
</Row> .map(|s| s.to_owned())
<Row> .collect()
<MultiEntry disabled=elements_disabled
data_map=expandable_react.data data_member="gender"
data_member="community_involvement" data_map=form_data
schema=vec![ required=true
input!(Text, "service_name", "Activity Name:", "Some service activity..."), />
input!(Number, "service_hours", "Total Service Hours", "20") </Row>
] <Row>
/> <MultiEntry
</Row> data_map=form_data
<Row> data_member="community_involvement"
<ActionButton schema=vec![
on:click=move |_| { input!(Text, "service_name", "Activity Name:", true, "Some service activity..."),
let captured_map = expandable_react.capture(); input!(Number, "service_hours", "Total Service Hours", true, "20")
console_log( ]
format!("Map values: {:?}", captured_map).as_str(), />
); </Row>
submit_action </ValidatedForm>
.dispatch(CreateSampleSubmission {
student_info: captured_map,
});
}
disabled=elements_disabled
>
"Submit"
</ActionButton>
</Row>
<Row>
<ActionButton on:click=move |_| {
console_log("Attempting to get PDF from server endpoint");
spawn_local(async move {
let result = create_example_pdf().await;
if let Ok(bytes) = result {
let base64 = base64::engine::general_purpose::STANDARD
.encode(bytes);
let data_url = format!(
"data:application/pdf;base64,{}",
base64,
);
console_log("Found document, opening in new tab...");
let link = document()
.create_element("a")
.unwrap()
.dyn_into::<HtmlAnchorElement>()
.unwrap();
link.set_href(&*data_url);
link.set_target("_blank");
link.click();
}
});
}>"Get PDF"</ActionButton>
</Row>
<Row>
<p>{result_msg}</p>
</Row>
</Panel> </Panel>
<div class="flex flex-col flex-1" /> <div class="flex flex-col flex-1" />
</Row> </Row>
} }
}) })
}} }}
<Row>
<ActionButton on:click=pdf_button_click>"Get PDF"</ActionButton>
</Row>
</Suspense> </Suspense>
</Authenticated> </Authenticated>
</AuthLoaded> </AuthLoaded>
} }.into_any()
} }
+81 -79
View File
@@ -4,10 +4,11 @@ use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValu
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use serde_dynamo::{from_item, to_item}; use serde_dynamo::{from_item, to_item};
use crate::common::{ExpandableInfo, ValueType}; #[cfg(feature = "ssr")]
use crate::components::{ use crate::common::ValueType;
ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select,
}; use crate::common::ExpandableInfo;
use crate::components::{ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select, TextFieldType, ValidatedForm};
use chrono::{FixedOffset, TimeZone}; use chrono::{FixedOffset, TimeZone};
use leptos::Params; use leptos::Params;
use leptos::either::{Either, EitherOf3}; use leptos::either::{Either, EitherOf3};
@@ -18,7 +19,6 @@ use leptos_router::hooks::{use_navigate, use_params};
use leptos_router::params::Params; use leptos_router::params::Params;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use traits::{AsReactive, ReactiveCapture};
#[derive(Params, PartialEq)] #[derive(Params, PartialEq)]
struct LoanerParams { struct LoanerParams {
@@ -245,83 +245,85 @@ pub fn LoanerPage() -> impl IntoView {
pub fn LoanerBorrowForm() -> impl IntoView { pub fn LoanerBorrowForm() -> impl IntoView {
// Register server actions. // Register server actions.
let create_entry_action = ServerAction::<CreateBorrowEntry>::new(); let create_entry_action = ServerAction::<CreateBorrowEntry>::new();
let navigate = use_navigate();
let temp = ExpandableInfo::new(uuid::Uuid::new_v4().to_string()); let data = RwSignal::new(HashMap::new());
let temp_reactive = temp.as_reactive(); let elements_disabled = Signal::derive(move || create_entry_action.pending().get());
let elements_disabled = RwSignal::new(false);
let on_submit = move |_| {
create_entry_action.dispatch(CreateBorrowEntry {
input: ExpandableInfo {
subject: uuid::Uuid::new_v4().to_string(),
data: data.get(),
}
});
};
view! { view! {
<div class="flex flex-col justify-center gap-2 mb-1 py-3 px-2"> <ValidatedForm on_submit=Callback::new(on_submit)>
<h2 class="text-2xl font-bold">"Borrower Information"</h2> <div class="flex flex-col justify-center gap-2 mb-1 py-3 px-2">
<p class="text-lg">"Please fill out the form below."</p> <h2 class="text-2xl font-bold">"Borrower Information"</h2>
</div> <p class="text-lg">"Please fill out the form below."</p>
<Row> </div>
<OutlinedTextField <Row>
label="First Name:" <OutlinedTextField
placeholder="John" label="First Name:"
disabled=elements_disabled placeholder="John"
data_member="first_name" disabled=elements_disabled
data_map=temp_reactive.data data_member="first_name"
/> data_map=data
<OutlinedTextField required=true
label="Last Name:" />
placeholder="Smith" <OutlinedTextField
disabled=elements_disabled label="Last Name:"
data_member="last_name" placeholder="Smith"
data_map=temp_reactive.data disabled=elements_disabled
/> data_member="last_name"
</Row> data_map=data
<Row> required=true
<OutlinedTextField />
label="Region 15 Email:" </Row>
placeholder="example@region15.org" <Row>
disabled=elements_disabled <OutlinedTextField
data_member="email" label="Region 15 Email:"
data_map=temp_reactive.data placeholder="example@region15.org"
/> disabled=elements_disabled
</Row> data_member="email"
<Row> data_map=data
<Select input_type=TextFieldType::Email
label="Collateral:" required=true
value_list=vec![ />
"Phone".to_string(), </Row>
"Earbuds/Headphones".to_string(), <Row>
"Keys".to_string(), <Select
"Wallet".to_string(), label="Collateral:"
"Laptop".to_string(), value_list=vec![
] "Phone".to_string(),
disabled=elements_disabled "Earbuds/Headphones".to_string(),
data_member="collateral" "Keys".to_string(),
data_map=temp_reactive.data "Wallet".to_string(),
/> "Laptop".to_string(),
</Row> ]
<Row> disabled=elements_disabled
<Select data_member="collateral"
label="Loan Taken:" data_map=data
value_list=vec![ required=true
"Chromebook".to_string(), />
"Charger".to_string(), </Row>
"Headphones".to_string(), <Row>
] <Select
disabled=elements_disabled label="Loan Taken:"
data_member="loan" value_list=vec![
data_map=temp_reactive.data "Chromebook".to_string(),
/> "Charger".to_string(),
</Row> "Headphones".to_string(),
<Row> ]
<ActionButton disabled=elements_disabled
on:click=move |_| { data_member="loan"
let captured = temp_reactive.capture(); data_map=data
log!("Loaner record taken."); required=true
log!("{:?}", captured); />
create_entry_action.dispatch(CreateBorrowEntry { </Row>
input: captured </ValidatedForm>
});
navigate("/loaners", Default::default());
}
>"Submit"</ActionButton>
</Row>
} }
} }
+155 -154
View File
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use leptos::either::Either; use leptos::either::Either;
use leptos::html::Dialog; use leptos::html::Dialog;
use leptos::logging::log; use leptos::logging::log;
@@ -5,12 +6,11 @@ use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated}; use leptos_oidc::{AuthLoaded, Authenticated};
use leptos_router::hooks::{use_navigate, use_params}; use leptos_router::hooks::{use_navigate, use_params};
use crate::common::{ExpandableInfo, ScholarshipFormParams, SubmitStatus, ValueType}; 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 super::UnauthenticatedPage;
use traits::{AsReactive, ReactiveCapture};
use crate::common::ComparisonData; use crate::common::ComparisonData;
use crate::pages::utils::get_user_claims; 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 /// # Scholarship Info Page
@@ -31,7 +31,7 @@ pub fn ScholarshipInfoPage() -> impl IntoView {
.and_then(|params| params.id.clone()) .and_then(|params| params.id.clone())
}); });
let create_test_comps = ServerAction::<CreateTestComparisons>::new(); // let create_test_comps = ServerAction::<CreateTestComparisons>::new();
let on_delete = move || { let on_delete = move || {
let navigate = use_navigate(); let navigate = use_navigate();
@@ -279,7 +279,7 @@ fn ScholarshipListEntry(
) -> impl IntoView { ) -> impl IntoView {
let navigate = use_navigate(); let navigate = use_navigate();
let user_claims = get_user_claims(); let user_claims = get_user_claims();
let user_id = Memo::new(move |_| { let _user_id = Memo::new(move |_| {
user_claims.get() user_claims.get()
.map(|info| info.claims.subject.clone()) .map(|info| info.claims.subject.clone())
}); });
@@ -350,24 +350,22 @@ fn ScholarshipForm(
/// submission fails, this function is not run. /// submission fails, this function is not run.
#[prop(into)] on_submit: Callback<()> #[prop(into)] on_submit: Callback<()>
) -> impl IntoView { ) -> impl IntoView {
let submit_status = RwSignal::new(SubmitStatus::Idle); // let result_msg = Signal::derive(move || {
let elements_disabled = Signal::derive(move || { // match submit_status.get() {
match submit_status.get() { // SubmitStatus::Idle => "".into(),
SubmitStatus::Sending => true, // SubmitStatus::Sending => "Sending submission...".into(),
_ => false // 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_action = ServerAction::<CreateScholarshipInfo>::new();
let submit_status = RwSignal::new(SubmitStatus::Idle);
let elements_disabled = Signal::derive(move || {
matches!(submit_action.pending().get(), true)
});
Effect::new(move || { Effect::new(move || {
if submit_action.pending().get() { if submit_action.pending().get() {
submit_status.set(SubmitStatus::Sending); submit_status.set(SubmitStatus::Sending);
@@ -380,7 +378,7 @@ fn ScholarshipForm(
submit_status.set(SubmitStatus::Success); submit_status.set(SubmitStatus::Success);
on_submit.run(()); on_submit.run(());
}, },
Err(err) => submit_status.set(SubmitStatus::Error(err.to_string())) Err(_err) => submit_status.set(SubmitStatus::Error(()))
} }
} }
}); });
@@ -406,6 +404,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 |_| { let comparison_ids = Memo::new(move |_| {
comparison_list.get().map(|list| list.iter() comparison_list.get().map(|list| list.iter()
@@ -424,7 +430,17 @@ fn ScholarshipForm(
// We'll collect the scholarship's name, num_awards, amount_per_award, total_awards, // We'll collect the scholarship's name, num_awards, amount_per_award, total_awards,
// fafsa_required, award_to, transcript_required, recipient_selection, essay_requirement (and prompt) // fafsa_required, award_to, transcript_required, recipient_selection, essay_requirement (and prompt)
// award_night_remarks // 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! { view! {
<Panel> <Panel>
<Show <Show
@@ -432,139 +448,124 @@ fn ScholarshipForm(
fallback=|| view! { <p>"Choose a scholarship from the right, or create a new one."</p> } fallback=|| view! { <p>"Choose a scholarship from the right, or create a new one."</p> }
> >
<Suspense fallback=Loading> <Suspense fallback=Loading>
{move || { <ValidatedForm on_submit=Callback::new(on_submit)>
scholarship_info.get() {move || {
.map(|res_scholarship| { scholarship_info.get()
match res_scholarship { .map(|res_scholarship| {
Ok(scholarship) => { match res_scholarship {
let reactive_info = scholarship.as_reactive(); Ok(_) => {
Either::Left(view! {
Either::Left(view! { <Row>
<Row> <h1 class="text-3xl font-bold">
<h1 class="text-3xl font-bold"> "Scholarship Info Form (test)"
"Scholarship Info Form (test)" </h1>
</h1> </Row>
</Row> <Row>
<Row> <OutlinedTextField
<OutlinedTextField label="Scholarship Name"
label="Scholarship Name" placeholder="Example Scholarship Name"
placeholder="Example Scholarship Name" disabled=elements_disabled
disabled=elements_disabled data_member="name"
data_member="name" data_map=form_data
data_map=reactive_info.data />
/> </Row>
</Row> <Row>
<Row> <OutlinedTextField
<OutlinedTextField label="Amount per award: (if multiple, enter the highest value)"
label="Amount per award: (if multiple, enter the highest value)" placeholder="Enter an amount..."
placeholder="Enter an amount..." disabled=elements_disabled
disabled=elements_disabled data_member="amount_per_award"
data_member="amount_per_award" data_map=form_data
data_map=reactive_info.data input_type=TextFieldType::Number
input_type="number" />
/> </Row>
</Row> <Row>
<Row> <OutlinedTextField
<OutlinedTextField label="Total number of awards:"
label="Total number of awards:" placeholder="Enter an amount..."
placeholder="Enter an amount..." disabled=elements_disabled
disabled=elements_disabled data_member="num_awards"
data_member="num_awards" data_map=form_data
data_map=reactive_info.data input_type=TextFieldType::Number
input_type="number" />
/> </Row>
</Row> <Row>
<Row> <OutlinedTextField
<OutlinedTextField label="Total amount of all awards:"
label="Total amount of all awards:" placeholder="Enter an amount..."
placeholder="Enter an amount..." disabled=elements_disabled
disabled=elements_disabled data_member="total_awards"
data_member="total_awards" data_map=form_data
data_map=reactive_info.data input_type=TextFieldType::Number
input_type="number" />
/> </Row>
</Row> <Row>
<Row> <RadioList
<RadioList data_member="fafsa_required"
data_member="fafsa_required" data_map=form_data
data_map=reactive_info.data items=vec!["Yes".to_string(), "No".to_string()]
items=vec!["Yes".to_string(), "No".to_string()] disabled=elements_disabled
disabled=elements_disabled label="Do you require student financial information?"
label="Do you require student financial information?" />
/> </Row>
</Row> <Row>
<Row> <RadioList
<RadioList data_member="transcript_required"
data_member="transcript_required" data_map=form_data
data_map=reactive_info.data items=vec!["Yes".to_string(), "No".to_string()]
items=vec!["Yes".to_string(), "No".to_string()] disabled=elements_disabled
disabled=elements_disabled label="Do you require a student transcript?"
label="Do you require a student transcript?" />
/> </Row>
</Row> <Row>
<Row> <RadioList
<RadioList data_member="award_to"
data_member="award_to" data_map=form_data
data_map=reactive_info.data items=vec!["School".to_string(), "Student".to_string()]
items=vec!["School".to_string(), "Student".to_string()] disabled=elements_disabled
disabled=elements_disabled label="Will the award be made to the school or the student?"
label="Will the award be made to the school or the student?" />
/> </Row>
</Row> <Row>
<Row> <RadioList
<RadioList data_member="essay_required"
data_member="essay_required" data_map=form_data
data_map=reactive_info.data items=vec!["Yes".to_string(), "No".to_string()]
items=vec!["Yes".to_string(), "No".to_string()] disabled=elements_disabled
disabled=elements_disabled label="Do you require a student essay?"
label="Do you require a student essay?" />
/> </Row>
</Row> <Row>
<Row> <RadioList
<RadioList data_member="essay_prompt"
data_member="essay_prompt" data_map=form_data
data_map=reactive_info.data items=vec!["Test 1", "Test 2", "Test 3"].iter().map(|s| s.to_string()).collect()
items=vec!["Test 1", "Test 2", "Test 3"].iter().map(|s| s.to_string()).collect() disabled=elements_disabled
disabled=elements_disabled label="If so, select a prompt from the list below."
label="If so, select a prompt from the list below." />
/> </Row>
</Row> <Row>
<Row> <ChipsList
<ChipsList label="Scholarship Requirements"
label="Scholarship Requirements" data_member="requirements"
data_member="requirements" data_map=form_data
data_map=reactive_info.data values=comparison_ids
values=comparison_ids displayed_text=comparison_text
displayed_text=comparison_text />
/> </Row>
</Row> })
<Row> }
<ActionButton Err(err) => {
disabled=elements_disabled Either::Right(view! {
on:click=move |_| { <p>"Error while getting scholarship: "{err.to_string()}</p>
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) => { }).collect_view()
Either::Right(view! { }}
<p>"Error while getting scholarship: "{err.to_string()}</p> </ValidatedForm>
})
}
}
}).collect_view()
}}
</Suspense> </Suspense>
</Show> </Show>
</Panel> </Panel>
} }.into_any()
} }
-23
View File
@@ -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()),
}
}