Lines
18.32 %
Functions
3.03 %
Branches
100 %
//! Form data collection and processing for transaction forms.
use serde::Serialize;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::{Document, Element, HtmlInputElement};
#[derive(Serialize)]
pub struct SplitData {
#[serde(rename = "from-account")]
pub from_account: Option<String>,
#[serde(rename = "to-account")]
pub to_account: Option<String>,
#[serde(rename = "from-commodity")]
pub from_commodity: Option<String>,
#[serde(rename = "to-commodity")]
pub to_commodity: Option<String>,
pub amount: Option<String>,
#[serde(rename = "amount-converted", skip_serializing_if = "Option::is_none")]
pub amount_converted: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub from_tags: Option<Vec<TagData>>,
pub to_tags: Option<Vec<TagData>>,
}
pub struct TagData {
pub name: String,
pub value: String,
pub description: Option<String>,
pub struct TransactionFormData {
pub splits: Vec<SplitData>,
pub note: String,
pub date: String,
pub transaction_id: Option<String>,
pub fn collect_splits_from_dom(document: &Document) -> Vec<SplitData> {
let mut splits = Vec::new();
let Ok(split_entries) = document.query_selector_all(".split-entry") else {
return splits;
};
for i in 0..split_entries.length() {
let Some(node) = split_entries.get(i) else {
continue;
let Ok(split_el) = node.dyn_into::<Element>() else {
let split = collect_split_data(&split_el);
splits.push(split);
splits
fn collect_split_data(split_el: &Element) -> SplitData {
let from_account = get_hidden_value(split_el, "from-account");
let to_account = get_hidden_value(split_el, "to-account");
let from_commodity = get_hidden_value(split_el, "from-commodity");
let to_commodity = get_hidden_value(split_el, "to-commodity");
let amount = get_field_value(split_el, "amount");
let amount_converted = get_field_value(split_el, "amount-converted").filter(|v| !v.is_empty());
let (from_tags, to_tags) = collect_tags(split_el);
SplitData {
from_account,
to_account,
from_commodity,
to_commodity,
amount,
amount_converted,
from_tags,
to_tags,
fn get_hidden_value(split_el: &Element, field: &str) -> Option<String> {
let selector = format!(r#"input[type="hidden"][data-field="{field}"][name*="["]"#);
split_el
.query_selector(&selector)
.ok()?
.and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
.map(|input| input.value())
.filter(|v| !v.is_empty())
fn get_field_value(split_el: &Element, field: &str) -> Option<String> {
let selector = format!(r#"input[data-field="{field}"]"#);
fn collect_tags(split_el: &Element) -> (Option<Vec<TagData>>, Option<Vec<TagData>>) {
let mut from_tags = Vec::new();
let mut to_tags = Vec::new();
let Ok(tag_rows) = split_el.query_selector_all(".tag-input-row") else {
return (None, None);
for i in 0..tag_rows.length() {
let Some(node) = tag_rows.get(i) else {
let Ok(tag_row) = node.dyn_into::<Element>() else {
let name = get_input_value(&tag_row, ".tag-name-input");
let value = get_input_value(&tag_row, ".tag-value-input");
let description =
get_input_value(&tag_row, ".tag-description-input").filter(|v| !v.is_empty());
if let (Some(name), Some(value)) = (name.clone(), value.clone())
&& (!name.is_empty() || !value.is_empty())
{
from_tags.push(TagData {
name: name.clone(),
value: value.clone(),
description: description.clone(),
});
let mirror_name = get_input_value(&tag_row, ".tag-name-mirror");
let mirror_value = get_input_value(&tag_row, ".tag-value-mirror");
let mirror_desc =
get_input_value(&tag_row, ".tag-description-mirror").filter(|v| !v.is_empty());
if let (Some(name), Some(value)) = (mirror_name, mirror_value)
to_tags.push(TagData {
name,
value,
description: mirror_desc,
let from = if from_tags.is_empty() {
None
} else {
Some(from_tags)
let to = if to_tags.is_empty() {
Some(to_tags)
(from, to)
fn get_input_value(parent: &Element, selector: &str) -> Option<String> {
parent
.query_selector(selector)
#[must_use]
pub fn process_form_data(document: &Document) -> TransactionFormData {
let splits = collect_splits_from_dom(document);
let note = document
.query_selector(r#"[name="note"]"#)
.ok()
.flatten()
.unwrap_or_default();
let date = document
.get_element_by_id("date")
let transaction_id = document
.query_selector(r#"[name="transaction_id"]"#)
.filter(|v| !v.is_empty());
TransactionFormData {
splits,
note,
date,
transaction_id,
pub fn setup_form_submit_handler() {
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
return;
let callback = Closure::wrap(Box::new(move |event: web_sys::CustomEvent| {
handle_config_request(event);
}) as Box<dyn FnMut(_)>);
let _ = document
.add_event_listener_with_callback("htmx:configRequest", callback.as_ref().unchecked_ref());
callback.forget();
fn handle_config_request(event: web_sys::CustomEvent) {
let detail = event.detail();
let Some(detail_obj) = detail.dyn_ref::<js_sys::Object>() else {
let verb = js_sys::Reflect::get(detail_obj, &"verb".into())
.and_then(|v| v.as_string())
let path = js_sys::Reflect::get(detail_obj, &"path".into())
if verb != "post"
|| (!path.contains("/transaction/create/submit")
&& !path.contains("/transaction/edit/submit"))
let form_data = process_form_data(&document);
let Ok(json) = serde_json::to_string(&form_data) else {
let Ok(body) = js_sys::JSON::parse(&json) else {
let _ = js_sys::Reflect::set(detail_obj, &"body".into(), &body);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tag_data_serializes_correctly() {
let tag = TagData {
name: "category".into(),
value: "groceries".into(),
description: Some("Weekly shopping".into()),
let json = serde_json::to_string(&tag).unwrap();
assert!(json.contains("category"));
assert!(json.contains("groceries"));
assert!(json.contains("Weekly shopping"));
fn tag_data_skips_none_description() {
name: "status".into(),
value: "pending".into(),
description: None,
assert!(!json.contains("description"));
fn split_data_uses_correct_field_names() {
let split = SplitData {
from_account: Some("acc-1".into()),
to_account: Some("acc-2".into()),
from_commodity: Some("com-1".into()),
to_commodity: Some("com-2".into()),
amount: Some("100".into()),
amount_converted: None,
from_tags: None,
to_tags: None,
let json = serde_json::to_string(&split).unwrap();
assert!(json.contains("from-account"));
assert!(json.contains("to-account"));
assert!(json.contains("from-commodity"));
assert!(json.contains("to-commodity"));