Implement new MultiEntry component #13

Merged
BobdaFett merged 4 commits from feature/multi-entry-final into main 2025-10-23 15:54:11 +00:00
9 changed files with 367 additions and 30 deletions
Showing only changes of commit 86bd471c1e - Show all commits

No files matched your search

+114 -12
View File
@@ -1,18 +1,120 @@
use std::collections::HashMap;
use leptos::{view, IntoView};
use leptos::either::EitherOf5;
use leptos::prelude::RwSignal;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use crate::components::{CheckboxList, OutlinedTextField, RadioList, Row, Select, ValueType};
/// Defines a series of options that allow a user to define a form. /// Defines a series of options that allow a user to define a form.
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub enum InputType{ pub enum InputType{
/// Represents a text input. Requires a `String` key. The entered value will be returned as a /// Represents a text input. Requires a `String` key, `String` label, and `String` placeholder.
/// single `String` value. /// The entered value will be returned as a single `String` value.
Text, Text(String, String, String),
/// Represents a number input. Requires a `String` key. The entered value will be returned as a /// Represents a number input. Requires a `String` key, `String` label, and a `String` placeholder.
/// single `i32` value. /// The entered value will be returned as a `String`, and will be parseable into any number type
Number, /// (i.e. an `i32`, `i64`, etc)
/// Represents a radio input. Requires a `String` key and a series of options. The selected Number(String, String, String),
/// value will be returned as a single `String` value. /// Represents a radio input. Requires a `String` key, `String` label, and a series of `String`
Radio(Vec<String>), /// options. The selected value will be returned as a `String`.
/// Represents a checkbox input. Requires a `String` key and a series of options. The selected Radio(String, String, Vec<String>),
/// value will be returned as a `Vec<String>`. /// Represents a checkbox input. Requires a `String` key, `String` label, and a series of `String`
Checkbox(Vec<String>) /// options. The selected values will be returned as a `Vec<String>`.
Checkbox(String, String, Vec<String>),
/// Represents a dropdown input. Requires a `String` key, `String` label, and a series
/// of `String` options. The selected value will be returned as a `String`.
Select(String, String, Vec<String>)
}
impl InputType {
pub fn into_view(self, data_map: RwSignal<HashMap<String, ValueType>>) -> impl IntoView {
view! {
{match self {
InputType::Text(member, label, placeholder) =>
EitherOf5::A(view! {
<Row>
<OutlinedTextField
label = label.clone()
placeholder = placeholder.clone()
data_member = member.clone()
data_map = data_map
/>
</Row>
}),
InputType::Number(member, label, placeholder) =>
EitherOf5::B(view! {
<OutlinedTextField
label = label.clone()
placeholder = placeholder.clone()
data_member = member.clone()
data_map = data_map
/>
}),
InputType::Checkbox(member, label, options) =>
EitherOf5::C(view! {
<CheckboxList
label = label.clone()
items = options.clone()
data_member = member.clone()
data_map = data_map
/>
}),
InputType::Radio(member, label, options) =>
EitherOf5::D(view! {
<RadioList
label = label.clone()
data_member = member.clone()
data_map = data_map
items = options.clone()
/>
}),
InputType::Select(member, label, options) =>
EitherOf5::E(view! {
<Select
label = label.clone()
value_list = options.clone()
data_member = member.clone()
data_map = data_map
/>
})
}}
}
}
pub fn into_view_rows(self, data_map: RwSignal<HashMap<String, ValueType>>) -> impl IntoView {
view! {
<Row>
{self.into_view(data_map)}
</Row>
}
}
}
/// Shorthand macro for creating `InputType` enums.
///
/// Example usage:
/// ```
/// // To create an InputType::Text
/// let text_input = input!(Text, "member", "label", "placeholder");
///
/// // To create an InputType::Select
/// let select_input = input!(Select, "member", "label", ["option1", "option2"]);
/// ```
#[macro_export]
macro_rules! input {
($variant:ident, $key:expr, $label:expr, [$($option:expr),* $(,)?]) => {
$crate::common::InputType::$variant(
$key.to_string(),
$label.to_string(),
vec![$($option.to_string()), *]
)
};
($variant:ident, $key:expr, $label:expr, $placeholder:expr) => {
$crate::common::InputType::$variant(
$key.to_string(),
$label.to_string(),
$placeholder.to_string()
)
};
} }
+25 -3
View File
@@ -1,9 +1,14 @@
use std::collections::HashMap;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use std::fmt::Display; use std::fmt::Display;
/// Defines a set of possible input types. They allow for the type and value of every data member /// Defines a set of possible input types. They allow for the type and value of every data member
/// to be stored within a single `HashMap` or `Vec`. This means that even though all the data is /// to be stored within a single `HashMap` or `Vec`. This means that even though all the data is
/// created as a single type, the values are still able to be type safe. /// created as a single type, the values are still able to be type safe.
///
/// A note: other structs cannot be stored in this enum, however it was designed to work with
/// a `HashMap` of more `ValueType` enums. There are numerous ways to convert a struct into a
/// `HashMap`, specifically storing all the struct's fields as `String` keys in the map.
#[derive(PartialEq, Clone, Serialize, Deserialize, Debug)] #[derive(PartialEq, Clone, Serialize, Deserialize, Debug)]
pub enum ValueType { pub enum ValueType {
/// Indicates a `String` value. /// Indicates a `String` value.
@@ -12,10 +17,12 @@ pub enum ValueType {
Number(Option<String>), Number(Option<String>),
/// Indicates a `Vec<T>`, where `T` can be any other `ValueType`. /// Indicates a `Vec<T>`, where `T` can be any other `ValueType`.
List(Option<Vec<ValueType>>), List(Option<Vec<ValueType>>),
/// Indicates a `HashMap<String, ValueType>`.
Map(Option<HashMap<String, ValueType>>),
} }
impl ValueType { impl ValueType {
/// Returns true if this is a `String`. /// Returns true if this is a `String`, otherwise false.
pub fn is_string(&self) -> bool { pub fn is_string(&self) -> bool {
if let ValueType::String(_) = self { true } else { false } if let ValueType::String(_) = self { true } else { false }
} }
@@ -29,7 +36,7 @@ impl ValueType {
} }
} }
/// Returns true if this is a `Number`. /// Returns true if this is a `Number`, otherwise false.
pub fn is_number(&self) -> bool { pub fn is_number(&self) -> bool {
if let ValueType::Number(_) = self { true } else { false } if let ValueType::Number(_) = self { true } else { false }
} }
@@ -43,7 +50,7 @@ impl ValueType {
} }
} }
/// Returns true if this is a `List`. /// Returns true if this is a `List`, otherwise false.
pub fn is_list(&self) -> bool { pub fn is_list(&self) -> bool {
if let ValueType::List(_) = self { true } else { false } if let ValueType::List(_) = self { true } else { false }
} }
@@ -57,6 +64,20 @@ impl ValueType {
Err(self.clone()) Err(self.clone())
} }
} }
/// Returns true if this is a `Map`, otherwise false.
pub fn is_map(&self) -> bool {
if let ValueType::Map(_) = self { true } else { false }
}
/// Attempts to get this value as a `HashMap<String, ValueType>`.
pub fn as_map(&self) -> Result<Option<HashMap<String, ValueType>>, Self> {
if let ValueType::Map(v) = self {
Ok(v.clone())
} else {
Err(self.clone())
}
}
} }
impl Default for ValueType { impl Default for ValueType {
@@ -71,6 +92,7 @@ impl Display for ValueType {
ValueType::String(s) => s.clone().unwrap_or("".to_owned()), ValueType::String(s) => s.clone().unwrap_or("".to_owned()),
ValueType::Number(n) => n.clone().unwrap_or("".to_owned()), ValueType::Number(n) => n.clone().unwrap_or("".to_owned()),
ValueType::List(l) => format!("{l:?}"), ValueType::List(l) => format!("{l:?}"),
ValueType::Map(m) => format!("{m:?}"),
}; };
write!(f, "{}", str) write!(f, "{}", str)
} }
+2
View File
@@ -1,10 +1,12 @@
pub mod multi_entry_data; pub mod multi_entry_data;
pub mod multi_entry; pub mod multi_entry;
mod multi_entry_member; mod multi_entry_member;
mod rewrite;
pub use crate::common::value_type::*; pub use crate::common::value_type::*;
pub use self::{ pub use self::{
multi_entry::*, multi_entry::*,
multi_entry_data::*, multi_entry_data::*,
multi_entry_member::*, multi_entry_member::*,
rewrite::*,
}; };
+2 -2
View File
@@ -12,7 +12,7 @@ fn render_entry_component(entry_data: MultiEntryData, member_info: MultiEntryMem
let data = entry_data.data.get(&member_info.member_name); let data = entry_data.data.get(&member_info.member_name);
let reactive = RwSignal::new(data.unwrap().clone()); let reactive = RwSignal::new(data.unwrap().clone());
let temp = either!(member_info.input_type, let temp = either!(member_info.input_type,
InputType::Text => view! { InputType::Text(_, _, _) => view! {
<OutlinedTextField <OutlinedTextField
label=member_info.display_name label=member_info.display_name
data_member=member_info.member_name data_member=member_info.member_name
@@ -65,7 +65,7 @@ pub fn Entry(
view! { view! {
// <div>{render_entry_component(data, input, "test".to_owned())}</div> // <div>{render_entry_component(data, input, "test".to_owned())}</div>
} }
}).collect::<Vec<_>>() }).collect_view()
} }
} }
} }
@@ -21,7 +21,7 @@ impl MultiEntryMember {
Self { Self {
display_name: String::new(), display_name: String::new(),
member_name: String::new(), member_name: String::new(),
input_type: InputType::Text, input_type: InputType::Text("".to_string(), "".to_string(), "".to_string()),
} }
} }
@@ -39,7 +39,7 @@ impl MultiEntryMember {
Self { Self {
display_name: display_name.into(), display_name: display_name.into(),
member_name: member_name.into(), member_name: member_name.into(),
input_type: InputType::Text input_type: InputType::Text("".to_string(), "".to_string(), "".to_string())
} }
} }
} }
+191
View File
@@ -0,0 +1,191 @@
use std::collections::HashMap;
use leptos::leptos_dom::logging::console_log;
use leptos::prelude::*;
use uuid::Uuid;
use crate::common::InputType;
use crate::components::{ActionButton, ValueType};
// region Helper Functions
/// Gets the UUID from a given `HashMap<String, ValueType>`, wrapped in an `Option`.
fn get_uuid_from_map(map: &HashMap<String, ValueType>) -> Option<String> {
map.get("uuid")?.as_string().unwrap_or(None)
}
/// Updates an Entry within the given `Vec`.
fn update_entry_list(list: &mut Vec<ValueType>, data: &HashMap<String, ValueType>) {
if let Some(uuid) = get_uuid_from_map(&data) {
if let Some(index) = list.iter()
.position(|x| get_uuid_from_map(&x.as_map().unwrap().unwrap_or_default()) == Some(uuid.clone()))
{
list[index] = ValueType::Map(Some(data.clone()));
}
}
}
/// Creates a new entry by creating a `ValueType::Map` with a unique ID at the key `uuid`.
/// This key can be found by using the `HashMap::get` function.
fn new_entry() -> ValueType {
let mut entry_map = HashMap::new();
entry_map.insert("uuid".into(), ValueType::String(Some(Uuid::new_v4().to_string())));
ValueType::Map(Some(entry_map))
}
// endregion
/// # Multi-Entry Component
///
/// A rewrite of the `MultiEntry` component. Uses a single source of truth in the form of the `data_map`
/// prop. Creates and displays a series of `Entry` components that use their own `HashMap` signals.
/// Each of these `Entry` components runs a callback that alters the original `data_map`.
///
/// The `schema` prop determines what inputs are available to all `Entry` components created from
/// this component. For example, using a `schema` that contains an `InputType::Text` and an
/// `InputType::Select` means that all `Entry` components will render a text and dropdown input
/// component. The users shall be able to use these components to alter the data.
///
/// Example usage:
/// ```
/// let data_signal = RwSignal::new(HashMap::new());
/// view! {
/// <MultiEntryRewrite
/// data_member = "example"
/// data_map = data_signal
/// schema = vec![
/// InputType::Text("first_name", "First Name:", "John"),
/// InputType::Text("last_name", "Last Name:", "Smith")
/// ]
/// />
/// }
/// ```
///
/// In this example, any time a new `Entry` is created, it will contain two text fields that edit the
/// `first_name` and `last_name` keys within that `Entry`.
#[component]
pub fn MultiEntryRewrite(
/// The data member that contains the list of entries within the `data_map`.
#[prop(into)] data_member: String,
/// The data map that contains all the information. Data is found within this map using the
/// component's `data_member` input.
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop(into)] name_member: String,
/// The type and order of the inputs for each `Entry` component within this `MultiEntry` component.
#[prop(optional)] schema: Vec<InputType>,
) -> impl IntoView {
// Make the values within the list reactive. Each of these values should be a ValueType::Map,
// so we can pass these directly to the application.
let data_reactive = RwSignal::new(data_map.get_untracked()
.get(&data_member)
.unwrap_or(&ValueType::List(None)).as_list()
.unwrap_or(Some(Vec::<ValueType>::new()))
.unwrap_or(Vec::new()));
// This effect updates the data_map with any new values from the data_reactive list, every time
// the data_reactive list is changed. This allows changes from within the Entry component to
// bubble up to the data_map effectively.
Effect::new(move |_| {
let entry_list = data_reactive.get();
data_map.update(|map| {
map.insert(data_member.clone(), ValueType::List(Some(entry_list)));
});
});
let add_entry = move |_| data_reactive.update(|list| list.push(new_entry()));
view! {
<div class="flex flex-col flex-1">
<div class="flex flex-col gap-2 p-2">
// Render the entries
<For
each=move || data_reactive.get()
key=|entry| get_uuid_from_map(&entry.as_map().unwrap().unwrap()) // Entries MUST be given a unique ID. This is absolutely ridiculous.
children=move |mut entry_map| {
// The map_signal is the map used for the internal element. This signal will
// keep track of the individual entry's data, and whenever it is changed,
// the parent data_map will also be updated to reflect these changes.
let map_signal = RwSignal::new(entry_map
.as_map()
.unwrap_or_default()
.unwrap_or_default());
// This effect updates the data_reactive list with the map_signal values, every
// time the map_signal is changed.
Effect::new(move |_| {
let child_map = map_signal.get(); // HashMap
data_reactive.update(|mut list| {
update_entry_list(&mut list, &child_map);
});
});
view! {
<Entry
data_map = map_signal
name_member = name_member.clone()
schema = schema.clone()
/>
}
}
/>
</div>
<ActionButton on:click = add_entry>"Add entry"</ActionButton>
<ActionButton on:click=move |_| {
console_log(format!("Entry list: {:?}", data_reactive.get()).as_str());
}
>"Print Info"</ActionButton>
</div>
}
}
/// # Entry Component
///
/// A single entry component. Should only ever be created from within a `MultiEntry` component.
/// Stores information within the `data_map` prop by using a series of data members that are found
/// within the `schema` list prop.
///
/// Example usage:
/// ```
/// // From within a MultiEntry component only
/// let data = RwSignal::new(HashMap::new());
/// view! {
/// <Entry
/// data_map = data
/// name_member = "first_name"
/// schema = vec![
/// InputType::Text("first_name", "First Name:", "John"),
/// InputType::Text("last_name", "Last Name:", "Smith")
/// ]
/// />
/// }
/// ```
///
/// In this example, the `Entry` component that is rendered will have two text inputs. Each of these
/// inputs will alter the `first_name` and `last_name` keys in the `data_map` respectively, and the
/// `Entry` component will run a callback to its parent `MultiEntry` component to update the `data_map`
/// accordingly.
#[component]
fn Entry(
/// The data map that contains all information. Data is found within this map using the `member`
/// props. This map should be unique to this entry; in other words, it should be contained and
/// passed down from within the entry list in the parent `MultiEntry` component.
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
/// The schema of this entry. Should contain all information about what inputs to use and what
/// data members those inputs modify.
#[prop()] schema: Vec<InputType>,
/// The display name member of this entry. Determines where the component should search for the
/// name of the entry within the `data_map`.
#[prop(into)] name_member: String,
/// The info member of this entry. Determines where the component should search for the extra
/// information of this entry within the `data_map`.
#[prop(optional, into)] info_member: String,
) -> impl IntoView {
view! {
<div class="flex flex-col flex-1 p-2 rounded-sm transition-shadow shadow-sm hover:shadow-lg/30">
{schema.iter()
.map(|input_type| {
input_type.clone().into_view(data_map.clone())
}).collect_view()
}
</div>
}
}
+15
View File
@@ -1,5 +1,20 @@
use leptos::prelude::*; use leptos::prelude::*;
/// # Row Component
///
/// This component is designed for use within the `Panel` component, however it may be used standalone
/// when used within any `div` with the `flex` property set.
///
/// Example usage (with `Panel`):
/// ```
/// view! {
/// <Panel>
/// <Row>
/// // More content here...
/// </Row>
/// </Panel>
/// }
/// ```
#[component] #[component]
pub fn Row(children: Children) -> impl IntoView { pub fn Row(children: Children) -> impl IntoView {
view! { view! {
-9
View File
@@ -31,12 +31,7 @@ pub fn OutlinedTextField(
error.set(false); error.set(false);
value.set(ValueType::String(Some(to_parse.clone()))); value.set(ValueType::String(Some(to_parse.clone())));
data_map.update(|map| { data_map.update(|map| {
if let Some(val) = map.get_mut(&data_member) {
*val = ValueType::String(Some(to_parse));
} else {
map.insert(data_member.clone(), ValueType::String(Some(to_parse))); map.insert(data_member.clone(), ValueType::String(Some(to_parse)));
}
}); });
}, },
"number" => { "number" => {
@@ -49,11 +44,7 @@ pub fn OutlinedTextField(
error.set(false); error.set(false);
value.set(value_type.clone()); value.set(value_type.clone());
data_map.update(|map| { data_map.update(|map| {
if let Some(val) = map.get_mut(&data_member) {
*val = value_type;
} else {
map.insert(data_member.clone(), value_type); map.insert(data_member.clone(), value_type);
}
}); });
}, },
Err(_) => { Err(_) => {
+16 -2
View File
@@ -1,4 +1,3 @@
use std::collections::HashMap;
// Server dependencies // Server dependencies
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue}; use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue};
@@ -8,11 +7,13 @@ use serde_dynamo::{from_item, to_item};
use crate::app::Unauthenticated; use crate::app::Unauthenticated;
use crate::common::{ExpandableInfo, UserClaims}; use crate::common::{ExpandableInfo, UserClaims};
use crate::components::{ActionButton, CheckboxList, Loading, OutlinedTextField, Panel, RadioList, Row, Select}; use crate::components::{ActionButton, CheckboxList, Loading, MultiEntryRewrite, OutlinedTextField, Panel, RadioList, Row, Select};
use leptos::leptos_dom::logging::console_log; use leptos::leptos_dom::logging::console_log;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated}; use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated};
use traits::{AsReactive, ReactiveCapture}; use traits::{AsReactive, ReactiveCapture};
use crate::input;
use std::collections::HashMap;
/// # Get Student Info /// # Get Student Info
/// Gets a student's information given their `subject`. /// Gets a student's information given their `subject`.
@@ -263,6 +264,19 @@ pub fn HomePage() -> impl IntoView {
data_map=expandable_react.data data_map=expandable_react.data
/> />
</Row> </Row>
<Row>
<MultiEntryRewrite
data_map = expandable_react.data
data_member = "test"
name_member = "first_name"
schema = vec![
input!(Text, "first_name", "First Name:", "John"),
input!(Text, "last_name", "Last Name:", "Smith"),
input!(Select, "gender", "Gender:", ["Male", "Female"]),
input!(Checkbox, "candy", "Favorite Candy:", ["Twizzlers", "Starburst"])
]
/>
</Row>
<Row> <Row>
<ActionButton <ActionButton
on:click=move |_| { on:click=move |_| {