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
    #[serde(skip_serializing_if = "Option::is_none")]
43
    pub tags: Option<Vec<TagData>>,
44
}
45

            
46
#[derive(Serialize)]
47
pub struct AccountTagsFormData {
48
    pub tags: Vec<TagData>,
49
}
50

            
51
#[must_use]
52
pub fn collect_splits_from_dom(document: &Document) -> Vec<SplitData> {
53
    let mut splits = Vec::new();
54

            
55
    let Ok(split_entries) = document.query_selector_all(".split-entry") else {
56
        return splits;
57
    };
58

            
59
    for i in 0..split_entries.length() {
60
        let Some(node) = split_entries.get(i) else {
61
            continue;
62
        };
63
        let Ok(split_el) = node.dyn_into::<Element>() else {
64
            continue;
65
        };
66

            
67
        let split = collect_split_data(&split_el);
68
        splits.push(split);
69
    }
70

            
71
    splits
72
}
73

            
74
fn collect_split_data(split_el: &Element) -> SplitData {
75
    let from_account = get_hidden_value(split_el, "from-account");
76
    let to_account = get_hidden_value(split_el, "to-account");
77
    let from_commodity = get_hidden_value(split_el, "from-commodity");
78
    let to_commodity = get_hidden_value(split_el, "to-commodity");
79
    let amount = get_field_value(split_el, "amount");
80
    let amount_converted = get_field_value(split_el, "amount-converted").filter(|v| !v.is_empty());
81

            
82
    let (from_tags, to_tags) = collect_tags(split_el);
83

            
84
    SplitData {
85
        from_account,
86
        to_account,
87
        from_commodity,
88
        to_commodity,
89
        amount,
90
        amount_converted,
91
        from_tags,
92
        to_tags,
93
    }
94
}
95

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

            
106
fn get_field_value(split_el: &Element, field: &str) -> Option<String> {
107
    let selector = format!(r#"input[data-field="{field}"]"#);
108
    split_el
109
        .query_selector(&selector)
110
        .ok()?
111
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
112
        .map(|input| input.value())
113
        .filter(|v| !v.is_empty())
114
}
115

            
116
fn collect_tags(split_el: &Element) -> (Option<Vec<TagData>>, Option<Vec<TagData>>) {
117
    let mut from_tags = Vec::new();
118
    let mut to_tags = Vec::new();
119

            
120
    let Ok(tag_rows) = split_el.query_selector_all(".tag-input-row") else {
121
        return (None, None);
122
    };
123

            
124
    for i in 0..tag_rows.length() {
125
        let Some(node) = tag_rows.get(i) else {
126
            continue;
127
        };
128
        let Ok(tag_row) = node.dyn_into::<Element>() else {
129
            continue;
130
        };
131

            
132
        let name = get_input_value(&tag_row, ".tag-name-input");
133
        let value = get_input_value(&tag_row, ".tag-value-input");
134
        let description =
135
            get_input_value(&tag_row, ".tag-description-input").filter(|v| !v.is_empty());
136

            
137
        if let (Some(name), Some(value)) = (name.clone(), value.clone())
138
            && (!name.is_empty() || !value.is_empty())
139
        {
140
            let tag = TagData {
141
                name,
142
                value,
143
                description,
144
            };
145

            
146
            let mirror_name = get_input_value(&tag_row, ".tag-name-mirror");
147
            let mirror_value = get_input_value(&tag_row, ".tag-value-mirror");
148
            let mirror_desc =
149
                get_input_value(&tag_row, ".tag-description-mirror").filter(|v| !v.is_empty());
150

            
151
            if let (Some(mn), Some(mv)) = (mirror_name, mirror_value)
152
                && (!mn.is_empty() || !mv.is_empty())
153
            {
154
                to_tags.push(TagData {
155
                    name: mn,
156
                    value: mv,
157
                    description: mirror_desc,
158
                });
159
            } else {
160
                to_tags.push(TagData {
161
                    name: tag.name.clone(),
162
                    value: tag.value.clone(),
163
                    description: tag.description.clone(),
164
                });
165
            }
166

            
167
            from_tags.push(tag);
168
        }
169
    }
170

            
171
    let from = if from_tags.is_empty() {
172
        None
173
    } else {
174
        Some(from_tags)
175
    };
176
    let to = if to_tags.is_empty() {
177
        None
178
    } else {
179
        Some(to_tags)
180
    };
181

            
182
    (from, to)
183
}
184

            
185
pub fn collect_entity_tags(document: &Document) -> Option<Vec<TagData>> {
186
    let container = document
187
        .query_selector(".entity-tags-container")
188
        .ok()
189
        .flatten()?;
190

            
191
    let Ok(tag_rows) = container.query_selector_all(".tag-input-row") else {
192
        return None;
193
    };
194

            
195
    let mut tags = Vec::new();
196

            
197
    for i in 0..tag_rows.length() {
198
        let Some(node) = tag_rows.get(i) else {
199
            continue;
200
        };
201
        let Ok(tag_row) = node.dyn_into::<Element>() else {
202
            continue;
203
        };
204

            
205
        let name = get_input_value(&tag_row, ".tag-name-input");
206
        let value = get_input_value(&tag_row, ".tag-value-input");
207
        let description =
208
            get_input_value(&tag_row, ".tag-description-input").filter(|v| !v.is_empty());
209

            
210
        if let (Some(name), Some(value)) = (name, value)
211
            && (!name.is_empty() || !value.is_empty())
212
        {
213
            tags.push(TagData {
214
                name,
215
                value,
216
                description,
217
            });
218
        }
219
    }
220

            
221
    if tags.is_empty() { None } else { Some(tags) }
222
}
223

            
224
fn get_input_value(parent: &Element, selector: &str) -> Option<String> {
225
    parent
226
        .query_selector(selector)
227
        .ok()?
228
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
229
        .map(|input| input.value())
230
}
231

            
232
#[must_use]
233
pub fn process_form_data(document: &Document) -> TransactionFormData {
234
    let splits = collect_splits_from_dom(document);
235

            
236
    let note = document
237
        .query_selector(r#"[name="note"]"#)
238
        .ok()
239
        .flatten()
240
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
241
        .map(|input| input.value())
242
        .unwrap_or_default();
243

            
244
    let date = document
245
        .get_element_by_id("date")
246
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
247
        .map(|input| input.value())
248
        .unwrap_or_default();
249

            
250
    let transaction_id = document
251
        .query_selector(r#"[name="transaction_id"]"#)
252
        .ok()
253
        .flatten()
254
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
255
        .map(|input| input.value())
256
        .filter(|v| !v.is_empty());
257

            
258
    let tags = collect_entity_tags(document);
259

            
260
    TransactionFormData {
261
        splits,
262
        note,
263
        date,
264
        transaction_id,
265
        tags,
266
    }
267
}
268

            
269
pub fn setup_form_submit_handler() {
270
    use std::cell::Cell;
271

            
272
    thread_local! {
273
        static REGISTERED: Cell<bool> = const { Cell::new(false) };
274
    }
275

            
276
    if REGISTERED.with(Cell::get) {
277
        return;
278
    }
279
    REGISTERED.with(|r| r.set(true));
280

            
281
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
282
        return;
283
    };
284

            
285
    let callback = Closure::wrap(Box::new(move |event: web_sys::CustomEvent| {
286
        handle_config_request(event);
287
    }) as Box<dyn FnMut(_)>);
288

            
289
    let _ = document
290
        .add_event_listener_with_callback("htmx:configRequest", callback.as_ref().unchecked_ref());
291
    callback.forget();
292
}
293

            
294
fn handle_config_request(event: web_sys::CustomEvent) {
295
    let detail = event.detail();
296
    let Some(detail_obj) = detail.dyn_ref::<js_sys::Object>() else {
297
        return;
298
    };
299

            
300
    let verb = js_sys::Reflect::get(detail_obj, &"verb".into())
301
        .ok()
302
        .and_then(|v| v.as_string())
303
        .unwrap_or_default();
304

            
305
    let path = js_sys::Reflect::get(detail_obj, &"path".into())
306
        .ok()
307
        .and_then(|v| v.as_string())
308
        .unwrap_or_default();
309

            
310
    if verb != "post" {
311
        return;
312
    }
313

            
314
    let is_transaction =
315
        path.contains("/transaction/create/submit") || path.contains("/transaction/edit/submit");
316
    let is_account_tags = path.contains("/account/") && path.contains("/tags/submit");
317

            
318
    if !is_transaction && !is_account_tags {
319
        return;
320
    }
321

            
322
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
323
        return;
324
    };
325

            
326
    if is_transaction {
327
        let form_data = process_form_data(&document);
328
        let Ok(json) = serde_json::to_string(&form_data) else {
329
            return;
330
        };
331
        let Ok(body) = js_sys::JSON::parse(&json) else {
332
            return;
333
        };
334
        let _ = js_sys::Reflect::set(detail_obj, &"body".into(), &body);
335
    } else {
336
        let tags = collect_entity_tags(&document).unwrap_or_default();
337
        let form_data = AccountTagsFormData { tags };
338
        let Ok(json) = serde_json::to_string(&form_data) else {
339
            return;
340
        };
341
        let Ok(body) = js_sys::JSON::parse(&json) else {
342
            return;
343
        };
344
        let _ = js_sys::Reflect::set(detail_obj, &"body".into(), &body);
345
    }
346
}
347

            
348
#[cfg(test)]
349
mod tests {
350
    use super::*;
351

            
352
    #[test]
353
1
    fn tag_data_serializes_correctly() {
354
1
        let tag = TagData {
355
1
            name: "category".into(),
356
1
            value: "groceries".into(),
357
1
            description: Some("Weekly shopping".into()),
358
1
        };
359
1
        let json = serde_json::to_string(&tag).unwrap();
360
1
        assert!(json.contains("category"));
361
1
        assert!(json.contains("groceries"));
362
1
        assert!(json.contains("Weekly shopping"));
363
1
    }
364

            
365
    #[test]
366
1
    fn tag_data_skips_none_description() {
367
1
        let tag = TagData {
368
1
            name: "status".into(),
369
1
            value: "pending".into(),
370
1
            description: None,
371
1
        };
372
1
        let json = serde_json::to_string(&tag).unwrap();
373
1
        assert!(!json.contains("description"));
374
1
    }
375

            
376
    #[test]
377
1
    fn split_data_uses_correct_field_names() {
378
1
        let split = SplitData {
379
1
            from_account: Some("acc-1".into()),
380
1
            to_account: Some("acc-2".into()),
381
1
            from_commodity: Some("com-1".into()),
382
1
            to_commodity: Some("com-2".into()),
383
1
            amount: Some("100".into()),
384
1
            amount_converted: None,
385
1
            from_tags: None,
386
1
            to_tags: None,
387
1
        };
388
1
        let json = serde_json::to_string(&split).unwrap();
389
1
        assert!(json.contains("from-account"));
390
1
        assert!(json.contains("to-account"));
391
1
        assert!(json.contains("from-commodity"));
392
1
        assert!(json.contains("to-commodity"));
393
1
    }
394
}