1
//! Validation logic for transaction forms.
2

            
3
use wasm_bindgen::JsCast;
4
use wasm_bindgen::prelude::*;
5
use web_sys::{Element, HtmlInputElement};
6

            
7
/// Sets up field validation handlers.
8
pub fn setup_field_validation() {
9
    setup_blur_validation();
10
    setup_input_clear_validation();
11
}
12

            
13
fn setup_blur_validation() {
14
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
15
        return;
16
    };
17

            
18
    let callback = Closure::wrap(Box::new(move |event: web_sys::FocusEvent| {
19
        let Some(target) = event.target() else {
20
            return;
21
        };
22
        let Ok(input) = target.dyn_into::<HtmlInputElement>() else {
23
            return;
24
        };
25

            
26
        let Some(field_id) = input.get_attribute("data-field") else {
27
            return;
28
        };
29

            
30
        if matches!(
31
            field_id.as_str(),
32
            "amount"
33
                | "amount-converted"
34
                | "from-account"
35
                | "to-account"
36
                | "from-commodity"
37
                | "to-commodity"
38
        ) {
39
            validate_field(&input, &field_id);
40
        }
41
    }) as Box<dyn FnMut(_)>);
42

            
43
    let _ = document.add_event_listener_with_callback_and_bool(
44
        "focusout",
45
        callback.as_ref().unchecked_ref(),
46
        true,
47
    );
48
    callback.forget();
49
}
50

            
51
fn setup_input_clear_validation() {
52
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
53
        return;
54
    };
55

            
56
    let callback = Closure::wrap(Box::new(move |event: web_sys::InputEvent| {
57
        let Some(target) = event.target() else {
58
            return;
59
        };
60
        let Ok(input) = target.dyn_into::<HtmlInputElement>() else {
61
            return;
62
        };
63

            
64
        let Some(field_id) = input.get_attribute("data-field") else {
65
            return;
66
        };
67

            
68
        if matches!(
69
            field_id.as_str(),
70
            "from-account" | "to-account" | "from-commodity" | "to-commodity"
71
        ) {
72
            clear_validation_message(&input);
73
        }
74
    }) as Box<dyn FnMut(_)>);
75

            
76
    let _ = document.add_event_listener_with_callback_and_bool(
77
        "input",
78
        callback.as_ref().unchecked_ref(),
79
        true,
80
    );
81
    callback.forget();
82
}
83

            
84
fn validate_field(input: &HtmlInputElement, field_id: &str) {
85
    let Some(split_entry) = input.closest(".split-entry").ok().flatten() else {
86
        return;
87
    };
88

            
89
    let feedback = split_entry
90
        .query_selector(".split-validation")
91
        .ok()
92
        .flatten();
93

            
94
    if let Some(ref fb) = feedback {
95
        fb.set_text_content(Some(""));
96
    }
97

            
98
    match field_id {
99
        "amount" | "amount-converted" => {
100
            let value = input.value();
101
            if let Err(msg) = validate_amount(&value)
102
                && let Some(fb) = feedback
103
            {
104
                fb.set_text_content(Some(&msg));
105
            }
106
        }
107
        "from-account" | "to-account" | "from-commodity" | "to-commodity" => {
108
            if let Err(msg) = validate_autocomplete_field(&split_entry, input, field_id)
109
                && let Some(fb) = feedback
110
            {
111
                fb.set_text_content(Some(&msg));
112
            }
113
        }
114
        _ => {}
115
    }
116
}
117

            
118
fn clear_validation_message(input: &HtmlInputElement) {
119
    let Some(split_entry) = input.closest(".split-entry").ok().flatten() else {
120
        return;
121
    };
122

            
123
    if let Some(feedback) = split_entry
124
        .query_selector(".split-validation")
125
        .ok()
126
        .flatten()
127
    {
128
        feedback.set_text_content(Some(""));
129
    }
130
}
131

            
132
9
pub fn validate_amount(value: &str) -> Result<(), String> {
133
9
    let trimmed = value.trim();
134

            
135
9
    if trimmed.is_empty() {
136
2
        return Ok(());
137
7
    }
138

            
139
7
    let num: f64 = trimmed
140
7
        .parse()
141
7
        .map_err(|_| "Invalid number format".to_string())?;
142

            
143
5
    if num <= 0.0 {
144
2
        return Err("Amount must be a positive number".to_string());
145
3
    }
146

            
147
3
    Ok(())
148
9
}
149

            
150
fn validate_autocomplete_field(
151
    split_entry: &Element,
152
    display_input: &HtmlInputElement,
153
    field_id: &str,
154
) -> Result<(), String> {
155
    let suggestions_visible = split_entry
156
        .query_selector(".autocomplete-results")
157
        .ok()
158
        .flatten()
159
        .and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok())
160
        .is_some_and(|el| el.style().get_property_value("display").unwrap_or_default() == "block");
161

            
162
    if suggestions_visible {
163
        return Ok(());
164
    }
165

            
166
    let display_value = display_input.value();
167
    if display_value.trim().is_empty() {
168
        return Ok(());
169
    }
170

            
171
    let class_name = display_input.class_name();
172
    let value_class = if class_name.contains("account") {
173
        "account-value"
174
    } else {
175
        "commodity-value"
176
    };
177

            
178
    let hidden_input = split_entry
179
        .query_selector(&format!(r#".{value_class}[data-field="{field_id}"]"#))
180
        .ok()
181
        .flatten()
182
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok());
183

            
184
    let hidden_value = hidden_input.map(|i| i.value()).unwrap_or_default();
185

            
186
    if hidden_value.is_empty() {
187
        let field_name = field_id.replace('-', " ");
188
        return Err(format!("Please select a valid {field_name} from the list"));
189
    }
190

            
191
    Ok(())
192
}
193

            
194
#[cfg(test)]
195
mod tests {
196
    use super::*;
197

            
198
    #[test]
199
1
    fn validate_amount_accepts_positive_numbers() {
200
1
        assert!(validate_amount("100").is_ok());
201
1
        assert!(validate_amount("100.50").is_ok());
202
1
        assert!(validate_amount("0.01").is_ok());
203
1
    }
204

            
205
    #[test]
206
1
    fn validate_amount_rejects_non_positive() {
207
1
        assert!(validate_amount("0").is_err());
208
1
        assert!(validate_amount("-10").is_err());
209
1
    }
210

            
211
    #[test]
212
1
    fn validate_amount_rejects_invalid_format() {
213
1
        assert!(validate_amount("abc").is_err());
214
1
        assert!(validate_amount("10.5.5").is_err());
215
1
    }
216

            
217
    #[test]
218
1
    fn validate_amount_accepts_empty() {
219
1
        assert!(validate_amount("").is_ok());
220
1
        assert!(validate_amount("   ").is_ok());
221
1
    }
222
}