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}