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
pub fn collect_splits_from_dom(document: &Document) -> Vec<SplitData> {
52
    let mut splits = Vec::new();
53

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

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

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

            
70
    splits
71
}
72

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

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

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

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

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

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

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

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

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

            
136
        if let (Some(name), Some(value)) = (name.clone(), value.clone())
137
            && (!name.is_empty() || !value.is_empty())
138
        {
139
            from_tags.push(TagData {
140
                name: name.clone(),
141
                value: value.clone(),
142
                description: description.clone(),
143
            });
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(name), Some(value)) = (mirror_name, mirror_value)
152
            && (!name.is_empty() || !value.is_empty())
153
        {
154
            to_tags.push(TagData {
155
                name,
156
                value,
157
                description: mirror_desc,
158
            });
159
        }
160
    }
161

            
162
    let from = if from_tags.is_empty() {
163
        None
164
    } else {
165
        Some(from_tags)
166
    };
167
    let to = if to_tags.is_empty() {
168
        None
169
    } else {
170
        Some(to_tags)
171
    };
172

            
173
    (from, to)
174
}
175

            
176
pub fn collect_entity_tags(document: &Document) -> Option<Vec<TagData>> {
177
    let container = document
178
        .query_selector(".entity-tags-container")
179
        .ok()
180
        .flatten()?;
181

            
182
    let Ok(tag_rows) = container.query_selector_all(".tag-input-row") else {
183
        return None;
184
    };
185

            
186
    let mut tags = Vec::new();
187

            
188
    for i in 0..tag_rows.length() {
189
        let Some(node) = tag_rows.get(i) else {
190
            continue;
191
        };
192
        let Ok(tag_row) = node.dyn_into::<Element>() else {
193
            continue;
194
        };
195

            
196
        let name = get_input_value(&tag_row, ".tag-name-input");
197
        let value = get_input_value(&tag_row, ".tag-value-input");
198
        let description =
199
            get_input_value(&tag_row, ".tag-description-input").filter(|v| !v.is_empty());
200

            
201
        if let (Some(name), Some(value)) = (name, value)
202
            && (!name.is_empty() || !value.is_empty())
203
        {
204
            tags.push(TagData {
205
                name,
206
                value,
207
                description,
208
            });
209
        }
210
    }
211

            
212
    if tags.is_empty() { None } else { Some(tags) }
213
}
214

            
215
fn get_input_value(parent: &Element, selector: &str) -> Option<String> {
216
    parent
217
        .query_selector(selector)
218
        .ok()?
219
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
220
        .map(|input| input.value())
221
}
222

            
223
#[must_use]
224
pub fn process_form_data(document: &Document) -> TransactionFormData {
225
    let splits = collect_splits_from_dom(document);
226

            
227
    let note = document
228
        .query_selector(r#"[name="note"]"#)
229
        .ok()
230
        .flatten()
231
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
232
        .map(|input| input.value())
233
        .unwrap_or_default();
234

            
235
    let date = document
236
        .get_element_by_id("date")
237
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
238
        .map(|input| input.value())
239
        .unwrap_or_default();
240

            
241
    let transaction_id = document
242
        .query_selector(r#"[name="transaction_id"]"#)
243
        .ok()
244
        .flatten()
245
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
246
        .map(|input| input.value())
247
        .filter(|v| !v.is_empty());
248

            
249
    let tags = collect_entity_tags(document);
250

            
251
    TransactionFormData {
252
        splits,
253
        note,
254
        date,
255
        transaction_id,
256
        tags,
257
    }
258
}
259

            
260
pub fn setup_form_submit_handler() {
261
    use std::cell::Cell;
262

            
263
    thread_local! {
264
        static REGISTERED: Cell<bool> = const { Cell::new(false) };
265
    }
266

            
267
    if REGISTERED.with(Cell::get) {
268
        return;
269
    }
270
    REGISTERED.with(|r| r.set(true));
271

            
272
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
273
        return;
274
    };
275

            
276
    let callback = Closure::wrap(Box::new(move |event: web_sys::CustomEvent| {
277
        handle_config_request(event);
278
    }) as Box<dyn FnMut(_)>);
279

            
280
    let _ = document
281
        .add_event_listener_with_callback("htmx:configRequest", callback.as_ref().unchecked_ref());
282
    callback.forget();
283
}
284

            
285
fn handle_config_request(event: web_sys::CustomEvent) {
286
    let detail = event.detail();
287
    let Some(detail_obj) = detail.dyn_ref::<js_sys::Object>() else {
288
        return;
289
    };
290

            
291
    let verb = js_sys::Reflect::get(detail_obj, &"verb".into())
292
        .ok()
293
        .and_then(|v| v.as_string())
294
        .unwrap_or_default();
295

            
296
    let path = js_sys::Reflect::get(detail_obj, &"path".into())
297
        .ok()
298
        .and_then(|v| v.as_string())
299
        .unwrap_or_default();
300

            
301
    if verb != "post" {
302
        return;
303
    }
304

            
305
    let is_transaction =
306
        path.contains("/transaction/create/submit") || path.contains("/transaction/edit/submit");
307
    let is_account_tags = path.contains("/account/") && path.contains("/tags/submit");
308

            
309
    if !is_transaction && !is_account_tags {
310
        return;
311
    }
312

            
313
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
314
        return;
315
    };
316

            
317
    if is_transaction {
318
        let form_data = process_form_data(&document);
319
        let Ok(json) = serde_json::to_string(&form_data) else {
320
            return;
321
        };
322
        let Ok(body) = js_sys::JSON::parse(&json) else {
323
            return;
324
        };
325
        let _ = js_sys::Reflect::set(detail_obj, &"body".into(), &body);
326
    } else {
327
        let tags = collect_entity_tags(&document).unwrap_or_default();
328
        let form_data = AccountTagsFormData { tags };
329
        let Ok(json) = serde_json::to_string(&form_data) else {
330
            return;
331
        };
332
        let Ok(body) = js_sys::JSON::parse(&json) else {
333
            return;
334
        };
335
        let _ = js_sys::Reflect::set(detail_obj, &"body".into(), &body);
336
    }
337
}
338

            
339
#[cfg(test)]
340
mod tests {
341
    use super::*;
342

            
343
    #[test]
344
1
    fn tag_data_serializes_correctly() {
345
1
        let tag = TagData {
346
1
            name: "category".into(),
347
1
            value: "groceries".into(),
348
1
            description: Some("Weekly shopping".into()),
349
1
        };
350
1
        let json = serde_json::to_string(&tag).unwrap();
351
1
        assert!(json.contains("category"));
352
1
        assert!(json.contains("groceries"));
353
1
        assert!(json.contains("Weekly shopping"));
354
1
    }
355

            
356
    #[test]
357
1
    fn tag_data_skips_none_description() {
358
1
        let tag = TagData {
359
1
            name: "status".into(),
360
1
            value: "pending".into(),
361
1
            description: None,
362
1
        };
363
1
        let json = serde_json::to_string(&tag).unwrap();
364
1
        assert!(!json.contains("description"));
365
1
    }
366

            
367
    #[test]
368
1
    fn split_data_uses_correct_field_names() {
369
1
        let split = SplitData {
370
1
            from_account: Some("acc-1".into()),
371
1
            to_account: Some("acc-2".into()),
372
1
            from_commodity: Some("com-1".into()),
373
1
            to_commodity: Some("com-2".into()),
374
1
            amount: Some("100".into()),
375
1
            amount_converted: None,
376
1
            from_tags: None,
377
1
            to_tags: None,
378
1
        };
379
1
        let json = serde_json::to_string(&split).unwrap();
380
1
        assert!(json.contains("from-account"));
381
1
        assert!(json.contains("to-account"));
382
1
        assert!(json.contains("from-commodity"));
383
1
        assert!(json.contains("to-commodity"));
384
1
    }
385
}