Skip to main content

fret_ui_headless/
form_state.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3
4pub type FormFieldId = Arc<str>;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
7pub enum FormValidateMode {
8    /// Validate only when explicitly requested (e.g. submit).
9    #[default]
10    OnSubmit,
11    /// Validate whenever a field's value changes.
12    OnChange,
13    /// Validate on submit and on change.
14    All,
15}
16
17/// Headless form state (field meta + errors) intended for shadcn-style composition.
18///
19/// This type is intentionally value-agnostic:
20/// - field values typically live in app-owned `Model<T>`s (ADR 0031)
21/// - the form tracks lifecycle metadata (dirty/touched/submitting) and validation outcomes
22#[derive(Debug, Clone, Default)]
23pub struct FormState {
24    pub validate_mode: FormValidateMode,
25    pub submit_count: u64,
26    pub is_submitting: bool,
27    pub registered_fields: Vec<FormFieldId>,
28    pub dirty_fields: HashSet<FormFieldId>,
29    pub touched_fields: HashSet<FormFieldId>,
30    pub errors: HashMap<FormFieldId, Arc<str>>,
31}
32
33impl FormState {
34    pub fn is_dirty(&self) -> bool {
35        !self.dirty_fields.is_empty()
36    }
37
38    pub fn is_touched(&self) -> bool {
39        !self.touched_fields.is_empty()
40    }
41
42    pub fn is_valid(&self) -> bool {
43        self.errors.is_empty()
44    }
45
46    pub fn has_error(&self, field: &str) -> bool {
47        self.errors.keys().any(|k| k.as_ref() == field)
48    }
49
50    pub fn error_for(&self, field: &str) -> Option<&Arc<str>> {
51        self.errors
52            .iter()
53            .find_map(|(k, v)| if k.as_ref() == field { Some(v) } else { None })
54    }
55
56    pub fn is_registered(&self, field: &str) -> bool {
57        self.registered_fields.iter().any(|k| k.as_ref() == field)
58    }
59
60    pub fn register_field(&mut self, field: impl Into<FormFieldId>) {
61        let field = field.into();
62        if self
63            .registered_fields
64            .iter()
65            .any(|k| k.as_ref() == field.as_ref())
66        {
67            return;
68        }
69        self.registered_fields.push(field);
70    }
71
72    pub fn unregister_field(&mut self, field: &str) {
73        if let Some(idx) = self
74            .registered_fields
75            .iter()
76            .position(|k| k.as_ref() == field)
77        {
78            let removed = self.registered_fields.remove(idx);
79            self.dirty_fields.remove(&removed);
80            self.touched_fields.remove(&removed);
81            self.errors.remove(&removed);
82        }
83    }
84
85    pub fn touch(&mut self, field: impl Into<FormFieldId>) {
86        self.touched_fields.insert(field.into());
87    }
88
89    pub fn touch_all_registered(&mut self) {
90        for field in self.registered_fields.iter().cloned() {
91            self.touched_fields.insert(field);
92        }
93    }
94
95    pub fn set_dirty(&mut self, field: impl Into<FormFieldId>, dirty: bool) {
96        let field = field.into();
97        if dirty {
98            self.dirty_fields.insert(field);
99        } else {
100            self.dirty_fields.remove(&field);
101        }
102    }
103
104    pub fn set_error(&mut self, field: impl Into<FormFieldId>, message: impl Into<Arc<str>>) {
105        self.errors.insert(field.into(), message.into());
106    }
107
108    pub fn set_error_opt(&mut self, field: impl Into<FormFieldId>, message: Option<Arc<str>>) {
109        let field = field.into();
110        match message {
111            Some(message) => {
112                self.errors.insert(field, message);
113            }
114            None => {
115                self.errors.remove(&field);
116            }
117        }
118    }
119
120    pub fn clear_error(&mut self, field: &str) {
121        if let Some(key) = self.errors.keys().find(|k| k.as_ref() == field).cloned() {
122            self.errors.remove(&key);
123        }
124    }
125
126    pub fn clear_errors(&mut self) {
127        self.errors.clear();
128    }
129
130    pub fn begin_submit(&mut self) {
131        self.is_submitting = true;
132    }
133
134    pub fn end_submit(&mut self) {
135        self.is_submitting = false;
136        self.submit_count = self.submit_count.saturating_add(1);
137    }
138
139    pub fn reset(&mut self) {
140        *self = Self::default();
141    }
142
143    pub fn validate_field(
144        &mut self,
145        field: impl Into<FormFieldId>,
146        validate: impl FnOnce() -> Option<Arc<str>>,
147    ) -> bool {
148        let field = field.into();
149        let error = validate();
150        self.set_error_opt(field, error);
151        self.is_valid()
152    }
153
154    pub fn validate_registered_fields(
155        &mut self,
156        mut validate: impl FnMut(&FormFieldId) -> Option<Arc<str>>,
157    ) -> bool {
158        let fields: Vec<FormFieldId> = self.registered_fields.to_vec();
159        for field in fields.iter() {
160            let error = validate(field);
161            self.set_error_opt(Arc::clone(field), error);
162        }
163        self.is_valid()
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn form_state_tracks_dirty_touched_and_errors() {
173        let mut st = FormState::default();
174        assert!(st.is_valid());
175        assert!(!st.is_dirty());
176        assert!(!st.is_touched());
177
178        st.touch("name");
179        st.set_dirty("name", true);
180        st.set_error("name", Arc::from("Required"));
181
182        assert!(st.is_touched());
183        assert!(st.is_dirty());
184        assert!(!st.is_valid());
185        assert!(st.has_error("name"));
186        assert_eq!(st.error_for("name").map(|s| s.as_ref()), Some("Required"));
187
188        st.clear_error("name");
189        assert!(st.is_valid());
190    }
191
192    #[test]
193    fn registered_fields_drive_bulk_validation() {
194        let mut st = FormState::default();
195        st.register_field("name");
196        st.register_field("email");
197
198        st.validate_registered_fields(|id| match id.as_ref() {
199            "name" => Some(Arc::from("Required")),
200            "email" => None,
201            _ => None,
202        });
203
204        assert!(!st.is_valid());
205        assert_eq!(st.error_for("name").map(|v| v.as_ref()), Some("Required"));
206        assert!(st.error_for("email").is_none());
207
208        st.unregister_field("name");
209        assert!(st.is_valid());
210    }
211}