Add more input types, instant validation

This commit is contained in:
asonix 2023-04-25 19:09:06 -05:00
parent a3bea8a62c
commit 29a045c86b
2 changed files with 623 additions and 114 deletions

View file

@ -1,18 +1,61 @@
use std::collections::HashMap;
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);
fn set(&mut self, field_name: Self::FieldName, value: &str, errors: &mut Errors<'_>);
}
fn get(&self, field_name: &Self::FieldName) -> Option<String>;
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>>,
errors: HashMap<FieldName, Vec<Box<dyn std::error::Error>>>,
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> {
@ -22,7 +65,12 @@ impl<FieldName> FormState<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(),
}
}
}
@ -31,33 +79,84 @@ impl<FieldName> FormState<FieldName>
where
FieldName: Eq + std::hash::Hash + 'static,
{
pub(crate) fn add_error<E: 'static>(&mut self, name: FieldName, error: E) -> &mut Self
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
E: std::error::Error,
FieldName: Clone,
{
self.errors.entry(name).or_default().push(Box::new(error));
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 clear_errors(&mut self) -> &mut Self {
self.errors.clear();
pub(crate) fn is_valid(&self) -> bool {
self.errors.is_empty()
}
pub(crate) fn disable(&mut self) -> &mut Self {
self.disabled = true;
self
}
fn set(&mut self, name: FieldName, value: &str) {
self.state.set(name, value);
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.state.get(name)
self.values.get(name).cloned()
}
fn get_error(&self, name: &FieldName) -> Option<&[Box<dyn std::error::Error>]> {
self.errors.get(name).and_then(|vec| {
if vec.is_empty() {
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(vec.as_slice())
Some(hm.values())
}
})
}
@ -69,17 +168,17 @@ fn InputErrors<'a, FieldName>(cx: Scope, name: &'a FieldName) -> Element
where
FieldName: Eq + std::hash::Hash + 'static,
{
let errors = use_shared_state::<FormState<FieldName>>(cx);
let form_state = use_shared_state::<FormState<FieldName>>(cx);
if let Some(errors) = errors {
let errors = errors.read();
if let Some(errors) = errors.get_error(name) {
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! {
div {
class: "text-input--errors",
errors.iter().map(|error| rsx! {
span {
class: "text-input--errors--error",
ul {
class: "form-input--errors",
errors.map(|error| rsx! {
li {
class: "form-input--errors--error",
"{error}"
}
})
@ -88,28 +187,49 @@ where
}
}
None
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>) -> Element
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,
{
cx.render(rsx! {
Input { kind: "number", name: name.clone(), label: label.clone() }
})
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>) -> Element
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() }
Input { kind: "text", name: name.clone(), label: label.clone(), disabled: disabled.unwrap_or(false) }
})
}
@ -119,12 +239,100 @@ 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() }
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)}"
},
},
},
}
})
}
@ -135,38 +343,71 @@ pub(crate) fn Input<'a, FieldName>(
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! {
label {
class: "text-input",
span {
class: "text-input--label",
label,
},
InputWrapper {
name: name,
label: label.clone(),
match use_shared_state::<FormState<FieldName>>(cx) {
Some(values) => {
let value = values.read().get_value(&name).unwrap_or_else(|| String::new());
Some(form_state) => {
cx.use_hook(|| {
form_state.write().fields.insert(name.clone());
});
rsx! {
input {
name: "{name}",
r#type: "{kind}",
value: "{value}",
oninput: move |event| values.write().set(name.clone(), &event.value),
},
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}"
},
},
},
InputErrors { name: name, }
}
})
}
@ -177,23 +418,24 @@ where
{
type FieldName = T::FieldName;
fn set(&mut self, field_name: Self::FieldName, value: &str) {
self.with_mut(|form| form.set(field_name, value));
}
fn get(&self, field_name: &Self::FieldName) -> Option<String> {
self.with(|form| form.get(field_name))
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 || FormState::<FieldName>::new(form));
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")
}

View file

@ -12,14 +12,14 @@ use dioxus_router::{Link, Redirect, Route, Router};
mod form;
use form::{use_form, NumberInput, PasswordInput, TextInput};
use form::{use_form, Errors, FormError, NumberInput, PasswordInput, TextInput, Textarea};
#[derive(Clone, Debug, Default)]
struct Form {
username: String,
password: String,
password_confirmation: String,
age: usize,
age: f64,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
@ -37,37 +37,58 @@ enum Error {
PasswordBlank,
PasswordTooShort,
PasswordMismatch,
ParseAge,
AgeTooYoung,
AgeRequired,
}
impl form::Form for Form {
type FieldName = Field;
fn set(&mut self, field_name: Self::FieldName, value: &str) {
fn set(&mut self, field_name: Self::FieldName, value: &str, errors: &mut Errors<'_>) {
match field_name {
Field::Username => {
errors
.test(value.is_empty(), Error::UsernameBlank)
.test(value.len() < 5, Error::UsernameTooShort);
self.username = value.to_owned();
}
Field::Password => {
errors
.test(value.is_empty(), Error::PasswordBlank)
.test(value.len() < 8, Error::PasswordTooShort);
self.password = value.to_owned();
}
Field::ConfirmPassword => {
errors.test(value != self.password, Error::PasswordMismatch);
self.password_confirmation = value.to_owned();
}
Field::Age => {
self.age = value.parse().unwrap_or(0);
errors.test(value.is_empty(), Error::AgeRequired);
if value.is_empty() {
return;
}
match value.parse() {
Ok(age) => {
errors
.remove_error(&Error::ParseAge)
.test(age < 18.0, Error::AgeTooYoung);
self.age = age;
}
Err(_) => {
errors.remove_error(&Error::AgeTooYoung);
errors.add_error(Error::ParseAge);
}
};
}
}
}
fn get(&self, field_name: &Self::FieldName) -> Option<String> {
Some(match field_name {
Field::Username => self.username.clone(),
Field::Password => self.password.clone(),
Field::ConfirmPassword => self.password_confirmation.clone(),
Field::Age => self.age.to_string(),
})
}
}
impl std::fmt::Display for Field {
@ -89,74 +110,242 @@ impl std::fmt::Display for Error {
Self::PasswordBlank => write!(f, "Password is required"),
Self::PasswordTooShort => write!(f, "Password is too short"),
Self::PasswordMismatch => write!(f, "Passwords must match"),
Self::ParseAge => write!(f, "Age is invalid"),
Self::AgeTooYoung => write!(f, "You are not old enough"),
Self::AgeRequired => write!(f, "Age is required"),
}
}
}
impl std::error::Error for Error {}
impl FormError for Error {
fn ident(&self) -> &'static str {
match self {
Self::UsernameBlank => "username-blank",
Self::UsernameTooShort => "username-too-short",
Self::PasswordBlank => "password-blank",
Self::PasswordTooShort => "password-too-shorrt",
Self::PasswordMismatch => "password-mismatch",
Self::ParseAge => "parse-age",
Self::AgeTooYoung => "age-too-young",
Self::AgeRequired => "age-required",
}
}
}
#[allow(non_snake_case)]
fn Home(cx: Scope) -> Element {
fn FirstForm(cx: Scope) -> Element {
let form = use_ref(cx, Form::default);
let form_state = use_form(cx, form.clone());
let form_state = use_form(cx, form.clone(), |_| {});
cx.render(rsx! {
form {
onsubmit: move |_| {
let form = form.read();
let mut form_state = form_state.write();
form_state.clear_errors();
div {
class: "form-container",
form {
onsubmit: move |_| {
let valid = form_state.write().validate().is_valid();
let form = form.read();
if form.username.is_empty() {
form_state.add_error(Field::Username, Error::UsernameBlank);
}
if form.username.len() < 5 {
form_state.add_error(Field::Username, Error::UsernameTooShort);
}
println!("{form:?} - valid: {valid}");
},
if form.password.is_empty() {
form_state.add_error(Field::Password, Error::PasswordBlank);
}
if form.password.len() < 8 {
form_state.add_error(Field::Password, Error::PasswordTooShort);
}
TextInput {
name: Field::Username,
label: cx.render(rsx! { "Username" }),
},
if form.password != form.password_confirmation {
form_state.add_error(Field::ConfirmPassword, Error::PasswordMismatch);
}
PasswordInput {
name: Field::Password,
label: cx.render(rsx! { "Password" }),
},
if form.age < 18 {
form_state.add_error(Field::Age, Error::AgeTooYoung);
}
PasswordInput {
name: Field::ConfirmPassword,
label: cx.render(rsx! { "Confirm Password" }),
},
println!("{:?}", form);
NumberInput {
name: Field::Age,
label: cx.render(rsx! { "Age" }),
step: 0.1,
},
input {
r#type: "submit",
value: "Sign Up"
},
},
},
})
}
TextInput {
name: Field::Username,
label: cx.render(rsx! { "Username" }),
#[allow(non_snake_case)]
fn SecondForm(cx: Scope) -> Element {
let form = use_ref(cx, Form::default);
let form_state = use_form(cx, form.clone(), |form| {
form.always_show_errors();
});
cx.render(rsx! {
div {
class: "form-container",
form {
onsubmit: move |_| {
let valid = form_state.write().validate().is_valid();
let form = form.read();
println!("{form:?} - valid: {valid}");
},
TextInput {
name: Field::Username,
label: cx.render(rsx! { "Username" }),
},
PasswordInput {
name: Field::Password,
label: cx.render(rsx! { "Password" }),
},
PasswordInput {
name: Field::ConfirmPassword,
label: cx.render(rsx! { "Confirm Password" }),
},
NumberInput {
name: Field::Age,
label: cx.render(rsx! { "Age" }),
step: 0.1,
},
input {
r#type: "submit",
value: "Sign Up"
},
},
}
})
}
PasswordInput {
name: Field::Password,
label: cx.render(rsx! { "Password" }),
#[allow(non_snake_case)]
fn ThirdForm(cx: Scope) -> Element {
let form = use_ref(cx, Form::default);
let form_state = use_form(cx, form.clone(), |form| {
form.disable();
});
cx.render(rsx! {
div {
class: "form-container",
form {
onsubmit: move |_| {
let valid = form_state.write().validate().is_valid();
let form = form.read();
println!("{form:?} - valid: {valid}");
},
TextInput {
name: Field::Username,
label: cx.render(rsx! { "Username" }),
},
PasswordInput {
name: Field::Password,
label: cx.render(rsx! { "Password" }),
},
PasswordInput {
name: Field::ConfirmPassword,
label: cx.render(rsx! { "Confirm Password" }),
},
NumberInput {
name: Field::Age,
label: cx.render(rsx! { "Age" }),
step: 0.1,
},
input {
r#type: "submit",
value: "Sign Up"
},
},
},
})
}
PasswordInput {
name: Field::ConfirmPassword,
label: cx.render(rsx! { "Confirm Password" }),
#[allow(non_snake_case)]
fn FourthForm(cx: Scope) -> Element {
cx.render(rsx! {
div {
class: "form-container",
form {
onsubmit: move |event| {
println!("{:?}", event.values);
},
TextInput {
name: Field::Username,
label: cx.render(rsx! { "Username" }),
},
PasswordInput {
name: Field::Password,
label: cx.render(rsx! { "Password" }),
},
PasswordInput {
name: Field::ConfirmPassword,
label: cx.render(rsx! { "Confirm Password" }),
},
NumberInput {
name: Field::Age,
label: cx.render(rsx! { "Age" }),
step: 0.1,
disabled: true,
}
input {
r#type: "submit",
value: "Sign Up"
},
},
}
})
}
NumberInput {
name: Field::Age,
label: cx.render(rsx! { "Age" }),
}
#[allow(non_snake_case)]
fn FifthForm(cx: Scope) -> Element {
cx.render(rsx! {
div {
class: "form-container",
form {
onsubmit: move |event| {
println!("{:?}", event.values);
},
input {
r#type: "submit",
value: "Sign Up"
}
Textarea {
name: "post"
label: cx.render(rsx! { "Text" })
},
},
},
})
}
#[allow(non_snake_case)]
fn Home(cx: Scope) -> Element {
cx.render(rsx! {
div {
class: "form-list",
FirstForm {},
SecondForm {},
ThirdForm {},
FourthForm {},
FifthForm {},
}
})
}
@ -187,11 +376,89 @@ async fn spa(addr: SocketAddr) -> Html<String> {
box-sizing: border-box;
}}
body {{
margin: 0;
min-height: 100vh;
background-color: #333;
color: #f5f5f5;
font-family: sans;
margin: 0;
min-height: 100vh;
}}
.form-list {{
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
}}
.form-container {{
padding: 16px;
max-width: 500px;
width: 100%;
}}
form {{
background-color: #fff;
border: 1px solid #e5e5e5;
border-radius: 8px;
margin: 0 auto;
color: #222;
padding: 16px;
width: 100%;
}}
.form-input {{
display: block;
}}
.form-input--label {{
display: block;
font-size: 14px;
font-weight: 500;
padding: 4px 0;
}}
.form-input input[type=text],
.form-input input[type=password],
.form-input input[type=number],
.form-input textarea {{
border: 1px solid #e5e5e5;
border-radius: 3px;
box-shadow: inset 0 0 4px rgba(50, 50, 50, 0.1);
display: block;
font-size: 16px;
line-height: 22px;
outline: none;
padding: 8px 16px;
width: 100%;
}}
.form-input input[type=text]:hover,
.form-input input[type=password]:hover,
.form-input input[type=number]:hover,
.form-input textarea:hover {{
border-color: #ff84ca;
}}
.form-input input[type=text]:focus,
.form-input input[type=password]:focus,
.form-input input[type=number]:focus,
.form-input textarea:focus {{
border-color: #ff84ca;
box-shadow: inset 0 0 6px rgba(255, 132, 202, 0.4), 0 0 4px rgba(255, 132, 202, 0.2);
}}
.form-input input[type=text].error,
.form-input input[type=password].error,
.form-input input[type=number].error,
.form-input textarea.error {{
border-color: #ca3e3e;
}}
.form-input input[disabled=true],
.form-input textarea[disabled=true] {{
background-color: #f5f5f5;
}}
.form-input textarea {{
min-height: 200px;
}}
.form-input--errors {{
color: #ca3e3e;
list-style: none;
margin: 0;
padding: 4px 0;
}}
.form-input--errors--error {{
font-size: 14px;
padding: 2px 0;
}}
</style>
</head>