Skip to main content

tpt_appfront_core/
form.rs

1//! `use_form`: a controlled-form helper bundling named signal-backed fields
2//! (string fields for `Input`/`Textarea`/`Select`/`Radio`, boolean fields for
3//! `Checkbox`) plus lightweight per-field validation — the "controlled-input
4//! helper" companion to the `Input`/`Textarea`/`Checkbox`/`Select`/`Radio`
5//! `NodeKind`s, mirroring React's controlled-form ergonomics without a full
6//! form library.
7//!
8//! Each field is its own [`Signal`], so binding a field's value to a node
9//! (and its validator's error, if any, to another) re-renders only that
10//! node's subtree on change — not the whole form.
11
12use std::cell::RefCell;
13use std::collections::HashMap;
14use std::rc::Rc;
15
16use crate::signal::Signal;
17
18type FieldValidator = dyn Fn(&str) -> Option<String>;
19
20struct StringField {
21    value: Signal<String>,
22    error: Signal<Option<String>>,
23    validator: Option<Rc<FieldValidator>>,
24}
25
26/// A controlled-form helper. Create one per form (typically stored in a
27/// component's local state / captured by its closures) and bind fields to
28/// `Input`/`Textarea`/`Select`/`Radio`/`Checkbox` nodes via
29/// [`FormState::field`]/[`FormState::checkbox`] for the current value and
30/// [`FormState::set`]/[`FormState::set_checkbox`] (typically from
31/// `.on_input`/`.on_toggle`) to write new values back.
32#[derive(Clone)]
33pub struct FormState {
34    fields: Rc<RefCell<HashMap<String, StringField>>>,
35    checks: Rc<RefCell<HashMap<String, Signal<bool>>>>,
36}
37
38impl FormState {
39    pub fn new() -> Self {
40        FormState {
41            fields: Rc::new(RefCell::new(HashMap::new())),
42            checks: Rc::new(RefCell::new(HashMap::new())),
43        }
44    }
45
46    /// The value signal for string field `name`, creating it with `default`
47    /// on first access. Read this to bind a node's current value.
48    pub fn field(&self, name: &str, default: impl Into<String>) -> Signal<String> {
49        self.fields
50            .borrow_mut()
51            .entry(name.to_string())
52            .or_insert_with(|| StringField {
53                value: Signal::new(default.into()),
54                error: Signal::new(None),
55                validator: None,
56            })
57            .value
58            .clone()
59    }
60
61    /// Registers a validator for `name`, run against every new value passed
62    /// to [`FormState::set`]. `f` returns `Some(message)` to reject the value
63    /// (recorded, but the value is still stored — callers can still see what
64    /// the user typed) or `None` when it's valid.
65    pub fn validate(&self, name: &str, f: impl Fn(&str) -> Option<String> + 'static) {
66        self.fields
67            .borrow_mut()
68            .entry(name.to_string())
69            .or_insert_with(|| StringField {
70                value: Signal::new(String::new()),
71                error: Signal::new(None),
72                validator: None,
73            })
74            .validator = Some(Rc::new(f));
75    }
76
77    /// Sets `name`'s value (creating the field with an empty default if this
78    /// is the first write) and re-runs its validator.
79    pub fn set(&self, name: &str, value: impl Into<String>) {
80        let value = value.into();
81        let mut fields = self.fields.borrow_mut();
82        let field = fields
83            .entry(name.to_string())
84            .or_insert_with(|| StringField {
85                value: Signal::new(String::new()),
86                error: Signal::new(None),
87                validator: None,
88            });
89        let error = field.validator.as_ref().and_then(|v| v(&value));
90        field.value.set(value);
91        field.error.set(error);
92    }
93
94    /// The current validation error for string field `name`, if any.
95    pub fn error(&self, name: &str) -> Option<String> {
96        self.fields.borrow().get(name).and_then(|f| f.error.get())
97    }
98
99    /// The boolean field backing a `Checkbox`, creating it with `default` on
100    /// first access.
101    pub fn checkbox(&self, name: &str, default: bool) -> Signal<bool> {
102        self.checks
103            .borrow_mut()
104            .entry(name.to_string())
105            .or_insert_with(|| Signal::new(default))
106            .clone()
107    }
108
109    /// Sets boolean field `name` (creating it if this is the first write).
110    pub fn set_checkbox(&self, name: &str, value: bool) {
111        self.checks
112            .borrow_mut()
113            .entry(name.to_string())
114            .or_insert_with(|| Signal::new(value))
115            .set(value);
116    }
117
118    /// `true` if every registered string field currently has no validation
119    /// error. Fields with no validator (or never validated) are always
120    /// considered valid.
121    pub fn is_valid(&self) -> bool {
122        self.fields.borrow().values().all(|f| f.error.get().is_none())
123    }
124
125    /// Snapshot of every string field's current value (e.g. for submission),
126    /// keyed by field name. Boolean (`Checkbox`) fields aren't included —
127    /// read those individually via [`FormState::checkbox`].
128    pub fn values(&self) -> HashMap<String, String> {
129        self.fields
130            .borrow()
131            .iter()
132            .map(|(k, f)| (k.clone(), f.value.get()))
133            .collect()
134    }
135}
136
137impl Default for FormState {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn field_defaults_and_updates() {
149        let form = FormState::new();
150        let email = form.field("email", "a@example.com");
151        assert_eq!(email.get(), "a@example.com");
152
153        form.set("email", "b@example.com");
154        assert_eq!(email.get(), "b@example.com");
155        assert_eq!(form.field("email", "unused").get(), "b@example.com");
156    }
157
158    #[test]
159    fn validator_runs_on_set_and_reports_error() {
160        let form = FormState::new();
161        form.field("age", "");
162        form.validate("age", |v| {
163            if v.parse::<u32>().is_ok() {
164                None
165            } else {
166                Some("must be a number".to_string())
167            }
168        });
169
170        form.set("age", "abc");
171        assert_eq!(form.error("age").as_deref(), Some("must be a number"));
172        assert!(!form.is_valid());
173
174        form.set("age", "42");
175        assert_eq!(form.error("age"), None);
176        assert!(form.is_valid());
177    }
178
179    #[test]
180    fn checkbox_field_defaults_and_updates() {
181        let form = FormState::new();
182        let agree = form.checkbox("agree", false);
183        assert!(!agree.get());
184
185        form.set_checkbox("agree", true);
186        assert!(agree.get());
187        assert!(form.checkbox("agree", false).get());
188    }
189
190    #[test]
191    fn values_snapshots_string_fields() {
192        let form = FormState::new();
193        form.set("name", "Ada");
194        form.set("email", "ada@example.com");
195
196        let values = form.values();
197        assert_eq!(values.get("name").map(String::as_str), Some("Ada"));
198        assert_eq!(values.get("email").map(String::as_str), Some("ada@example.com"));
199    }
200}