Skip to main content

herogpui_components/
validation.rs

1//! The shared `validate` / `validationErrors` model.
2//!
3//! v3's `validate` is a function the *component* runs: it receives the current
4//! value and returns either nothing or a message to display. Treating it as
5//! "the caller validates and passes `isInvalid`" loses that — the component
6//! never runs anything and the prop does not exist. This module is the shape
7//! every field uses instead, so the eight components that document `validate`
8//! behave identically.
9
10use std::collections::BTreeMap;
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::Arc;
13
14use gpui::SharedString;
15
16/// Mints record identity: every *constructed or rebuilt* [`ValidationErrors`]
17/// is a new server response, while a `clone` keeps the revision it was copied
18/// from. Structural equality cannot carry this — a re-sent response can be
19/// content-equal to the old one and must still re-arm.
20static NEXT_RECORD_REVISION: AtomicU64 = AtomicU64::new(1);
21
22fn next_record_revision() -> u64 {
23    NEXT_RECORD_REVISION.fetch_add(1, Ordering::Relaxed)
24}
25
26/// HeroUI v3's `ValidationErrors` — `Record<string, string | string[]>`, the
27/// server-side errors [`Form`](crate::form::Form) maps by field name.
28///
29/// The record is a *name-keyed mapping*, not a message list: the form routes
30/// each entry into the named field's own error slot, never into a form-level
31/// stack. Names the fields do not register display nowhere and block nothing.
32///
33/// Identity is part of the contract. Delivery is keyed to [`Self::revision`],
34/// not to equality:
35///
36/// - a `clone` keeps its revision, so re-rendering with the same record never
37///   resurrects a message the user edited away;
38/// - any freshly built record — [`new`](Self::new), [`default`](Default::default)
39///   or [`set`](Self::set) — mints a new revision and re-arms every named
40///   field, even when the content equals the old record (a server that
41///   re-sends the same error is a new response, not silence).
42///
43/// The caller half of that contract: *retain one record for as long as a
44/// response is current* — in app state, a `thread_local`, wherever the frame
45/// rebuilds from — and hand the form a [`Clone`] of it every frame.
46/// Constructing a fresh record per frame (`ValidationErrors::new()`, or
47/// re-running the builder chain in `render`) is a brand-new server response
48/// every frame and re-arms every named field every frame, however equal the
49/// content looks. This is exactly React Stately's reference identity: what
50/// re-arms is a *new record*, never a re-sent one.
51///
52/// [`PartialEq`] compares content only, so records can be compared while the
53/// identity stays a separate, explicit question.
54#[derive(Clone, Debug)]
55pub struct ValidationErrors {
56    revision: u64,
57    entries: BTreeMap<SharedString, Vec<SharedString>>,
58}
59
60impl ValidationErrors {
61    /// A new, empty record: a fresh server response.
62    pub fn new() -> Self {
63        Self {
64            revision: next_record_revision(),
65            entries: BTreeMap::new(),
66        }
67    }
68
69    /// The record's identity. Two records with equal content but different
70    /// revisions are two different responses; a clone shares the revision.
71    pub fn revision(&self) -> u64 {
72        self.revision
73    }
74
75    /// Whether no name carries a message.
76    pub fn is_empty(&self) -> bool {
77        self.entries.is_empty()
78    }
79
80    /// The messages recorded for `name` — `record[name]`, `None` when absent.
81    pub fn get(&self, name: &str) -> Option<&[SharedString]> {
82        self.entries.get(name).map(Vec::as_slice)
83    }
84
85    /// Iterates `(name, messages)` in deterministic name order.
86    pub fn iter(&self) -> impl Iterator<Item = (&SharedString, &[SharedString])> {
87        self.entries
88            .iter()
89            .map(|(name, messages)| (name, &**messages))
90    }
91
92    /// The names carrying at least one message, in deterministic name order.
93    pub fn names(&self) -> impl Iterator<Item = &SharedString> {
94        self.entries.keys()
95    }
96
97    /// `record[name] = message` — the single-message spelling.
98    ///
99    /// Rebuilding a record is a new response: the revision is minted, so a
100    /// form handed the result re-arms its fields.
101    pub fn set(mut self, name: impl Into<SharedString>, message: impl Into<SharedString>) -> Self {
102        self.entries.insert(name.into(), vec![message.into()]);
103        self.revision = next_record_revision();
104        self
105    }
106
107    /// `record[name] = [m0, m1, ...]` — the multiple-messages spelling, shown
108    /// in the order given.
109    pub fn set_many(
110        mut self,
111        name: impl Into<SharedString>,
112        messages: impl IntoIterator<Item = impl Into<SharedString>>,
113    ) -> Self {
114        let messages: Vec<SharedString> = messages.into_iter().map(Into::into).collect();
115        self.entries.insert(name.into(), messages);
116        self.revision = next_record_revision();
117        self
118    }
119}
120
121impl Default for ValidationErrors {
122    fn default() -> Self {
123        Self::new()
124    }
125}
126
127impl PartialEq for ValidationErrors {
128    /// Content equality. Identity is [`Self::revision`] and deliberately
129    /// excluded: two content-equal records can be two different responses.
130    fn eq(&self, other: &Self) -> bool {
131        self.entries == other.entries
132    }
133}
134
135impl Eq for ValidationErrors {}
136
137/// `validate` — returns `None` when the value is acceptable, or the message to
138/// show when it is not.
139///
140/// v3 types this `(value) => ValidationError | true | null | undefined`, where
141/// a returned error marks the field invalid; `None` here is that `null`.
142pub type Validator<T> = Arc<dyn Fn(&T) -> Option<SharedString> + 'static>;
143// `T` may be unsized (`str`); a type alias does not enforce bounds, so none
144// is written here.
145
146/// The messages a field should display, and whether it is invalid.
147///
148/// Order matches v3: an explicit `isInvalid` always wins, then server-supplied
149/// `validationErrors`, then whatever `validate` returns.
150#[derive(Clone, Debug, Default, PartialEq, Eq)]
151pub struct Validity {
152    /// Whether the value is invalid.
153    pub is_invalid: bool,
154    /// The validation messages, if any.
155    pub messages: Vec<SharedString>,
156}
157
158impl Validity {
159    /// The first message, if any.
160    ///
161    /// The single-line text family — `Input`, `NumberField`, `InputOTP` —
162    /// no longer renders this: their error slots show every message through
163    /// [`Self::joined`], the way React Aria's `FieldError` default renders
164    /// them. The compound controls (Switch, Checkbox, the date and time
165    /// fields, the colour pickers) still show the one message this returns.
166    pub fn first(&self) -> Option<SharedString> {
167        self.messages.first().cloned()
168    }
169
170    /// Every message joined the way React Aria's `FieldError` default renders
171    /// them: one string, messages space-joined in upstream order.
172    pub fn joined(&self) -> String {
173        self.messages
174            .iter()
175            .map(|message| &message[..])
176            .collect::<Vec<&str>>()
177            .join(" ")
178    }
179}
180
181/// Resolves a field's validity from the three sources v3 defines.
182///
183/// * `is_invalid` — the controlled flag; forces invalid even with no message.
184/// * `errors` — `validationErrors`, e.g. from a server round-trip.
185/// * `validate_result` — whatever the `validate` function returned.
186/// * `own_message` — the component's own `errorMessage` prop.
187pub fn resolve(
188    is_invalid: bool,
189    errors: &[SharedString],
190    validate_result: Option<SharedString>,
191    own_message: Option<SharedString>,
192) -> Validity {
193    let mut messages: Vec<SharedString> = Vec::new();
194    messages.extend(errors.iter().cloned());
195    if let Some(m) = validate_result {
196        messages.push(m);
197    }
198    // The component's own message is the fallback, not an addition: showing it
199    // alongside a specific validation failure would duplicate the reason.
200    if messages.is_empty() {
201        if let Some(m) = own_message {
202            messages.push(m);
203        }
204    }
205    Validity {
206        is_invalid: is_invalid || !messages.is_empty(),
207        messages,
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    fn s(v: &str) -> SharedString {
216        SharedString::from(v.to_owned())
217    }
218
219    #[test]
220    fn clean_value_is_valid() {
221        let v = resolve(false, &[], None, None);
222        assert!(!v.is_invalid);
223        assert!(v.messages.is_empty());
224        assert_eq!(v.first(), None);
225    }
226
227    #[test]
228    fn is_invalid_alone_marks_the_field() {
229        // No message, but the caller says it is wrong.
230        let v = resolve(true, &[], None, None);
231        assert!(v.is_invalid);
232        assert!(v.messages.is_empty());
233    }
234
235    #[test]
236    fn a_validate_failure_marks_and_reports() {
237        let v = resolve(false, &[], Some(s("Too short")), None);
238        assert!(v.is_invalid);
239        assert_eq!(v.first(), Some(s("Too short")));
240    }
241
242    #[test]
243    fn server_errors_come_first() {
244        let v = resolve(false, &[s("Already taken")], Some(s("Too short")), None);
245        assert_eq!(v.messages, vec![s("Already taken"), s("Too short")]);
246        assert_eq!(v.first(), Some(s("Already taken")));
247    }
248
249    #[test]
250    fn own_message_is_a_fallback_not_an_addition() {
251        // With a specific failure, the generic message would duplicate it.
252        let v = resolve(
253            false,
254            &[],
255            Some(s("Too short")),
256            Some(s("Check this field")),
257        );
258        assert_eq!(v.messages, vec![s("Too short")]);
259        // With nothing specific, it is what the field shows.
260        let v = resolve(false, &[], None, Some(s("Check this field")));
261        assert_eq!(v.messages, vec![s("Check this field")]);
262        assert!(v.is_invalid);
263    }
264
265    #[test]
266    fn joined_renders_every_message_in_upstream_order() {
267        // React Aria's FieldError default is `validationErrors.join(' ')`:
268        // all messages, one string, upstream order.
269        let v = resolve(
270            false,
271            &[s("Already registered"), s("Check the server")],
272            Some(s("Too short")),
273            None,
274        );
275        assert_eq!(v.joined(), "Already registered Check the server Too short");
276        assert_eq!(v.first(), Some(s("Already registered")));
277        assert_eq!(resolve(false, &[], None, None).joined(), "");
278    }
279
280    #[test]
281    fn a_record_maps_names_to_one_or_many_messages() {
282        let record = ValidationErrors::new()
283            .set("email", "Already registered")
284            .set_many("roles", ["Role A", "Role B"]);
285        assert_eq!(record.get("email"), Some(&[s("Already registered")][..]));
286        assert_eq!(record.get("roles"), Some(&[s("Role A"), s("Role B")][..]));
287        assert_eq!(record.get("absent"), None);
288        assert!(!record.is_empty());
289        // Deterministic name order, whatever the insertion order was.
290        assert_eq!(
291            record.names().cloned().collect::<Vec<_>>(),
292            vec![s("email"), s("roles")]
293        );
294        assert_eq!(record.iter().count(), 2);
295        assert!(ValidationErrors::new().is_empty());
296    }
297
298    #[test]
299    fn a_clone_keeps_record_identity_while_a_new_record_mints() {
300        let record = ValidationErrors::new().set("email", "Taken");
301        let clone = record.clone();
302        assert_eq!(clone.revision(), record.revision(), "clones share identity");
303        assert_eq!(clone, record, "equality is content only");
304
305        // The same content, rebuilt: a new response, not the old one.
306        let rebuilt = ValidationErrors::new().set("email", "Taken");
307        assert_eq!(rebuilt, record, "content equality holds");
308        assert_ne!(
309            rebuilt.revision(),
310            record.revision(),
311            "a genuinely new record must carry a new revision"
312        );
313        assert_ne!(
314            ValidationErrors::default().revision(),
315            ValidationErrors::default().revision(),
316            "even two defaults are distinct responses"
317        );
318
319        // Rebuilding through a builder mints too: `default().set(..)` is a
320        // fresh record, never a revision-0 orphan that would dedup against
321        // another default-built record.
322        let built = ValidationErrors::default().set("email", "Taken");
323        assert_ne!(built.revision(), 0);
324        assert_ne!(built.revision(), ValidationErrors::default().revision());
325    }
326}