Lines
0 %
Functions
Branches
100 %
use serde::{Deserialize, Serialize};
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::{
Element, HtmlElement, HtmlInputElement, HtmlSelectElement, HtmlTemplateElement,
HtmlTextAreaElement,
};
use crate::autocomplete;
/// Matches the server-side `ActivityGroup` wire format
/// (`server/src/command/mod.rs::ActivityGroup`). Each row in the visual
/// editor maps to exactly one of these.
#[derive(Serialize, Deserialize)]
struct ActivityGroupItem {
label: String,
filter: Filter,
#[serde(default)]
flip_sign: bool,
}
/// Adjacently-tagged `ReportFilter` — server uses `#[serde(tag = "op",
/// content = "args", rename_all = "snake_case")]`, so the only variant we
/// emit from the visual editor maps to `{"op":"tag","args":{...}}`.
#[serde(tag = "op", content = "args", rename_all = "snake_case")]
enum Filter {
Tag {
entity: String,
name: String,
value: String,
},
fn find_root_container(el: &Element) -> Option<Element> {
el.closest(".activity-groups-container").ok().flatten()
fn find_items_container(container: &Element) -> Option<Element> {
container
.query_selector(":scope > .activity-groups-visual")
.ok()
.flatten()
fn clone_template(template_id: &str) -> Option<Element> {
let document = web_sys::window()?.document()?;
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 next_id_counter(container: &Element) -> u32 {
.query_selector_all(".activity-group-row")
.map_or(0, |list| list.length())
fn sync_activity_groups(container: &Element) {
let Some(form) = container.closest("form").ok().flatten() else {
return;
let Some(hidden) = form
.query_selector("input[name=\"groups\"]")
.and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
else {
let mode_input = form
.query_selector("input[name=\"groups_mode\"]")
.and_then(|el| el.dyn_into::<HtmlInputElement>().ok());
let is_script = mode_input
.as_ref()
.map(web_sys::HtmlInputElement::value)
.as_deref()
== Some("script");
if is_script {
if let Some(textarea) = container
.query_selector(".activity-groups-script-input")
.and_then(|el| el.dyn_into::<HtmlTextAreaElement>().ok())
{
hidden.set_value(&textarea.value());
let Some(visual) = find_items_container(container) else {
hidden.set_value("");
let rows = visual
.query_selector_all(":scope > .activity-group-row")
.ok();
let mut items = Vec::new();
if let Some(rows) = rows {
for i in 0..rows.length() {
let Some(row) = rows.get(i).and_then(|n| n.dyn_into::<Element>().ok()) else {
continue;
if let Some(item) = collect_row(&row) {
items.push(item);
if items.is_empty() {
} else if let Ok(json) = serde_json::to_string(&items) {
hidden.set_value(&json);
fn collect_row(row: &Element) -> Option<ActivityGroupItem> {
let label = row
.query_selector(".activity-group-label-input")
.map(|i| i.value())
.unwrap_or_default()
.trim()
.to_owned();
let entity = row
.query_selector(".activity-group-entity")
.and_then(|el| el.dyn_into::<HtmlSelectElement>().ok())
.map_or_else(|| "account".to_owned(), |s| s.value());
let name = row
.query_selector(".activity-group-tag-name")
let value = row
.query_selector(".activity-group-tag-value")
let flip_sign = row
.query_selector(".activity-group-flip")
.is_some_and(|cb| cb.checked());
if label.is_empty() || name.is_empty() || value.is_empty() {
return None;
Some(ActivityGroupItem {
label,
filter: Filter::Tag {
entity,
name,
value,
flip_sign,
})
fn update_fetch_urls(row: &Element) {
.map_or_else(|| "account".to_string(), |s| s.value());
let base = match entity.as_str() {
"split" => "/api/tags/split",
"transaction" => "/api/tags/transaction",
_ => "/api/tags/account",
if let Some(w) = row
.query_selector(".activity-group-tag-name-wrapper")
let _ = w.set_attribute("data-fetch-url", &format!("{base}/names"));
.query_selector(".activity-group-tag-value-wrapper")
let _ = w.set_attribute("data-fetch-url", &format!("{base}/values"));
fn setup_sync_listeners(element: &Element, container: &Element) {
let container_clone = container.clone();
let callback = Closure::wrap(Box::new(move |_: web_sys::Event| {
sync_activity_groups(&container_clone);
}) as Box<dyn FnMut(_)>);
let selectors = [
".activity-group-label-input",
".activity-group-entity",
".activity-group-tag-name",
".activity-group-tag-value",
".activity-group-flip",
];
for sel in selectors {
if let Ok(nodes) = element.query_selector_all(sel) {
for i in 0..nodes.length() {
if let Some(el) = nodes.get(i) {
let _ = el.add_event_listener_with_callback(
"change",
callback.as_ref().unchecked_ref(),
);
"input",
callback.forget();
fn setup_entity_change_listener(row: &Element, container: &Element) {
let row_clone = row.clone();
let cb = Closure::wrap(Box::new(move |_: web_sys::Event| {
update_fetch_urls(&row_clone);
if let Some(sel) = row.query_selector(".activity-group-entity").ok().flatten() {
let _ = sel.add_event_listener_with_callback("change", cb.as_ref().unchecked_ref());
cb.forget();
fn append_row(
items: &Element,
container: &Element,
counter: u32,
group: Option<&ActivityGroupItem>,
) {
let Some(row) = clone_template("activity-group-row-template") else {
let name_id = format!("activity-group-{counter}-name");
let value_id = format!("activity-group-{counter}-value");
if let Some(input) = row
&& let Some(g) = group
input.set_value(&g.label);
if let Some(sel) = row
let Filter::Tag { entity, .. } = &g.filter;
sel.set_value(entity);
input.set_id(&name_id);
if let Some(g) = group {
let Filter::Tag { name, .. } = &g.filter;
input.set_value(name);
input.set_id(&value_id);
let Filter::Tag { value, .. } = &g.filter;
input.set_value(value);
if let Some(wrapper) = row
let _ = wrapper.set_attribute("data-display-input", &name_id);
let _ = wrapper.set_attribute("data-display-input", &value_id);
let _ = wrapper.set_attribute("data-depends-on", &name_id);
if let Some(cb) = row
cb.set_checked(g.flip_sign);
update_fetch_urls(&row);
setup_entity_change_listener(&row, container);
let _ = items.append_child(&row);
setup_sync_listeners(&row, container);
fn build_rows(container: &Element, groups: &[ActivityGroupItem]) {
visual.set_inner_html("");
for (i, g) in groups.iter().enumerate() {
append_row(&visual, container, i as u32, Some(g));
fn restore_container(container: &Element) {
let raw = container
.closest("form")
.and_then(|f| f.query_selector("input[name=\"groups\"]").ok().flatten())
.unwrap_or_default();
let mode = container
.and_then(|f| {
f.query_selector("input[name=\"groups_mode\"]")
if mode == "script"
&& !raw.is_empty()
&& let Some(textarea) = container
textarea.set_value(&raw);
let groups: Vec<ActivityGroupItem> =
serde_json::from_str(&raw).unwrap_or_else(|_| default_groups());
build_rows(container, &groups);
setup_textarea_sync(container);
fn setup_textarea_sync(container: &Element) {
let Some(textarea) = container
let _ = textarea.add_event_listener_with_callback("input", callback.as_ref().unchecked_ref());
fn default_groups() -> Vec<ActivityGroupItem> {
vec![
ActivityGroupItem {
label: "Income".to_owned(),
entity: "account".to_owned(),
name: "type".to_owned(),
value: "income".to_owned(),
flip_sign: true,
label: "Expense".to_owned(),
value: "expense".to_owned(),
flip_sign: false,
]
pub fn restore_all_groups() {
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
let Ok(containers) = document.query_selector_all(".activity-groups-container") else {
for i in 0..containers.length() {
if let Some(el) = containers.get(i).and_then(|n| n.dyn_into::<Element>().ok()) {
restore_container(&el);
#[wasm_bindgen(js_name = addActivityGroup)]
pub fn add_activity_group(button: &HtmlElement) {
let Some(container) = button.dyn_ref::<Element>().and_then(find_root_container) else {
let Some(items) = find_items_container(&container) else {
let counter = next_id_counter(&container);
append_row(&items, &container, counter, None);
sync_activity_groups(&container);
autocomplete::init_all();
#[wasm_bindgen(js_name = removeActivityGroup)]
pub fn remove_activity_group(button: &HtmlElement) {
let container = button.dyn_ref::<Element>().and_then(find_root_container);
if let Some(row) = button.closest(".activity-group-row").ok().flatten() {
row.remove();
if let Some(container) = container {
#[wasm_bindgen(js_name = syncActivityGroupsMode)]
pub fn sync_activity_groups_mode(radio: &HtmlElement) {
let Some(container) = radio.dyn_ref::<Element>().and_then(find_root_container) else {
let mode = radio
.dyn_ref::<HtmlInputElement>()
.map_or_else(|| "visual".to_string(), web_sys::HtmlInputElement::value);
if let Some(visual) = container
.query_selector(".activity-groups-visual")
let html: &HtmlElement = visual.unchecked_ref();
let _ = html
.style()
.set_property("display", if mode == "script" { "none" } else { "" });
if let Some(script) = container
.query_selector(".activity-groups-script")
let html: &HtmlElement = script.unchecked_ref();
.set_property("display", if mode == "script" { "" } else { "none" });
if let Some(form) = container.closest("form").ok().flatten()
&& let Some(hidden) = form
hidden.set_value(&mode);