1
//! HTMX event handlers for form validation and error handling.
2
//!
3
//! This module provides WASM-based handlers for HTMX events, replacing
4
//! the JavaScript `htmx-validate.js` script.
5

            
6
use wasm_bindgen::JsCast;
7
use wasm_bindgen::prelude::*;
8
use web_sys::Element;
9

            
10
/// Sets up the HTMX beforeSwap event listener for form validation.
11
///
12
/// This handler:
13
/// - Allows 4xx responses to be swapped in
14
/// - Parses JSON error responses and formats them as HTML
15
/// - Redirects error content to the result-box element
16
/// - Manages CSS classes for success/error styling
17
pub fn setup_htmx_validation() {
18
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
19
        return;
20
    };
21

            
22
    let callback =
23
        Closure::wrap(Box::new(handle_before_swap) as Box<dyn FnMut(web_sys::CustomEvent)>);
24

            
25
    let _ = document
26
        .add_event_listener_with_callback("htmx:beforeSwap", callback.as_ref().unchecked_ref());
27
    callback.forget();
28
}
29

            
30
fn handle_before_swap(evt: web_sys::CustomEvent) {
31
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
32
        return;
33
    };
34

            
35
    let detail = evt.detail();
36
    let Some(detail_obj) = detail.dyn_ref::<js_sys::Object>() else {
37
        return;
38
    };
39

            
40
    let xhr_val = match js_sys::Reflect::get(detail_obj, &"xhr".into()) {
41
        Ok(v) => v,
42
        Err(_) => return,
43
    };
44
    let Some(xhr) = xhr_val.dyn_ref::<web_sys::XmlHttpRequest>() else {
45
        return;
46
    };
47

            
48
    let status = xhr.status().unwrap_or(0);
49
    let result_box = document.get_element_by_id("result-box");
50

            
51
    // Every error, not only 4xx: a 5xx carries a message the user needs (a
52
    // failed save, a store outage), and htmx swaps none of them by default.
53
    if status >= 400 {
54
        handle_client_error(detail_obj, xhr, result_box.as_ref());
55
    } else if (200..300).contains(&status) {
56
        handle_success(result_box.as_ref());
57
    }
58
}
59

            
60
fn handle_client_error(
61
    detail: &js_sys::Object,
62
    xhr: &web_sys::XmlHttpRequest,
63
    result_box: Option<&Element>,
64
) {
65
    // An empty error body carries nothing to show: forcing it in would swap
66
    // blank content over the request's own target — a report and its filter
67
    // form, where the page has no result box — so leave htmx's default of not
68
    // swapping an error.
69
    if xhr
70
        .response_text()
71
        .ok()
72
        .flatten()
73
        .is_none_or(|text| text.is_empty())
74
    {
75
        return;
76
    }
77

            
78
    // Allow the error content to be swapped
79
    let _ = js_sys::Reflect::set(detail, &"shouldSwap".into(), &true.into());
80

            
81
    // Check if response is JSON
82
    let content_type = xhr
83
        .get_response_header("Content-Type")
84
        .ok()
85
        .flatten()
86
        .unwrap_or_default();
87

            
88
    if content_type.contains("application/json")
89
        && let Ok(Some(text)) = xhr.response_text()
90
    {
91
        let error_html = parse_json_error(&text);
92
        let _ = js_sys::Reflect::set(detail, &"serverResponse".into(), &error_html.into());
93
    }
94

            
95
    // Redirect to result-box and update styling
96
    if let Some(box_el) = result_box {
97
        let _ = js_sys::Reflect::set(detail, &"target".into(), box_el);
98
        let _ = box_el.class_list().remove_1("valid-message");
99
        let _ = box_el.class_list().add_1("error-message");
100
    }
101
}
102

            
103
fn handle_success(result_box: Option<&Element>) {
104
    if let Some(box_el) = result_box {
105
        let _ = box_el.class_list().remove_1("error-message");
106
        let _ = box_el.class_list().add_1("valid-message");
107
    }
108
}
109

            
110
fn parse_json_error(text: &str) -> String {
111
    match serde_json::from_str::<serde_json::Value>(text) {
112
        Ok(json) => {
113
            let status = json
114
                .get("status")
115
                .and_then(|v| v.as_str())
116
                .unwrap_or("An error occurred");
117
            let message = json.get("message").and_then(|v| v.as_str()).unwrap_or("");
118
            format!(r#"<div class="error-alert">{status}: {message}</div>"#)
119
        }
120
        Err(_) => "Unparseable response received".to_string(),
121
    }
122
}