Skip to main content

guise/reactive/
form.rs

1//! Form state: values, validators, and errors keyed by field name.
2//!
3//! Two layers:
4//!
5//! - [`FormState`] — the pure model (values + validators + errors), unit
6//!   testable with no gpui. Hold it in a [`Signal`] via [`use_form`] when a
7//!   plain map is all you need.
8//! - [`Form`] — the reactive layer: **every field is its own
9//!   `Signal<String>`**, so it plugs straight into any input's `bind`
10//!   (`TextInput::bind(&input, form.signal("email"), cx)`). Rules can see
11//!   the whole form (cross-field), errors live in a signal views can watch,
12//!   and fields that failed validation re-validate live as they're edited.
13//!
14//! ```ignore
15//! let form = Form::new(cx)
16//!     .field(cx, "email", "")
17//!     .rule("email", validators::required())
18//!     .rule("email", validators::email())
19//!     .field(cx, "confirm", "")
20//!     .rule("confirm", validators::equals_field("email", "Emails must match"));
21//!
22//! TextInput::bind(&email_input, form.signal("email"), cx);
23//! // in the submit handler:
24//! if form.validate(cx) { save(form.value(cx, "email")); }
25//! // in render:
26//! Field::new().error_opt(form.error(cx, "email"))
27//! ```
28
29use std::cell::RefCell;
30use std::collections::{HashMap, HashSet};
31use std::rc::Rc;
32
33use gpui::App;
34
35use super::signal::Signal;
36
37/// A validator: returns `Some(message)` when the value is invalid.
38pub type Validator = Box<dyn Fn(&str) -> Option<String> + 'static>;
39
40/// Form values + validators + the errors produced by the last validation.
41#[derive(Default)]
42pub struct FormState {
43    values: HashMap<&'static str, String>,
44    errors: HashMap<&'static str, String>,
45    validators: HashMap<&'static str, Validator>,
46}
47
48impl FormState {
49    pub fn new() -> Self {
50        FormState::default()
51    }
52
53    /// Register a field with an initial value (builder form).
54    pub fn field(mut self, name: &'static str, initial: impl Into<String>) -> Self {
55        self.values.insert(name, initial.into());
56        self
57    }
58
59    /// Attach a validator to a field (builder form).
60    pub fn validator(mut self, name: &'static str, validator: Validator) -> Self {
61        self.validators.insert(name, validator);
62        self
63    }
64
65    /// The current value of a field (empty string if unset).
66    pub fn value(&self, name: &str) -> &str {
67        self.values.get(name).map(String::as_str).unwrap_or("")
68    }
69
70    /// Set a field's value and clear its error.
71    pub fn set(&mut self, name: &'static str, value: impl Into<String>) {
72        self.values.insert(name, value.into());
73        self.errors.remove(name);
74    }
75
76    /// Validate one field, recording or clearing its error. Returns validity.
77    pub fn validate_field(&mut self, name: &'static str) -> bool {
78        if let Some(validator) = self.validators.get(name) {
79            let value = self.values.get(name).map(String::as_str).unwrap_or("");
80            match validator(value) {
81                Some(message) => {
82                    self.errors.insert(name, message);
83                    return false;
84                }
85                None => {
86                    self.errors.remove(name);
87                }
88            }
89        }
90        true
91    }
92
93    /// Validate every field with a validator. Returns whether all passed.
94    pub fn validate(&mut self) -> bool {
95        let names: Vec<&'static str> = self.validators.keys().copied().collect();
96        let mut ok = true;
97        for name in names {
98            ok &= self.validate_field(name);
99        }
100        ok
101    }
102
103    /// The error message for a field, if the last validation produced one.
104    pub fn error(&self, name: &str) -> Option<&str> {
105        self.errors.get(name).map(String::as_str)
106    }
107
108    /// Whether there are no recorded errors.
109    pub fn is_valid(&self) -> bool {
110        self.errors.is_empty()
111    }
112}
113
114/// A snapshot of every field's value, passed to [`Rule`]s so they can
115/// cross-reference other fields.
116pub type FormValues = HashMap<&'static str, String>;
117
118/// A form-aware validator: sees the field's value and the whole form.
119/// Plain [`Validator`]s lift into rules automatically via [`Form::rule`].
120pub type Rule = Box<dyn Fn(&str, &FormValues) -> Option<String> + 'static>;
121
122/// Built-in validators.
123pub mod validators {
124    use super::{FormValues, Rule, Validator};
125
126    /// Fails when the trimmed value is empty.
127    pub fn required() -> Validator {
128        Box::new(|v: &str| {
129            if v.trim().is_empty() {
130                Some("Required".to_string())
131            } else {
132                None
133            }
134        })
135    }
136
137    /// Fails when the value is shorter than `n` characters.
138    pub fn min_len(n: usize) -> Validator {
139        Box::new(move |v: &str| {
140            if v.chars().count() < n {
141                Some(format!("Must be at least {n} characters"))
142            } else {
143                None
144            }
145        })
146    }
147
148    /// Fails when the value is longer than `n` characters.
149    pub fn max_len(n: usize) -> Validator {
150        Box::new(move |v: &str| {
151            if v.chars().count() > n {
152                Some(format!("Must be at most {n} characters"))
153            } else {
154                None
155            }
156        })
157    }
158
159    /// A permissive `a@b.c` email shape check.
160    pub fn email() -> Validator {
161        Box::new(|v: &str| {
162            let ok = v
163                .split_once('@')
164                .map(|(user, domain)| {
165                    !user.is_empty() && domain.contains('.') && !domain.starts_with('.')
166                })
167                .unwrap_or(false);
168            if ok {
169                None
170            } else {
171                Some("Enter a valid email".to_string())
172            }
173        })
174    }
175
176    /// Fails when the value doesn't parse as a number.
177    pub fn numeric() -> Validator {
178        Box::new(|v: &str| {
179            if v.trim().parse::<f64>().is_ok() {
180                None
181            } else {
182                Some("Enter a number".to_string())
183            }
184        })
185    }
186
187    /// Fails when the value parses below `min` (non-numbers fail too).
188    pub fn min_value(min: f64) -> Validator {
189        Box::new(move |v: &str| match v.trim().parse::<f64>() {
190            Ok(n) if n >= min => None,
191            _ => Some(format!("Must be at least {min}")),
192        })
193    }
194
195    /// Fails when the value parses above `max` (non-numbers fail too).
196    pub fn max_value(max: f64) -> Validator {
197        Box::new(move |v: &str| match v.trim().parse::<f64>() {
198            Ok(n) if n <= max => None,
199            _ => Some(format!("Must be at most {max}")),
200        })
201    }
202
203    /// Fails when the value isn't one of the allowed options.
204    pub fn one_of(options: &'static [&'static str]) -> Validator {
205        Box::new(move |v: &str| {
206            if options.contains(&v) {
207                None
208            } else {
209                Some("Not an allowed value".to_string())
210            }
211        })
212    }
213
214    /// Custom check: `pred` returns whether the value is valid.
215    pub fn matches(pred: impl Fn(&str) -> bool + 'static, message: &'static str) -> Validator {
216        Box::new(move |v: &str| {
217            if pred(v) {
218                None
219            } else {
220                Some(message.to_string())
221            }
222        })
223    }
224
225    /// Cross-field: fails unless this value equals the named field's
226    /// ("confirm password"). A [`Rule`], for [`super::Form::rule_form`].
227    pub fn equals_field(other: &'static str, message: &'static str) -> Rule {
228        Box::new(move |v: &str, values: &FormValues| {
229            if values.get(other).map(String::as_str) == Some(v) {
230                None
231            } else {
232                Some(message.to_string())
233            }
234        })
235    }
236}
237
238/// The reactive form. Cheap to clone (`Rc`-shared) and `'static`, so it can
239/// be captured by handlers. Field order is registration order.
240pub struct Form {
241    inner: Rc<FormInner>,
242}
243
244struct FormInner {
245    order: RefCell<Vec<&'static str>>,
246    fields: RefCell<HashMap<&'static str, Signal<String>>>,
247    rules: RefCell<HashMap<&'static str, Vec<Rule>>>,
248    errors: Signal<HashMap<&'static str, String>>,
249    touched: RefCell<HashSet<&'static str>>,
250}
251
252impl Clone for Form {
253    fn clone(&self) -> Self {
254        Form {
255            inner: self.inner.clone(),
256        }
257    }
258}
259
260impl Form {
261    pub fn new(cx: &mut App) -> Self {
262        Form {
263            inner: Rc::new(FormInner {
264                order: RefCell::new(Vec::new()),
265                fields: RefCell::new(HashMap::new()),
266                rules: RefCell::new(HashMap::new()),
267                errors: Signal::new(cx, HashMap::new()),
268                touched: RefCell::new(HashSet::new()),
269            }),
270        }
271    }
272
273    /// Register a field with an initial value. Each field is a
274    /// `Signal<String>`; edits mark it touched, and a field carrying an error
275    /// re-validates live as it changes.
276    pub fn field(self, cx: &mut App, name: &'static str, initial: impl Into<String>) -> Self {
277        let signal = Signal::new(cx, initial.into());
278        let form = self.clone();
279        cx.observe(signal.entity(), move |_observed, cx| {
280            form.inner.touched.borrow_mut().insert(name);
281            if form.inner.errors.read(cx).contains_key(name) {
282                form.validate_field(cx, name);
283            }
284        })
285        .detach();
286        self.inner.order.borrow_mut().push(name);
287        self.inner.fields.borrow_mut().insert(name, signal);
288        self
289    }
290
291    /// Attach a plain [`Validator`] to a field. Multiple rules run in order;
292    /// the first failure wins.
293    pub fn rule(self, name: &'static str, validator: Validator) -> Self {
294        self.rule_form(name, Box::new(move |value, _values| validator(value)))
295    }
296
297    /// Attach a form-aware [`Rule`] (cross-field checks like
298    /// [`validators::equals_field`]).
299    pub fn rule_form(self, name: &'static str, rule: Rule) -> Self {
300        self.inner
301            .rules
302            .borrow_mut()
303            .entry(name)
304            .or_default()
305            .push(rule);
306        self
307    }
308
309    /// The field's value signal — plug it into `TextInput::bind` and friends.
310    /// Panics on an unregistered name (a typo you want loud).
311    pub fn signal(&self, name: &str) -> Signal<String> {
312        self.inner
313            .fields
314            .borrow()
315            .get(name)
316            .unwrap_or_else(|| panic!("guise: unknown form field {name:?}"))
317            .clone()
318    }
319
320    /// The errors signal (field -> message). `watch` it to re-render on
321    /// validation changes.
322    pub fn errors(&self) -> Signal<HashMap<&'static str, String>> {
323        self.inner.errors.clone()
324    }
325
326    pub fn value(&self, cx: &App, name: &str) -> String {
327        self.signal(name).get(cx)
328    }
329
330    pub fn set(&self, cx: &mut App, name: &str, value: impl Into<String>) {
331        self.signal(name).set_if_changed(cx, value.into());
332    }
333
334    /// Every field's current value, keyed by name.
335    pub fn values(&self, cx: &App) -> FormValues {
336        let fields = self.inner.fields.borrow();
337        fields
338            .iter()
339            .map(|(name, signal)| (*name, signal.get(cx)))
340            .collect()
341    }
342
343    /// The current error for a field, if any.
344    pub fn error(&self, cx: &App, name: &str) -> Option<String> {
345        self.inner.errors.read(cx).get(name).cloned()
346    }
347
348    /// Whether the field has been edited since registration.
349    pub fn touched(&self, name: &str) -> bool {
350        self.inner.touched.borrow().contains(name)
351    }
352
353    /// Run one field's rules. Returns validity and updates the errors signal.
354    pub fn validate_field(&self, cx: &mut App, name: &'static str) -> bool {
355        let values = self.values(cx);
356        let value = values.get(name).cloned().unwrap_or_default();
357        let failure = {
358            let rules = self.inner.rules.borrow();
359            rules
360                .get(name)
361                .and_then(|list| list.iter().find_map(|rule| rule(&value, &values)))
362        };
363        let ok = failure.is_none();
364        self.inner.errors.update(cx, |errors| match failure {
365            Some(message) => {
366                errors.insert(name, message);
367            }
368            None => {
369                errors.remove(name);
370            }
371        });
372        ok
373    }
374
375    /// Run every field's rules (in registration order). Returns whether all
376    /// passed; the errors signal ends up reflecting exactly this pass.
377    pub fn validate(&self, cx: &mut App) -> bool {
378        let names: Vec<&'static str> = self.inner.order.borrow().clone();
379        let mut ok = true;
380        for name in names {
381            ok &= self.validate_field(cx, name);
382        }
383        ok
384    }
385
386    /// Whether the last validation left no errors.
387    pub fn is_valid(&self, cx: &App) -> bool {
388        self.inner.errors.read(cx).is_empty()
389    }
390
391    /// Validate and hand back the values on success — the submit-handler
392    /// one-liner.
393    pub fn submit(&self, cx: &mut App) -> Option<FormValues> {
394        if self.validate(cx) {
395            Some(self.values(cx))
396        } else {
397            None
398        }
399    }
400}
401
402/// Create a reactive form: a [`Signal`] wrapping the given [`FormState`].
403pub fn use_form(cx: &mut App, state: FormState) -> Signal<FormState> {
404    Signal::new(cx, state)
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn required_and_min_len() {
413        let req = validators::required();
414        assert!(req("").is_some());
415        assert!(req("  ").is_some());
416        assert!(req("x").is_none());
417
418        let min = validators::min_len(3);
419        assert!(min("ab").is_some());
420        assert!(min("abc").is_none());
421    }
422
423    #[test]
424    fn email_shape() {
425        let email = validators::email();
426        assert!(email("nope").is_some());
427        assert!(email("a@b").is_some());
428        assert!(email("a@b.com").is_none());
429    }
430
431    #[test]
432    fn length_and_numeric_bounds() {
433        let max = validators::max_len(3);
434        assert!(max("abcd").is_some());
435        assert!(max("abc").is_none());
436
437        let num = validators::numeric();
438        assert!(num("12.5").is_none());
439        assert!(num(" 7 ").is_none());
440        assert!(num("seven").is_some());
441
442        let min = validators::min_value(18.0);
443        assert!(min("17").is_some());
444        assert!(min("18").is_none());
445        assert!(min("x").is_some());
446
447        let max = validators::max_value(100.0);
448        assert!(max("101").is_some());
449        assert!(max("99.9").is_none());
450    }
451
452    #[test]
453    fn one_of_and_matches() {
454        let choice = validators::one_of(&["red", "green", "blue"]);
455        assert!(choice("green").is_none());
456        assert!(choice("mauve").is_some());
457
458        let upper = validators::matches(|v| v.chars().any(char::is_uppercase), "Need a capital");
459        assert!(upper("hello").is_some());
460        assert_eq!(upper("Hello"), None);
461    }
462
463    #[test]
464    fn equals_field_reads_the_other_value() {
465        let rule = validators::equals_field("password", "Must match");
466        let mut values = FormValues::new();
467        values.insert("password", "hunter2".into());
468        assert!(rule("hunter2", &values).is_none());
469        assert_eq!(rule("hunter3", &values), Some("Must match".to_string()));
470        // Missing other field never matches.
471        assert!(rule("", &FormValues::new()).is_some());
472    }
473
474    #[test]
475    fn set_clears_error_then_validate_repopulates() {
476        let mut form = FormState::new()
477            .field("name", "")
478            .validator("name", validators::required());
479        assert!(!form.validate());
480        assert_eq!(form.error("name"), Some("Required"));
481
482        form.set("name", "Ada");
483        // set clears the field's error eagerly.
484        assert_eq!(form.error("name"), None);
485        assert!(form.validate());
486        assert!(form.is_valid());
487    }
488}