feat: implement working MultiEntry component

This MultiEntry (Rewrite) is capable of storing and sending values through the server functions, as well as dynamically updating the master list as values are changed.
This commit is contained in:
alphafetus committed 2025-10-16 11:51:27 -04:00
1 parent 6b1e2174b6
commit 86bd471c1e
9 files changed
+369 -32

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 crate::components::{CheckboxList, OutlinedTextField, RadioList, Row, Select, ValueType};
/// Defines a series of options that allow a user to define a form.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum InputType{
/// Represents a text input. Requires a `String` key. The entered value will be returned as a
/// single `String` value.
Text,
/// Represents a number input. Requires a `String` key. The entered value will be returned as a
/// single `i32` value.
Number,
/// Represents a radio input. Requires a `String` key and a series of options. The selected
/// value will be returned as a single `String` value.
Radio(Vec<String>),
/// Represents a checkbox input. Requires a `String` key and a series of options. The selected
/// value will be returned as a `Vec<String>`.
Checkbox(Vec<String>)
/// 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),
/// 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),
/// Represents a radio input. Requires a `String` key, `String` label, and a series of `String`
/// options. The selected value will be returned as a `String`.
Radio(String, String, Vec<String>),
/// Represents a checkbox input. Requires a `String` key, `String` label, and a series of `String`
/// options. The selected values will be returned as a `Vec<String>`.
Checkbox(String, String, Vec<String>),
/// 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 std::fmt::Display;
/// 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
/// 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)]
pub enum ValueType {
/// Indicates a `String` value.
@@ -12,10 +17,12 @@ pub enum ValueType {
Number(Option<String>),
/// Indicates a `Vec<T>`, where `T` can be any other `ValueType`.
List(Option<Vec<ValueType>>),
/// Indicates a `HashMap<String, ValueType>`.
Map(Option<HashMap<String, 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 {
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 {
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 {
if let ValueType::List(_) = self { true } else { false }
}
@@ -57,6 +64,20 @@ impl ValueType {
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 {
@@ -71,6 +92,7 @@ impl Display for ValueType {
ValueType::String(s) => s.clone().unwrap_or("".to_owned()),
ValueType::Number(n) => n.clone().unwrap_or("".to_owned()),
ValueType::List(l) => format!("{l:?}"),
ValueType::Map(m) => format!("{m:?}"),
};
write!(f, "{}", str)
}
+2
View File
@@ -1,10 +1,12 @@
pub mod multi_entry_data;
pub mod multi_entry;
mod multi_entry_member;
mod rewrite;
pub use crate::common::value_type::*;
pub use self::{
multi_entry::*,
multi_entry_data::*,
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 reactive = RwSignal::new(data.unwrap().clone());
let temp = either!(member_info.input_type,
InputType::Text => view! {
InputType::Text(_, _, _) => view! {
<OutlinedTextField
label=member_info.display_name
data_member=member_info.member_name
@@ -65,7 +65,7 @@ pub fn Entry(
view! {
// <div>{render_entry_component(data, input, "test".to_owned())}</div>
}
}).collect::<Vec<_>>()
}).collect_view()
}
}
}
@@ -21,7 +21,7 @@ impl MultiEntryMember {
Self {
display_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 {
display_name: display_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::*;
/// # 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]
pub fn Row(children: Children) -> impl IntoView {
view! {
+2 -11
View File
@@ -31,12 +31,7 @@ pub fn OutlinedTextField(
error.set(false);
value.set(ValueType::String(Some(to_parse.clone())));
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" => {
@@ -49,11 +44,7 @@ pub fn OutlinedTextField(
error.set(false);
value.set(value_type.clone());
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(_) => {
+16 -2
View File
@@ -1,4 +1,3 @@
use std::collections::HashMap;
// Server dependencies
#[cfg(feature = "ssr")]
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::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::prelude::*;
use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated};
use traits::{AsReactive, ReactiveCapture};
use crate::input;
use std::collections::HashMap;
/// # Get Student Info
/// Gets a student's information given their `subject`.
@@ -263,6 +264,19 @@ pub fn HomePage() -> impl IntoView {
data_map=expandable_react.data
/>
</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>
<ActionButton
on:click=move |_| {