mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 00:24:15 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user