1
use serde::{Deserialize, Serialize};
2
use wasm_bindgen::JsCast;
3
use wasm_bindgen::prelude::*;
4
use web_sys::{
5
    Element, HtmlElement, HtmlInputElement, HtmlSelectElement, HtmlTemplateElement,
6
    HtmlTextAreaElement,
7
};
8

            
9
use crate::autocomplete;
10

            
11
/// Matches the server-side `ActivityGroup` wire format
12
/// (`server/src/command/mod.rs::ActivityGroup`). Each row in the visual
13
/// editor maps to exactly one of these.
14
#[derive(Serialize, Deserialize)]
15
struct ActivityGroupItem {
16
    label: String,
17
    filter: Filter,
18
    #[serde(default)]
19
    flip_sign: bool,
20
}
21

            
22
/// Adjacently-tagged `ReportFilter` — server uses `#[serde(tag = "op",
23
/// content = "args", rename_all = "snake_case")]`, so the only variant we
24
/// emit from the visual editor maps to `{"op":"tag","args":{...}}`.
25
#[derive(Serialize, Deserialize)]
26
#[serde(tag = "op", content = "args", rename_all = "snake_case")]
27
enum Filter {
28
    Tag {
29
        entity: String,
30
        name: String,
31
        value: String,
32
    },
33
}
34

            
35
fn find_root_container(el: &Element) -> Option<Element> {
36
    el.closest(".activity-groups-container").ok().flatten()
37
}
38

            
39
fn find_items_container(container: &Element) -> Option<Element> {
40
    container
41
        .query_selector(":scope > .activity-groups-visual")
42
        .ok()
43
        .flatten()
44
}
45

            
46
fn clone_template(template_id: &str) -> Option<Element> {
47
    let document = web_sys::window()?.document()?;
48
    let template: HtmlTemplateElement = document.get_element_by_id(template_id)?.dyn_into().ok()?;
49
    let first = template.content().first_element_child()?;
50
    first
51
        .clone_node_with_deep(true)
52
        .ok()?
53
        .dyn_into::<Element>()
54
        .ok()
55
}
56

            
57
fn next_id_counter(container: &Element) -> u32 {
58
    container
59
        .query_selector_all(".activity-group-row")
60
        .map_or(0, |list| list.length())
61
}
62

            
63
fn sync_activity_groups(container: &Element) {
64
    let Some(form) = container.closest("form").ok().flatten() else {
65
        return;
66
    };
67
    let Some(hidden) = form
68
        .query_selector("input[name=\"groups\"]")
69
        .ok()
70
        .flatten()
71
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
72
    else {
73
        return;
74
    };
75

            
76
    let mode_input = form
77
        .query_selector("input[name=\"groups_mode\"]")
78
        .ok()
79
        .flatten()
80
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok());
81

            
82
    let is_script = mode_input
83
        .as_ref()
84
        .map(web_sys::HtmlInputElement::value)
85
        .as_deref()
86
        == Some("script");
87

            
88
    if is_script {
89
        if let Some(textarea) = container
90
            .query_selector(".activity-groups-script-input")
91
            .ok()
92
            .flatten()
93
            .and_then(|el| el.dyn_into::<HtmlTextAreaElement>().ok())
94
        {
95
            hidden.set_value(&textarea.value());
96
        }
97
        return;
98
    }
99

            
100
    let Some(visual) = find_items_container(container) else {
101
        hidden.set_value("");
102
        return;
103
    };
104

            
105
    let rows = visual
106
        .query_selector_all(":scope > .activity-group-row")
107
        .ok();
108
    let mut items = Vec::new();
109
    if let Some(rows) = rows {
110
        for i in 0..rows.length() {
111
            let Some(row) = rows.get(i).and_then(|n| n.dyn_into::<Element>().ok()) else {
112
                continue;
113
            };
114
            if let Some(item) = collect_row(&row) {
115
                items.push(item);
116
            }
117
        }
118
    }
119

            
120
    if items.is_empty() {
121
        hidden.set_value("");
122
    } else if let Ok(json) = serde_json::to_string(&items) {
123
        hidden.set_value(&json);
124
    }
125
}
126

            
127
fn collect_row(row: &Element) -> Option<ActivityGroupItem> {
128
    let label = row
129
        .query_selector(".activity-group-label-input")
130
        .ok()
131
        .flatten()
132
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
133
        .map(|i| i.value())
134
        .unwrap_or_default()
135
        .trim()
136
        .to_owned();
137

            
138
    let entity = row
139
        .query_selector(".activity-group-entity")
140
        .ok()
141
        .flatten()
142
        .and_then(|el| el.dyn_into::<HtmlSelectElement>().ok())
143
        .map_or_else(|| "account".to_owned(), |s| s.value());
144

            
145
    let name = row
146
        .query_selector(".activity-group-tag-name")
147
        .ok()
148
        .flatten()
149
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
150
        .map(|i| i.value())
151
        .unwrap_or_default()
152
        .trim()
153
        .to_owned();
154

            
155
    let value = row
156
        .query_selector(".activity-group-tag-value")
157
        .ok()
158
        .flatten()
159
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
160
        .map(|i| i.value())
161
        .unwrap_or_default()
162
        .trim()
163
        .to_owned();
164

            
165
    let flip_sign = row
166
        .query_selector(".activity-group-flip")
167
        .ok()
168
        .flatten()
169
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
170
        .is_some_and(|cb| cb.checked());
171

            
172
    if label.is_empty() || name.is_empty() || value.is_empty() {
173
        return None;
174
    }
175

            
176
    Some(ActivityGroupItem {
177
        label,
178
        filter: Filter::Tag {
179
            entity,
180
            name,
181
            value,
182
        },
183
        flip_sign,
184
    })
185
}
186

            
187
fn update_fetch_urls(row: &Element) {
188
    let entity = row
189
        .query_selector(".activity-group-entity")
190
        .ok()
191
        .flatten()
192
        .and_then(|el| el.dyn_into::<HtmlSelectElement>().ok())
193
        .map_or_else(|| "account".to_string(), |s| s.value());
194

            
195
    let base = match entity.as_str() {
196
        "split" => "/api/tags/split",
197
        "transaction" => "/api/tags/transaction",
198
        _ => "/api/tags/account",
199
    };
200

            
201
    if let Some(w) = row
202
        .query_selector(".activity-group-tag-name-wrapper")
203
        .ok()
204
        .flatten()
205
    {
206
        let _ = w.set_attribute("data-fetch-url", &format!("{base}/names"));
207
    }
208
    if let Some(w) = row
209
        .query_selector(".activity-group-tag-value-wrapper")
210
        .ok()
211
        .flatten()
212
    {
213
        let _ = w.set_attribute("data-fetch-url", &format!("{base}/values"));
214
    }
215
}
216

            
217
fn setup_sync_listeners(element: &Element, container: &Element) {
218
    let container_clone = container.clone();
219
    let callback = Closure::wrap(Box::new(move |_: web_sys::Event| {
220
        sync_activity_groups(&container_clone);
221
    }) as Box<dyn FnMut(_)>);
222

            
223
    let selectors = [
224
        ".activity-group-label-input",
225
        ".activity-group-entity",
226
        ".activity-group-tag-name",
227
        ".activity-group-tag-value",
228
        ".activity-group-flip",
229
    ];
230
    for sel in selectors {
231
        if let Ok(nodes) = element.query_selector_all(sel) {
232
            for i in 0..nodes.length() {
233
                if let Some(el) = nodes.get(i) {
234
                    let _ = el.add_event_listener_with_callback(
235
                        "change",
236
                        callback.as_ref().unchecked_ref(),
237
                    );
238
                    let _ = el.add_event_listener_with_callback(
239
                        "input",
240
                        callback.as_ref().unchecked_ref(),
241
                    );
242
                }
243
            }
244
        }
245
    }
246
    callback.forget();
247
}
248

            
249
fn setup_entity_change_listener(row: &Element, container: &Element) {
250
    let row_clone = row.clone();
251
    let container_clone = container.clone();
252
    let cb = Closure::wrap(Box::new(move |_: web_sys::Event| {
253
        update_fetch_urls(&row_clone);
254
        sync_activity_groups(&container_clone);
255
    }) as Box<dyn FnMut(_)>);
256
    if let Some(sel) = row.query_selector(".activity-group-entity").ok().flatten() {
257
        let _ = sel.add_event_listener_with_callback("change", cb.as_ref().unchecked_ref());
258
    }
259
    cb.forget();
260
}
261

            
262
fn append_row(
263
    items: &Element,
264
    container: &Element,
265
    counter: u32,
266
    group: Option<&ActivityGroupItem>,
267
) {
268
    let Some(row) = clone_template("activity-group-row-template") else {
269
        return;
270
    };
271

            
272
    let name_id = format!("activity-group-{counter}-name");
273
    let value_id = format!("activity-group-{counter}-value");
274

            
275
    if let Some(input) = row
276
        .query_selector(".activity-group-label-input")
277
        .ok()
278
        .flatten()
279
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
280
        && let Some(g) = group
281
    {
282
        input.set_value(&g.label);
283
    }
284

            
285
    if let Some(sel) = row
286
        .query_selector(".activity-group-entity")
287
        .ok()
288
        .flatten()
289
        .and_then(|el| el.dyn_into::<HtmlSelectElement>().ok())
290
        && let Some(g) = group
291
    {
292
        let Filter::Tag { entity, .. } = &g.filter;
293
        sel.set_value(entity);
294
    }
295

            
296
    if let Some(input) = row
297
        .query_selector(".activity-group-tag-name")
298
        .ok()
299
        .flatten()
300
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
301
    {
302
        input.set_id(&name_id);
303
        if let Some(g) = group {
304
            let Filter::Tag { name, .. } = &g.filter;
305
            input.set_value(name);
306
        }
307
    }
308

            
309
    if let Some(input) = row
310
        .query_selector(".activity-group-tag-value")
311
        .ok()
312
        .flatten()
313
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
314
    {
315
        input.set_id(&value_id);
316
        if let Some(g) = group {
317
            let Filter::Tag { value, .. } = &g.filter;
318
            input.set_value(value);
319
        }
320
    }
321

            
322
    if let Some(wrapper) = row
323
        .query_selector(".activity-group-tag-name-wrapper")
324
        .ok()
325
        .flatten()
326
    {
327
        let _ = wrapper.set_attribute("data-display-input", &name_id);
328
    }
329
    if let Some(wrapper) = row
330
        .query_selector(".activity-group-tag-value-wrapper")
331
        .ok()
332
        .flatten()
333
    {
334
        let _ = wrapper.set_attribute("data-display-input", &value_id);
335
        let _ = wrapper.set_attribute("data-depends-on", &name_id);
336
    }
337

            
338
    if let Some(cb) = row
339
        .query_selector(".activity-group-flip")
340
        .ok()
341
        .flatten()
342
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
343
        && let Some(g) = group
344
    {
345
        cb.set_checked(g.flip_sign);
346
    }
347

            
348
    update_fetch_urls(&row);
349
    setup_entity_change_listener(&row, container);
350
    let _ = items.append_child(&row);
351
    setup_sync_listeners(&row, container);
352
}
353

            
354
fn build_rows(container: &Element, groups: &[ActivityGroupItem]) {
355
    let Some(visual) = find_items_container(container) else {
356
        return;
357
    };
358
    visual.set_inner_html("");
359
    for (i, g) in groups.iter().enumerate() {
360
        append_row(&visual, container, i as u32, Some(g));
361
    }
362
}
363

            
364
fn restore_container(container: &Element) {
365
    let raw = container
366
        .closest("form")
367
        .ok()
368
        .flatten()
369
        .and_then(|f| f.query_selector("input[name=\"groups\"]").ok().flatten())
370
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
371
        .map(|i| i.value())
372
        .unwrap_or_default();
373

            
374
    let mode = container
375
        .closest("form")
376
        .ok()
377
        .flatten()
378
        .and_then(|f| {
379
            f.query_selector("input[name=\"groups_mode\"]")
380
                .ok()
381
                .flatten()
382
        })
383
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
384
        .map(|i| i.value())
385
        .unwrap_or_default();
386

            
387
    if mode == "script"
388
        && !raw.is_empty()
389
        && let Some(textarea) = container
390
            .query_selector(".activity-groups-script-input")
391
            .ok()
392
            .flatten()
393
            .and_then(|el| el.dyn_into::<HtmlTextAreaElement>().ok())
394
    {
395
        textarea.set_value(&raw);
396
    }
397

            
398
    let groups: Vec<ActivityGroupItem> =
399
        serde_json::from_str(&raw).unwrap_or_else(|_| default_groups());
400

            
401
    build_rows(container, &groups);
402
    setup_textarea_sync(container);
403
}
404

            
405
fn setup_textarea_sync(container: &Element) {
406
    let Some(textarea) = container
407
        .query_selector(".activity-groups-script-input")
408
        .ok()
409
        .flatten()
410
    else {
411
        return;
412
    };
413
    let container_clone = container.clone();
414
    let callback = Closure::wrap(Box::new(move |_: web_sys::Event| {
415
        sync_activity_groups(&container_clone);
416
    }) as Box<dyn FnMut(_)>);
417
    let _ = textarea.add_event_listener_with_callback("input", callback.as_ref().unchecked_ref());
418
    callback.forget();
419
}
420

            
421
fn default_groups() -> Vec<ActivityGroupItem> {
422
    vec![
423
        ActivityGroupItem {
424
            label: "Income".to_owned(),
425
            filter: Filter::Tag {
426
                entity: "account".to_owned(),
427
                name: "type".to_owned(),
428
                value: "income".to_owned(),
429
            },
430
            flip_sign: true,
431
        },
432
        ActivityGroupItem {
433
            label: "Expense".to_owned(),
434
            filter: Filter::Tag {
435
                entity: "account".to_owned(),
436
                name: "type".to_owned(),
437
                value: "expense".to_owned(),
438
            },
439
            flip_sign: false,
440
        },
441
    ]
442
}
443

            
444
pub fn restore_all_groups() {
445
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
446
        return;
447
    };
448
    let Ok(containers) = document.query_selector_all(".activity-groups-container") else {
449
        return;
450
    };
451
    for i in 0..containers.length() {
452
        if let Some(el) = containers.get(i).and_then(|n| n.dyn_into::<Element>().ok()) {
453
            restore_container(&el);
454
        }
455
    }
456
}
457

            
458
#[wasm_bindgen(js_name = addActivityGroup)]
459
pub fn add_activity_group(button: &HtmlElement) {
460
    let Some(container) = button.dyn_ref::<Element>().and_then(find_root_container) else {
461
        return;
462
    };
463
    let Some(items) = find_items_container(&container) else {
464
        return;
465
    };
466
    let counter = next_id_counter(&container);
467
    append_row(&items, &container, counter, None);
468
    sync_activity_groups(&container);
469
    autocomplete::init_all();
470
}
471

            
472
#[wasm_bindgen(js_name = removeActivityGroup)]
473
pub fn remove_activity_group(button: &HtmlElement) {
474
    let container = button.dyn_ref::<Element>().and_then(find_root_container);
475
    if let Some(row) = button.closest(".activity-group-row").ok().flatten() {
476
        row.remove();
477
    }
478
    if let Some(container) = container {
479
        sync_activity_groups(&container);
480
    }
481
}
482

            
483
#[wasm_bindgen(js_name = syncActivityGroupsMode)]
484
pub fn sync_activity_groups_mode(radio: &HtmlElement) {
485
    let Some(container) = radio.dyn_ref::<Element>().and_then(find_root_container) else {
486
        return;
487
    };
488

            
489
    let mode = radio
490
        .dyn_ref::<HtmlInputElement>()
491
        .map_or_else(|| "visual".to_string(), web_sys::HtmlInputElement::value);
492

            
493
    if let Some(visual) = container
494
        .query_selector(".activity-groups-visual")
495
        .ok()
496
        .flatten()
497
    {
498
        let html: &HtmlElement = visual.unchecked_ref();
499
        let _ = html
500
            .style()
501
            .set_property("display", if mode == "script" { "none" } else { "" });
502
    }
503
    if let Some(script) = container
504
        .query_selector(".activity-groups-script")
505
        .ok()
506
        .flatten()
507
    {
508
        let html: &HtmlElement = script.unchecked_ref();
509
        let _ = html
510
            .style()
511
            .set_property("display", if mode == "script" { "" } else { "none" });
512
    }
513
    if let Some(form) = container.closest("form").ok().flatten()
514
        && let Some(hidden) = form
515
            .query_selector("input[name=\"groups_mode\"]")
516
            .ok()
517
            .flatten()
518
            .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
519
    {
520
        hidden.set_value(&mode);
521
    }
522

            
523
    sync_activity_groups(&container);
524
}