1
//! Form data collection and processing for transaction forms.
2

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

            
8
#[derive(Serialize)]
9
pub struct SplitData {
10
    #[serde(rename = "from-account")]
11
    pub from_account: Option<String>,
12
    #[serde(rename = "to-account")]
13
    pub to_account: Option<String>,
14
    #[serde(rename = "from-commodity")]
15
    pub from_commodity: Option<String>,
16
    #[serde(rename = "to-commodity")]
17
    pub to_commodity: Option<String>,
18
    pub amount: Option<String>,
19
    #[serde(rename = "amount-converted", skip_serializing_if = "Option::is_none")]
20
    pub amount_converted: Option<String>,
21
    #[serde(skip_serializing_if = "Option::is_none")]
22
    pub from_tags: Option<Vec<TagData>>,
23
    #[serde(skip_serializing_if = "Option::is_none")]
24
    pub to_tags: Option<Vec<TagData>>,
25
}
26

            
27
#[derive(Serialize)]
28
pub struct TagData {
29
    pub name: String,
30
    pub value: String,
31
    #[serde(skip_serializing_if = "Option::is_none")]
32
    pub description: Option<String>,
33
}
34

            
35
#[derive(Serialize)]
36
pub struct TransactionFormData {
37
    pub splits: Vec<SplitData>,
38
    pub note: String,
39
    pub date: String,
40
    #[serde(skip_serializing_if = "Option::is_none")]
41
    pub transaction_id: Option<String>,
42
}
43

            
44
pub fn collect_splits_from_dom(document: &Document) -> Vec<SplitData> {
45
    let mut splits = Vec::new();
46

            
47
    let Ok(split_entries) = document.query_selector_all(".split-entry") else {
48
        return splits;
49
    };
50

            
51
    for i in 0..split_entries.length() {
52
        let Some(node) = split_entries.get(i) else {
53
            continue;
54
        };
55
        let Ok(split_el) = node.dyn_into::<Element>() else {
56
            continue;
57
        };
58

            
59
        let split = collect_split_data(&split_el);
60
        splits.push(split);
61
    }
62

            
63
    splits
64
}
65

            
66
fn collect_split_data(split_el: &Element) -> SplitData {
67
    let from_account = get_hidden_value(split_el, "from-account");
68
    let to_account = get_hidden_value(split_el, "to-account");
69
    let from_commodity = get_hidden_value(split_el, "from-commodity");
70
    let to_commodity = get_hidden_value(split_el, "to-commodity");
71
    let amount = get_field_value(split_el, "amount");
72
    let amount_converted = get_field_value(split_el, "amount-converted").filter(|v| !v.is_empty());
73

            
74
    let (from_tags, to_tags) = collect_tags(split_el);
75

            
76
    SplitData {
77
        from_account,
78
        to_account,
79
        from_commodity,
80
        to_commodity,
81
        amount,
82
        amount_converted,
83
        from_tags,
84
        to_tags,
85
    }
86
}
87

            
88
fn get_hidden_value(split_el: &Element, field: &str) -> Option<String> {
89
    let selector = format!(r#"input[type="hidden"][data-field="{field}"][name*="["]"#);
90
    split_el
91
        .query_selector(&selector)
92
        .ok()?
93
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
94
        .map(|input| input.value())
95
        .filter(|v| !v.is_empty())
96
}
97

            
98
fn get_field_value(split_el: &Element, field: &str) -> Option<String> {
99
    let selector = format!(r#"input[data-field="{field}"]"#);
100
    split_el
101
        .query_selector(&selector)
102
        .ok()?
103
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
104
        .map(|input| input.value())
105
        .filter(|v| !v.is_empty())
106
}
107

            
108
fn collect_tags(split_el: &Element) -> (Option<Vec<TagData>>, Option<Vec<TagData>>) {
109
    let mut from_tags = Vec::new();
110
    let mut to_tags = Vec::new();
111

            
112
    let Ok(tag_rows) = split_el.query_selector_all(".tag-input-row") else {
113
        return (None, None);
114
    };
115

            
116
    for i in 0..tag_rows.length() {
117
        let Some(node) = tag_rows.get(i) else {
118
            continue;
119
        };
120
        let Ok(tag_row) = node.dyn_into::<Element>() else {
121
            continue;
122
        };
123

            
124
        let name = get_input_value(&tag_row, ".tag-name-input");
125
        let value = get_input_value(&tag_row, ".tag-value-input");
126
        let description =
127
            get_input_value(&tag_row, ".tag-description-input").filter(|v| !v.is_empty());
128

            
129
        if let (Some(name), Some(value)) = (name.clone(), value.clone())
130
            && (!name.is_empty() || !value.is_empty())
131
        {
132
            from_tags.push(TagData {
133
                name: name.clone(),
134
                value: value.clone(),
135
                description: description.clone(),
136
            });
137
        }
138

            
139
        let mirror_name = get_input_value(&tag_row, ".tag-name-mirror");
140
        let mirror_value = get_input_value(&tag_row, ".tag-value-mirror");
141
        let mirror_desc =
142
            get_input_value(&tag_row, ".tag-description-mirror").filter(|v| !v.is_empty());
143

            
144
        if let (Some(name), Some(value)) = (mirror_name, mirror_value)
145
            && (!name.is_empty() || !value.is_empty())
146
        {
147
            to_tags.push(TagData {
148
                name,
149
                value,
150
                description: mirror_desc,
151
            });
152
        }
153
    }
154

            
155
    let from = if from_tags.is_empty() {
156
        None
157
    } else {
158
        Some(from_tags)
159
    };
160
    let to = if to_tags.is_empty() {
161
        None
162
    } else {
163
        Some(to_tags)
164
    };
165

            
166
    (from, to)
167
}
168

            
169
fn get_input_value(parent: &Element, selector: &str) -> Option<String> {
170
    parent
171
        .query_selector(selector)
172
        .ok()?
173
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
174
        .map(|input| input.value())
175
}
176

            
177
#[must_use]
178
pub fn process_form_data(document: &Document) -> TransactionFormData {
179
    let splits = collect_splits_from_dom(document);
180

            
181
    let note = document
182
        .query_selector(r#"[name="note"]"#)
183
        .ok()
184
        .flatten()
185
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
186
        .map(|input| input.value())
187
        .unwrap_or_default();
188

            
189
    let date = document
190
        .get_element_by_id("date")
191
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
192
        .map(|input| input.value())
193
        .unwrap_or_default();
194

            
195
    let transaction_id = document
196
        .query_selector(r#"[name="transaction_id"]"#)
197
        .ok()
198
        .flatten()
199
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
200
        .map(|input| input.value())
201
        .filter(|v| !v.is_empty());
202

            
203
    TransactionFormData {
204
        splits,
205
        note,
206
        date,
207
        transaction_id,
208
    }
209
}
210

            
211
pub fn setup_form_submit_handler() {
212
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
213
        return;
214
    };
215

            
216
    let callback = Closure::wrap(Box::new(move |event: web_sys::CustomEvent| {
217
        handle_config_request(event);
218
    }) as Box<dyn FnMut(_)>);
219

            
220
    let _ = document
221
        .add_event_listener_with_callback("htmx:configRequest", callback.as_ref().unchecked_ref());
222
    callback.forget();
223
}
224

            
225
fn handle_config_request(event: web_sys::CustomEvent) {
226
    let detail = event.detail();
227
    let Some(detail_obj) = detail.dyn_ref::<js_sys::Object>() else {
228
        return;
229
    };
230

            
231
    let verb = js_sys::Reflect::get(detail_obj, &"verb".into())
232
        .ok()
233
        .and_then(|v| v.as_string())
234
        .unwrap_or_default();
235

            
236
    let path = js_sys::Reflect::get(detail_obj, &"path".into())
237
        .ok()
238
        .and_then(|v| v.as_string())
239
        .unwrap_or_default();
240

            
241
    if verb != "post"
242
        || (!path.contains("/transaction/create/submit")
243
            && !path.contains("/transaction/edit/submit"))
244
    {
245
        return;
246
    }
247

            
248
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
249
        return;
250
    };
251

            
252
    let form_data = process_form_data(&document);
253
    let Ok(json) = serde_json::to_string(&form_data) else {
254
        return;
255
    };
256

            
257
    let Ok(body) = js_sys::JSON::parse(&json) else {
258
        return;
259
    };
260

            
261
    let _ = js_sys::Reflect::set(detail_obj, &"body".into(), &body);
262
}
263

            
264
#[cfg(test)]
265
mod tests {
266
    use super::*;
267

            
268
    #[test]
269
1
    fn tag_data_serializes_correctly() {
270
1
        let tag = TagData {
271
1
            name: "category".into(),
272
1
            value: "groceries".into(),
273
1
            description: Some("Weekly shopping".into()),
274
1
        };
275
1
        let json = serde_json::to_string(&tag).unwrap();
276
1
        assert!(json.contains("category"));
277
1
        assert!(json.contains("groceries"));
278
1
        assert!(json.contains("Weekly shopping"));
279
1
    }
280

            
281
    #[test]
282
1
    fn tag_data_skips_none_description() {
283
1
        let tag = TagData {
284
1
            name: "status".into(),
285
1
            value: "pending".into(),
286
1
            description: None,
287
1
        };
288
1
        let json = serde_json::to_string(&tag).unwrap();
289
1
        assert!(!json.contains("description"));
290
1
    }
291

            
292
    #[test]
293
1
    fn split_data_uses_correct_field_names() {
294
1
        let split = SplitData {
295
1
            from_account: Some("acc-1".into()),
296
1
            to_account: Some("acc-2".into()),
297
1
            from_commodity: Some("com-1".into()),
298
1
            to_commodity: Some("com-2".into()),
299
1
            amount: Some("100".into()),
300
1
            amount_converted: None,
301
1
            from_tags: None,
302
1
            to_tags: None,
303
1
        };
304
1
        let json = serde_json::to_string(&split).unwrap();
305
1
        assert!(json.contains("from-account"));
306
1
        assert!(json.contains("to-account"));
307
1
        assert!(json.contains("from-commodity"));
308
1
        assert!(json.contains("to-commodity"));
309
1
    }
310
}