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)| !user.is_empty() && domain.contains('.') && !domain.starts_with('.'))
165        .unwrap_or(false);
166      if ok {
167        None
168      } else {
169        Some("Enter a valid email".to_string())
170      }
171    })
172  }
173
174  /// Fails when the value doesn't parse as a number.
175  pub fn numeric() -> Validator {
176    Box::new(|v: &str| {
177      if v.trim().parse::<f64>().is_ok() {
178        None
179      } else {
180        Some("Enter a number".to_string())
181      }
182    })
183  }
184
185  /// Fails when the value parses below `min` (non-numbers fail too).
186  pub fn min_value(min: f64) -> Validator {
187    Box::new(move |v: &str| match v.trim().parse::<f64>() {
188      Ok(n) if n >= min => None,
189      _ => Some(format!("Must be at least {min}")),
190    })
191  }
192
193  /// Fails when the value parses above `max` (non-numbers fail too).
194  pub fn max_value(max: f64) -> Validator {
195    Box::new(move |v: &str| match v.trim().parse::<f64>() {
196      Ok(n) if n <= max => None,
197      _ => Some(format!("Must be at most {max}")),
198    })
199  }
200
201  /// Fails when the value isn't one of the allowed options.
202  pub fn one_of(options: &'static [&'static str]) -> Validator {
203    Box::new(move |v: &str| {
204      if options.contains(&v) {
205        None
206      } else {
207        Some("Not an allowed value".to_string())
208      }
209    })
210  }
211
212  /// Custom check: `pred` returns whether the value is valid.
213  pub fn matches(pred: impl Fn(&str) -> bool + 'static, message: &'static str) -> Validator {
214    Box::new(move |v: &str| {
215      if pred(v) {
216        None
217      } else {
218        Some(message.to_string())
219      }
220    })
221  }
222
223  /// Cross-field: fails unless this value equals the named field's
224  /// ("confirm password"). A [`Rule`], for [`super::Form::rule_form`].
225  pub fn equals_field(other: &'static str, message: &'static str) -> Rule {
226    Box::new(move |v: &str, values: &FormValues| {
227      if values.get(other).map(String::as_str) == Some(v) {
228        None
229      } else {
230        Some(message.to_string())
231      }
232    })
233  }
234}
235
236/// The reactive form. Cheap to clone (`Rc`-shared) and `'static`, so it can
237/// be captured by handlers. Field order is registration order.
238pub struct Form {
239  inner: Rc<FormInner>,
240}
241
242struct FormInner {
243  order: RefCell<Vec<&'static str>>,
244  fields: RefCell<HashMap<&'static str, Signal<String>>>,
245  rules: RefCell<HashMap<&'static str, Vec<Rule>>>,
246  errors: Signal<HashMap<&'static str, String>>,
247  touched: RefCell<HashSet<&'static str>>,
248}
249
250impl Clone for Form {
251  fn clone(&self) -> Self {
252    Form {
253      inner: self.inner.clone(),
254    }
255  }
256}
257
258impl Form {
259  pub fn new(cx: &mut App) -> Self {
260    Form {
261      inner: Rc::new(FormInner {
262        order: RefCell::new(Vec::new()),
263        fields: RefCell::new(HashMap::new()),
264        rules: RefCell::new(HashMap::new()),
265        errors: Signal::new(cx, HashMap::new()),
266        touched: RefCell::new(HashSet::new()),
267      }),
268    }
269  }
270
271  /// Register a field with an initial value. Each field is a
272  /// `Signal<String>`; edits mark it touched, and a field carrying an error
273  /// re-validates live as it changes.
274  pub fn field(self, cx: &mut App, name: &'static str, initial: impl Into<String>) -> Self {
275    let signal = Signal::new(cx, initial.into());
276    let form = self.clone();
277    cx.observe(signal.entity(), move |_observed, cx| {
278      form.inner.touched.borrow_mut().insert(name);
279      if form.inner.errors.read(cx).contains_key(name) {
280        form.validate_field(cx, name);
281      }
282    })
283    .detach();
284    self.inner.order.borrow_mut().push(name);
285    self.inner.fields.borrow_mut().insert(name, signal);
286    self
287  }
288
289  /// Attach a plain [`Validator`] to a field. Multiple rules run in order;
290  /// the first failure wins.
291  pub fn rule(self, name: &'static str, validator: Validator) -> Self {
292    self.rule_form(name, Box::new(move |value, _values| validator(value)))
293  }
294
295  /// Attach a form-aware [`Rule`] (cross-field checks like
296  /// [`validators::equals_field`]).
297  pub fn rule_form(self, name: &'static str, rule: Rule) -> Self {
298    self
299      .inner
300      .rules
301      .borrow_mut()
302      .entry(name)
303      .or_default()
304      .push(rule);
305    self
306  }
307
308  /// The field's value signal — plug it into `TextInput::bind` and friends.
309  /// Panics on an unregistered name (a typo you want loud).
310  pub fn signal(&self, name: &str) -> Signal<String> {
311    self
312      .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}