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
pub fn validate_form() -> bool {
195
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
196
        return false;
197
    };
198

            
199
    let mut is_valid = true;
200

            
201
    if let Some(result_box) = document.get_element_by_id("result-box") {
202
        result_box.set_inner_html("");
203
    }
204

            
205
    let Ok(feedbacks) = document.query_selector_all(".validation-feedback") else {
206
        return false;
207
    };
208

            
209
    for i in 0..feedbacks.length() {
210
        if let Some(node) = feedbacks.get(i)
211
            && let Ok(el) = node.dyn_into::<Element>()
212
        {
213
            el.set_text_content(Some(""));
214
        }
215
    }
216

            
217
    let Ok(splits) = document.query_selector_all(".split-entry") else {
218
        return false;
219
    };
220

            
221
    for i in 0..splits.length() {
222
        let Some(node) = splits.get(i) else {
223
            continue;
224
        };
225
        let Ok(split) = node.dyn_into::<Element>() else {
226
            continue;
227
        };
228

            
229
        if !validate_split(&split) {
230
            is_valid = false;
231
        }
232
    }
233

            
234
    if !is_valid && let Some(result_box) = document.get_element_by_id("result-box") {
235
        result_box.set_inner_html(
236
            r#"<div class="error">Please correct the errors before submitting</div>"#,
237
        );
238
    }
239

            
240
    is_valid
241
}
242

            
243
fn validate_split(split: &Element) -> bool {
244
    let mut is_valid = true;
245
    let feedback = split.query_selector(".split-validation").ok().flatten();
246

            
247
    let amount = get_input_value(split, r#"input[data-field="amount"]"#);
248
    if let Some(value) = amount {
249
        if validate_amount(&value).is_err() {
250
            set_feedback(
251
                &feedback,
252
                "Amount is required and must be a positive number",
253
            );
254
            is_valid = false;
255
        }
256
    } else {
257
        set_feedback(&feedback, "Amount is required");
258
        is_valid = false;
259
    }
260

            
261
    let from_account = get_hidden_value(split, "from-account");
262
    if from_account.is_none()
263
        || from_account
264
            .as_ref()
265
            .is_none_or(std::string::String::is_empty)
266
    {
267
        set_feedback(&feedback, "From account is required");
268
        is_valid = false;
269
    }
270

            
271
    let to_account = get_hidden_value(split, "to-account");
272
    if to_account.is_none()
273
        || to_account
274
            .as_ref()
275
            .is_none_or(std::string::String::is_empty)
276
    {
277
        set_feedback(&feedback, "To account is required");
278
        is_valid = false;
279
    }
280

            
281
    let from_commodity = get_hidden_value(split, "from-commodity");
282
    if from_commodity.is_none()
283
        || from_commodity
284
            .as_ref()
285
            .is_none_or(std::string::String::is_empty)
286
    {
287
        set_feedback(&feedback, "From commodity is required");
288
        is_valid = false;
289
    }
290

            
291
    let to_commodity = get_hidden_value(split, "to-commodity");
292
    if to_commodity.is_none()
293
        || to_commodity
294
            .as_ref()
295
            .is_none_or(std::string::String::is_empty)
296
    {
297
        set_feedback(&feedback, "To commodity is required");
298
        is_valid = false;
299
    }
300

            
301
    is_valid
302
}
303

            
304
fn get_input_value(parent: &Element, selector: &str) -> Option<String> {
305
    parent
306
        .query_selector(selector)
307
        .ok()?
308
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
309
        .map(|input| input.value())
310
        .filter(|v| !v.is_empty())
311
}
312

            
313
fn get_hidden_value(parent: &Element, field: &str) -> Option<String> {
314
    let selector = format!(r#"input[type="hidden"][data-field="{field}"]"#);
315
    parent
316
        .query_selector(&selector)
317
        .ok()?
318
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
319
        .map(|input| input.value())
320
        .filter(|v| !v.is_empty())
321
}
322

            
323
fn set_feedback(feedback: &Option<Element>, message: &str) {
324
    if let Some(fb) = feedback {
325
        fb.set_text_content(Some(message));
326
    }
327
}
328

            
329
#[cfg(test)]
330
mod tests {
331
    use super::*;
332

            
333
    #[test]
334
1
    fn validate_amount_accepts_positive_numbers() {
335
1
        assert!(validate_amount("100").is_ok());
336
1
        assert!(validate_amount("100.50").is_ok());
337
1
        assert!(validate_amount("0.01").is_ok());
338
1
    }
339

            
340
    #[test]
341
1
    fn validate_amount_rejects_non_positive() {
342
1
        assert!(validate_amount("0").is_err());
343
1
        assert!(validate_amount("-10").is_err());
344
1
    }
345

            
346
    #[test]
347
1
    fn validate_amount_rejects_invalid_format() {
348
1
        assert!(validate_amount("abc").is_err());
349
1
        assert!(validate_amount("10.5.5").is_err());
350
1
    }
351

            
352
    #[test]
353
1
    fn validate_amount_accepts_empty() {
354
1
        assert!(validate_amount("").is_ok());
355
1
        assert!(validate_amount("   ").is_ok());
356
1
    }
357
}