1
//! Entity tag management for transaction and account tags.
2
//!
3
//! Provides inline add, edit, cancel, delete, and filter operations
4
//! powered by WASM, replacing the old table-based JS approach.
5

            
6
use wasm_bindgen::JsCast;
7
use wasm_bindgen::prelude::*;
8
use web_sys::{Element, HtmlElement, HtmlInputElement, HtmlTemplateElement};
9

            
10
use crate::autocomplete;
11

            
12
#[wasm_bindgen(js_name = addEntityTag)]
13
pub fn add_entity_tag(button: &HtmlElement) {
14
    let Some(container) = button.closest(".tag-editor").ok().flatten() else {
15
        return;
16
    };
17
    let Some(input_row) = button.closest(".tag-input-row").ok().flatten() else {
18
        return;
19
    };
20

            
21
    let name = input_value(&input_row, ".tag-name-input");
22
    let value = input_value(&input_row, ".tag-value-input");
23
    let desc = input_value(&input_row, ".tag-desc-input");
24
    if name.is_empty() || value.is_empty() {
25
        return;
26
    }
27

            
28
    let create_url = container
29
        .get_attribute("data-create-url")
30
        .unwrap_or_default();
31
    let container_id = container.id();
32

            
33
    wasm_bindgen_futures::spawn_local(async move {
34
        let body = if desc.is_empty() {
35
            format!(
36
                r#"{{"tag_name":"{}","tag_value":"{}"}}"#,
37
                escape_json(&name),
38
                escape_json(&value)
39
            )
40
        } else {
41
            format!(
42
                r#"{{"tag_name":"{}","tag_value":"{}","description":"{}"}}"#,
43
                escape_json(&name),
44
                escape_json(&value),
45
                escape_json(&desc)
46
            )
47
        };
48

            
49
        let Ok(response_text) = post_json(&create_url, &body).await else {
50
            web_sys::console::error_1(&"Failed to create tag".into());
51
            return;
52
        };
53

            
54
        let tag_id = extract_uuid(&response_text);
55
        let Some(document) = web_sys::window().and_then(|w| w.document()) else {
56
            return;
57
        };
58
        let Some(container) = document.get_element_by_id(&container_id) else {
59
            return;
60
        };
61

            
62
        let template_id = format!("{container_id}-row-template");
63
        let Some(row) = clone_template(&document, &template_id) else {
64
            return;
65
        };
66

            
67
        let _ = row.set_attribute("data-tag-id", &tag_id);
68
        let _ = row.set_attribute("data-tag-name", &name);
69
        let _ = row.set_attribute("data-tag-value", &value);
70
        let _ = row.set_attribute("data-tag-desc", &desc);
71
        set_cell_text(&row, ".tag-name-cell", &name);
72
        set_cell_text(&row, ".tag-value-cell", &value);
73
        set_cell_text(&row, ".tag-desc-cell", &desc);
74

            
75
        if let Some(tag_rows) = container.query_selector(".tag-rows").ok().flatten() {
76
            let _ = tag_rows.append_child(&row);
77
        }
78

            
79
        if let Some(add_row) = container.query_selector(".tag-add-row").ok().flatten() {
80
            clear_input(&add_row, ".tag-name-input");
81
            clear_input(&add_row, ".tag-value-input");
82
            clear_input(&add_row, ".tag-desc-input");
83
            focus_input(&add_row, ".tag-name-input");
84
        }
85
    });
86
}
87

            
88
#[wasm_bindgen(js_name = editEntityTag)]
89
pub fn edit_entity_tag(button: &HtmlElement) {
90
    let Some(tag_row) = button.closest(".tag-row").ok().flatten() else {
91
        return;
92
    };
93
    let Some(container) = tag_row.closest(".tag-editor").ok().flatten() else {
94
        return;
95
    };
96

            
97
    let tag_id = tag_row.get_attribute("data-tag-id").unwrap_or_default();
98
    let name = tag_row.get_attribute("data-tag-name").unwrap_or_default();
99
    let value = tag_row.get_attribute("data-tag-value").unwrap_or_default();
100
    let desc = tag_row.get_attribute("data-tag-desc").unwrap_or_default();
101

            
102
    let container_id = container.id();
103
    let refresh_url = container
104
        .get_attribute("data-refresh-url")
105
        .unwrap_or_default();
106

            
107
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
108
        return;
109
    };
110

            
111
    let template_id = format!("{container_id}-edit-template");
112
    let Some(form) = clone_template(&document, &template_id) else {
113
        return;
114
    };
115

            
116
    set_input_attr_value(&form, "input[name='tag_name']", &name);
117
    set_input_attr_value(&form, "input[name='tag_value']", &value);
118
    set_input_attr_value(&form, "input[name='description']", &desc);
119

            
120
    if let Some(id_input) = form.query_selector("input[name='id']").ok().flatten() {
121
        let _ = id_input.set_attribute("value", &tag_id);
122
    }
123

            
124
    let handler = format!(
125
        "if(event.detail.successful) {{ htmx.ajax('GET', '{refresh_url}', \
126
         {{target: '#{container_id}', swap: 'outerHTML', select: '#{container_id}'}}); }}"
127
    );
128
    let _ = form.set_attribute("hx-on::after-request", &handler);
129

            
130
    hide_element(&tag_row);
131
    if let Some(parent) = tag_row.parent_node() {
132
        let _ = parent.insert_before(&form, tag_row.next_sibling().as_ref());
133
    }
134

            
135
    call_htmx_process(&form);
136
    autocomplete::init_all();
137
}
138

            
139
#[wasm_bindgen(js_name = cancelEntityTag)]
140
pub fn cancel_entity_tag(button: &HtmlElement) {
141
    let Some(form) = button.closest(".tag-edit-form").ok().flatten() else {
142
        return;
143
    };
144

            
145
    if let Some(prev) = form.previous_element_sibling()
146
        && prev.class_list().contains("tag-row")
147
    {
148
        show_element(&prev);
149
    }
150

            
151
    form.remove();
152
}
153

            
154
#[wasm_bindgen(js_name = deleteEntityTag)]
155
pub fn delete_entity_tag(button: &HtmlElement) {
156
    let Some(tag_row) = button.closest(".tag-row").ok().flatten() else {
157
        return;
158
    };
159

            
160
    let tag_id = tag_row.get_attribute("data-tag-id").unwrap_or_default();
161

            
162
    let Some(window) = web_sys::window() else {
163
        return;
164
    };
165
    if !window
166
        .confirm_with_message("Are you sure you want to delete this tag?")
167
        .unwrap_or(false)
168
    {
169
        return;
170
    }
171

            
172
    tag_row.remove();
173

            
174
    let delete_url = format!("/api/tag/delete/{tag_id}");
175
    wasm_bindgen_futures::spawn_local(async move {
176
        if post_json(&delete_url, "").await.is_err() {
177
            web_sys::console::error_1(&"Failed to delete tag".into());
178
        }
179
    });
180
}
181

            
182
#[wasm_bindgen(js_name = filterEntityTags)]
183
pub fn filter_entity_tags(input: &HtmlElement) {
184
    let Some(container) = input.closest(".tag-editor").ok().flatten() else {
185
        return;
186
    };
187
    let Some(filter_row) = container.query_selector(".tag-filter-row").ok().flatten() else {
188
        return;
189
    };
190

            
191
    let name_filter = input_value(&filter_row, ".tag-name-filter").to_lowercase();
192
    let value_filter = input_value(&filter_row, ".tag-value-filter").to_lowercase();
193
    let desc_filter = input_value(&filter_row, ".tag-desc-filter").to_lowercase();
194

            
195
    let Ok(rows) = container.query_selector_all(".tag-row") else {
196
        return;
197
    };
198

            
199
    for i in 0..rows.length() {
200
        let Some(node) = rows.get(i) else {
201
            continue;
202
        };
203
        let Ok(row) = node.dyn_into::<Element>() else {
204
            continue;
205
        };
206

            
207
        let name = row
208
            .get_attribute("data-tag-name")
209
            .unwrap_or_default()
210
            .to_lowercase();
211
        let value = row
212
            .get_attribute("data-tag-value")
213
            .unwrap_or_default()
214
            .to_lowercase();
215
        let desc = row
216
            .get_attribute("data-tag-desc")
217
            .unwrap_or_default()
218
            .to_lowercase();
219

            
220
        let matches = (name_filter.is_empty() || name.contains(&name_filter))
221
            && (value_filter.is_empty() || value.contains(&value_filter))
222
            && (desc_filter.is_empty() || desc.contains(&desc_filter));
223

            
224
        if matches {
225
            show_element(&row);
226
        } else {
227
            hide_element(&row);
228
        }
229
    }
230
}
231

            
232
fn input_value(parent: &Element, selector: &str) -> String {
233
    parent
234
        .query_selector(selector)
235
        .ok()
236
        .flatten()
237
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
238
        .map(|input| input.value())
239
        .unwrap_or_default()
240
}
241

            
242
fn set_input_attr_value(parent: &Element, selector: &str, val: &str) {
243
    if let Some(input) = parent
244
        .query_selector(selector)
245
        .ok()
246
        .flatten()
247
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
248
    {
249
        input.set_value(val);
250
    }
251
}
252

            
253
fn clear_input(parent: &Element, selector: &str) {
254
    set_input_attr_value(parent, selector, "");
255
}
256

            
257
fn focus_input(parent: &Element, selector: &str) {
258
    if let Some(input) = parent
259
        .query_selector(selector)
260
        .ok()
261
        .flatten()
262
        .and_then(|el| el.dyn_into::<HtmlElement>().ok())
263
    {
264
        let _ = input.focus();
265
    }
266
}
267

            
268
fn set_cell_text(row: &Element, selector: &str, text: &str) {
269
    if let Some(cell) = row.query_selector(selector).ok().flatten() {
270
        cell.set_text_content(Some(text));
271
    }
272
}
273

            
274
fn clone_template(document: &web_sys::Document, template_id: &str) -> Option<Element> {
275
    let template: HtmlTemplateElement = document.get_element_by_id(template_id)?.dyn_into().ok()?;
276
    let first = template.content().first_element_child()?;
277
    first
278
        .clone_node_with_deep(true)
279
        .ok()?
280
        .dyn_into::<Element>()
281
        .ok()
282
}
283

            
284
fn hide_element(el: &Element) {
285
    if let Ok(html) = el.clone().dyn_into::<HtmlElement>() {
286
        let _ = html.style().set_property("display", "none");
287
    }
288
}
289

            
290
fn show_element(el: &Element) {
291
    if let Ok(html) = el.clone().dyn_into::<HtmlElement>() {
292
        let _ = html.style().remove_property("display");
293
    }
294
}
295

            
296
fn call_htmx_process(element: &Element) {
297
    let Some(window) = web_sys::window() else {
298
        return;
299
    };
300
    let Ok(htmx) = js_sys::Reflect::get(&window, &"htmx".into()) else {
301
        return;
302
    };
303
    if htmx.is_undefined() {
304
        return;
305
    }
306
    let Ok(process_fn) = js_sys::Reflect::get(&htmx, &"process".into()) else {
307
        return;
308
    };
309
    if let Ok(func) = process_fn.dyn_into::<js_sys::Function>() {
310
        let _ = func.call1(&htmx, element);
311
    }
312
}
313

            
314
fn escape_json(s: &str) -> String {
315
    s.replace('\\', "\\\\")
316
        .replace('"', "\\\"")
317
        .replace('\n', "\\n")
318
        .replace('\r', "\\r")
319
        .replace('\t', "\\t")
320
}
321

            
322
fn extract_uuid(text: &str) -> String {
323
    text.split(": ")
324
        .last()
325
        .unwrap_or_default()
326
        .trim()
327
        .to_string()
328
}
329

            
330
async fn post_json(url: &str, body: &str) -> Result<String, JsValue> {
331
    let window = web_sys::window().ok_or(JsValue::NULL)?;
332

            
333
    let headers = web_sys::Headers::new()?;
334
    headers.set("Content-Type", "application/json")?;
335

            
336
    let opts = web_sys::RequestInit::new();
337
    opts.set_method("POST");
338
    opts.set_headers(headers.as_ref());
339

            
340
    if !body.is_empty() {
341
        opts.set_body(&JsValue::from_str(body));
342
    }
343

            
344
    let request = web_sys::Request::new_with_str_and_init(url, &opts)?;
345
    let resp_value =
346
        wasm_bindgen_futures::JsFuture::from(window.fetch_with_request(&request)).await?;
347
    let resp: web_sys::Response = resp_value.dyn_into()?;
348

            
349
    if !resp.ok() {
350
        return Err(JsValue::from_str("Request failed"));
351
    }
352

            
353
    let text = wasm_bindgen_futures::JsFuture::from(resp.text()?).await?;
354
    text.as_string().ok_or(JsValue::NULL)
355
}