dioxus-forms/src/form.rs

442 lines
12 KiB
Rust

use std::{
collections::{HashMap, HashSet},
rc::Rc,
};
use dioxus::prelude::*;
pub(crate) trait FormError: std::error::Error {
fn ident(&self) -> &'static str;
}
pub(crate) struct Errors<'a> {
errors: &'a mut HashMap<&'static str, Rc<dyn FormError>>,
}
pub(crate) trait Form {
type FieldName: Eq + std::hash::Hash + 'static;
fn set(&mut self, field_name: Self::FieldName, value: &str, errors: &mut Errors<'_>);
}
impl<'a> Errors<'a> {
pub(crate) fn test<E: 'static>(&mut self, predicate: bool, error: E) -> &mut Self
where
E: FormError,
{
if predicate {
self.add_error(error)
} else {
self.remove_error(&error)
}
}
pub(crate) fn add_error<E: 'static>(&mut self, error: E) -> &mut Self
where
E: FormError,
{
self.errors.insert(error.ident(), Rc::new(error));
self
}
pub(crate) fn remove_error<E: 'static>(&mut self, error: &E) -> &mut Self
where
E: FormError,
{
self.errors.remove(&error.ident());
self
}
}
pub(crate) struct FormState<FieldName> {
state: Box<dyn Form<FieldName = FieldName>>,
values: HashMap<FieldName, String>,
shown_errors: HashMap<FieldName, HashMap<&'static str, Rc<dyn FormError>>>,
errors: HashMap<FieldName, HashMap<&'static str, Rc<dyn FormError>>>,
always_show_errors: bool,
disabled: bool,
fields: HashSet<FieldName>,
}
impl<FieldName> FormState<FieldName> {
fn new<S: 'static>(state: S) -> Self
where
S: Form<FieldName = FieldName>,
{
Self {
state: Box::new(state),
values: Default::default(),
shown_errors: Default::default(),
errors: Default::default(),
always_show_errors: false,
disabled: false,
fields: Default::default(),
}
}
}
impl<FieldName> FormState<FieldName>
where
FieldName: Eq + std::hash::Hash + 'static,
{
pub(crate) fn always_show_errors(&mut self) -> &mut Self {
self.always_show_errors = true;
self
}
pub(crate) fn validate(&mut self) -> &mut Self
where
FieldName: Clone,
{
for field in self
.fields
.iter()
.filter(|field| !self.values.contains_key(field))
{
self.state.set(
field.clone(),
"",
&mut Errors {
errors: self.errors.entry(field.clone()).or_default(),
},
);
}
self.shown_errors = self.errors.clone();
self
}
pub(crate) fn is_valid(&self) -> bool {
self.errors.is_empty()
}
pub(crate) fn disable(&mut self) -> &mut Self {
self.disabled = true;
self
}
pub(crate) fn enable(&mut self) -> &mut Self {
self.disabled = false;
self
}
fn set(&mut self, name: FieldName, value: &str)
where
FieldName: Clone,
{
self.values.insert(name.clone(), value.to_owned());
self.state.set(
name.clone(),
value,
&mut Errors {
errors: self.errors.entry(name).or_default(),
},
);
if self.always_show_errors {
for (name, error) in self.errors.drain() {
if self.values.contains_key(&name) {
self.shown_errors.insert(name, error);
}
}
}
}
fn get_value(&self, name: &FieldName) -> Option<String> {
self.values.get(name).cloned()
}
fn get_errors(
&self,
name: &FieldName,
) -> Option<std::collections::hash_map::Values<'_, &'static str, Rc<dyn FormError>>> {
self.shown_errors.get(name).and_then(|hm| {
if hm.is_empty() {
None
} else {
Some(hm.values())
}
})
}
}
#[inline_props]
#[allow(non_snake_case)]
fn InputErrors<'a, FieldName>(cx: Scope, name: &'a FieldName) -> Element
where
FieldName: Eq + std::hash::Hash + 'static,
{
let form_state = use_shared_state::<FormState<FieldName>>(cx);
if let Some(form_state) = form_state {
let form_state = form_state.read();
if let Some(errors) = form_state.get_errors(name) {
return cx.render(rsx! {
ul {
class: "form-input--errors",
errors.map(|error| rsx! {
li {
class: "form-input--errors--error",
"{error}"
}
})
}
});
}
}
return cx.render(rsx! {
ul {
class: "form-input--errors",
}
});
}
#[inline_props]
#[allow(non_snake_case)]
pub(crate) fn NumberInput<'a, FieldName>(
cx: Scope,
name: FieldName,
label: Element<'a>,
step: Option<f64>,
disabled: Option<bool>,
) -> Element
where
FieldName: Clone + Eq + std::hash::Hash + std::fmt::Display + 'static,
{
if let Some(step) = step {
cx.render(rsx! {
Input { kind: "number", name: name.clone(), label: label.clone(), step: *step, disabled: disabled.unwrap_or(false) }
})
} else {
cx.render(rsx! {
Input { kind: "number", name: name.clone(), label: label.clone(), disabled: disabled.unwrap_or(false) }
})
}
}
#[inline_props]
#[allow(non_snake_case)]
pub(crate) fn TextInput<'a, FieldName>(
cx: Scope,
name: FieldName,
label: Element<'a>,
disabled: Option<bool>,
) -> Element
where
FieldName: Clone + Eq + std::hash::Hash + std::fmt::Display + 'static,
{
cx.render(rsx! {
Input { kind: "text", name: name.clone(), label: label.clone(), disabled: disabled.unwrap_or(false) }
})
}
#[inline_props]
#[allow(non_snake_case)]
pub(crate) fn PasswordInput<'a, FieldName>(
cx: Scope,
name: FieldName,
label: Element<'a>,
disabled: Option<bool>,
) -> Element
where
FieldName: Clone + Eq + std::hash::Hash + std::fmt::Display + 'static,
{
cx.render(rsx! {
Input { kind: "password", name: name.clone(), label: label.clone(), disabled: disabled.unwrap_or(false) }
})
}
#[inline_props]
#[allow(non_snake_case)]
pub(crate) fn InputWrapper<'a, FieldName>(
cx: Scope,
name: &'a FieldName,
label: Element<'a>,
children: Element<'a>,
) -> Element
where
FieldName: Eq + std::hash::Hash + std::fmt::Display + 'static,
{
cx.render(rsx! {
label {
class: "form-input",
span {
class: "form-input--label",
label,
},
children,
InputErrors { name: *name, }
}
})
}
#[inline_props]
#[allow(non_snake_case)]
pub(crate) fn Textarea<'a, FieldName>(
cx: Scope,
name: FieldName,
label: Element<'a>,
disabled: Option<bool>,
) -> Element
where
FieldName: Clone + Eq + std::hash::Hash + std::fmt::Display + 'static,
{
cx.render(rsx! {
InputWrapper {
name: name,
label: label.clone(),
match use_shared_state::<FormState<FieldName>>(cx) {
Some(form_state) => {
cx.use_hook(|| {
form_state.write().fields.insert(name.clone());
});
let value = form_state.read().get_value(&name).unwrap_or_default();
let classes = if form_state.read().get_errors(&name).is_some() {
"error"
} else {
""
};
let disabled = disabled.unwrap_or(false) || form_state.read().disabled;
if disabled {
rsx! {
textarea {
class: classes,
name: "{name}",
value: "{value}",
disabled: "{disabled}",
},
}
} else {
rsx! {
textarea {
class: classes,
name: "{name}",
value: "{value}",
disabled: "{disabled}",
oninput: move |event| form_state.write().set(name.clone(), &event.value),
},
}
}
},
None => rsx! {
textarea {
name: "{name}",
disabled: "{disabled.unwrap_or(false)}"
},
},
},
}
})
}
#[inline_props]
#[allow(non_snake_case)]
pub(crate) fn Input<'a, FieldName>(
cx: Scope,
kind: &'static str,
name: FieldName,
label: Element<'a>,
step: Option<f64>,
disabled: bool,
) -> Element
where
FieldName: Clone + Eq + std::hash::Hash + std::fmt::Display + 'static,
{
cx.render(rsx! {
InputWrapper {
name: name,
label: label.clone(),
match use_shared_state::<FormState<FieldName>>(cx) {
Some(form_state) => {
cx.use_hook(|| {
form_state.write().fields.insert(name.clone());
});
let value = form_state.read().get_value(&name).unwrap_or_default();
let classes = if form_state.read().get_errors(&name).is_some() {
"error"
} else {
""
};
let step = if *kind == "number" {
*step
} else {
None
};
let disabled = *disabled || form_state.read().disabled;
if disabled {
rsx! {
input {
class: classes,
name: "{name}",
r#type: "{kind}",
value: "{value}",
step: step,
disabled: "{disabled}",
},
}
} else {
rsx! {
input {
class: classes,
name: "{name}",
r#type: "{kind}",
value: "{value}",
step: step,
disabled: "{disabled}",
oninput: move |event| form_state.write().set(name.clone(), &event.value),
},
}
}
},
None => rsx! {
input {
name: "{name}",
r#type: "{kind}",
disabled: "{disabled}"
},
},
},
}
})
}
impl<T> Form for UseRef<T>
where
T: Form,
{
type FieldName = T::FieldName;
fn set(&mut self, field_name: Self::FieldName, value: &str, errors: &mut Errors<'_>) {
self.with_mut(|form| form.set(field_name, value, errors));
}
}
pub(crate) fn use_form<FieldName, State>(
cx: &ScopeState,
form: UseRef<State>,
init: impl FnOnce(&mut FormState<FieldName>),
) -> UseSharedState<'_, FormState<FieldName>>
where
State: Form<FieldName = FieldName> + Default + 'static,
{
use_shared_state_provider(cx, move || {
let mut state = FormState::<FieldName>::new(form);
(init)(&mut state);
state
});
use_shared_state(cx).expect("Just set up")
}