Skip to main content

rosace_forms/
field.rs

1use std::sync::Arc;
2use rosace_state::use_atom;
3use crate::validator::Validator;
4use crate::error::FieldError;
5
6/// A single form field: a name, a shared string value, and validation
7/// rules. Cloning a `FormField` is cheap and shares state (D116 Phase 28
8/// Step 8) — every clone reads/writes the SAME underlying atoms, the same
9/// "clone shares identity" convention `EditController`/`ScrollController`
10/// already use in this codebase. This is what lets `TextInput::field(f)`,
11/// `Form::field(f)`, and the app's own submit-button closure all see the
12/// same live value/touched/errors without any manual synchronization.
13#[derive(Clone)]
14pub struct FormField {
15    pub name: String,
16    value: rosace_state::Atom<String>,
17    validators: Vec<Arc<dyn Validator>>,
18    /// Last validation errors (populated by `validate()`).
19    errors: rosace_state::Atom<Vec<FieldError>>,
20    /// Whether the field has been interacted with (touched = show errors).
21    touched: rosace_state::Atom<bool>,
22}
23
24impl FormField {
25    /// Plain constructor — the atoms it creates (`use_atom`) are NOT tied
26    /// to any component, so nothing rebuilds when they change. Call this
27    /// directly only when you don't need live UI updates (e.g. a
28    /// throwaway validation check); for a real form field bound to a
29    /// widget, use [`FormField::for_ctx`] instead.
30    pub fn new(name: impl Into<String>) -> Self {
31        Self {
32            name: name.into(),
33            value: use_atom(String::new()),
34            validators: Vec::new(),
35            errors: use_atom(Vec::new()),
36            touched: use_atom(false),
37        }
38    }
39
40    /// Create (or retrieve) a field persisted in component state — the
41    /// value/touched/errors survive rebuilds AND writing to them re-dirties
42    /// the owning component (so a submit button's disabled state and an
43    /// inline error message actually refresh live). Follows the same hook
44    /// rules as `ctx.state`/`ScrollController::for_ctx`: call
45    /// unconditionally in `build()`, stable order.
46    pub fn for_ctx(ctx: &mut rosace_core::Context, name: impl Into<String>) -> Self {
47        let field = ctx.state(Self::new(name)).get();
48        // The inner atoms are framework-created (`use_atom`) — nothing
49        // subscribes to them by default, so a `.set()`/`.validate()` would
50        // request a frame that repaints nothing (cache-hit). Subscribing
51        // the owning component makes field writes dirty it like `ctx.state`
52        // atoms do (duplicate subscribes are ignored).
53        let id = ctx.component_id();
54        field.value.subscribe(id);
55        field.touched.subscribe(id);
56        field.errors.subscribe(id);
57        field
58    }
59
60    pub fn with_value(self, v: impl Into<String>) -> Self {
61        self.value.set(v.into());
62        self
63    }
64
65    pub fn rule(mut self, v: impl Validator) -> Self {
66        self.validators.push(Arc::new(v));
67        self
68    }
69
70    /// Current string value.
71    pub fn get(&self) -> String { self.value.get() }
72
73    /// Set the string value and mark the field touched.
74    pub fn set(&self, v: impl Into<String>) {
75        self.touched.set(true);
76        self.value.set(v.into());
77    }
78
79    /// Run all validators against the current value, publish the result,
80    /// and return whether it passed. `&self`, not `&mut self` — every
81    /// clone of this field shares the same underlying atoms, so any
82    /// clone can validate and every other clone (and the app's own
83    /// `Form`) sees the result immediately.
84    pub fn validate(&self) -> bool {
85        let val = self.value.get();
86        let errs: Vec<FieldError> = self.validators.iter()
87            .filter_map(|v| v.validate(&val).map(|msg| FieldError::new(&self.name, msg)))
88            .collect();
89        let ok = errs.is_empty();
90        self.errors.set(errs);
91        ok
92    }
93
94    /// Current validation errors (from the last `validate()` call).
95    pub fn errors(&self) -> Vec<FieldError> { self.errors.get() }
96
97    /// True if the field has no validation errors after the last
98    /// `validate()` call. Defaults to `true` before the first
99    /// `validate()` — an unvalidated field isn't KNOWN invalid; callers
100    /// that need "definitely passes all rules" should call `validate()`
101    /// (or rely on Step 8's live-validating `.field()` binding, which
102    /// validates on every edit) before trusting this for gating.
103    pub fn is_valid(&self) -> bool { self.errors.get().is_empty() }
104
105    /// True if the field has been interacted with (`set()` called at
106    /// least once) — the standard "don't show errors until touched"
107    /// convention, so a blank required field doesn't show red before the
108    /// user has even had a chance to fill it in.
109    pub fn is_touched(&self) -> bool { self.touched.get() }
110
111    /// Reset value, errors, and touched state.
112    pub fn reset(&self) {
113        self.value.set(String::new());
114        self.errors.set(Vec::new());
115        self.touched.set(false);
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::validator::{Required, MinLength};
123
124    #[test]
125    fn form_field_new_empty() {
126        let f = FormField::new("username");
127        assert_eq!(f.name, "username");
128        assert_eq!(f.get(), "");
129        assert!(!f.is_touched());
130        assert!(f.errors().is_empty());
131    }
132
133    #[test]
134    fn form_field_set_marks_touched() {
135        let f = FormField::new("username");
136        f.set("alice");
137        assert!(f.is_touched());
138        assert_eq!(f.get(), "alice");
139    }
140
141    #[test]
142    fn form_field_validate_no_rules_passes() {
143        let f = FormField::new("bio");
144        assert!(f.validate());
145        assert!(f.is_valid());
146    }
147
148    #[test]
149    fn form_field_validate_required_fails_empty() {
150        let f = FormField::new("name").rule(Required);
151        assert!(!f.validate());
152        assert!(!f.is_valid());
153    }
154
155    #[test]
156    fn form_field_validate_passes_with_value() {
157        let f = FormField::new("name").rule(Required);
158        f.set("alice");
159        assert!(f.validate());
160        assert!(f.is_valid());
161    }
162
163    #[test]
164    fn form_field_multiple_rules_all_checked() {
165        let f = FormField::new("name").rule(Required).rule(MinLength(5));
166        f.set("ab");
167        assert!(!f.validate());
168        // Only MinLength fails (Required passes since "ab" is non-empty)
169        assert_eq!(f.errors().len(), 1);
170        assert!(f.errors()[0].message.contains("5 characters"));
171    }
172
173    #[test]
174    fn form_field_errors_after_validate() {
175        let f = FormField::new("email").rule(Required);
176        f.validate();
177        assert!(!f.errors().is_empty());
178        assert_eq!(f.errors()[0].field, "email");
179    }
180
181    #[test]
182    fn form_field_reset_clears() {
183        let f = FormField::new("name").rule(Required);
184        f.set("alice");
185        f.validate();
186        f.reset();
187        assert_eq!(f.get(), "");
188        assert!(!f.is_touched());
189        assert!(f.errors().is_empty());
190    }
191
192    #[test]
193    fn form_field_with_value() {
194        let f = FormField::new("city").with_value("London");
195        assert_eq!(f.get(), "London");
196    }
197
198    #[test]
199    fn cloning_a_field_shares_the_same_live_state() {
200        // The whole point of the atom-backed redesign (D116 Step 8): a
201        // clone handed to a widget and the original kept by the app must
202        // see each other's writes.
203        let original = FormField::new("name").rule(Required);
204        let widget_copy = original.clone();
205        widget_copy.set("alice");
206        assert_eq!(original.get(), "alice", "a clone's write must be visible through the original handle");
207        assert!(original.is_touched());
208        original.validate();
209        assert!(widget_copy.is_valid(), "a clone must see validation results run through a DIFFERENT clone");
210    }
211}