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! { - // This div displays the box for the checkbox, and applies some styling to the child - // span element. -
- // 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. - -
- {value.clone()} - - } -} - -#[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! { -
- {label} - {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 - .unwrap_or(&ValueType::List(None)) // ValueType - .as_list() // Result>, ValueType> - .unwrap_or(Some(vec!())) // Option> - .unwrap_or(vec!()) // Vec - .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! { - - } - }) - .collect::>()} -
- } -} 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! {
diff --git a/src/components/radio.rs b/src/components/radio.rs deleted file mode 100644 index fd8af70..0000000 --- a/src/components/radio.rs +++ /dev/null @@ -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, - #[prop(into)] on_change: Callback<(), ()>, - #[prop(into)] value: String, - #[prop(into)] name: String, - #[prop(optional, into)] disabled: Signal, -) -> impl IntoView { - let id = create_unique_id(&name, &value); - - view! { - - } -} - -#[component] -pub fn RadioList( - // #[prop(default = RwSignal::new(String::new()))] selected: RwSignal, - #[prop(into)] data_member: String, - #[prop()] data_map: RwSignal>, - #[prop()] items: Vec, - #[prop(optional, into)] disabled: Signal, - #[prop(optional, into)] label: 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 `
` 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::()` to register +/// themselves with the form. +/// +/// Example usage in the view macro: +/// ``` +/// view! { +/// +/// {/* As many input components as you want */} +/// +/// } +/// ``` +/// +/// Example input component implementation: +/// ``` +/// fn SomeComponent() -> impl IntoView { +/// // Get FormValidationContext +/// let context = use_context::() +/// .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! { + + {children()} + + "Submit form (but actually)" + + + } +} diff --git a/src/lib.rs b/src/lib.rs index 7ed69e6..9958e9f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,6 @@ pub mod app; mod common; mod components; mod pages; -mod utils; #[cfg(feature = "hydrate")] #[wasm_bindgen::prelude::wasm_bindgen] diff --git a/src/pages/api/comparisons.rs b/src/pages/api/comparisons.rs index b343b24..6691efb 100644 --- a/src/pages/api/comparisons.rs +++ b/src/pages/api/comparisons.rs @@ -8,12 +8,12 @@ use crate::common::{ ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison, TextComparison, TextListComparison, ValueType, }; -use leptos::leptos_dom::log; use leptos::prelude::ServerFnError; use leptos::server; /// Helper function to create all sports comparisons. Only used to create initial /// comparison data, and should be removed upon completion of a comparison editor page. +#[allow(unused)] fn create_sports_comparisons() -> Vec { vec![ ComparisonData::new( @@ -184,12 +184,8 @@ fn create_sports_comparisons() -> Vec { ] } -#[server(CreateTestComparisons, endpoint = "/comparisons/create-test")] -pub async fn create_test_comparisons() -> Result<(), ServerFnError> { - let client = create_dynamo_client().await; - - log!("Creating test comparisons"); - +#[allow(unused)] +fn make_comp_list() -> Vec { let test_comp = ComparisonData::new( "math_sat_comp", "math_sat", @@ -304,6 +300,19 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> { comp_list.append(&mut sports_comps); comp_list.append(&mut major_comps); + comp_list +} + +#[server(CreateTestComparisons, endpoint = "/comparisons/create-test")] +pub async fn create_test_comparisons() -> Result<(), ServerFnError> { + use leptos::logging::log; + + let client = create_dynamo_client().await; + + log!("Creating test comparisons"); + + let comp_list = make_comp_list(); + for comparison in comp_list { if let Err(err) = client .put_item() @@ -322,6 +331,8 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> { #[server] pub async fn get_comparison_info() -> Result, ServerFnError> { + use leptos::logging::log; + let client = create_dynamo_client().await; log!("Getting all comparisons from the database"); diff --git a/src/pages/api/scholarships.rs b/src/pages/api/scholarships.rs index b006143..005db4b 100644 --- a/src/pages/api/scholarships.rs +++ b/src/pages/api/scholarships.rs @@ -3,15 +3,19 @@ use aws_sdk_dynamodb::{ types::AttributeValue, error::ProvideErrorMetadata, }; - +#[cfg(feature = "ssr")] use leptos::leptos_dom::log; -use leptos::prelude::ServerFnError; -use leptos::server; +#[cfg(feature = "ssr")] use uuid::Uuid; -use crate::common::{ExpandableInfo, ValueType}; - #[cfg(feature = "ssr")] use crate::pages::utils::server_utils::create_dynamo_client; +#[cfg(feature = "ssr")] +use crate::common::ValueType; + +use crate::common::ExpandableInfo; + +use leptos::prelude::ServerFnError; +use leptos::server; #[server(GetScholarshipInfo, endpoint = "/scholarship/info/get")] pub async fn get_scholarship_info(id: String) -> Result { diff --git a/src/pages/home_page.rs b/src/pages/home_page.rs index f4c6ed0..40555b7 100644 --- a/src/pages/home_page.rs +++ b/src/pages/home_page.rs @@ -1,7 +1,6 @@ // Server dependencies #[cfg(feature = "ssr")] use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue}; - #[cfg(feature = "ssr")] use serde_dynamo::{from_item, to_item}; #[cfg(feature = "ssr")] @@ -9,22 +8,20 @@ use std::process::{Command, Stdio}; #[cfg(feature = "ssr")] use std::io::Write; -use crate::common::{ExpandableInfo, UserClaims}; +use crate::common::ExpandableInfo; use crate::pages::UnauthenticatedPage; -use crate::components::{ - ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row, - Select, -}; +use crate::pages::utils::get_user_claims; +use crate::components::{ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row, Select, TextFieldType, ValidatedForm}; use crate::input; use leptos::leptos_dom::logging::console_log; use leptos::prelude::*; use leptos_oidc::{AuthLoaded, Authenticated}; use base64::Engine; +use leptos::logging::log; use leptos::task::spawn_local; use leptos::web_sys::HtmlAnchorElement; use leptos::wasm_bindgen::JsCast; -use traits::{AsReactive, ReactiveCapture}; -use crate::pages::utils::get_user_claims; +use std::collections::HashMap; /// # Get Student Info /// Gets a student's information given their `subject`. @@ -144,23 +141,73 @@ pub async fn create_example_pdf() -> Result, ServerFnError> { /// The main home page component. Contains a simple contact form. #[component] pub fn HomePage() -> impl IntoView { - // Creates a reactive value to update the button + //#region Server Resources let user_claims = get_user_claims(); + let user_subject = Memo::new(move |_| { + user_claims.get() + .map(|claim| claim.claims.subject.clone()) + }); - // Note that the value passed in MUST be equatable. - // We get/unwrap the value repeatedly until we get a simple string value, then clone it so that - // we don't lose access to it in the future, should we need it again. - let server_resource: Resource = Resource::new( - move || user_claims.get().map(|claim| claim.claims.subject.clone()), - async |opt_username| match opt_username { - Some(subject) => get_submission(subject.clone()).await.unwrap_or_else(|e| { - console_log(e.to_string().as_str()); - ExpandableInfo::new(subject) - }), - None => ExpandableInfo::new("".to_owned()), - }, + let form_data = RwSignal::new(HashMap::new()); + + let server_resource = Resource::new( + move || user_subject.get(), + async move |subject| get_submission(subject.unwrap_or_default()).await ); let submit_action = ServerAction::::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::() + .unwrap(); + link.set_href(&*data_url); + link.set_target("_blank"); + link.click(); + } + }); + }; + //#endregion view! { @@ -170,195 +217,143 @@ pub fn HomePage() -> impl IntoView { {move || { server_resource .get() - .map(|submission| { - let expandable_react = submission.as_reactive(); - let elements_disabled = RwSignal::new(false); - let result_msg = Signal::derive(move || { - if submit_action.pending().get() { - elements_disabled.set(true); - "Sending request...".to_owned() - } else { - elements_disabled.set(false); - if let Some(result) = submit_action.value().get() { - match result { - Ok(_) => "Request sent successfully.".to_owned(), - Err(e) => format!("Failed to send request: {:?}", e), - } - } else { - "".to_owned() - } - } - }); + .map(|_| { view! {
- -

- "Region 15 Scholarship Application (DEMO)" -

-
- -

- "This is a very simple demonstration of the scholarship application written in Rust." -

-
- - // Text { data_member, label, placeholder } - - - - - - - - - - - - - - - - - // Radio { data_member, label, items } - - - - // Checkbox { data_member, label, items } - - - - // Select { data_member, label, items } - + + + + +
} }) }} + + "Get PDF" + - } + }.into_any() } diff --git a/src/pages/loaner_page.rs b/src/pages/loaner_page.rs index b03cd9b..095ed8e 100644 --- a/src/pages/loaner_page.rs +++ b/src/pages/loaner_page.rs @@ -4,10 +4,11 @@ use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValu #[cfg(feature = "ssr")] use serde_dynamo::{from_item, to_item}; -use crate::common::{ExpandableInfo, ValueType}; -use crate::components::{ - ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select, -}; +#[cfg(feature = "ssr")] +use crate::common::ValueType; + +use crate::common::ExpandableInfo; +use crate::components::{ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select, TextFieldType, ValidatedForm}; use chrono::{FixedOffset, TimeZone}; use leptos::Params; use leptos::either::{Either, EitherOf3}; @@ -18,7 +19,6 @@ use leptos_router::hooks::{use_navigate, use_params}; use leptos_router::params::Params; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use traits::{AsReactive, ReactiveCapture}; #[derive(Params, PartialEq)] struct LoanerParams { @@ -245,83 +245,85 @@ pub fn LoanerPage() -> impl IntoView { pub fn LoanerBorrowForm() -> impl IntoView { // Register server actions. let create_entry_action = ServerAction::::new(); - let navigate = use_navigate(); - let temp = ExpandableInfo::new(uuid::Uuid::new_v4().to_string()); - let temp_reactive = temp.as_reactive(); - let elements_disabled = RwSignal::new(false); + let data = RwSignal::new(HashMap::new()); + let elements_disabled = Signal::derive(move || create_entry_action.pending().get()); + + let on_submit = move |_| { + create_entry_action.dispatch(CreateBorrowEntry { + input: ExpandableInfo { + subject: uuid::Uuid::new_v4().to_string(), + data: data.get(), + } + }); + }; view! { -
-

"Borrower Information"

-

"Please fill out the form below."

-
- - - - - - - - - - - - "Submit" - + +
+

"Borrower Information"

+

"Please fill out the form below."

+
+ + + + + + + + + + +
} } diff --git a/src/pages/scholarship_info.rs b/src/pages/scholarship_info.rs index b40bff1..8cc196d 100644 --- a/src/pages/scholarship_info.rs +++ b/src/pages/scholarship_info.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use leptos::either::Either; use leptos::html::Dialog; use leptos::logging::log; @@ -5,12 +6,11 @@ use leptos::prelude::*; use leptos_oidc::{AuthLoaded, Authenticated}; use leptos_router::hooks::{use_navigate, use_params}; use crate::common::{ExpandableInfo, ScholarshipFormParams, SubmitStatus, ValueType}; -use crate::components::{ActionButton, Banner, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row}; +use crate::components::{ActionButton, Banner, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row, TextFieldType, ValidatedForm}; use super::UnauthenticatedPage; -use traits::{AsReactive, ReactiveCapture}; use crate::common::ComparisonData; use crate::pages::utils::get_user_claims; -use super::api::{get_comparison_info, get_provider_scholarships, get_scholarship_info, CreateScholarshipInfo, CreateTestComparisons, RegisterScholarship, DeleteProviderScholarship}; +use super::api::{get_comparison_info, get_provider_scholarships, get_scholarship_info, CreateScholarshipInfo, RegisterScholarship, DeleteProviderScholarship}; /// # Scholarship Info Page @@ -31,7 +31,7 @@ pub fn ScholarshipInfoPage() -> impl IntoView { .and_then(|params| params.id.clone()) }); - let create_test_comps = ServerAction::::new(); + // let create_test_comps = ServerAction::::new(); let on_delete = move || { let navigate = use_navigate(); @@ -279,7 +279,7 @@ fn ScholarshipListEntry( ) -> impl IntoView { let navigate = use_navigate(); let user_claims = get_user_claims(); - let user_id = Memo::new(move |_| { + let _user_id = Memo::new(move |_| { user_claims.get() .map(|info| info.claims.subject.clone()) }); @@ -350,24 +350,22 @@ fn ScholarshipForm( /// submission fails, this function is not run. #[prop(into)] on_submit: Callback<()> ) -> impl IntoView { - let submit_status = RwSignal::new(SubmitStatus::Idle); - let elements_disabled = Signal::derive(move || { - match submit_status.get() { - SubmitStatus::Sending => true, - _ => false - } - }); - let result_msg = Signal::derive(move || { - match submit_status.get() { - SubmitStatus::Idle => "".into(), - SubmitStatus::Sending => "Sending submission...".into(), - SubmitStatus::Success => "Success".into(), - SubmitStatus::Error(msg) => format!("Error: {}", msg), - } - }); + // let result_msg = Signal::derive(move || { + // match submit_status.get() { + // SubmitStatus::Idle => "".into(), + // SubmitStatus::Sending => "Sending submission...".into(), + // SubmitStatus::Success => "Success".into(), + // SubmitStatus::Error(msg) => format!("Error: {}", msg), + // } + // }); let submit_action = ServerAction::::new(); - + + let submit_status = RwSignal::new(SubmitStatus::Idle); + let elements_disabled = Signal::derive(move || { + matches!(submit_action.pending().get(), true) + }); + Effect::new(move || { if submit_action.pending().get() { submit_status.set(SubmitStatus::Sending); @@ -380,7 +378,7 @@ fn ScholarshipForm( submit_status.set(SubmitStatus::Success); on_submit.run(()); }, - Err(err) => submit_status.set(SubmitStatus::Error(err.to_string())) + Err(_err) => submit_status.set(SubmitStatus::Error(())) } } }); @@ -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 |_| { 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, // fafsa_required, award_to, transcript_required, recipient_selection, essay_requirement (and prompt) // award_night_remarks - + + let on_submit = move |_| { + let mut info = ExpandableInfo::new(scholarship_id.get().unwrap_or_default()); + info.data = form_data.get(); + + log!("Map values: {:?}", info.data); + submit_action.dispatch(CreateScholarshipInfo { + info + }); + }; + view! { "Choose a scholarship from the right, or create a new one."

} > - {move || { - scholarship_info.get() - .map(|res_scholarship| { - match res_scholarship { - Ok(scholarship) => { - let reactive_info = scholarship.as_reactive(); - - Either::Left(view! { - -

- "Scholarship Info Form (test)" -

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - "Submit" - - -

{result_msg}

-
- }) + + {move || { + scholarship_info.get() + .map(|res_scholarship| { + match res_scholarship { + Ok(_) => { + Either::Left(view! { + +

+ "Scholarship Info Form (test)" +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + }) + } + Err(err) => { + Either::Right(view! { +

"Error while getting scholarship: "{err.to_string()}

+ }) + } } - Err(err) => { - Either::Right(view! { -

"Error while getting scholarship: "{err.to_string()}

- }) - } - } - }).collect_view() - }} + }).collect_view() + }} +
- } + }.into_any() } diff --git a/src/utils/mod.rs b/src/utils/mod.rs deleted file mode 100644 index c99d1ab..0000000 --- a/src/utils/mod.rs +++ /dev/null @@ -1,23 +0,0 @@ -use crate::common::{ExpandableInfo, ValueType}; -use std::ops::ControlFlow; - -pub fn get_number_list(student_info: &ExpandableInfo, member: &String) -> Result, 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::().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()), - } -}