Lines
0 %
Functions
Branches
100 %
//! Entity tag management for transaction and account tags.
//!
//! Provides inline add, edit, cancel, delete, and filter operations
//! powered by WASM, replacing the old table-based JS approach.
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::{Element, HtmlElement, HtmlInputElement, HtmlTemplateElement};
use crate::autocomplete;
#[wasm_bindgen(js_name = addEntityTag)]
pub fn add_entity_tag(button: &HtmlElement) {
let Some(container) = button.closest(".tag-editor").ok().flatten() else {
return;
};
let Some(input_row) = button.closest(".tag-input-row").ok().flatten() else {
let name = input_value(&input_row, ".tag-name-input");
let value = input_value(&input_row, ".tag-value-input");
let desc = input_value(&input_row, ".tag-desc-input");
if name.is_empty() || value.is_empty() {
}
let create_url = container
.get_attribute("data-create-url")
.unwrap_or_default();
let container_id = container.id();
wasm_bindgen_futures::spawn_local(async move {
let body = if desc.is_empty() {
format!(
r#"{{"tag_name":"{}","tag_value":"{}"}}"#,
escape_json(&name),
escape_json(&value)
)
} else {
r#"{{"tag_name":"{}","tag_value":"{}","description":"{}"}}"#,
escape_json(&value),
escape_json(&desc)
let Ok(response_text) = post_json(&create_url, &body).await else {
web_sys::console::error_1(&"Failed to create tag".into());
let tag_id = extract_uuid(&response_text);
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
let Some(container) = document.get_element_by_id(&container_id) else {
let template_id = format!("{container_id}-row-template");
let Some(row) = clone_template(&document, &template_id) else {
let _ = row.set_attribute("data-tag-id", &tag_id);
let _ = row.set_attribute("data-tag-name", &name);
let _ = row.set_attribute("data-tag-value", &value);
let _ = row.set_attribute("data-tag-desc", &desc);
set_cell_text(&row, ".tag-name-cell", &name);
set_cell_text(&row, ".tag-value-cell", &value);
set_cell_text(&row, ".tag-desc-cell", &desc);
if let Some(tag_rows) = container.query_selector(".tag-rows").ok().flatten() {
let _ = tag_rows.append_child(&row);
if let Some(add_row) = container.query_selector(".tag-add-row").ok().flatten() {
clear_input(&add_row, ".tag-name-input");
clear_input(&add_row, ".tag-value-input");
clear_input(&add_row, ".tag-desc-input");
focus_input(&add_row, ".tag-name-input");
});
#[wasm_bindgen(js_name = editEntityTag)]
pub fn edit_entity_tag(button: &HtmlElement) {
let Some(tag_row) = button.closest(".tag-row").ok().flatten() else {
let Some(container) = tag_row.closest(".tag-editor").ok().flatten() else {
let tag_id = tag_row.get_attribute("data-tag-id").unwrap_or_default();
let name = tag_row.get_attribute("data-tag-name").unwrap_or_default();
let value = tag_row.get_attribute("data-tag-value").unwrap_or_default();
let desc = tag_row.get_attribute("data-tag-desc").unwrap_or_default();
let refresh_url = container
.get_attribute("data-refresh-url")
let template_id = format!("{container_id}-edit-template");
let Some(form) = clone_template(&document, &template_id) else {
set_input_attr_value(&form, "input[name='tag_name']", &name);
set_input_attr_value(&form, "input[name='tag_value']", &value);
set_input_attr_value(&form, "input[name='description']", &desc);
if let Some(id_input) = form.query_selector("input[name='id']").ok().flatten() {
let _ = id_input.set_attribute("value", &tag_id);
let handler = format!(
"if(event.detail.successful) {{ htmx.ajax('GET', '{refresh_url}', \
{{target: '#{container_id}', swap: 'outerHTML', select: '#{container_id}'}}); }}"
);
let _ = form.set_attribute("hx-on::after-request", &handler);
hide_element(&tag_row);
if let Some(parent) = tag_row.parent_node() {
let _ = parent.insert_before(&form, tag_row.next_sibling().as_ref());
call_htmx_process(&form);
autocomplete::init_all();
#[wasm_bindgen(js_name = cancelEntityTag)]
pub fn cancel_entity_tag(button: &HtmlElement) {
let Some(form) = button.closest(".tag-edit-form").ok().flatten() else {
if let Some(prev) = form.previous_element_sibling()
&& prev.class_list().contains("tag-row")
{
show_element(&prev);
form.remove();
#[wasm_bindgen(js_name = deleteEntityTag)]
pub fn delete_entity_tag(button: &HtmlElement) {
let Some(window) = web_sys::window() else {
if !window
.confirm_with_message("Are you sure you want to delete this tag?")
.unwrap_or(false)
tag_row.remove();
let delete_url = format!("/api/tag/delete/{tag_id}");
if post_json(&delete_url, "").await.is_err() {
web_sys::console::error_1(&"Failed to delete tag".into());
#[wasm_bindgen(js_name = filterEntityTags)]
pub fn filter_entity_tags(input: &HtmlElement) {
let Some(container) = input.closest(".tag-editor").ok().flatten() else {
let Some(filter_row) = container.query_selector(".tag-filter-row").ok().flatten() else {
let name_filter = input_value(&filter_row, ".tag-name-filter").to_lowercase();
let value_filter = input_value(&filter_row, ".tag-value-filter").to_lowercase();
let desc_filter = input_value(&filter_row, ".tag-desc-filter").to_lowercase();
let Ok(rows) = container.query_selector_all(".tag-row") else {
for i in 0..rows.length() {
let Some(node) = rows.get(i) else {
continue;
let Ok(row) = node.dyn_into::<Element>() else {
let name = row
.get_attribute("data-tag-name")
.unwrap_or_default()
.to_lowercase();
let value = row
.get_attribute("data-tag-value")
let desc = row
.get_attribute("data-tag-desc")
let matches = (name_filter.is_empty() || name.contains(&name_filter))
&& (value_filter.is_empty() || value.contains(&value_filter))
&& (desc_filter.is_empty() || desc.contains(&desc_filter));
if matches {
show_element(&row);
hide_element(&row);
fn input_value(parent: &Element, selector: &str) -> String {
parent
.query_selector(selector)
.ok()
.flatten()
.and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
.map(|input| input.value())
fn set_input_attr_value(parent: &Element, selector: &str, val: &str) {
if let Some(input) = parent
input.set_value(val);
fn clear_input(parent: &Element, selector: &str) {
set_input_attr_value(parent, selector, "");
fn focus_input(parent: &Element, selector: &str) {
.and_then(|el| el.dyn_into::<HtmlElement>().ok())
let _ = input.focus();
fn set_cell_text(row: &Element, selector: &str, text: &str) {
if let Some(cell) = row.query_selector(selector).ok().flatten() {
cell.set_text_content(Some(text));
fn clone_template(document: &web_sys::Document, template_id: &str) -> Option<Element> {
let template: HtmlTemplateElement = document.get_element_by_id(template_id)?.dyn_into().ok()?;
let first = template.content().first_element_child()?;
first
.clone_node_with_deep(true)
.ok()?
.dyn_into::<Element>()
fn hide_element(el: &Element) {
if let Ok(html) = el.clone().dyn_into::<HtmlElement>() {
let _ = html.style().set_property("display", "none");
fn show_element(el: &Element) {
let _ = html.style().remove_property("display");
fn call_htmx_process(element: &Element) {
let Ok(htmx) = js_sys::Reflect::get(&window, &"htmx".into()) else {
if htmx.is_undefined() {
let Ok(process_fn) = js_sys::Reflect::get(&htmx, &"process".into()) else {
if let Ok(func) = process_fn.dyn_into::<js_sys::Function>() {
let _ = func.call1(&htmx, element);
fn escape_json(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
fn extract_uuid(text: &str) -> String {
text.split(": ")
.last()
.trim()
.to_string()
async fn post_json(url: &str, body: &str) -> Result<String, JsValue> {
let window = web_sys::window().ok_or(JsValue::NULL)?;
let headers = web_sys::Headers::new()?;
headers.set("Content-Type", "application/json")?;
let opts = web_sys::RequestInit::new();
opts.set_method("POST");
opts.set_headers(headers.as_ref());
if !body.is_empty() {
opts.set_body(&JsValue::from_str(body));
let request = web_sys::Request::new_with_str_and_init(url, &opts)?;
let resp_value =
wasm_bindgen_futures::JsFuture::from(window.fetch_with_request(&request)).await?;
let resp: web_sys::Response = resp_value.dyn_into()?;
if !resp.ok() {
return Err(JsValue::from_str("Request failed"));
let text = wasm_bindgen_futures::JsFuture::from(resp.text()?).await?;
text.as_string().ok_or(JsValue::NULL)