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}