Merge branch 'main' into feat/date-component
# Conflicts: # src/app.rs # src/common/mod.rs # src/components/mod.rs # src/pages/api/scholarships.rs # src/pages/home_page.rs # src/pages/mod.rs # src/pages/scholarship_info.rs
This commit is contained in:
commit
26ee1cf7b4
50 files changed
+3428
-2237
No files matched your search
@@ -2,14 +2,14 @@ use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn ActionButton(
|
||||
#[prop(default = String::from("button"))] button_type: String,
|
||||
#[prop(default = String::from("button"), into)] button_type: String,
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<button
|
||||
class="m-1.5 p-2.5 rounded-lg font-semibold text-white transition-all duration-400"
|
||||
class=(["bg-red-700", "hover:bg-red-800"], move || !disabled.get())
|
||||
class="m-1.5 p-2.5 rounded-lg font-semibold text-white transition-all duration-400 cursor-pointer"
|
||||
class=(["bg-red-800", "hover:bg-red-900"], move || !disabled.get())
|
||||
class=(["bg-gray-600", "pointer-events-none"], move || disabled.get())
|
||||
r#type=button_type
|
||||
disabled=move || disabled.get()
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
use crate::common::ValueType;
|
||||
use crate::components::utils::create_unique_id;
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[component]
|
||||
pub fn Checkbox(
|
||||
#[prop()] checked: Signal<bool>,
|
||||
#[prop(into)] on_change: Callback<(), ()>,
|
||||
#[prop(into)] value: String,
|
||||
#[prop(into)] name: String,
|
||||
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
let id = create_unique_id(&name, &value);
|
||||
|
||||
view! {
|
||||
<label for=id class="flex items-center">
|
||||
<input
|
||||
// Using the "peer" class links this input with the div "peer" at the same level.
|
||||
// By using the "peer-checked" selector this div can toggle states based on this
|
||||
// input's state (checked/unchecked)
|
||||
class="
|
||||
relative peer shrink-0
|
||||
hidden"
|
||||
type="checkbox"
|
||||
id=id.clone()
|
||||
name=name.clone()
|
||||
on:change=move |_| on_change.run(())
|
||||
prop:checked=checked
|
||||
disabled=disabled
|
||||
/>
|
||||
// This div displays the box for the checkbox, and applies some styling to the child
|
||||
// span element.
|
||||
<div class="relative inline-block h-5 w-5 m-1 mr-2 rounded-sm border border-gray-300 bg-white
|
||||
transition-bg duration-150 peer-checked:bg-red-700 peer-checked:border-red-700
|
||||
[&>span]:opacity-0 peer-checked:[&>span]:opacity-100
|
||||
peer-disabled:bg-gray-600/33 peer-disabled:border-gray-600
|
||||
peer-disabled:peer-checked:bg-gray-600">
|
||||
// This span is the check icon. It is centered within the div and has a border on the bottom
|
||||
// and right sides, which is then rotated to appear like a checkmark.
|
||||
<span class="absolute left-1/2 top-1/2 block h-3 w-1.5 -translate-x-1/2 -translate-y-1/2
|
||||
rotate-45 transform border-b-3 border-r-3
|
||||
border-white transition-opacity duration-150"></span>
|
||||
</div>
|
||||
<span>{value.clone()}</span>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn CheckboxList(
|
||||
#[prop(optional)] selected: RwSignal<Vec<String>>,
|
||||
#[prop(into)] data_member: String,
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
#[prop()] items: Vec<String>,
|
||||
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
|
||||
#[prop(optional, into)] label: String,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="m-1.5 mt-0 mb-0">
|
||||
<span class="font-bold">{label}</span>
|
||||
{items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let checked_signal = Signal::derive({
|
||||
let item_name = item.clone();
|
||||
let data_member = data_member.clone();
|
||||
move || {
|
||||
data_map.get() // HashMap
|
||||
.get(&data_member) // Option<ValueType>
|
||||
.unwrap_or(&ValueType::List(None)) // ValueType
|
||||
.as_list() // Result<Option<Vec<ValueType>>, ValueType>
|
||||
.unwrap_or(Some(vec!())) // Option<Vec<ValueType>>
|
||||
.unwrap_or(vec!()) // Vec<ValueType>
|
||||
.contains(&ValueType::String(Some(item_name.clone()))) // bool
|
||||
}
|
||||
});
|
||||
|
||||
let on_change = {
|
||||
let item_name = item.clone();
|
||||
let data_member = data_member.clone();
|
||||
move || {
|
||||
let result = ValueType::String(Some(item_name.clone()));
|
||||
data_map.update(|map| {
|
||||
// Attempt to get the value type from the hash map.
|
||||
match map.get_mut(&data_member) {
|
||||
Some(value_type) => {
|
||||
// Attempt to get the value as a list.
|
||||
match value_type.as_list().unwrap_or(None) {
|
||||
Some(mut list) => {
|
||||
// Attempt to find the selected value in the selected list.
|
||||
// If it's present, remove it, or vice versa.
|
||||
match list.iter().position(|val| *val == result) {
|
||||
Some(index) => { list.remove(index); },
|
||||
None => { list.push(result); }
|
||||
};
|
||||
// Update the existing value_type entry in the hash map.
|
||||
*value_type = ValueType::List(Some(list));
|
||||
},
|
||||
// Insert a new value_type entry.
|
||||
None => { *value_type = ValueType::List(Some(vec![result])); }
|
||||
};
|
||||
},
|
||||
// Insert a new value_type entry.
|
||||
None => { map.insert(data_member.clone(), ValueType::List(Some(vec![result]))); }
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<Checkbox
|
||||
checked=checked_signal
|
||||
on_change=on_change
|
||||
// The actual selected values are tracked by this element, not by the checkboxes themselves.
|
||||
value=item.clone()
|
||||
name=data_member.clone()
|
||||
disabled=disabled
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
use crate::common::ValueType;
|
||||
use crate::components::utils::create_unique_id;
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// # Chip Component
|
||||
///
|
||||
/// This component defines a single chip for use in the `ChipsList` component. It functions almost
|
||||
/// identically to the `Checkbox` component. It should only be created from within its parent
|
||||
/// `CheckboxList` component.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```
|
||||
/// view! {
|
||||
/// <Chip
|
||||
/// value="Some Value"
|
||||
/// name="list_name"
|
||||
/// on_change=move || { println!("Some work here.") }
|
||||
/// checked=RwSignal::new(false)
|
||||
/// disabled=RwSignal::new(false)
|
||||
/// />
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn Chip(
|
||||
#[prop(into)] display_text: String,
|
||||
#[prop(into)] value: String,
|
||||
#[prop(into)] name: String,
|
||||
#[prop(into)] on_change: Callback<(), ()>,
|
||||
#[prop()] checked: Signal<bool>,
|
||||
#[prop(optional)] disabled: RwSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
// Generate a unique id - ensure that the value doesn't contain spaces.
|
||||
// Without this id, checkbox/radio inputs can interfere with each other.
|
||||
let id = create_unique_id(&name, &value);
|
||||
|
||||
view! {
|
||||
<label for=id>
|
||||
<input
|
||||
r#type="checkbox"
|
||||
class="relative peer shrink-0 hidden"
|
||||
name=name.clone()
|
||||
id=id.clone()
|
||||
prop:checked=checked
|
||||
on:change=move |_| on_change.run(())
|
||||
disabled=disabled />
|
||||
<div class="rounded-full p-2.5
|
||||
cursor-pointer text-base
|
||||
bg-transparent border-1 border-gray-300
|
||||
shadow-md/50 hover:shadow-md/70
|
||||
peer-checked:bg-red-700 peer-checked:border-red-700 peer-checked:text-white
|
||||
peer-disabled:peer-checked:bg-gray-700
|
||||
peer-disabled:peer-checked:border-gray-700
|
||||
peer-disabled:cursor-default
|
||||
transition-all duration-150">
|
||||
<span>{display_text.clone()}</span>
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
/// # Chips List Component
|
||||
///
|
||||
/// This defines a list of `Chip` components. The list of values is created based on the data from the
|
||||
/// `data_map` prop, which is found using the given `data_member`.
|
||||
///
|
||||
/// Any values selected from the list are stored within the `data_map` immediately as part of a callback
|
||||
/// passed to the `on_change` prop for each individual `Chip` component.
|
||||
#[component]
|
||||
pub fn ChipsList(
|
||||
#[prop(into)] data_member: String,
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
#[prop(into)] displayed_text: Signal<Vec<String>>,
|
||||
#[prop(into)] values: Signal<Vec<String>>,
|
||||
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
|
||||
#[prop(optional, into)] label: String,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="m-1.5 mt-0 mb-0">
|
||||
<span class="font-bold">{label}</span>
|
||||
<div class="flex flex-row flex-wrap gap-2 mt-1">
|
||||
{move || { displayed_text.get().into_iter().zip(values.get())
|
||||
.into_iter()
|
||||
.map(|(text, value)| {
|
||||
let checked_signal = Signal::derive({
|
||||
let item_name = value.clone();
|
||||
let data_member = data_member.clone();
|
||||
move || {
|
||||
data_map.get()
|
||||
.get(&data_member)
|
||||
.unwrap_or(&ValueType::List(None))
|
||||
.as_list()
|
||||
.unwrap_or(Some(vec!()))
|
||||
.unwrap_or(vec!())
|
||||
.contains(&ValueType::String(Some(item_name.clone())))
|
||||
}
|
||||
});
|
||||
|
||||
let on_change = {
|
||||
let item_name = value.clone();
|
||||
let data_member = data_member.clone();
|
||||
move || {
|
||||
let result = ValueType::String(Some(item_name.clone()));
|
||||
data_map.update(|map| {
|
||||
// Attempt to get the value type from the hash map.
|
||||
match map.get_mut(&data_member) {
|
||||
Some(value_type) => {
|
||||
// Attempt to get the value as a list.
|
||||
match value_type.as_list().unwrap_or(None) {
|
||||
Some(mut list) => {
|
||||
// Attempt to find the selected value in the selected list.
|
||||
// If it's present, remove it, or vice versa.
|
||||
match list.iter().position(|val| *val == result) {
|
||||
Some(index) => { list.remove(index); },
|
||||
None => { list.push(result); }
|
||||
};
|
||||
// Update the existing value_type entry in the hash map.
|
||||
*value_type = ValueType::List(Some(list));
|
||||
},
|
||||
// Insert a new value_type entry.
|
||||
None => { *value_type = ValueType::List(Some(vec![result])); }
|
||||
};
|
||||
},
|
||||
// Insert a new value_type entry.
|
||||
None => { map.insert(data_member.clone(), ValueType::List(Some(vec![result]))); }
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<Chip
|
||||
checked=checked_signal
|
||||
name=data_member.clone()
|
||||
on_change=on_change
|
||||
value=value.clone()
|
||||
display_text=text.clone()
|
||||
disabled=disabled
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// # Header Component
|
||||
///
|
||||
/// A simple header that may also contain a description. Should be used to annotate sections and
|
||||
/// forms, with extra information about how that section/form will work.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```
|
||||
/// view! {
|
||||
/// <Header
|
||||
/// title: "Example header"
|
||||
/// description: "Example description"
|
||||
/// />
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn Header(
|
||||
/// The title of the header. Must always be present.
|
||||
#[prop(into)] title: Signal<String>,
|
||||
/// The description of the header. Hidden when nothing i s
|
||||
#[prop(optional, into)] description: Signal<String>,
|
||||
) -> impl IntoView {
|
||||
let show_description = Signal::derive(move || {
|
||||
!description.get().is_empty()
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="m-1.5 mb-5">
|
||||
<div class="text-3xl font-bold">
|
||||
{title}
|
||||
</div>
|
||||
<Show when=move || show_description.get()>
|
||||
<div>
|
||||
{description}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod multi;
|
||||
mod single;
|
||||
|
||||
pub use {
|
||||
multi::*, single::*
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
use crate::common::ValueType;
|
||||
use crate::components::utils::create_unique_id;
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use super::utils::use_selectable_list;
|
||||
use crate::components::ValidationState;
|
||||
|
||||
/// # Checkbox Component
|
||||
/// This component should only be used from within a [`CheckboxList`] component.
|
||||
///
|
||||
/// It creates a single checkbox with a specific value. Indicating if it is checked is done
|
||||
/// _externally_ from the component, and requires some sort of signal. The component itself
|
||||
/// does not track whether it is checked, it only runs the `on_change` callback.
|
||||
///
|
||||
/// Example usage (from within a [`CheckboxList`]:
|
||||
/// ```
|
||||
/// let disabled_signal = RwSignal::new(false);
|
||||
/// let checked_signal = Signal::derive(move || {
|
||||
/// // It's recommended to use a derived signal of some sort, but if you want to create
|
||||
/// // a manually controlled RwSignal, that's fine too.
|
||||
/// true
|
||||
/// });
|
||||
///
|
||||
/// view! {
|
||||
/// <Checkbox
|
||||
/// checked=checked_signal
|
||||
/// on_change=move || log!("A callback that's run on change.")
|
||||
/// name="some_name"
|
||||
/// disabled=disabled_signal
|
||||
/// value="Some Value"
|
||||
/// />
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn Checkbox(
|
||||
/// A [`Signal`] indicating whether the checkbox is checked.
|
||||
#[prop()] checked: Signal<bool>,
|
||||
/// A callback that is run when the checkbox is checked and unchecked.
|
||||
#[prop(into)] on_change: Callback<(), ()>,
|
||||
/// The value of the checkbox.
|
||||
#[prop(into)] value: Signal<String>,
|
||||
/// The name of the checkbox. This is used to group checkboxes together, so every checkbox
|
||||
/// in a group should have the same name.
|
||||
#[prop(into)] name: Signal<String>,
|
||||
/// A [`Signal`] indicating whether the checkbox is disabled.
|
||||
#[prop(into)] disabled: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
let id = create_unique_id(&name.get(), &value.get_untracked());
|
||||
|
||||
view! {
|
||||
<label for=id class="flex items-center">
|
||||
<input
|
||||
// Using the "peer" class links this input with the div "peer" at the same level.
|
||||
// By using the "peer-checked" selector this div can toggle states based on this
|
||||
// input's state (checked/unchecked)
|
||||
class="
|
||||
relative peer shrink-0
|
||||
hidden"
|
||||
type="checkbox"
|
||||
id=id.clone()
|
||||
name=name.clone()
|
||||
on:change=move |_| on_change.run(())
|
||||
prop:checked=checked
|
||||
disabled=disabled
|
||||
/>
|
||||
// This div displays the box for the checkbox, and applies some styling to the child
|
||||
// span element.
|
||||
<div class="relative inline-block h-5 w-5 m-1 mr-2 rounded-sm border border-gray-300 bg-white
|
||||
transition-bg duration-150 peer-checked:bg-red-700 peer-checked:border-red-700
|
||||
[&>span]:opacity-0 peer-checked:[&>span]:opacity-100
|
||||
peer-disabled:bg-gray-600/33 peer-disabled:border-gray-600
|
||||
peer-disabled:peer-checked:bg-gray-600">
|
||||
// This span is the check icon. It is centered within the div and has a border on the bottom
|
||||
// and right sides, which is then rotated to appear like a checkmark.
|
||||
<span class="absolute left-1/2 top-1/2 block h-3 w-1.5 -translate-x-1/2 -translate-y-1/2
|
||||
rotate-45 transform border-b-3 border-r-3
|
||||
border-white transition-opacity duration-150"></span>
|
||||
</div>
|
||||
<span>{value}</span>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
/// # Checkbox List Component
|
||||
/// This component creates a list of checkboxes, each with a specific value. The values are passed
|
||||
/// through a prop rather than manually creating children.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```
|
||||
/// let items = vec! [
|
||||
/// "Item 1",
|
||||
/// "Item 2",
|
||||
/// "Item 3"
|
||||
/// ];
|
||||
///
|
||||
/// view! {
|
||||
/// <CheckboxList
|
||||
/// data_member="data_map_member"
|
||||
/// data_map=RwSignal::new(HashMap::new())
|
||||
/// items=items
|
||||
/// />
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn CheckboxList(
|
||||
/// The data member that this list should edit/display from the `data_map`.
|
||||
#[prop(into)] data_member: Signal<String>,
|
||||
/// A [`Signal`] allowing access to a data store.
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
/// The available choices for this list.
|
||||
#[prop()] items: Vec<String>,
|
||||
/// A [`Signal`] indicating whether the checkbox list is disabled.
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
/// A label for the list. Shown like a field name in a form.
|
||||
#[prop(optional, into)] label: String,
|
||||
/// A [`Signal`] indicating whether the list is required. Used for validation.
|
||||
#[prop(optional, into)] required: Signal<bool>
|
||||
) -> impl IntoView {
|
||||
let controller = use_selectable_list(
|
||||
data_member.clone(),
|
||||
data_map.clone(),
|
||||
required.clone()
|
||||
);
|
||||
|
||||
//#region Render Logic
|
||||
view! {
|
||||
<div class="flex flex-col">
|
||||
<div class="m-1.5 mt-0 mb-0">
|
||||
<span class="font-bold">{label}</span>
|
||||
{items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let item = Signal::derive({
|
||||
let cloned = item.clone();
|
||||
move || cloned.clone()
|
||||
});
|
||||
|
||||
let checked = Signal::derive(move || {
|
||||
controller.selected_list.get().contains(&item.get())
|
||||
});
|
||||
|
||||
let on_change = move || {
|
||||
// Update the data list by checking if the value is present in it. If so,
|
||||
// remove it. If not, add it.
|
||||
controller.selected_list.update(|list| {
|
||||
let item = item.get();
|
||||
if list.contains(&item) {
|
||||
list.retain(|val| *val != item);
|
||||
} else {
|
||||
list.push(item);
|
||||
}
|
||||
});
|
||||
controller.dirty.set(true);
|
||||
};
|
||||
|
||||
view! {
|
||||
<Checkbox
|
||||
checked=checked
|
||||
on_change=on_change
|
||||
// The actual selected values are tracked by this element, not by the checkboxes themselves.
|
||||
value=item
|
||||
name=data_member
|
||||
disabled=disabled
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect_view()}
|
||||
</div>
|
||||
<Show when=move || controller.show_errors.get()>
|
||||
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
|
||||
{move || {
|
||||
match controller.error.get() {
|
||||
ValidationState::Invalid(msg) => msg,
|
||||
_ => "There is no error - should not see this message.".to_string()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
use crate::common::ValueType;
|
||||
use crate::components::utils::create_unique_id;
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use crate::components::ValidationState;
|
||||
use super::utils::use_selectable_list;
|
||||
|
||||
/// # Chip Component
|
||||
///
|
||||
/// This component defines a single chip for use in the `ChipsList` component. It functions almost
|
||||
/// identically to the `Checkbox` component. It should only be created from within its parent
|
||||
/// `CheckboxList` component.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```
|
||||
/// view! {
|
||||
/// <Chip
|
||||
/// value="Some Value"
|
||||
/// name="list_name"
|
||||
/// on_change=move || { println!("Some work here.") }
|
||||
/// checked=RwSignal::new(false)
|
||||
/// disabled=RwSignal::new(false)
|
||||
/// />
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn Chip(
|
||||
#[prop(into)] display_text: Signal<String>,
|
||||
#[prop(into)] value: Signal<String>,
|
||||
#[prop(into)] name: Signal<String>,
|
||||
#[prop(into)] on_change: Callback<(), ()>,
|
||||
#[prop()] checked: Signal<bool>,
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
// Generate a unique id - ensure that the value doesn't contain spaces.
|
||||
// Without this id, checkbox/radio inputs can interfere with each other.
|
||||
let id = create_unique_id(&name.get(), &value.get_untracked());
|
||||
|
||||
view! {
|
||||
<label for=id>
|
||||
<input
|
||||
r#type="checkbox"
|
||||
class="relative peer shrink-0 hidden"
|
||||
name=name.clone()
|
||||
id=id.clone()
|
||||
prop:checked=checked
|
||||
on:change=move |_| on_change.run(())
|
||||
disabled=disabled />
|
||||
<div class="rounded-full p-2.5
|
||||
cursor-pointer text-base
|
||||
bg-transparent border-1 border-gray-300
|
||||
shadow-md/50 hover:shadow-md/70
|
||||
peer-checked:bg-red-700 peer-checked:border-red-700 peer-checked:text-white
|
||||
peer-disabled:peer-checked:bg-gray-700
|
||||
peer-disabled:peer-checked:border-gray-700
|
||||
peer-disabled:cursor-default
|
||||
transition-all duration-150">
|
||||
<span>{display_text.clone()}</span>
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
/// # Chips List Component
|
||||
///
|
||||
/// This defines a list of `Chip` components. The list of values is created based on the data from the
|
||||
/// `data_map` prop, which is found using the given `data_member`.
|
||||
///
|
||||
/// Any values selected from the list are stored within the `data_map` immediately as part of a callback
|
||||
/// passed to the `on_change` prop for each individual `Chip` component.
|
||||
#[component]
|
||||
pub fn ChipsList(
|
||||
/// The member in the data map that this input affects.
|
||||
#[prop(into)] data_member: Signal<String>,
|
||||
/// The data map that contains the list of values. This will be edited as the input changes.
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
/// The list of strings that should be displayed in the list.
|
||||
#[prop(into)] displayed_text: Signal<Vec<String>>,
|
||||
/// The list of values that should be displayed in the list.
|
||||
#[prop(into)] values: Signal<Vec<String>>,
|
||||
/// Indicates if the input should be disabled.
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
/// The label for the header of the input.
|
||||
#[prop(optional, into)] label: String,
|
||||
/// Indicates if this input is required.
|
||||
#[prop(optional, into)] required: Signal<bool>,
|
||||
/// Determines if this list behaves like a checkbox or radio list.
|
||||
#[prop(default = true.into(), into)] allows_multiple: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
let controller = use_selectable_list(
|
||||
data_member.clone(),
|
||||
data_map.clone(),
|
||||
required.clone()
|
||||
);
|
||||
|
||||
view! {
|
||||
<div class="flex flex-col">
|
||||
<div class="m-1.5 mt-0 mb-0">
|
||||
<span class="font-bold">{label}</span>
|
||||
<div class="flex flex-row flex-wrap gap-2 mb-1">
|
||||
{move || 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.
|
||||
let value = value.get();
|
||||
let list = controller.selected_list.get();
|
||||
|
||||
if list.contains(&value) {
|
||||
allows_multiple.get()
|
||||
.then(|| controller.selected_list.update(|list| list.retain(|v| v != &value)))
|
||||
.or_else(|| Some(controller.selected_list.set(Vec::new())));
|
||||
} else {
|
||||
allows_multiple.get()
|
||||
.then(|| controller.selected_list.update(|list| list.push(value.clone())))
|
||||
.or_else(|| Some(controller.selected_list.set(vec![value.clone()])));
|
||||
}
|
||||
|
||||
controller.dirty.set(true);
|
||||
};
|
||||
|
||||
view! {
|
||||
<Chip
|
||||
checked=checked
|
||||
on_change=on_change
|
||||
value=value
|
||||
display_text=text
|
||||
name=data_member
|
||||
disabled=disabled
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<Show when=move || controller.show_errors.get()>
|
||||
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
|
||||
{move || {
|
||||
match controller.error.get() {
|
||||
ValidationState::Invalid(msg) => msg,
|
||||
_ => "There is no error - should not see this message.".to_string()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod utils;
|
||||
mod checkbox;
|
||||
mod chips;
|
||||
|
||||
pub use checkbox::*;
|
||||
pub use chips::*;
|
||||
@@ -0,0 +1,110 @@
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use crate::common::ValueType;
|
||||
use crate::components::{use_validation_context, InputState, ValidationState};
|
||||
|
||||
pub struct ListController {
|
||||
pub error: Signal<ValidationState>,
|
||||
pub dirty: RwSignal<bool>,
|
||||
pub show_errors: Signal<bool>,
|
||||
pub selected_list: RwSignal<Vec<String>>,
|
||||
}
|
||||
|
||||
fn value_type_to_vec(value_type: &ValueType) -> Vec<String> {
|
||||
let ValueType::List(Some(list)) = value_type else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
list.iter().map(|val| {
|
||||
val.as_string()
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default()
|
||||
}).collect::<Vec<String>>()
|
||||
}
|
||||
|
||||
pub fn use_selectable_list(
|
||||
data_member: Signal<String>,
|
||||
data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
required: Signal<bool>
|
||||
) -> ListController {
|
||||
//#region Central List Logic
|
||||
|
||||
// "Raw" selected values - just strings, no ValueTyping
|
||||
let selected_list = RwSignal::new(Vec::new());
|
||||
let hydrated = RwSignal::new(false);
|
||||
let dirty = RwSignal::new(false);
|
||||
|
||||
// Hydration effect - runs only once, to hydrate data.
|
||||
Effect::new(move || {
|
||||
if hydrated.get() {
|
||||
return;
|
||||
}
|
||||
|
||||
// React to data_map changes
|
||||
let map = data_map.get();
|
||||
let key = data_member.get();
|
||||
|
||||
let list = map
|
||||
.get(&key)
|
||||
.unwrap_or(&ValueType::List(None));
|
||||
|
||||
selected_list.set(value_type_to_vec(list));
|
||||
|
||||
hydrated.set(true);
|
||||
});
|
||||
|
||||
Effect::new({
|
||||
move || {
|
||||
// Convert selected strings into ValueTypes
|
||||
let typed_list = selected_list.with(|list|
|
||||
list.iter().cloned().map(|val|
|
||||
ValueType::String(Some(val))
|
||||
).collect::<Vec<ValueType>>()
|
||||
);
|
||||
|
||||
// Update the map
|
||||
data_map.update(|map| {
|
||||
map.insert(data_member.get(), 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 show_errors = Signal::derive(move || dirty.get() && matches!(error.get(), ValidationState::Invalid(_)));
|
||||
|
||||
let validator = RwSignal::new(InputState::new(
|
||||
data_member.get(),
|
||||
error.clone(),
|
||||
dirty.clone()
|
||||
));
|
||||
|
||||
validation_context.validators.update(|list| list.push(validator.clone()));
|
||||
|
||||
on_cleanup(move || {
|
||||
validation_context.validators.update(|list| {
|
||||
list.retain(|v| *v.get_untracked().input_name != *validator.get_untracked().input_name)
|
||||
});
|
||||
});
|
||||
|
||||
//#endregion
|
||||
|
||||
ListController {
|
||||
error,
|
||||
dirty,
|
||||
show_errors,
|
||||
selected_list,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod radio;
|
||||
pub mod select;
|
||||
|
||||
pub use radio::*;
|
||||
pub use select::*;
|
||||
@@ -0,0 +1,164 @@
|
||||
use crate::common::ValueType;
|
||||
use crate::components::utils::create_unique_id;
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use crate::components::{use_validation_context, InputState, ValidationState};
|
||||
|
||||
#[component]
|
||||
pub fn Radio(
|
||||
#[prop()] checked: Signal<bool>,
|
||||
#[prop(into)] on_change: Callback<(), ()>,
|
||||
#[prop(into)] value: Signal<String>,
|
||||
#[prop(into)] name: Signal<String>,
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
let id = create_unique_id(&name.get_untracked(), &value.get_untracked());
|
||||
|
||||
view! {
|
||||
<label for=id class="flex items-center">
|
||||
<input
|
||||
// Using the "peer" class links this input with the div "peer" at the same level.
|
||||
// By using the "peer-checked" selector this div can toggle states based on this
|
||||
// input's state (checked/unchecked)
|
||||
class="
|
||||
relative peer shrink-0
|
||||
hidden"
|
||||
type="radio"
|
||||
id=id.clone()
|
||||
name=name
|
||||
on:change=move |_| on_change.run(())
|
||||
prop:checked=checked
|
||||
disabled=disabled
|
||||
/>
|
||||
// This div displays the box for the checkbox, and applies some styling to the child
|
||||
// span element.
|
||||
<div class="relative inline-block h-5 w-5 m-1 mr-2 rounded-full border border-gray-300 bg-white
|
||||
transition-bg duration-200 peer-checked:bg-red-700 peer-checked:border-red-700
|
||||
[&>span]:opacity-0 peer-checked:[&>span]:opacity-100
|
||||
peer-disabled:bg-gray-600/33 peer-disabled:border-gray-600
|
||||
peer-disabled:peer-checked:bg-gray-600">
|
||||
// This span is the check icon. It is centered within the div and has a border on the bottom
|
||||
// and right sides, which is then rotated to appear like a checkmark.
|
||||
<span class="absolute left-1/2 top-1/2 block h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2
|
||||
rotate-45 transform border-3 border-3 rounded-full bg-white
|
||||
border-white transition-opacity duration-150"></span>
|
||||
</div>
|
||||
<span>{value.clone()}</span>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(value: String, required: bool) -> ValidationState {
|
||||
if required && value.is_empty() {
|
||||
ValidationState::Invalid("This field is required".into())
|
||||
} else {
|
||||
ValidationState::Valid
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn RadioList(
|
||||
#[prop(into)] data_member: Signal<String>,
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
#[prop()] items: Vec<String>,
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
#[prop(optional, into)] label: String,
|
||||
#[prop(optional, into)] required: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
//#region Value Logic
|
||||
|
||||
// Store a single signal that is the currently selected value. We can only have one selected
|
||||
// value, so there's no list needed. The default selected value is whatever the map has selected
|
||||
// already, or nothing.
|
||||
let default_value = match data_map.get_untracked().get(&data_member.get_untracked()) {
|
||||
Some(ValueType::String(Some(value))) => value.clone(),
|
||||
_ => String::new()
|
||||
};
|
||||
|
||||
let selected_value = RwSignal::new(default_value);
|
||||
|
||||
// When the selected value changes, update the data map at the correct location
|
||||
Effect::new(move || {
|
||||
data_map.update(|map| {
|
||||
map.insert(data_member.get(), ValueType::String(Some(selected_value.get())));
|
||||
});
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region Validation Logic
|
||||
|
||||
let validation_context = use_validation_context()
|
||||
.expect("Could not find FormValidationRegistry context");
|
||||
|
||||
let error = RwSignal::new(validate(selected_value.get_untracked(), required.get_untracked()));
|
||||
let dirty = RwSignal::new(false);
|
||||
let show_errors = Signal::derive(move || {
|
||||
dirty.get() && matches!(error.get(), ValidationState::Invalid(_))
|
||||
});
|
||||
|
||||
let input_state = RwSignal::new(InputState::new(
|
||||
data_member.get_untracked(),
|
||||
error.clone(),
|
||||
dirty.clone()
|
||||
));
|
||||
|
||||
validation_context.validators.update(|list| list.push(input_state));
|
||||
|
||||
on_cleanup(move || {
|
||||
validation_context.validators.update(|list| {
|
||||
list.retain(|v| *v.get_untracked().input_name != *input_state.get_untracked().input_name)
|
||||
});
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region Render Logic
|
||||
|
||||
view! {
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="m-1.5 mt-0 mb-0">
|
||||
<span class="font-bold">{label}</span>
|
||||
{items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let item = RwSignal::new(item);
|
||||
|
||||
let checked_signal = Signal::derive(move || {
|
||||
selected_value.get() == item.get()
|
||||
});
|
||||
|
||||
let on_change = move || {
|
||||
// on_change only runs when the radio button is checked, not when unchecked.
|
||||
// Change the selected value to the new one.
|
||||
selected_value.set(item.get());
|
||||
error.set(validate(selected_value.get_untracked(), required.get_untracked()));
|
||||
dirty.set(true);
|
||||
};
|
||||
|
||||
view! {
|
||||
<Radio
|
||||
checked=checked_signal
|
||||
on_change=on_change
|
||||
// The actual selected values are tracked by this element, not by the checkboxes themselves.
|
||||
value=item
|
||||
name=data_member
|
||||
disabled=disabled
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()}
|
||||
</div>
|
||||
<Show when=move || show_errors.get()>
|
||||
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
|
||||
{move || {
|
||||
match error.get() {
|
||||
ValidationState::Invalid(msg) => msg,
|
||||
_ => "There is no error - should not see this message.".to_string()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
use crate::common::ValueType;
|
||||
use leptos::ev::{Event, Targeted};
|
||||
use leptos::prelude::*;
|
||||
use leptos::web_sys::HtmlSelectElement;
|
||||
use std::collections::HashMap;
|
||||
use leptos::logging::{log, warn};
|
||||
use crate::components::{use_validation_context, InputState, ValidationState};
|
||||
|
||||
fn validate(required: bool, value: String) -> ValidationState {
|
||||
if required && (value.is_empty()) {
|
||||
ValidationState::Invalid("This input is required.".to_string())
|
||||
} else {
|
||||
ValidationState::Valid
|
||||
}
|
||||
}
|
||||
|
||||
/// A custom-styled select input.
|
||||
///
|
||||
/// Takes a `Vec<String>` as the set of inputs. Only one value may be selected, which is indicated
|
||||
/// by the `value` prop of the component.
|
||||
#[component]
|
||||
pub fn Select(
|
||||
#[prop()] value_list: Vec<String>,
|
||||
#[prop(into)] data_member: Signal<String>,
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
#[prop(optional, into)] label: String,
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
#[prop(optional, into)] required: Signal<bool>
|
||||
) -> impl IntoView {
|
||||
//#region Value Logic
|
||||
|
||||
let default_raw_value = data_map.get_untracked()
|
||||
.get(&data_member.get_untracked())
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.as_string().unwrap_or_default().unwrap_or_default();
|
||||
|
||||
let raw_value = RwSignal::new(default_raw_value);
|
||||
|
||||
Effect::new(move || {
|
||||
data_map.update(|map| {
|
||||
map.insert(data_member.get(), ValueType::String(Some(raw_value.get())));
|
||||
});
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region Validation Logic
|
||||
|
||||
let error = RwSignal::new(
|
||||
validate(required.get_untracked(), raw_value.get_untracked())
|
||||
);
|
||||
warn!("Current error state: {:?}", error.get_untracked());
|
||||
let dirty = RwSignal::new(false);
|
||||
let show_errors = Signal::derive(move || dirty.get() && matches!(error.get(), ValidationState::Invalid(_)));
|
||||
|
||||
let input_state = RwSignal::new(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(input_state));
|
||||
|
||||
on_cleanup(move || {
|
||||
validation_context.validators.update(|list| {
|
||||
list.retain(|v| *v.get_untracked().input_name != *input_state.get_untracked().input_name)
|
||||
});
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region Event Logic
|
||||
|
||||
let on_change = move |e: Targeted<Event, HtmlSelectElement>| {
|
||||
let value = e.target().value();
|
||||
log!("Found value {}", value);
|
||||
raw_value.set(value.clone());
|
||||
error.set(validate(required.get(), raw_value.get()));
|
||||
dirty.set(true);
|
||||
data_map.update(|map| {
|
||||
if let Some(val) = map.get_mut(&data_member.get()) {
|
||||
*val = ValueType::String(Some(value.clone()));
|
||||
} else {
|
||||
map.insert(data_member.get(), ValueType::String(Some(value)));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
view! {
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex flex-1">
|
||||
<label class="flex flex-col flex-1">
|
||||
<span class="ml-1.5 mb-0 font-bold">{label}</span>
|
||||
<select
|
||||
class="relative flex-1 border-2 m-1.5 mt-0 p-1.5 rounded-md
|
||||
transition-border duration-150
|
||||
border-red-700 bg-transparent
|
||||
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
|
||||
on:change:target=on_change
|
||||
disabled=disabled
|
||||
>
|
||||
// This closure handles the display of the options.
|
||||
<option
|
||||
value=""
|
||||
disabled
|
||||
hidden
|
||||
selected={move || raw_value.get().is_empty()}
|
||||
>"Select one..."</option>
|
||||
{move || {
|
||||
value_list.iter().map(
|
||||
move |value| {
|
||||
let display = value.to_owned();
|
||||
view! { <option value={display}>{display.clone()}</option> }
|
||||
}
|
||||
).collect::<Vec<_>>()
|
||||
}}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<Show when=move || show_errors.get()>
|
||||
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
|
||||
{move || {
|
||||
match error.get() {
|
||||
ValidationState::Invalid(msg) => msg,
|
||||
_ => "There is no error - should not see this message.".to_string()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
mod action_button;
|
||||
mod banner;
|
||||
mod checkbox;
|
||||
mod chips;
|
||||
mod dashboard_button;
|
||||
mod date;
|
||||
mod loading;
|
||||
mod multi_entry;
|
||||
mod panel;
|
||||
mod radio;
|
||||
mod row;
|
||||
mod select;
|
||||
mod text_field;
|
||||
mod utils;
|
||||
mod validated_form;
|
||||
mod lists;
|
||||
mod toasts;
|
||||
mod header;
|
||||
|
||||
pub use self::{
|
||||
action_button::*, banner::*, checkbox::*, chips::*, dashboard_button::*, date::*, loading::*,
|
||||
multi_entry::*, panel::*, radio::*, row::*, select::*, text_field::*,
|
||||
action_button::*, banner::*, dashboard_button::*, lists::*,
|
||||
loading::*, multi_entry::*, panel::*, row::*, text_field::*,
|
||||
validated_form::*, toasts::*, header::*, date::*
|
||||
};
|
||||
@@ -73,9 +73,6 @@ pub fn MultiEntry(
|
||||
/// component's `data_member` input.
|
||||
#[prop()]
|
||||
data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
/// Currently unused.
|
||||
#[prop(optional, into)]
|
||||
name_member: String,
|
||||
/// The type and order of the inputs for each `Entry` component within this `MultiEntry` component.
|
||||
#[prop(optional)]
|
||||
schema: Vec<InputType>,
|
||||
@@ -132,7 +129,6 @@ pub fn MultiEntry(
|
||||
view! {
|
||||
<Entry
|
||||
data_map = map_signal
|
||||
name_member = name_member.clone()
|
||||
schema = schema.clone()
|
||||
/>
|
||||
}
|
||||
@@ -186,14 +182,6 @@ fn Entry(
|
||||
/// 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">
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
use crate::common::ValueType;
|
||||
use crate::components::utils::create_unique_id;
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[component]
|
||||
pub fn Radio(
|
||||
#[prop()] checked: Signal<bool>,
|
||||
#[prop(into)] on_change: Callback<(), ()>,
|
||||
#[prop(into)] value: String,
|
||||
#[prop(into)] name: String,
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
let id = create_unique_id(&name, &value);
|
||||
|
||||
view! {
|
||||
<label for=id class="flex items-center">
|
||||
<input
|
||||
// Using the "peer" class links this input with the div "peer" at the same level.
|
||||
// By using the "peer-checked" selector this div can toggle states based on this
|
||||
// input's state (checked/unchecked)
|
||||
class="
|
||||
relative peer shrink-0
|
||||
hidden"
|
||||
type="radio"
|
||||
id=id.clone()
|
||||
name=name.clone()
|
||||
on:change=move |_| on_change.run(())
|
||||
prop:checked=checked
|
||||
disabled=disabled
|
||||
/>
|
||||
// This div displays the box for the checkbox, and applies some styling to the child
|
||||
// span element.
|
||||
<div class="relative inline-block h-5 w-5 m-1 mr-2 rounded-full border border-gray-300 bg-white
|
||||
transition-bg duration-200 peer-checked:bg-red-700 peer-checked:border-red-700
|
||||
[&>span]:opacity-0 peer-checked:[&>span]:opacity-100
|
||||
peer-disabled:bg-gray-600/33 peer-disabled:border-gray-600
|
||||
peer-disabled:peer-checked:bg-gray-600">
|
||||
// This span is the check icon. It is centered within the div and has a border on the bottom
|
||||
// and right sides, which is then rotated to appear like a checkmark.
|
||||
<span class="absolute left-1/2 top-1/2 block h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2
|
||||
rotate-45 transform border-3 border-3 rounded-full bg-white
|
||||
border-white transition-opacity duration-150"></span>
|
||||
</div>
|
||||
<span>{value.clone()}</span>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn RadioList(
|
||||
// #[prop(default = RwSignal::new(String::new()))] selected: RwSignal<ValueType>,
|
||||
#[prop(into)] data_member: String,
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
#[prop()] items: Vec<String>,
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
#[prop(optional, into)] label: String,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="m-1.5 mt-0 mb-0">
|
||||
<span class="font-bold">{label}</span>
|
||||
{items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let checked_signal = Signal::derive({
|
||||
let item_name = item.clone();
|
||||
let data_member = data_member.clone();
|
||||
move || {
|
||||
// Check if the value is contained in the data map's fields
|
||||
let map = data_map.get();
|
||||
let value = map.get(&data_member);
|
||||
let mut result = false;
|
||||
if let Some(val) = value {
|
||||
if val.is_string() {
|
||||
result = val.as_string().unwrap().unwrap() == item_name
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
});
|
||||
|
||||
let on_change = {
|
||||
let item_name = item.clone();
|
||||
let data_member = data_member.clone();
|
||||
move || {
|
||||
// Update the hash map
|
||||
data_map.update(|map| {
|
||||
if let Some(val) = map.get_mut(&data_member) {
|
||||
*val = ValueType::String(Some(item_name.clone()));
|
||||
} else {
|
||||
map.insert(data_member.clone(), ValueType::String(Some(item_name.clone())));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<Radio
|
||||
checked=checked_signal
|
||||
on_change=on_change
|
||||
// The actual selected values are tracked by this element, not by the checkboxes themselves.
|
||||
value=item.clone()
|
||||
name=data_member.clone()
|
||||
disabled=disabled
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
use crate::common::ValueType;
|
||||
use leptos::ev::{Event, Targeted};
|
||||
use leptos::prelude::*;
|
||||
use leptos::web_sys::HtmlSelectElement;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A custom-styled select input.
|
||||
///
|
||||
/// Takes a `Vec<String>` as the set of inputs. Only one value may be selected, which is indicated
|
||||
/// by the `value` prop of the component.
|
||||
#[component]
|
||||
pub fn Select(
|
||||
#[prop()] value_list: Vec<String>,
|
||||
#[prop(optional)] value: RwSignal<String>,
|
||||
#[prop(into)] data_member: String,
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
#[prop(optional, into)] label: String,
|
||||
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
let on_change = {
|
||||
let data_member = data_member.clone();
|
||||
move |e: Targeted<Event, HtmlSelectElement>| {
|
||||
let target_value = e.target().value();
|
||||
data_map.update(|map| {
|
||||
if let Some(val) = map.get_mut(&data_member) {
|
||||
*val = ValueType::String(Some(target_value));
|
||||
} else {
|
||||
map.insert(data_member.clone(), ValueType::String(Some(target_value)));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="flex flex-1">
|
||||
<label class="flex flex-col flex-1">
|
||||
<span class="ml-1.5 mb-0 font-bold">{label}</span>
|
||||
<select
|
||||
class="relative flex-1 border-2 m-1.5 mt-0 p-1.5 rounded-md
|
||||
transition-border duration-150
|
||||
border-red-700 bg-transparent
|
||||
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
|
||||
on:change:target=on_change
|
||||
disabled = disabled >
|
||||
// This closure handles the display of the options.
|
||||
{move || {
|
||||
value_list.iter().map(
|
||||
move |value| {
|
||||
let display = value.to_owned();
|
||||
view! { <option value={display}>{display.clone()}</option> }
|
||||
}
|
||||
).collect::<Vec<_>>()
|
||||
}}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
+132
-59
@@ -1,69 +1,139 @@
|
||||
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 the specified email domains.
|
||||
/// Values should be given as the string following the '@' in an address, or '*' for any.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```
|
||||
/// TextFieldType::Email(vec!["gmail.com".to_string(), "customdomain.org".to_string()]);
|
||||
/// TextFieldType::Email(vec!["*".to_string()]);
|
||||
/// ```
|
||||
Email(Vec<String>)
|
||||
}
|
||||
|
||||
/// 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(domains) => validate_email(value, domains),
|
||||
TextFieldType::Number => validate_number(value),
|
||||
TextFieldType::Text => ValidationState::Valid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates an email address.
|
||||
fn validate_email(input: &str, valid_domains: &Vec<String>) -> ValidationState {
|
||||
// Check for wildcard domain. We won't check domains if it's present.
|
||||
let wild = valid_domains.contains(&"*".to_string());
|
||||
|
||||
input.split_once('@')
|
||||
.and_then(|(_, domain)| {
|
||||
(wild || valid_domains.contains(&domain.to_string()))
|
||||
.then(|| ValidationState::Valid)
|
||||
.or_else(|| Some(ValidationState::Invalid(
|
||||
format!("Email address must match one of the following: {}", valid_domains.join(", "))
|
||||
)))
|
||||
}).unwrap_or(ValidationState::Invalid("Invalid email address.".to_string()))
|
||||
}
|
||||
|
||||
/// Validates a number.
|
||||
fn validate_number(input: &str) -> ValidationState {
|
||||
if input.parse::<i32>().is_ok() {
|
||||
ValidationState::Valid
|
||||
} else {
|
||||
ValidationState::Invalid("Value is not a valid number.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn OutlinedTextField(
|
||||
#[prop(optional, into)] placeholder: String,
|
||||
// #[prop()] value: Subfield<TStore, TInner, ValueType>,
|
||||
#[prop(into)] data_member: String, // should this be an RwSignal??
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
#[prop(optional)] value: RwSignal<ValueType>,
|
||||
#[prop(default = "text".to_owned(), into)] input_type: String,
|
||||
#[prop(into, optional)] disabled: Signal<bool>,
|
||||
#[prop(optional)] error: RwSignal<bool>,
|
||||
#[prop(optional, into)] input_type: Signal<TextFieldType>,
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
#[prop(optional, into)] name: String,
|
||||
#[prop(optional, into)] label: String,
|
||||
#[prop(optional, into)] error_text: RwSignal<String>,
|
||||
#[prop(optional, into)] required: Signal<bool>
|
||||
) -> impl IntoView {
|
||||
let input_ref = NodeRef::<Input>::new();
|
||||
|
||||
// Register this input's validation signal.
|
||||
let validator_context = use_validation_context()
|
||||
.expect("FormValidSignal was not found");
|
||||
|
||||
let raw_value = Signal::derive({
|
||||
let data_member = data_member.clone();
|
||||
move ||
|
||||
data_map
|
||||
.get()
|
||||
.get(&data_member)
|
||||
.unwrap_or(&ValueType::String(None))
|
||||
.to_string()
|
||||
});
|
||||
|
||||
let dirty = RwSignal::new(false);
|
||||
let error = Signal::derive(move ||
|
||||
validate(required.get(), &raw_value.get(), &input_type.get())
|
||||
);
|
||||
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 = Signal::derive(move || match input_type.get_untracked() {
|
||||
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::<i32>()
|
||||
.map(|n| ValueType::Number(Some(n.to_string())))
|
||||
{
|
||||
Ok(value_type) => {
|
||||
error.set(false);
|
||||
value.set(value_type.clone());
|
||||
data_map.update(|map| {
|
||||
map.insert(data_member.clone(), value_type);
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
error.set(true);
|
||||
error_text.set("Value is not a valid number.".to_owned());
|
||||
}
|
||||
};
|
||||
}
|
||||
_ => {
|
||||
// Create some custom function that reports failure.
|
||||
let msg = format!("Input {input_type:?} is not a valid type.");
|
||||
console_log(msg.as_str());
|
||||
error.set(true);
|
||||
error_text.set(msg);
|
||||
}
|
||||
}
|
||||
let into_map = match input_type.get() {
|
||||
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,26 +142,29 @@ pub fn OutlinedTextField(
|
||||
<label class="flex flex-col flex-1">
|
||||
<span class="block ml-1.5 mb-0 font-bold">{label}</span>
|
||||
<input
|
||||
node_ref=input_ref
|
||||
class="border-2 m-1.5 p-1.5 mt-0 rounded-md bg-transparent relative flex-1
|
||||
transition-all duration-150
|
||||
border-red-700 bg-transparent
|
||||
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
|
||||
r#type={input_type}
|
||||
r#type={str_type}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
prop:name=name
|
||||
prop:value=move || data_map
|
||||
.get() // HashMap
|
||||
.get(&data_member) // Option<ValueType>
|
||||
.unwrap_or(&ValueType::String(None)) // ValueType
|
||||
.to_string()
|
||||
prop:value=raw_value
|
||||
on:input=on_input
|
||||
on:blur=on_blur
|
||||
/>
|
||||
<div
|
||||
class="text-red-600 text-sm mr-1.5 ml-1.5"
|
||||
class=(["hidden"], move || !error.get())>
|
||||
{error_text}
|
||||
</div>
|
||||
<Show when=move || show_errors.get()>
|
||||
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
|
||||
{move || {
|
||||
match error.get() {
|
||||
ValidationState::Invalid(msg) => msg,
|
||||
_ => "There is no error - should not see this message.".to_string()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</Show>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
mod toast_list;
|
||||
mod toast;
|
||||
|
||||
pub use toast_list::*;
|
||||
@@ -0,0 +1,37 @@
|
||||
use std::time::Duration;
|
||||
use leptos::prelude::*;
|
||||
use leptos_animate::animate;
|
||||
use leptos_animate::animations::classes::{In, Out};
|
||||
|
||||
#[component]
|
||||
pub fn Toast(
|
||||
#[prop(into)] on_close: Callback<()>,
|
||||
#[prop(into)] message: Signal<String>,
|
||||
#[prop(into)] title: Signal<String>
|
||||
) -> impl IntoView {
|
||||
set_timeout(
|
||||
move || {
|
||||
on_close.run(())
|
||||
},
|
||||
Duration::from_secs(3)
|
||||
);
|
||||
|
||||
view! {
|
||||
<div
|
||||
class="flex flex-col rounded-md shadow-lg/33 bg-white p-3 m-1.5 mx-auto min-w-lg max-w-lg"
|
||||
use:animate=(
|
||||
In::default()
|
||||
.source("opacity-0 -translate-y-2 scale-90")
|
||||
.active("transition-all duration-150 transform-gpu")
|
||||
.target("opacity-100 translate-y-0 scale-100"),
|
||||
Out::default()
|
||||
.source("opacity-100 translate-y-0 scale-100")
|
||||
.active("transition-all duration-150 transform-gpu")
|
||||
.target("opacity-0 -translate-y-2 scale-90"),
|
||||
)
|
||||
>
|
||||
<h3 class="font-bold text-lg">{title}</h3>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use leptos::prelude::*;
|
||||
use crate::components::toasts::toast::Toast;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Toast {
|
||||
id: String,
|
||||
header: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl Toast {
|
||||
pub fn new() -> Self {
|
||||
Toast {
|
||||
id: "".to_owned(),
|
||||
header: "".to_owned(),
|
||||
message: "".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn header(mut self, header: impl Into<String>) -> Self {
|
||||
self.header = header.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn msg(mut self, msg: impl Into<String>) -> Self {
|
||||
self.message = msg.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn id(mut self, id: impl Into<String>) -> Self {
|
||||
self.id = id.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ToastContext {
|
||||
messages: RwSignal<Vec<Toast>>,
|
||||
}
|
||||
|
||||
impl ToastContext {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
messages: RwSignal::new(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toast(&mut self, toast: Toast) {
|
||||
self.messages.update(|list| list.push(toast));
|
||||
}
|
||||
}
|
||||
|
||||
/// # Toast List Component
|
||||
/// Displays a list of toasts. They must be queued using the [`ToastContext`].
|
||||
#[component]
|
||||
pub fn ToastList(
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
// This context should be used wherever the application might need to use a toast.
|
||||
// That specific component should also be wrapped in a ToastList component, otherwise
|
||||
// this context won't exist.
|
||||
let context = use_context::<ToastContext>()
|
||||
.unwrap_or_else(provide_toast_context);
|
||||
|
||||
view! {
|
||||
{children()}
|
||||
<div class="fixed top-1 left-1/2 right-1/2 -translate-x-1/2 w-full">
|
||||
<For
|
||||
each=move || context.messages.get()
|
||||
key=|toast| toast.id.clone()
|
||||
let(Toast { id, header, message })
|
||||
>
|
||||
<Toast
|
||||
message=message
|
||||
title=header
|
||||
on_close=move || {
|
||||
context.messages.update(|list|
|
||||
list.retain(|v| v.id != id)
|
||||
);
|
||||
}
|
||||
/>
|
||||
</For>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn provide_toast_context() -> ToastContext {
|
||||
let context = ToastContext::new();
|
||||
provide_context(context);
|
||||
context
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use leptos::logging::log;
|
||||
use leptos::prelude::*;
|
||||
use leptos::web_sys::SubmitEvent;
|
||||
use crate::components::{ActionButton, Header};
|
||||
|
||||
/// Indicates the current state of an input.
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub enum ValidationState {
|
||||
/// A valid input.
|
||||
#[default]
|
||||
Valid,
|
||||
/// An invalid input with an error message.
|
||||
Invalid(String)
|
||||
}
|
||||
|
||||
/// A struct that contains all information required for a form to make real-time validation
|
||||
/// decisions, including display of error messages.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InputState {
|
||||
/// The name of the input. This is used to identify the input in the form.
|
||||
pub input_name: String,
|
||||
/// Indicates the current state of the input.
|
||||
error: Signal<ValidationState>,
|
||||
/// Indicates whether the form has requested the error state. This means that the input
|
||||
/// should show their error appearances immediately.
|
||||
form_requested_validation: RwSignal<bool>,
|
||||
}
|
||||
|
||||
impl InputState {
|
||||
/// Creates a new [`InputState`] with the given error and form requested validation signals.
|
||||
pub fn new(
|
||||
name: impl Into<String>,
|
||||
error: impl Into<Signal<ValidationState>>,
|
||||
form_requested_errors: RwSignal<bool>
|
||||
) -> Self {
|
||||
Self {
|
||||
input_name: name.into(),
|
||||
error: error.into(),
|
||||
form_requested_validation: form_requested_errors
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(&self) -> Signal<ValidationState> {
|
||||
self.error.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of [`InputState`]s.
|
||||
pub type ValidatorList = Vec<RwSignal<InputState>>;
|
||||
|
||||
/// A struct that keeps track of all components that must be validated.
|
||||
/// All of our custom-made components must be placed within an area that contains context with
|
||||
/// this struct.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FormValidationRegistry {
|
||||
pub validators: RwSignal<ValidatorList>
|
||||
}
|
||||
|
||||
/// Gets the [`FormValidationRegistry`] context if it exists.
|
||||
pub fn use_validation_context() -> Option<FormValidationRegistry> {
|
||||
use_context::<FormValidationRegistry>()
|
||||
}
|
||||
|
||||
/// # Validated Form Component
|
||||
///
|
||||
/// A simple HTML `<form>` wrapper that executes validation logic using a [`FormValidationRegistry`].
|
||||
/// It will ensure that any invalid inputs are caught before submission. All input components that
|
||||
/// are used within this form should call `use_context::<FormValidationRegistry>()` to register
|
||||
/// themselves with the form.
|
||||
///
|
||||
/// Example usage in the view macro:
|
||||
/// ```
|
||||
/// view! {
|
||||
/// <ValidatedForm
|
||||
/// on_submit=Callback::new(move || {/* something here */})
|
||||
/// title="Example Title"
|
||||
/// description="Example description."
|
||||
/// >
|
||||
/// {/* As many input components as you want */}
|
||||
/// </ValidatedForm>
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Example input component implementation:
|
||||
/// ```
|
||||
/// fn SomeComponent() -> impl IntoView {
|
||||
/// // Get FormValidationContext
|
||||
/// let context = use_context::<FormValidationRegistry>()
|
||||
/// .expect("Couldn't find FormValidationRegsitry context");
|
||||
///
|
||||
/// // Register with the form
|
||||
/// let error = RwSignal::new(ValidationState::Valid);
|
||||
/// let form_requested_errors = RwSignal::new(false);
|
||||
/// let state = InputState::new(
|
||||
/// "the_input_name",
|
||||
/// error.clone(),
|
||||
/// form_requested_errors.clone()
|
||||
/// );
|
||||
/// context.validators.update(|list| list.push(RwSignal::new(state)));
|
||||
///
|
||||
/// /* Other input view logic */
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn ValidatedForm(
|
||||
children: Children,
|
||||
/// A callback that runs when the form successfully validates.
|
||||
#[prop(into)] on_submit: Callback<()>,
|
||||
/// The title of the included header.
|
||||
#[prop(into)] title: Signal<String>,
|
||||
/// The description for the included header.
|
||||
#[prop(optional, into)] description: Signal<String>,
|
||||
) -> impl IntoView {
|
||||
let validators = RwSignal::new(vec![]);
|
||||
|
||||
// Provide context to all children nested within this component. They will be able to access
|
||||
// this no matter how nested they are.
|
||||
provide_context(FormValidationRegistry {validators});
|
||||
|
||||
let can_submit = Memo::new(move |_| {
|
||||
log!("Checking {} inputs...", validators.get().len());
|
||||
validators
|
||||
.with(|list| {
|
||||
list.iter()
|
||||
// TODO all() short circuits on the first false
|
||||
.fold(true, |acc, v| {
|
||||
v.update(|validator| validator.form_requested_validation.set(true));
|
||||
matches!(v.get().error().get(), ValidationState::Valid) && acc
|
||||
})
|
||||
// .all(|v| {
|
||||
// // Not sure how this will work reactively.
|
||||
// let result = matches!(v.get().error().get(), ValidationState::Valid);
|
||||
// v.update(|validator| validator.form_requested_validation.set(true));
|
||||
// result
|
||||
// })
|
||||
})
|
||||
});
|
||||
|
||||
let submit_success_event = move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
|
||||
if !can_submit.get() {
|
||||
log!("Form failed to submit, there are errors in form inputs.");
|
||||
return;
|
||||
}
|
||||
|
||||
log!("Form validation successful.");
|
||||
on_submit.run(());
|
||||
};
|
||||
|
||||
view! {
|
||||
<form novalidate on:submit=submit_success_event>
|
||||
<Header
|
||||
title=title
|
||||
description=description
|
||||
/>
|
||||
{children()}
|
||||
<ActionButton
|
||||
on:click=move |_| {
|
||||
log!("{:?}", can_submit.get());
|
||||
}
|
||||
button_type="submit"
|
||||
>
|
||||
"Submit"
|
||||
</ActionButton>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user