diff --git a/Cargo.lock b/Cargo.lock
index 67bcabe..3f10111 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2947,6 +2947,7 @@ dependencies = [
"leptos_oidc",
"leptos_router",
"macros",
+ "regex",
"serde",
"serde_dynamo",
"tokio",
diff --git a/Cargo.toml b/Cargo.toml
index 690e781..3bd7f60 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,6 +23,7 @@ uuid = { version = "1.17.0", features = ["serde", "v4", "js"] }
leptos_oidc = "0.9.0"
base64 = "0.22.1"
chrono = "0.4.42"
+regex = "1.11.2"
macros = { path = "./macros" }
traits = { path = "./traits" }
diff --git a/scratch.md b/scratch.md
index e8065a7..0ffd88b 100644
--- a/scratch.md
+++ b/scratch.md
@@ -1,136 +1,86 @@
-Forms should be built for this application with a series of JSON-type files.
-Each form will have a series of sections, and each section will have a series of
-questions. Each question has a type and a label - question styles may come later.
-It may be possible for us to call out specific question types without needing
-to parse the types separately, but that feels not-really-worth-it.
+To track errors and force form input validation, we're going to create a custom `ValidatedForm` component.
-Example:
+This component will do two things:
+ - Provide a submit button (that is disabled if the form is invalid, still thinking about that)
+ - Define a validation method that will be called on submit that's updated on a per-input basis
+ (this likely looks like a `Vec>`)
-```
-{
- "SectionDefinitions": [
- "SectionTitle": [
- "text": {
- "label": "First name question!",
- "placeholder": "John",
- },
- "text": {
- "label": "Last name question!",
- "placeholder": "Smith",
- }
- ],
- "Section Two": [
- "text": {
- "label": "Section two testing question",
- "placeholder": "Some example here",
- },
- "select": {
- "label": "This is a dropdown element!",
- "placeholder": "option1",
- "possibleValues": [
- "option1": "something",
- "option2": "another something",
- ]
- }
- ]
- ]
-}
-```
+In order for this to work, the form will `provide_context` to the `Vec`, and then it will check this
+vector on submit. It doesn't worry about the states inbetween, only during the submission logic.
-...and so on. Here's a likely set of structures to use:
+The inputs start off with a default state, which will be defined by that input's validation method.
+This means that every input will run the validation method on mount and on each change. The first time the
+function is called should be during mount, which will set the default state. This does mean that any inputs
+that are marked as required will always be invalid on mount, and so we need to determine when these
+error messages should be shown and updated.
-```
-struct FormDefinitionDoc {
- Vec section_defs;
- // and any extra values that should be applied
- // across the entire form, like settings.
+I think they should be shown in these situations:
+ - Text inputs: on blur - we don't want to scream at users as they type.
+ - Selectable inputs: on change and form submit - only show the message if the value is invalid, and not at mount.
+
+To check values for the selectable inputs at submit time, we'll probably need to provide a refresh method
+that can be called on submit. This method must somehow be callable from the outside of the component and
+will show the error messages. We'd likely store all this in a struct of some kind, which could contain an
+`error` and a `show_message` signal.
+
+We already know that we can send any `Signal` to another component, so we can just send the `show_message`
+signal to the parent component without any issues. We'll just use that signal to show the error messages
+in the component as well. Usually we don't want to edit too many signals outside their local context,
+but this is a special case – documentation is therefore incredibly important! Remember that we may also
+edit the `show_message` signal within its local context as well, so we need to make sure there are no cyclical
+dependencies. Alternatively, we could not edit the `show_message` signal at all locally and just use it
+to trigger side effects that show messages. Have to think about this more before implementation.
+
+Here's a rough sketch of it all put together:
+```rust
+enum ValidationState {
+ /// A valid input!
+ Valid,
+ /// An invalid input with an error message.
+ Invalid(String)
}
-struct Section {
- Vec questions;
- // each section may have a series of settings including how to submit,
- // when to submit, and where to submit to at the very least.
+// We'll store this in a Vec>
+struct InputState {
+ /// Determines if there is an error and provides an error message.
+ error: Signal,
+ /// Determines if the form has requested to show error messages.
+ show_message: RwSignal
}
-struct QuestionComponent {
- label: String,
- placeholder: String,
- possible_values: HashMap,
- // there are definitely values that i'll find while creating this structure.
+// Define a new type for ease of use.
+type ValidationVec = Vec>;
+
+// Parent form component
+#[component]
+fn Form() -> impl IntoView {
+ let validation_states: ValidationVec = Vec::new();
+
+ provide_context(validation_states);
+
+ /* All other form logic */
+}
+
+fn validation_function(input: String) -> ValidationState {
+ // Validation logic is specific to each input type.
+}
+
+// Child component
+#[component]
+fn AnyInput() -> impl IntoView {
+ let validation_states = use_context::().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.
diff --git a/src/common/comparison/base.rs b/src/common/comparison/base.rs
index aa6aea8..afca34b 100644
--- a/src/common/comparison/base.rs
+++ b/src/common/comparison/base.rs
@@ -23,6 +23,7 @@ pub struct ComparisonData {
}
impl ComparisonData {
+ #[allow(unused)]
pub fn new(
id: impl Into,
member: impl Into,
diff --git a/src/common/comparison/mod.rs b/src/common/comparison/mod.rs
index bcf85b1..ba9cac6 100644
--- a/src/common/comparison/mod.rs
+++ b/src/common/comparison/mod.rs
@@ -3,7 +3,6 @@ mod lists;
mod number;
mod text;
mod traits;
-mod utils;
pub use self::{base::*, lists::*, number::*, text::*, traits::*};
diff --git a/src/common/comparison/utils.rs b/src/common/comparison/utils.rs
deleted file mode 100644
index 244f78a..0000000
--- a/src/common/comparison/utils.rs
+++ /dev/null
@@ -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(
- list: &Vec,
- key: &String,
- verifier: F,
-) -> Result, 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)
-}
diff --git a/src/common/form_schema.rs b/src/common/form_schema.rs
index e5ec97e..4825f60 100644
--- a/src/common/form_schema.rs
+++ b/src/common/form_schema.rs
@@ -1,8 +1,6 @@
use crate::common::ValueType;
-use crate::components::{CheckboxList, OutlinedTextField, RadioList, Row, Select};
-use leptos::either::EitherOf5;
-use leptos::prelude::RwSignal;
-use leptos::{IntoView, view};
+use crate::components::{CheckboxList, OutlinedTextField, RadioList, Row, Select, TextFieldType};
+use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -11,85 +9,82 @@ use std::collections::HashMap;
pub enum InputType {
/// Represents a text input. Requires a `String` key, `String` label, and `String` placeholder.
/// The entered value will be returned as a single `String` value.
- Text(String, String, String),
+ Text(String, String, String, bool),
/// Represents a number input. Requires a `String` key, `String` label, and a `String` placeholder.
/// The entered value will be returned as a `String`, and will be parseable into any number type
/// (i.e. an `i32`, `i64`, etc)
- Number(String, String, String),
+ Number(String, String, String, bool),
/// Represents a radio input. Requires a `String` key, `String` label, and a series of `String`
/// options. The selected value will be returned as a `String`.
- Radio(String, String, Vec),
+ Radio(String, String, Vec, bool),
/// Represents a checkbox input. Requires a `String` key, `String` label, and a series of `String`
/// options. The selected values will be returned as a `Vec`.
- Checkbox(String, String, Vec),
+ Checkbox(String, String, Vec, bool),
/// Represents a dropdown input. Requires a `String` key, `String` label, and a series
/// of `String` options. The selected value will be returned as a `String`.
- Select(String, String, Vec),
+ Select(String, String, Vec, bool),
}
impl InputType {
pub fn into_view(self, data_map: RwSignal>) -> impl IntoView {
view! {
{match self {
- InputType::Text(member, label, placeholder) =>
- EitherOf5::A(view! {
+ InputType::Text(member, label, placeholder, required) =>
+ view! {
- }),
- InputType::Number(member, label, placeholder) =>
- EitherOf5::B(view! {
+ }.into_any(),
+ InputType::Number(member, label, placeholder, required) =>
+ view! {
- }),
- InputType::Checkbox(member, label, options) =>
- EitherOf5::C(view! {
+ }.into_any(),
+ InputType::Checkbox(member, label, options, required) =>
+ view! {
- }),
- InputType::Radio(member, label, options) =>
- EitherOf5::D(view! {
+ }.into_any(),
+ InputType::Radio(member, label, options, required) =>
+ view! {
- }),
- InputType::Select(member, label, options) =>
- EitherOf5::E(view! {
+ }.into_any(),
+ InputType::Select(member, label, options, required) =>
+ view! {
- })
+ }.into_any()
}}
}
}
-
- pub fn into_view_rows(self, data_map: RwSignal>) -> impl IntoView {
- view! {
-
- {self.into_view(data_map)}
-
- }
- }
}
/// Shorthand macro for creating `InputType` enums.
@@ -104,19 +99,21 @@ impl InputType {
/// ```
#[macro_export]
macro_rules! input {
- ($variant:ident, $key:expr, $label:expr, [$($option:expr),* $(,)?]) => {
+ ($variant:ident, $key:expr, $label:expr, $required:expr, [$($option:expr),* $(,)?]) => {
$crate::common::InputType::$variant(
$key.to_string(),
$label.to_string(),
- vec![$($option.to_string()), *]
+ vec![$($option.to_string()), *],
+ $required
)
};
- ($variant:ident, $key:expr, $label:expr, $placeholder:expr) => {
+ ($variant:ident, $key:expr, $label:expr, $required:expr, $placeholder:expr) => {
$crate::common::InputType::$variant(
$key.to_string(),
$label.to_string(),
- $placeholder.to_string()
+ $placeholder.to_string(),
+ $required
)
};
}
diff --git a/src/common/submit_status.rs b/src/common/submit_status.rs
index c6b8a90..f66fe83 100644
--- a/src/common/submit_status.rs
+++ b/src/common/submit_status.rs
@@ -3,5 +3,5 @@ pub enum SubmitStatus {
Idle,
Sending,
Success,
- Error(String),
+ Error(()),
}
diff --git a/src/components/action_button.rs b/src/components/action_button.rs
index 8d9728e..9207d84 100644
--- a/src/components/action_button.rs
+++ b/src/components/action_button.rs
@@ -2,7 +2,7 @@ use leptos::prelude::*;
#[component]
pub fn ActionButton(
- #[prop(default = String::from("button"))] button_type: String,
+ #[prop(default = String::from("button"), into)] button_type: String,
#[prop(optional, into)] disabled: Signal,
children: Children,
) -> impl IntoView {
diff --git a/src/components/checkbox.rs b/src/components/checkbox.rs
deleted file mode 100644
index ba711dc..0000000
--- a/src/components/checkbox.rs
+++ /dev/null
@@ -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,
- #[prop(into)] on_change: Callback<(), ()>,
- #[prop(into)] value: String,
- #[prop(into)] name: String,
- #[prop(default = RwSignal::new(false))] disabled: RwSignal,
-) -> impl IntoView {
- let id = create_unique_id(&name, &value);
-
- view! {
-
- }
-}
-
-#[component]
-pub fn CheckboxList(
- #[prop(optional)] selected: RwSignal>,
- #[prop(into)] data_member: String,
- #[prop()] data_map: RwSignal>,
- #[prop()] items: Vec,
- #[prop(default = RwSignal::new(false))] disabled: RwSignal,
- #[prop(optional, into)] label: String,
-) -> impl IntoView {
- view! {
-
- }
-}
diff --git a/src/components/chips.rs b/src/components/chips.rs
deleted file mode 100644
index bf57241..0000000
--- a/src/components/chips.rs
+++ /dev/null
@@ -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! {
-///
-/// }
-/// ```
-#[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,
- #[prop(optional)] disabled: RwSignal,
-) -> 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! {
-
- }
-}
-
-/// # 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>,
- #[prop(into)] displayed_text: Signal>,
- #[prop(into)] values: Signal>,
- #[prop(default = RwSignal::new(false))] disabled: RwSignal,
- #[prop(optional, into)] label: String,
-) -> impl IntoView {
- view! {
-
- {label}
-
- {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! {
-
- }
- })
- .collect_view()
- }}
-
-
- }
-}
diff --git a/src/components/lists/mod.rs b/src/components/lists/mod.rs
new file mode 100644
index 0000000..3939485
--- /dev/null
+++ b/src/components/lists/mod.rs
@@ -0,0 +1,6 @@
+mod multi;
+mod single;
+
+pub use {
+ multi::*, single::*
+};
\ No newline at end of file
diff --git a/src/components/lists/multi/checkbox.rs b/src/components/lists/multi/checkbox.rs
new file mode 100644
index 0000000..cf07dd0
--- /dev/null
+++ b/src/components/lists/multi/checkbox.rs
@@ -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! {
+///
+/// }
+/// ```
+#[component]
+pub fn Checkbox(
+ /// A [`Signal`] indicating whether the checkbox is checked.
+ #[prop()] checked: Signal,
+ /// 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,
+ /// 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,
+) -> impl IntoView {
+ let id = create_unique_id(&name, &value.get_untracked());
+
+ view! {
+
+ }
+}
+
+/// # 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! {
+///
+/// }
+/// ```
+#[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>,
+ /// The available choices for this list.
+ #[prop()] items: Vec,
+ /// A [`Signal`] indicating whether the checkbox list is disabled.
+ #[prop(optional, into)] disabled: Signal,
+ /// 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
+) -> impl IntoView {
+ let controller = use_selectable_list(
+ data_member.clone(),
+ data_map.clone(),
+ required.clone()
+ );
+
+ //#region Render Logic
+ view! {
+
+
+ {label}
+ {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! {
+
+ }
+ })
+ .collect_view()}
+
+
+
+ {move || {
+ match controller.error.get() {
+ ValidationState::Invalid(msg) => msg,
+ _ => "There is no error - should not see this message.".to_string()
+ }
+ }}
+
+
+
+ }
+ //#endregion
+}
diff --git a/src/components/lists/multi/chips.rs b/src/components/lists/multi/chips.rs
new file mode 100644
index 0000000..35197ad
--- /dev/null
+++ b/src/components/lists/multi/chips.rs
@@ -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! {
+///
+/// }
+/// ```
+#[component]
+pub fn Chip(
+ #[prop(into)] display_text: Signal,
+ #[prop(into)] value: Signal,
+ #[prop(into)] name: String,
+ #[prop(into)] on_change: Callback<(), ()>,
+ #[prop()] checked: Signal,
+ #[prop(optional, into)] disabled: Signal,
+) -> 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! {
+
+ }
+}
+
+/// # 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>,
+ #[prop(into)] displayed_text: Signal>,
+ #[prop(into)] values: Signal>,
+ #[prop(optional, into)] disabled: Signal,
+ #[prop(optional, into)] label: String,
+ #[prop(optional, into)] required: Signal
+) -> impl IntoView {
+ let controller = use_selectable_list(
+ data_member.clone(),
+ data_map.clone(),
+ required.clone()
+ );
+
+ view! {
+
+
+ {label}
+ {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! {
+
+ }
+ })
+ .collect_view()}
+
+
+
+ {move || {
+ match controller.error.get() {
+ ValidationState::Invalid(msg) => msg,
+ _ => "There is no error - should not see this message.".to_string()
+ }
+ }}
+
+
+
+ }
+}
diff --git a/src/components/lists/multi/mod.rs b/src/components/lists/multi/mod.rs
new file mode 100644
index 0000000..c3fcbbc
--- /dev/null
+++ b/src/components/lists/multi/mod.rs
@@ -0,0 +1,6 @@
+mod utils;
+mod checkbox;
+mod chips;
+
+pub use checkbox::*;
+pub use chips::*;
\ No newline at end of file
diff --git a/src/components/lists/multi/utils.rs b/src/components/lists/multi/utils.rs
new file mode 100644
index 0000000..3b151b0
--- /dev/null
+++ b/src/components/lists/multi/utils.rs
@@ -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,
+ pub dirty: RwSignal,
+ pub show_errors: Signal,
+ pub selected_list: RwSignal>,
+}
+
+fn value_type_to_vec(value_type: &ValueType) -> Vec {
+ 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::>()
+}
+
+pub fn use_selectable_list(
+ data_member: String,
+ data_map: RwSignal>,
+ required: Signal
+) -> 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::>()
+ );
+
+ // 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,
+ }
+}
diff --git a/src/components/lists/single/mod.rs b/src/components/lists/single/mod.rs
new file mode 100644
index 0000000..f54cd68
--- /dev/null
+++ b/src/components/lists/single/mod.rs
@@ -0,0 +1,5 @@
+pub mod radio;
+pub mod select;
+
+pub use radio::*;
+pub use select::*;
\ No newline at end of file
diff --git a/src/components/lists/single/radio.rs b/src/components/lists/single/radio.rs
new file mode 100644
index 0000000..32680ac
--- /dev/null
+++ b/src/components/lists/single/radio.rs
@@ -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,
+ #[prop(into)] on_change: Callback<(), ()>,
+ #[prop(into)] value: Signal,
+ #[prop(into)] name: Signal,
+ #[prop(optional, into)] disabled: Signal,
+) -> impl IntoView {
+ let id = create_unique_id(&name.get_untracked(), &value.get_untracked());
+
+ view! {
+
+ }
+}
+
+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,
+ #[prop()] data_map: RwSignal>,
+ #[prop()] items: Vec,
+ #[prop(optional, into)] disabled: Signal,
+ #[prop(optional, into)] label: String,
+ #[prop(optional, into)] required: Signal,
+) -> 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! {
+
+
+ {label}
+ {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! {
+
+ }
+ })
+ .collect::>()}
+
+
+
+ {move || {
+ match error.get() {
+ ValidationState::Invalid(msg) => msg,
+ _ => "There is no error - should not see this message.".to_string()
+ }
+ }}
+
+
+
+ }
+
+ //#endregion
+}
diff --git a/src/components/lists/single/select.rs b/src/components/lists/single/select.rs
new file mode 100644
index 0000000..fa5a732
--- /dev/null
+++ b/src/components/lists/single/select.rs
@@ -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` 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,
+ #[prop(into)] data_member: Signal,
+ #[prop()] data_map: RwSignal>,
+ #[prop(optional, into)] label: String,
+ #[prop(optional, into)] disabled: Signal,
+ #[prop(optional, into)] required: Signal
+) -> 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| {
+ 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! {
+
+
+
+
+
+
+ {move || {
+ match error.get() {
+ ValidationState::Invalid(msg) => msg,
+ _ => "There is no error - should not see this message.".to_string()
+ }
+ }}
+
+
+
+ }
+}
diff --git a/src/components/mod.rs b/src/components/mod.rs
index a66f141..d9d9084 100644
--- a/src/components/mod.rs
+++ b/src/components/mod.rs
@@ -1,18 +1,17 @@
mod action_button;
mod banner;
-mod checkbox;
-mod chips;
mod dashboard_button;
mod loading;
mod multi_entry;
mod panel;
-mod radio;
mod row;
-mod select;
mod text_field;
mod utils;
+mod validated_form;
+mod lists;
pub use self::{
- action_button::*, banner::*, checkbox::*, chips::*, dashboard_button::*, loading::*,
- multi_entry::*, panel::*, radio::*, row::*, select::*, text_field::*,
+ action_button::*, banner::*, dashboard_button::*, lists::*,
+ loading::*, multi_entry::*, panel::*, row::*, text_field::*,
+ validated_form::*
};
diff --git a/src/components/multi_entry.rs b/src/components/multi_entry.rs
index 8d7aca3..c67b706 100644
--- a/src/components/multi_entry.rs
+++ b/src/components/multi_entry.rs
@@ -73,9 +73,6 @@ pub fn MultiEntry(
/// component's `data_member` input.
#[prop()]
data_map: RwSignal>,
- /// Currently unused.
- #[prop(optional, into)]
- name_member: String,
/// The type and order of the inputs for each `Entry` component within this `MultiEntry` component.
#[prop(optional)]
schema: Vec,
@@ -132,7 +129,6 @@ pub fn MultiEntry(
view! {
}
@@ -186,14 +182,6 @@ fn Entry(
/// data members those inputs modify.
#[prop()]
schema: Vec,
- /// The display name member of this entry. Determines where the component should search for the
- /// name of the entry within the `data_map`.
- #[prop(into)]
- name_member: String,
- /// The info member of this entry. Determines where the component should search for the extra
- /// information of this entry within the `data_map`.
- #[prop(optional, into)]
- info_member: String,
) -> impl IntoView {
view! {
- {label}
- {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! {
-
- }
- })
- .collect::>()}
-
- }
-}
diff --git a/src/components/select.rs b/src/components/select.rs
deleted file mode 100644
index 9f5fe3c..0000000
--- a/src/components/select.rs
+++ /dev/null
@@ -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` 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,
- #[prop(optional)] value: RwSignal,
- #[prop(into)] data_member: String,
- #[prop()] data_map: RwSignal>,
- #[prop(optional, into)] label: String,
- #[prop(default = RwSignal::new(false))] disabled: RwSignal,
-) -> impl IntoView {
- let on_change = {
- let data_member = data_member.clone();
- move |e: Targeted| {
- 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! {
-
-
-
- }
-}
diff --git a/src/components/text_field.rs b/src/components/text_field.rs
index 4dae16c..173ee5a 100644
--- a/src/components/text_field.rs
+++ b/src/components/text_field.rs
@@ -1,69 +1,125 @@
use crate::common::ValueType;
-use leptos::leptos_dom::logging::console_log;
use leptos::prelude::*;
use std::collections::HashMap;
+use leptos::html::Input;
+use crate::components::{use_validation_context, InputState};
+use crate::components::validated_form::ValidationState;
+
+/// Determines the available types of text fields.
+#[derive(Default, Debug, Clone)]
+pub enum TextFieldType {
+ /// A text field that accepts any string.
+ #[default]
+ Text,
+ /// A text field that accepts only numbers.
+ Number,
+ /// A text field that accepts only Region 15 email addresses.
+ /// TODO Accept more than just Region 15 addresses.
+ Email
+}
+
+/// Entry point for validating a text field.
+fn validate(required: bool, value: &str, input_type: &TextFieldType) -> ValidationState {
+ if required && value.is_empty() {
+ ValidationState::Invalid("This field is required.".to_string())
+ } else {
+ match input_type {
+ TextFieldType::Email => validate_email(value),
+ TextFieldType::Number => validate_number(value),
+ TextFieldType::Text => ValidationState::Valid
+ }
+ }
+}
+
+/// Validates an email address.
+fn validate_email(input: &str) -> ValidationState {
+ // TODO Accept more than just Region 15 addresses.
+ let pattern = regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@region15\.org$").expect("Invalid regex pattern");
+
+ if pattern.is_match(input) {
+ ValidationState::Valid
+ } else {
+ ValidationState::Invalid("Please provide a valid Region 15 email address.".to_string())
+ }
+}
+
+/// Validates a number.
+fn validate_number(input: &str) -> ValidationState {
+ if input.parse::().is_ok() {
+ ValidationState::Valid
+ } else {
+ ValidationState::Invalid("Value is not a valid number.".to_string())
+ }
+}
#[component]
pub fn OutlinedTextField(
#[prop(optional, into)] placeholder: String,
- // #[prop()] value: Subfield,
#[prop(into)] data_member: String, // should this be an RwSignal??
#[prop()] data_map: RwSignal>,
- #[prop(optional)] value: RwSignal,
- #[prop(default = "text".to_owned(), into)] input_type: String,
+ #[prop(optional)] input_type: TextFieldType,
#[prop(into, optional)] disabled: Signal,
- #[prop(optional)] error: RwSignal,
#[prop(optional, into)] name: String,
#[prop(optional, into)] label: String,
- #[prop(optional, into)] error_text: RwSignal,
+ #[prop(optional, into)] required: Signal
) -> impl IntoView {
+ let input_ref = NodeRef::::new();
+
+ // Register this input's validation signal.
+ let validator_context = use_validation_context()
+ .expect("FormValidSignal was not found");
+
+ let raw_value = RwSignal::new(data_map.get_untracked()
+ .get(&data_member)
+ .unwrap_or(&ValueType::String(None))
+ .to_string());
+
+ let dirty = RwSignal::new(false);
+ let error = RwSignal::new(validate(required.get_untracked(), &raw_value.get_untracked(), &input_type));
+ let show_errors = Signal::derive(move || {
+ dirty.get() && matches!(error.get(), ValidationState::Invalid(_))
+ });
+
+ let validator = RwSignal::new(InputState::new(data_member.clone(), error.clone(), dirty.clone()));
+
+ validator_context.validators.update(|list| list.push(validator));
+
+ on_cleanup(move || {
+ validator_context.validators.update(|list| {
+ list.retain(|v| *v.get_untracked().input_name != *validator.get_untracked().input_name)
+ });
+ });
+
+ let str_type = match input_type {
+ TextFieldType::Number => "number",
+ _ => "text" // This is fine even for emails, as we're doing our own validation.
+ };
+
+ let on_blur = move |_| {
+ // Check validity of input's current value. The value will be updated by on_input,
+ // and it should be a raw input (not parsed into a ValueType).
+ dirty.set(true);
+ };
+
// This function parses the input into the correct type. It only accepts numbers or strings as
// valid types, and parses them accordingly.
let on_input = {
- let input_type = input_type.clone();
let data_member = data_member.clone();
move |e| {
let to_parse = event_target_value(&e);
- match input_type.as_str() {
- "text" => {
- // String parse function. It will always parse, but make sure the error
- // state is set properly anyway.
- error.set(false);
- value.set(ValueType::String(Some(to_parse.clone())));
- data_map.update(|map| {
- map.insert(data_member.clone(), ValueType::String(Some(to_parse)));
- });
- }
- "number" => {
- // Define number parse function.
- // If the number parses, Some(num), else None, but we want to keep the value that's
- // in the text field at this point, just set the error.
- console_log(format!("Parsing {to_parse} to a number").as_str());
- match to_parse
- .parse::()
- .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);
- }
- }
+ raw_value.set(to_parse.clone());
+ error.set(validate(required.get(), &to_parse, &input_type));
+ let into_map = match input_type {
+ TextFieldType::Number => ValueType::Number(Some(to_parse)),
+ _ => ValueType::String(Some(to_parse))
+ };
+
+ // Edit the data map. Note that this should be uncoupled in the future - we don't want
+ // to update the map inside the component definition, but inside the page/form that
+ // created this input.
+ data_map.update(|map| {
+ map.insert(data_member.clone(), into_map);
+ });
}
};
@@ -72,11 +128,12 @@ pub fn OutlinedTextField(
}
diff --git a/src/components/validated_form.rs b/src/components/validated_form.rs
new file mode 100644
index 0000000..c862459
--- /dev/null
+++ b/src/components/validated_form.rs
@@ -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,
+ /// Indicates whether the form has requested the error state. This means that the input
+ /// should show their error appearances immediately.
+ form_requested_validation: RwSignal,
+}
+
+impl InputState {
+ /// Creates a new [`InputState`] with the given error and form requested validation signals.
+ pub fn new(
+ name: impl Into,
+ error: impl Into>,
+ form_requested_errors: RwSignal
+ ) -> Self {
+ Self {
+ input_name: name.into(),
+ error: error.into(),
+ form_requested_validation: form_requested_errors
+ }
+ }
+
+ pub fn error(&self) -> Signal {
+ self.error.clone()
+ }
+}
+
+/// A list of [`InputState`]s.
+pub type ValidatorList = Vec>;
+
+/// 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
+}
+
+/// Gets the [`FormValidationRegistry`] context if it exists.
+pub fn use_validation_context() -> Option {
+ use_context::()
+}
+
+/// # Validated Form Component
+///
+/// A simple HTML `