use crate::common::ValueType; 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) } /// 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) -> 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::().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(into)] data_member: String, // should this be an RwSignal?? #[prop()] data_map: RwSignal>, #[prop(optional, into)] input_type: Signal, #[prop(optional, into)] disabled: Signal, #[prop(optional, into)] name: String, #[prop(optional, into)] label: String, #[prop(optional, into)] required: Signal ) -> impl IntoView { let input_ref = NodeRef::::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 data_member = data_member.clone(); move |e| { let to_parse = event_target_value(&e); 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); }); } }; view! {
} }