Lines
2.26 %
Functions
0.77 %
Branches
100 %
//! Split management for transaction forms.
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::{Element, HtmlElement, HtmlInputElement};
use crate::autocomplete;
/// Returns the current number of split entries in the DOM.
/// Exported to window for htmx hx-vals usage.
#[wasm_bindgen(js_name = getSplitCount)]
#[must_use]
pub fn get_split_count() -> u32 {
web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.query_selector_all(".split-entry").ok())
.map_or(0, |list| list.length())
}
/// Sets up event handlers for split management.
pub fn setup_split_handlers() {
setup_split_removal_handler();
setup_currency_change_handler();
fn setup_split_removal_handler() {
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
return;
};
let callback = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
let Some(target) = event.target() else {
let Ok(el) = target.dyn_into::<HtmlElement>() else {
if !el.class_list().contains("remove-split-btn") {
handle_split_removal(&el);
}) as Box<dyn FnMut(_)>);
let _ = document.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
callback.forget();
fn handle_split_removal(button: &HtmlElement) {
let split_count = document
.query_selector_all(".split-entry")
.map(|list| list.length())
.unwrap_or(0);
if split_count <= 1 {
if let Some(window) = web_sys::window() {
let _ = window.alert_with_message(
"Cannot remove the last split. At least one split is required.",
);
let Some(split_entry) = button.closest(".split-entry").ok().flatten() else {
split_entry.remove();
update_split_labels();
fn update_split_labels() {
let Ok(splits) = document.query_selector_all(".split-entry") else {
for i in 0..splits.length() {
let Some(node) = splits.get(i) else {
continue;
let Ok(split) = node.dyn_into::<Element>() else {
if let Some(label) = split.query_selector(".split-label").ok().flatten() {
label.set_text_content(Some(&format!("Split {}", i + 1)));
let _ = split.set_attribute("data-split-index", &i.to_string());
fn setup_currency_change_handler() {
let callback = Closure::wrap(Box::new(move |event: web_sys::Event| {
let Ok(input) = target.dyn_into::<HtmlInputElement>() else {
let class_list = input.class_name();
if !class_list.contains("commodity-value") {
let Some(split_entry) = input.closest(".split-entry").ok().flatten() else {
check_currency_mismatch(&split_entry);
let _ = document.add_event_listener_with_callback("change", callback.as_ref().unchecked_ref());
fn check_currency_mismatch(split_entry: &Element) {
let from_commodity = split_entry
.query_selector(r#".commodity-value[data-field="from-commodity"]"#)
.ok()
.flatten()
.and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
.map(|input| input.value());
let to_commodity = split_entry
.query_selector(r#".commodity-value[data-field="to-commodity"]"#)
let Some(amount_converted_group) = split_entry
.query_selector(".amount-converted-group")
else {
let show_converted = match (from_commodity, to_commodity) {
(Some(from), Some(to)) if !from.is_empty() && !to.is_empty() => from != to,
_ => false,
if show_converted {
let _ = amount_converted_group.class_list().remove_1("hidden-field");
} else {
let _ = amount_converted_group.class_list().add_1("hidden-field");
/// Initializes the transaction form on page load.
pub fn initialize_transaction_form() {
let Some(window) = web_sys::window() else {
let Some(document) = window.document() else {
set_default_datetime(&document);
fetch_initial_split();
fn set_default_datetime(document: &web_sys::Document) {
let Some(date_input) = document
.get_element_by_id("date")
if !date_input.value().is_empty() {
let now = js_sys::Date::new_0();
let offset_ms = now.get_timezone_offset() * 60.0 * 1000.0;
let local_time = now.get_time() - offset_ms;
let local_date = js_sys::Date::new(&JsValue::from_f64(local_time));
let iso_string = local_date.to_iso_string();
let datetime_local = iso_string
.as_string()
.map(|s| s.chars().take(16).collect::<String>())
.unwrap_or_default();
date_input.set_value(&datetime_local);
fn fetch_initial_split() {
let Some(container) = document.get_element_by_id("splits-container") else {
// If the container already has splits (edit page), just init autocomplete
if container
.query_selector(".split-entry")
.is_some()
{
autocomplete::init_all();
// Otherwise fetch the initial empty split (create page)
wasm_bindgen_futures::spawn_local(async move {
let url = "/api/transaction/split/create?display_index=0";
let Ok(response) = fetch_text(url).await else {
web_sys::console::error_1(&"Error loading initial split".into());
container.set_inner_html(&response);
if let Some(prefilled) = get_prefilled_account() {
prefill_from_account(&prefilled).await;
});
async fn fetch_text(url: &str) -> Result<String, JsValue> {
let window = web_sys::window().ok_or("no window")?;
let response = wasm_bindgen_futures::JsFuture::from(window.fetch_with_str(url)).await?;
let response: web_sys::Response = response.dyn_into()?;
let text = wasm_bindgen_futures::JsFuture::from(response.text()?).await?;
text.as_string().ok_or_else(|| "not a string".into())
async fn fetch_json(url: &str) -> Result<String, JsValue> {
let headers = web_sys::Headers::new()?;
headers.set("Accept", "application/json")?;
let opts = web_sys::RequestInit::new();
opts.set_method("GET");
opts.set_headers(&headers);
let request = web_sys::Request::new_with_str_and_init(url, &opts)?;
let response =
wasm_bindgen_futures::JsFuture::from(window.fetch_with_request(&request)).await?;
fn get_prefilled_account() -> Option<String> {
let window = web_sys::window()?;
js_sys::Reflect::get(&window, &"prefilledFromAccount".into())
.and_then(|v| v.as_string())
async fn prefill_from_account(account_id: &str) {
let Some(first_split) = document.query_selector(".split-entry").ok().flatten() else {
let Some(hidden_input) = first_split
.query_selector(r#".account-value[data-field="from-account"]"#)
let Some(display_input) = first_split
.query_selector(r#".account-display[data-field="from-account"]"#)
hidden_input.set_value(account_id);
let Ok(accounts_json) = fetch_json("/api/account/list").await else {
let Ok(accounts) = serde_json::from_str::<Vec<AccountInfo>>(&accounts_json) else {
if let Some(account) = accounts.iter().find(|a| a.id == account_id) {
display_input.set_value(&account.name);
#[derive(serde::Deserialize)]
struct AccountInfo {
id: String,
name: String,
#[cfg(test)]
mod tests {
#[test]
fn split_index_format() {
let index = 2u32;
let label = format!("Split {}", index + 1);
assert_eq!(label, "Split 3");