Skip to main content

guise/reactive/
form.rs

1//! A small form-state layer: values, validators, and errors keyed by field name.
2//!
3//! Pure and unit-testable on its own ([`FormState`]); make it reactive by
4//! holding it in a [`Signal`](super::Signal) and mutating through
5//! `signal.update(cx, |form| form.set(..))`. [`use_form`] is the shorthand.
6//!
7//! ```ignore
8//! let form = use_form(cx, FormState::new()
9//!     .field("email", "")
10//!     .validator("email", validators::email()));
11//! // later, in a handler:
12//! form.update(cx, |f| { f.set("email", "a@b.com"); f.validate(); });
13//! ```
14
15use std::collections::HashMap;
16
17use gpui::App;
18
19use super::signal::Signal;
20
21/// A validator: returns `Some(message)` when the value is invalid.
22pub type Validator = Box<dyn Fn(&str) -> Option<String> + 'static>;
23
24/// Form values + validators + the errors produced by the last validation.
25#[derive(Default)]
26pub struct FormState {
27    values: HashMap<&'static str, String>,
28    errors: HashMap<&'static str, String>,
29    validators: HashMap<&'static str, Validator>,
30}
31
32impl FormState {
33    pub fn new() -> Self {
34        FormState::default()
35    }
36
37    /// Register a field with an initial value (builder form).
38    pub fn field(mut self, name: &'static str, initial: impl Into<String>) -> Self {
39        self.values.insert(name, initial.into());
40        self
41    }
42
43    /// Attach a validator to a field (builder form).
44    pub fn validator(mut self, name: &'static str, validator: Validator) -> Self {
45        self.validators.insert(name, validator);
46        self
47    }
48
49    /// The current value of a field (empty string if unset).
50    pub fn value(&self, name: &str) -> &str {
51        self.values.get(name).map(String::as_str).unwrap_or("")
52    }
53
54    /// Set a field's value and clear its error.
55    pub fn set(&mut self, name: &'static str, value: impl Into<String>) {
56        self.values.insert(name, value.into());
57        self.errors.remove(name);
58    }
59
60    /// Validate one field, recording or clearing its error. Returns validity.
61    pub fn validate_field(&mut self, name: &'static str) -> bool {
62        if let Some(validator) = self.validators.get(name) {
63            let value = self.values.get(name).map(String::as_str).unwrap_or("");
64            match validator(value) {
65                Some(message) => {
66                    self.errors.insert(name, message);
67                    return false;
68                }
69                None => {
70                    self.errors.remove(name);
71                }
72            }
73        }
74        true
75    }
76
77    /// Validate every field with a validator. Returns whether all passed.
78    pub fn validate(&mut self) -> bool {
79        let names: Vec<&'static str> = self.validators.keys().copied().collect();
80        let mut ok = true;
81        for name in names {
82            ok &= self.validate_field(name);
83        }
84        ok
85    }
86
87    /// The error message for a field, if the last validation produced one.
88    pub fn error(&self, name: &str) -> Option<&str> {
89        self.errors.get(name).map(String::as_str)
90    }
91
92    /// Whether there are no recorded errors.
93    pub fn is_valid(&self) -> bool {
94        self.errors.is_empty()
95    }
96}
97
98/// Built-in validators.
99pub mod validators {
100    use super::Validator;
101
102    /// Fails when the trimmed value is empty.
103    pub fn required() -> Validator {
104        Box::new(|v: &str| {
105            if v.trim().is_empty() {
106                Some("Required".to_string())
107            } else {
108                None
109            }
110        })
111    }
112
113    /// Fails when the value is shorter than `n` characters.
114    pub fn min_len(n: usize) -> Validator {
115        Box::new(move |v: &str| {
116            if v.chars().count() < n {
117                Some(format!("Must be at least {n} characters"))
118            } else {
119                None
120            }
121        })
122    }
123
124    /// A permissive `a@b.c` email shape check.
125    pub fn email() -> Validator {
126        Box::new(|v: &str| {
127            let ok = v
128                .split_once('@')
129                .map(|(user, domain)| {
130                    !user.is_empty() && domain.contains('.') && !domain.starts_with('.')
131                })
132                .unwrap_or(false);
133            if ok {
134                None
135            } else {
136                Some("Enter a valid email".to_string())
137            }
138        })
139    }
140}
141
142/// Create a reactive form: a [`Signal`] wrapping the given [`FormState`].
143pub fn use_form(cx: &mut App, state: FormState) -> Signal<FormState> {
144    Signal::new(cx, state)
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn required_and_min_len() {
153        let req = validators::required();
154        assert!(req("").is_some());
155        assert!(req("  ").is_some());
156        assert!(req("x").is_none());
157
158        let min = validators::min_len(3);
159        assert!(min("ab").is_some());
160        assert!(min("abc").is_none());
161    }
162
163    #[test]
164    fn email_shape() {
165        let email = validators::email();
166        assert!(email("nope").is_some());
167        assert!(email("a@b").is_some());
168        assert!(email("a@b.com").is_none());
169    }
170
171    #[test]
172    fn set_clears_error_then_validate_repopulates() {
173        let mut form = FormState::new()
174            .field("name", "")
175            .validator("name", validators::required());
176        assert!(!form.validate());
177        assert_eq!(form.error("name"), Some("Required"));
178
179        form.set("name", "Ada");
180        // set clears the field's error eagerly.
181        assert_eq!(form.error("name"), None);
182        assert!(form.validate());
183        assert!(form.is_valid());
184    }
185}