Skip to main content

euv_core/reactive/form/
impl.rs

1//! `App::use_form` and the matching `HookContext::form`
2//! factory. Identical pattern to the profiler factory —
3//! see `reactive/profiler/handle.rs` for prior art and the
4//! reasoning behind the downcast-ref + index-increment
5//! implementation.
6use super::*;
7
8impl HookContextFormExt for HookContext {
9    fn form() -> FormState {
10        let hook_context: HookContext = Self::current();
11        let Ok(mut inner) = hook_context.get_inner().try_borrow_mut() else {
12            return FormState::new(
13                Signal::create(HashMap::new()),
14                Signal::create(HashMap::new()),
15                Signal::create(HashSet::new()),
16                Signal::create(false),
17            );
18        };
19        let index: usize = inner.get_hook_index();
20        inner.set_hook_index(index + 1);
21        if index < inner.get_hooks().len()
22            && let Some(existing) = inner.get_hooks()[index].downcast_ref::<FormState>()
23        {
24            return existing.clone();
25        }
26        let state: FormState = FormState::new(
27            Signal::create(HashMap::new()),
28            Signal::create(HashMap::new()),
29            Signal::create(HashSet::new()),
30            Signal::create(false),
31        );
32        if index < inner.get_hooks().len() {
33            inner.get_mut_hooks()[index] = Box::new(state.clone());
34        } else {
35            inner.get_mut_hooks().push(Box::new(state.clone()));
36        }
37        state
38    }
39}
40
41impl FormState {
42    /// Returns a `Signal` clone of the `values` map.
43    ///
44    /// Read with `.get()` inside a render closure to
45    /// subscribe to value changes. Callers needing a
46    /// single field's value should use
47    /// `FormState::field(name)` instead, which is a
48    /// convenience that avoids re-reading the whole map.
49    pub fn values(&self) -> Signal<HashMap<&'static str, String>> {
50        self.get_values().clone()
51    }
52
53    /// Returns a `Signal` clone of the `errors` map.
54    pub fn errors(&self) -> Signal<HashMap<&'static str, String>> {
55        self.get_errors().clone()
56    }
57
58    /// Returns a `Signal` clone of the `touched` set.
59    pub fn touched(&self) -> Signal<HashSet<&'static str>> {
60        self.get_touched().clone()
61    }
62
63    /// Returns a `Signal<bool>` clone of the `submitting`
64    /// flag.
65    pub fn submitting(&self) -> Signal<bool> {
66        self.get_submitting().clone()
67    }
68
69    /// Returns the current value of the named field, or
70    /// `""` if the field has never been set.
71    ///
72    /// This is a snapshot read, not a subscription —
73    /// callers inside a render closure that want to
74    /// re-render on value changes should use
75    /// `state.values().get().get(name).cloned().unwrap_or_default()`
76    /// instead, so the closure actually subscribes.
77    pub fn field(&self, name: &'static str) -> String {
78        self.get_values()
79            .get()
80            .get(name)
81            .cloned()
82            .unwrap_or_default()
83    }
84
85    /// Returns the current error for the named field, or
86    /// `""` if the field has no error.
87    ///
88    /// Snapshot read — see `field` for the subscription
89    /// caveat.
90    pub fn error(&self, name: &'static str) -> String {
91        self.get_errors()
92            .get()
93            .get(name)
94            .cloned()
95            .unwrap_or_default()
96    }
97
98    /// Returns `true` if the user has interacted with the
99    /// named field.
100    pub fn is_touched(&self, name: &'static str) -> bool {
101        self.get_touched().get().contains(name)
102    }
103
104    /// Sets the value of the named field.
105    ///
106    /// Marks the field as touched (mirroring the
107    /// `oninput` event that triggered the call) and
108    /// clears any prior error for the field. The error
109    /// clear is a UX choice — the next `validate` call
110    /// will repopulate it if the new value is still
111    /// invalid.
112    pub fn set_field(&self, name: &'static str, value: &str) {
113        let mut current: HashMap<&'static str, String> = self.get_values().get();
114        current.insert(name, value.to_string());
115        self.get_values().set(current);
116
117        let mut touched: HashSet<&'static str> = self.get_touched().get();
118        touched.insert(name);
119        self.get_touched().set(touched);
120
121        let mut errors: HashMap<&'static str, String> = self.get_errors().get();
122        errors.remove(name);
123        self.get_errors().set(errors);
124    }
125
126    /// Marks the named field as touched without changing
127    /// its value. Used by `onblur` handlers — "the user
128    /// left this field, so it counts as interacted".
129    pub fn touch(&self, name: &'static str) {
130        let mut touched: HashSet<&'static str> = self.get_touched().get();
131        touched.insert(name);
132        self.get_touched().set(touched);
133    }
134
135    /// Runs every validator in `validators` and updates the
136    /// `errors` signal.
137    ///
138    /// Returns `true` if every field validated
139    /// successfully (i.e. every validator returned
140    /// `None`), `false` otherwise. The errors signal is
141    /// always updated, regardless of return value —
142    /// callers should call `validate` and then branch on
143    /// the boolean.
144    ///
145    /// Fields with no validator are silently skipped —
146    /// they cannot produce an error.
147    ///
148    /// # Arguments
149    ///
150    /// - `&HashMap<&'static str, Validator>` -
151    ///   Per-field validator map. Each validator is a
152    ///   closure that takes the current value and
153    ///   returns `Some(error_message)` or `None`.
154    pub fn validate(&self, validators: &HashMap<&'static str, Validator>) -> bool {
155        let values: HashMap<&'static str, String> = self.get_values().get();
156        let mut next_errors: HashMap<&'static str, String> = HashMap::new();
157        let mut all_valid: bool = true;
158        for (name, validator) in validators.iter() {
159            let current_value: &str = values.get(name).map(String::as_str).unwrap_or("");
160            if let Some(error_message) = validator(current_value) {
161                if !error_message.is_empty() {
162                    all_valid = false;
163                }
164                next_errors.insert(name, error_message);
165            }
166        }
167        self.get_errors().set(next_errors);
168        all_valid
169    }
170
171    /// Runs the user-supplied submit handler if all
172    /// validators pass.
173    ///
174    /// Sets `submitting` to `true` for the duration of the
175    /// call (so a `disabled={state.submitting().get()}`
176    /// button stays disabled until the handler returns),
177    /// then resets it to `false`. If validators were
178    /// supplied AND at least one field failed validation,
179    /// the submit handler is NOT invoked and `submitting`
180    /// is left `false`.
181    ///
182    /// Returns `true` if the handler was invoked,
183    /// `false` if validation failed and the handler was
184    /// skipped.
185    ///
186    /// # Arguments
187    ///
188    /// - `&HashMap<&'static str, Validator>` -
189    ///   Validators to run before invoking the handler.
190    ///   Pass an empty map to skip validation entirely
191    ///   (the handler always runs).
192    /// - `impl FnOnce(&HashMap<&'static str, String>)` -
193    ///   The submit handler. Receives the current values
194    ///   map by reference — clone what you need to keep
195    ///   past the call.
196    pub fn submit<F>(&self, validators: &HashMap<&'static str, Validator>, on_submit: F) -> bool
197    where
198        F: FnOnce(&HashMap<&'static str, String>),
199    {
200        let all_valid: bool = if validators.is_empty() {
201            true
202        } else {
203            self.validate(validators)
204        };
205        if !all_valid {
206            return false;
207        }
208        self.get_submitting().set(true);
209        let snapshot: HashMap<&'static str, String> = self.get_values().get();
210        on_submit(&snapshot);
211        self.get_submitting().set(false);
212        true
213    }
214
215    /// Clears values, errors, and touched state. Leaves
216    /// `submitting` untouched (it should already be
217    /// `false`).
218    ///
219    /// Useful for "form submitted successfully, reset for
220    /// the next entry" UX flows.
221    pub fn reset(&self) {
222        self.get_values().set(HashMap::new());
223        self.get_errors().set(HashMap::new());
224        self.get_touched().set(HashSet::new());
225    }
226
227    /// Returns the number of fields that currently have
228    /// a non-empty error. Useful for "submit button stays
229    /// disabled until form is valid" without re-running
230    /// validation.
231    pub fn error_count(&self) -> usize {
232        self.get_errors()
233            .get()
234            .values()
235            .filter(|message: &&String| !message.is_empty())
236            .count()
237    }
238}