feat(transactions): cascade parent/subcategory checkboxes in the category filter (#3356)

* feat(transactions): cascade parent/subcategory checkboxes in the category filter

Checking a parent category in the transaction filter sidebar now auto-checks
its subcategories, and vice versa. Unchecking a single subcategory also
unchecks the parent so the submitted filter never silently includes a
category the user just deselected — Transaction::Search#apply_category_filter
includes every subcategory whenever a parent name is present, with no way to
exclude one individually, so the parent checkbox must reflect exactly what
gets submitted.

Also fixes the "swipe-to-categorize" pill picker (transactions/categorizes/show.html.erb),
which was still a flat alphabetical list with no parent/child indication —
now grouped and labeled consistently with the rest of the app (PR #2845, #3292).

Adds an :indeterminate style for .checkbox--light (only .checkbox--dark had one).

Closes discussion #3149.

This code was written by Claude Code (Anthropic).

* fix(transactions): preserve parent-only category filters on reopen

connect() derived each checkbox's parent/child state independently from
server-rendered checked attributes, so a parent-only filter (e.g. an
incoming link naming only the parent category) rendered the parent
checked with its children unchecked — syncParentState() then read that
as "some children unchecked" and cleared the parent, silently dropping
the filter on the next Apply. Cascade checked parents to their children
before deriving parent state so the picker matches the active query.

Also makes the internal helper methods private per review feedback.

* fix(transactions): eager-load category parent, fix categorize-pill filter text

Addresses PR #3356 review from jjmata:
- Current.family.categories.alphabetically caused an N+1 (SELECT per
  parent) via display_name_with_parent in the categorize-wizard pill
  loop. Added .includes(:parent) at all three call sites.
- data-filter-name still used the bare category name while the pill
  label showed "Parent > Child" for subcategories, so searching by the
  visible parent prefix found nothing. Both now share one computed
  label.

---------

Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com>
This commit is contained in:
GFR
2026-09-04 20:55:42 +02:00
committed by GitHub
co-authored by Gerald
parent 4794d4ee15
commit 4f14bc7859
7 changed files with 226 additions and 19 deletions
@@ -0,0 +1,73 @@
import { Controller } from "@hotwired/stimulus";
// Cascading parent/subcategory checkboxes for the transaction category filter.
//
// A parent checkbox is only ever `checked` (and therefore only ever submitted
// with the form) when *all* of its children are checked. The backend query
// (Transaction::Search#apply_category_filter) includes every subcategory
// whenever a parent category name is present in the submitted params, with
// no way to exclude an individual child. So the moment a user unchecks one
// child, the parent must be unchecked too — otherwise the parent would still
// be submitted and the backend would silently keep including the
// deselected child's transactions, ignoring what the user just did.
export default class extends Controller {
static targets = ["checkbox"];
connect() {
// Server-rendered `checked` state is derived independently per checkbox
// from the submitted query params, so a parent-only filter (e.g. an
// incoming link that only names the parent category) renders the parent
// checked with its children unchecked. Cascade checked parents down to
// their children first so the pass below doesn't read that as "some
// children unchecked" and clear the parent.
this.checkboxTargets.forEach((checkbox) => {
if (checkbox.checked) {
this.#childCheckboxesFor(checkbox.dataset.categoryId).forEach((child) => {
child.checked = true;
});
}
});
this.checkboxTargets.forEach((checkbox) => {
if (checkbox.dataset.parentId) {
this.#syncParentState(checkbox.dataset.parentId);
}
});
}
toggle(event) {
const checkbox = event.target;
const categoryId = checkbox.dataset.categoryId;
const children = this.#childCheckboxesFor(categoryId);
children.forEach((child) => {
child.checked = checkbox.checked;
child.indeterminate = false;
});
const parentId = checkbox.dataset.parentId;
if (parentId) {
this.#syncParentState(parentId);
}
}
#childCheckboxesFor(parentId) {
if (!parentId) return [];
return this.checkboxTargets.filter((cb) => cb.dataset.parentId === parentId);
}
#syncParentState(parentId) {
const parentCheckbox = this.checkboxTargets.find(
(cb) => cb.dataset.categoryId === parentId,
);
if (!parentCheckbox) return;
const children = this.#childCheckboxesFor(parentId);
if (children.length === 0) return;
const checkedCount = children.filter((cb) => cb.checked).length;
parentCheckbox.checked = checkedCount === children.length;
parentCheckbox.indeterminate = checkedCount > 0 && checkedCount < children.length;
}
}