From 3631ef0b642a9536ac43134bcf489845580814ce Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 18 Oct 2026 18:34:44 -0400 Subject: [PATCH 1/6] feat: render the category combobox's open list in a portal (#429) The open list of CategoryCombobox was positioned `absolute` inside its input's container, so a scrolling ancestor clipped it: the body of the split window, the `overflow-x-auto` wrapper of the Transactions table. It is now rendered in a portal on document.body, `position: fixed`, at z-[300] (above every window, the highest at z-[210]). The pure rule lives in placeListbox.ts: under the input, as wide as it, 4 px below; above it when the room below is short of the list's 240 px and the room above has them; below when neither side has them. The list is placed in a layout effect before the first paint, then follows the input on every scroll (capture phase, any scrolling container) and resize while it is open, skipping scrolls of the list itself; the listeners are removed with the list (followAnchor returns the cleanup). The outside press counts the list as inside, since it no longer sits in the container's DOM. The closed markup is unchanged. Co-Authored-By: Claude Opus 5.5 --- src/components/shared/CategoryCombobox.tsx | 54 ++++++- src/components/shared/placeListbox.test.ts | 175 +++++++++++++++++++++ src/components/shared/placeListbox.ts | 105 +++++++++++++ 3 files changed, 329 insertions(+), 5 deletions(-) create mode 100644 src/components/shared/placeListbox.test.ts create mode 100644 src/components/shared/placeListbox.ts diff --git a/src/components/shared/CategoryCombobox.tsx b/src/components/shared/CategoryCombobox.tsx index 41cc9ee..4a2c869 100644 --- a/src/components/shared/CategoryCombobox.tsx +++ b/src/components/shared/CategoryCombobox.tsx @@ -1,5 +1,13 @@ -import { useState, useRef, useEffect, useCallback, useId, useMemo } from "react"; +import { useState, useRef, useEffect, useLayoutEffect, useCallback, useId, useMemo } from "react"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; +import { + LISTBOX_MAX_HEIGHT, + followAnchor, + placeListbox, + samePlacement, + type ListboxPlacement, +} from "./placeListbox"; /** * Minimal shape the combobox needs. `Category` (transactions, reports) and * `CategoryTreeNode` (categories page) both satisfy it, so callers pass their @@ -135,6 +143,7 @@ export default function CategoryCombobox( const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [highlightIndex, setHighlightIndex] = useState(0); + const [placement, setPlacement] = useState(null); const inputRef = useRef(null); const listRef = useRef(null); const containerRef = useRef(null); @@ -187,10 +196,36 @@ export default function CategoryCombobox( } }, [highlightIndex, open]); + // The open list lives in a portal on document.body (#429): no scrolling + // container clips it. It is placed on its input before the first paint, + // then follows the input on every scroll and resize, even out of the + // visible part of its container; the listeners go with the list. + const updatePlacement = useCallback(() => { + const input = inputRef.current; + if (!input) return; + const next = placeListbox({ + anchor: input.getBoundingClientRect(), + viewportHeight: window.innerHeight, + }); + setPlacement((prev) => (samePlacement(prev, next) ? prev : next)); + }, []); + + useLayoutEffect(() => { + if (!open) return; + updatePlacement(); + return followAnchor(window, updatePlacement, () => listRef.current); + }, [open, updatePlacement]); + useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + // The list is outside the container in the DOM: a press on an option + // counts as inside, or the list would close before the click selects. + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) && + !listRef.current?.contains(e.target as Node) + ) { setOpen(false); setQuery(""); } @@ -277,12 +312,20 @@ export default function CategoryCombobox( onKeyDown={handleKeyDown} className={`w-full ${px} ${py} text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]`} /> - {open && totalItems > 0 && ( + {open && totalItems > 0 && placement && createPortal(
    {filteredExtras.map((opt, i) => (
  • (
  • ); })} -
+ , + document.body, )} ); diff --git a/src/components/shared/placeListbox.test.ts b/src/components/shared/placeListbox.test.ts new file mode 100644 index 0000000..ccc9950 --- /dev/null +++ b/src/components/shared/placeListbox.test.ts @@ -0,0 +1,175 @@ +// placeListbox — where the open list of a category combobox goes (#429). +// The portal itself needs a DOM (manual check); its placement rule and the +// listeners that keep it on its input do not, and are pinned here. +import { describe, it, expect, vi } from "vitest"; +import { + LISTBOX_GAP, + LISTBOX_MAX_HEIGHT, + followAnchor, + placeListbox, + samePlacement, + type ListenerTarget, + type ListboxPlacement, +} from "./placeListbox"; + +/** An input 30 px high and 200 px wide, its top at `top`. */ +const anchorAt = (top: number) => ({ top, bottom: top + 30, left: 50, width: 200 }); + +describe("placeListbox", () => { + it("keeps the list's size of max-h-60 and the gap of mt-1", () => { + expect(LISTBOX_MAX_HEIGHT).toBe(240); + expect(LISTBOX_GAP).toBe(4); + }); + + it("places the list under the input, as wide as it, at the input's left edge", () => { + expect(placeListbox({ anchor: anchorAt(100), viewportHeight: 800 })).toEqual({ + left: 50, + width: 200, + openUpward: false, + top: 134, + }); + }); + + it("stays below when the room below is exactly the list's maximum height", () => { + // 130 (input's bottom) + 4 + 240 = 374. + const placement = placeListbox({ anchor: anchorAt(100), viewportHeight: 374 }); + expect(placement.openUpward).toBe(false); + expect(placement.top).toBe(134); + }); + + it("opens upward when the room below is short and the room above suffices", () => { + const placement = placeListbox({ anchor: anchorAt(700), viewportHeight: 800 }); + expect(placement).toEqual({ left: 50, width: 200, openUpward: true, bottom: 104 }); + // Its bottom edge sits the gap above the input, whatever its height. + expect(800 - placement.bottom!).toBe(700 - LISTBOX_GAP); + }); + + it("opens upward when the room above is exactly the list's maximum height", () => { + // Room above: 244 - 4 = 240; room below: 300 - 274 - 4 = 22. + expect(placeListbox({ anchor: anchorAt(244), viewportHeight: 300 }).openUpward).toBe(true); + }); + + it("stays below when room is short on both sides, even with more room above", () => { + // Room above: 200 - 4 = 196; room below: 400 - 230 - 4 = 166. + expect(placeListbox({ anchor: anchorAt(200), viewportHeight: 400 })).toEqual({ + left: 50, + width: 200, + openUpward: false, + top: 234, + }); + // One pixel short above: still below. + expect(placeListbox({ anchor: anchorAt(243), viewportHeight: 300 }).openUpward).toBe(false); + }); + + it("reads the maximum height and the gap it is given", () => { + // Room below: 800 - 730 - 10 = 60, under 100; room above: 700 - 10 = 690. + expect( + placeListbox({ anchor: anchorAt(700), viewportHeight: 800, maxHeight: 100, gap: 10 }), + ).toEqual({ left: 50, width: 200, openUpward: true, bottom: 110 }); + // Room below: 60, enough for a list of 50 px. + expect( + placeListbox({ anchor: anchorAt(700), viewportHeight: 800, maxHeight: 50, gap: 10 }), + ).toEqual({ left: 50, width: 200, openUpward: false, top: 740 }); + }); +}); + +describe("samePlacement", () => { + const below: ListboxPlacement = { left: 50, width: 200, openUpward: false, top: 134 }; + + it("tells an unchanged position apart from a moved one", () => { + expect(samePlacement({ ...below }, below)).toBe(true); + expect(samePlacement(null, below)).toBe(false); + expect(samePlacement({ ...below, top: 135 }, below)).toBe(false); + expect(samePlacement({ ...below, left: 51 }, below)).toBe(false); + expect(samePlacement({ ...below, width: 201 }, below)).toBe(false); + expect( + samePlacement({ left: 50, width: 200, openUpward: true, bottom: 134 }, below), + ).toBe(false); + }); +}); + +/** + * A fake `window` with the DOM's rule: a listener is removed only by the same + * type, the same function and the same capture flag. + */ +function fakeTarget() { + const listeners: Array<{ type: string; fn: (event: Event) => void; capture: boolean }> = []; + const target: ListenerTarget = { + addEventListener: (type, fn, capture = false) => { + listeners.push({ type, fn, capture }); + }, + removeEventListener: (type, fn, capture = false) => { + const at = listeners.findIndex((l) => l.type === type && l.fn === fn && l.capture === capture); + if (at >= 0) listeners.splice(at, 1); + }, + }; + const fire = (type: string, eventTarget: unknown = null) => { + for (const l of [...listeners]) if (l.type === type) l.fn({ target: eventTarget } as unknown as Event); + }; + return { target, listeners, fire }; +} + +/** A node of the list: it contains itself and its options. */ +const option = { id: "option" }; +const listNode = { contains: (node: unknown) => node === listNode || node === option } as unknown as Node; +const outside = { id: "a scrolling container" }; + +describe("followAnchor", () => { + it("listens to scroll in the capture phase, since scroll does not bubble, and to resize", () => { + const { target, listeners } = fakeTarget(); + followAnchor(target, vi.fn(), () => listNode); + expect(listeners.map(({ type, capture }) => ({ type, capture }))).toEqual([ + { type: "scroll", capture: true }, + { type: "resize", capture: false }, + ]); + }); + + it("moves the list on a scroll of the page or of a container, and on a resize", () => { + const { target, fire } = fakeTarget(); + const onMove = vi.fn(); + followAnchor(target, onMove, () => listNode); + fire("scroll", outside); + expect(onMove).toHaveBeenCalledTimes(1); + fire("resize"); + expect(onMove).toHaveBeenCalledTimes(2); + }); + + it("does not move the list on a scroll of the list itself", () => { + const { target, fire } = fakeTarget(); + const onMove = vi.fn(); + followAnchor(target, onMove, () => listNode); + fire("scroll", listNode); + fire("scroll", option); + expect(onMove).not.toHaveBeenCalled(); + // Before the list is in the page, every scroll counts. + const early = fakeTarget(); + const onEarlyMove = vi.fn(); + followAnchor(early.target, onEarlyMove, () => null); + early.fire("scroll", outside); + expect(onEarlyMove).toHaveBeenCalledTimes(1); + }); + + it("removes exactly its own listeners, capture flag included", () => { + const { target, listeners, fire } = fakeTarget(); + const onMove = vi.fn(); + const stop = followAnchor(target, onMove, () => listNode); + stop(); + expect(listeners).toEqual([]); + fire("scroll", outside); + fire("resize"); + expect(onMove).not.toHaveBeenCalled(); + }); + + it("leaves another open list's listeners in place", () => { + const { target, listeners, fire } = fakeTarget(); + const first = vi.fn(); + const second = vi.fn(); + const stopFirst = followAnchor(target, first, () => listNode); + followAnchor(target, second, () => listNode); + stopFirst(); + expect(listeners).toHaveLength(2); + fire("scroll", outside); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/shared/placeListbox.ts b/src/components/shared/placeListbox.ts new file mode 100644 index 0000000..a3f4ba8 --- /dev/null +++ b/src/components/shared/placeListbox.ts @@ -0,0 +1,105 @@ +// placeListbox — where the open list of a combobox goes (#429). +// +// `CategoryCombobox` renders its open list in a portal on `document.body`, +// with `position: fixed`, so no scrolling container clips it: the body of the +// split window, the wrapper of the Transactions table, a modal. What does not +// need a DOM lives here, so vitest's node environment can pin it: the +// placement rule, and the listeners that keep the list on its input. + +/** Maximum height of the list, in pixels (Tailwind's `max-h-60`). */ +export const LISTBOX_MAX_HEIGHT = 240; + +/** Gap between the input and the list, in pixels (the former `mt-1`). */ +export const LISTBOX_GAP = 4; + +/** What the placement reads of the input: a `DOMRect` fits. */ +export interface ListboxAnchor { + top: number; + bottom: number; + left: number; + width: number; +} + +/** + * Fixed position of the list, in viewport pixels, as wide as the input. + * Below the input, `top` is the list's top edge. Above it, `bottom` is the + * distance from the viewport's bottom edge to the list's bottom edge, so a + * list shorter than its maximum height still ends right above the input. + */ +export interface ListboxPlacement { + left: number; + width: number; + openUpward: boolean; + top?: number; + bottom?: number; +} + +export interface PlaceListboxInput { + anchor: ListboxAnchor; + viewportHeight: number; + maxHeight?: number; + gap?: number; +} + +/** + * Under the input, unless the list does not fit below and does fit above: + * then above. With room on neither side, it stays below, its usual place. + */ +export function placeListbox({ + anchor, + viewportHeight, + maxHeight = LISTBOX_MAX_HEIGHT, + gap = LISTBOX_GAP, +}: PlaceListboxInput): ListboxPlacement { + const spaceBelow = viewportHeight - anchor.bottom - gap; + const spaceAbove = anchor.top - gap; + const base = { left: anchor.left, width: anchor.width }; + if (spaceBelow < maxHeight && spaceAbove >= maxHeight) { + return { ...base, openUpward: true, bottom: viewportHeight - anchor.top + gap }; + } + return { ...base, openUpward: false, top: anchor.bottom + gap }; +} + +/** Same position: a scroll that does not move the input renders nothing again. */ +export function samePlacement(a: ListboxPlacement | null, b: ListboxPlacement): boolean { + return ( + a !== null && + a.left === b.left && + a.width === b.width && + a.openUpward === b.openUpward && + a.top === b.top && + a.bottom === b.bottom + ); +} + +/** The part of `window` the listeners use, so a test can pass a fake. */ +export interface ListenerTarget { + addEventListener(type: string, listener: (event: Event) => void, capture?: boolean): void; + removeEventListener(type: string, listener: (event: Event) => void, capture?: boolean): void; +} + +/** + * Keeps the list on its input while it is open: `onMove` runs on every resize + * and on every scroll, of the page or of any scrolling container (a modal's + * body, the page's main area), hence the capture phase, since `scroll` does + * not bubble. A scroll of the list itself is skipped: its options moving do + * not move the input. Returns the cleanup, which removes exactly these + * listeners, capture flag included. + */ +export function followAnchor( + target: ListenerTarget, + onMove: () => void, + list: () => Pick | null, +): () => void { + const onScroll = (event: Event) => { + if (list()?.contains(event.target as Node | null)) return; + onMove(); + }; + const onResize = () => onMove(); + target.addEventListener("scroll", onScroll, true); + target.addEventListener("resize", onResize); + return () => { + target.removeEventListener("scroll", onScroll, true); + target.removeEventListener("resize", onResize); + }; +} -- 2.45.2 From 619637e91b2e2a2662d6ce0cb6e0e1dd23b3f370 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 18 Oct 2026 18:34:49 -0400 Subject: [PATCH 2/6] feat: pick an adjustment line's category in a combobox (#429) Each line of the adjustment form chose its category in a native select showing raw names. It now takes the compact CategoryCombobox of the split window: typing filters, hierarchical order, seed names translated. A new line still takes the first category of the list. D2: the hook reads the Transactions list, getAllCategories (active and open to input), instead of its own query on every active category. The list feeds two pickers only, the form and the split window of the Adjustments page; the detail view names a line's category from the entries' join, so a line saved on a category hidden from input keeps its name there and its value, its field empty until changed. Co-Authored-By: Claude Opus 5.5 --- .../AdjustmentForm.render.test.tsx | 87 +++++++++++++ src/components/adjustments/AdjustmentForm.tsx | 21 ++-- .../adjustments/AdjustmentForm.wiring.test.ts | 116 ++++++++++++++++++ src/hooks/useAdjustments.test.ts | 85 +++++++++++++ src/hooks/useAdjustments.ts | 12 +- 5 files changed, 305 insertions(+), 16 deletions(-) create mode 100644 src/components/adjustments/AdjustmentForm.render.test.tsx create mode 100644 src/components/adjustments/AdjustmentForm.wiring.test.ts create mode 100644 src/hooks/useAdjustments.test.ts diff --git a/src/components/adjustments/AdjustmentForm.render.test.tsx b/src/components/adjustments/AdjustmentForm.render.test.tsx new file mode 100644 index 0000000..d29e233 --- /dev/null +++ b/src/components/adjustments/AdjustmentForm.render.test.tsx @@ -0,0 +1,87 @@ +// AdjustmentForm — each adjustment line picks its category in a combobox +// (#429). No jsdom: react-dom/server renders the initial markup in vitest's +// node environment, the combobox closed, so without its list in a portal. +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock()); + +import AdjustmentForm from "./AdjustmentForm"; +import type { Category } from "../../shared/types"; +import type { EntryFormData } from "../../hooks/useAdjustments"; + +const category = (id: number, name: string, extra: Partial = {}): Category => ({ + id, + name, + type: "expense", + is_active: true, + is_inputable: true, + sort_order: id, + created_at: "", + ...extra, +}); + +/** A seed category, translated through its key, and one the user created. */ +const GROCERIES = category(10, "Épicerie régulière", { + i18n_key: "categoriesSeed.alimentation.epicerie.reguliere", +}); +const CABIN = category(20, "Chalet"); + +const line = (categoryId: number): EntryFormData => ({ + category_id: categoryId, + amount: 12.5, + description: "", +}); + +const markup = (entries: EntryFormData[]) => + renderToStaticMarkup( + , + ); + +/** The comboboxes' inputs, in the order of the lines. */ +const comboboxes = (html: string) => html.match(/]*role="combobox"[^>]*>/g) ?? []; + +describe("AdjustmentForm — category of a line (#429)", () => { + it("gives every line a combobox, and no select", () => { + const html = markup([line(10), line(20)]); + expect(comboboxes(html)).toHaveLength(2); + expect(html).not.toContain(" { + const [seed, user] = comboboxes(markup([line(10), line(20)])); + expect(seed).toContain( + 'value="t(categoriesSeed.alimentation.epicerie.reguliere[defaultValue=Épicerie régulière])"', + ); + expect(user).toContain('value="Chalet"'); + }); + + it("names every combobox and renders it compact, as in the split window", () => { + const inputs = comboboxes(markup([line(10), line(20)])); + expect(inputs).toHaveLength(2); + for (const input of inputs) { + expect(input).toContain('aria-label="t(adjustments.category)"'); + expect(input).toMatch(/class="w-full px-2 py-1 /); + } + }); + + it("leaves the field empty for a line on a category the list does not offer (D2)", () => { + // A category hidden from input is not in the list: nothing to show. + const [input] = comboboxes(markup([line(99)])); + expect(input).toContain('value=""'); + }); + + it("keeps the empty state of an adjustment without a line", () => { + const html = markup([]); + expect(comboboxes(html)).toHaveLength(0); + expect(html).toContain(">t(adjustments.noEntries)<"); + }); +}); diff --git a/src/components/adjustments/AdjustmentForm.tsx b/src/components/adjustments/AdjustmentForm.tsx index ab052f1..7befe4f 100644 --- a/src/components/adjustments/AdjustmentForm.tsx +++ b/src/components/adjustments/AdjustmentForm.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Plus, Trash2 } from "lucide-react"; import type { Category } from "../../shared/types"; import type { AdjustmentFormData, EntryFormData } from "../../hooks/useAdjustments"; +import CategoryCombobox from "../shared/CategoryCombobox"; interface Props { initialData: AdjustmentFormData; @@ -120,17 +121,15 @@ export default function AdjustmentForm({
{entries.map((entry, index) => (
- +
+ updateEntryField(index, "category_id", id)} + compact + ariaLabel={t("adjustments.category")} + /> +
(await import("../../test/hookHarness")).fakeReact(await orig())); +vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock()); + +import { hookHarness } from "../../test/hookHarness"; +import AdjustmentForm from "./AdjustmentForm"; +import CategoryCombobox from "../shared/CategoryCombobox"; +import type { Category } from "../../shared/types"; +import type { AdjustmentFormData, EntryFormData } from "../../hooks/useAdjustments"; + +const harness = hookHarness(); + +type Props = Record & { children?: ReactNode }; + +function find(node: ReactNode, match: (el: ReactElement) => boolean): ReactElement[] { + if (Array.isArray(node)) return node.flatMap((n) => find(n, match)); + if (!isValidElement(node)) return []; + const el = node as ReactElement; + return [...(match(el) ? [el] : []), ...find(el.props.children, match)]; +} + +/** Text of an element's children, elements left out. */ +const text = (node: ReactNode): string => + Array.isArray(node) ? node.map(text).join("") : typeof node === "string" ? node : ""; + +const category = (id: number, name: string): Category => ({ + id, + name, + type: "expense", + is_active: true, + is_inputable: true, + sort_order: id, + created_at: "", +}); + +const RENT = category(30, "Loyer"); +const CABIN = category(20, "Chalet"); +/** In the list's order: a new line takes the first one. */ +const CATEGORIES = [RENT, CABIN]; + +const DATA: AdjustmentFormData = { name: "Chalet", description: "", date: "2026-10-01", is_recurring: false }; + +let onSave = vi.fn(); + +/** One render of the mounted form; the props keep their identity from one render to the next. */ +const mount = (entries: EntryFormData[]) => { + const props = { + initialData: DATA, + initialEntries: entries, + categories: CATEGORIES, + isCreating: false, + isSaving: false, + onSave, + onCancel: vi.fn(), + }; + return () => harness.render(() => AdjustmentForm(props)); +}; + +const comboboxes = (tree: ReactNode) => find(tree, (el) => el.type === CategoryCombobox); +const addButton = (tree: ReactNode) => + find(tree, (el) => el.type === "button" && text(el.props.children) === "t(adjustments.addEntry)")[0]; +const form = (tree: ReactNode) => find(tree, (el) => el.type === "form")[0]; + +beforeEach(() => { + harness.clear(); + onSave = vi.fn(); +}); + +describe("AdjustmentForm — a line's category (#429)", () => { + it("gives a new line the first category of the list", () => { + const render = mount([]); + let tree = render(); + expect(comboboxes(tree)).toHaveLength(0); + (addButton(tree).props.onClick as () => void)(); + tree = render(); + const [combobox] = comboboxes(tree); + expect(combobox.props.value).toBe(RENT.id); + expect(combobox.props.categories).toBe(CATEGORIES); + expect(combobox.props.compact).toBe(true); + expect(combobox.props.ariaLabel).toBe("t(adjustments.category)"); + }); + + it("changes the category of the line picked, and of that line only", () => { + const render = mount([ + { id: 1, category_id: RENT.id, amount: 1200, description: "" }, + { id: 2, category_id: RENT.id, amount: 80, description: "Électricité" }, + ]); + let tree = render(); + (comboboxes(tree)[1].props.onChange as (id: number) => void)(CABIN.id); + tree = render(); + expect(comboboxes(tree).map((c) => c.props.value)).toEqual([RENT.id, CABIN.id]); + }); + + it("saves every line with its category, its amount and its description, as before", () => { + const render = mount([{ id: 1, category_id: RENT.id, amount: 1200, description: "" }]); + let tree = render(); + (addButton(tree).props.onClick as () => void)(); + tree = render(); + (comboboxes(tree)[1].props.onChange as (id: number) => void)(CABIN.id); + tree = render(); + (form(tree).props.onSubmit as (e: { preventDefault: () => void }) => void)({ preventDefault: vi.fn() }); + expect(onSave).toHaveBeenCalledWith(DATA, [ + { id: 1, category_id: RENT.id, amount: 1200, description: "" }, + { category_id: CABIN.id, amount: 0, description: "" }, + ]); + }); +}); diff --git a/src/hooks/useAdjustments.test.ts b/src/hooks/useAdjustments.test.ts new file mode 100644 index 0000000..038c8f6 --- /dev/null +++ b/src/hooks/useAdjustments.test.ts @@ -0,0 +1,85 @@ +// useAdjustments — the categories an adjustment line can pick (#429, D2). +// +// React's hooks come from the shared harness (src/test/hookHarness.ts, #409): +// the hook runs as a plain function, its reducer is applied, and `commit()` +// runs its mount effect. The services are mocked; the base answers a raw +// query with every active category, so a hook that read the base itself, +// instead of the Transactions list, would show it. +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig())); +const { getAllCategories, getAllAdjustments, select } = vi.hoisted(() => ({ + getAllCategories: vi.fn(), + getAllAdjustments: vi.fn(), + select: vi.fn(), +})); +vi.mock("../services/transactionService", () => ({ getAllCategories })); +vi.mock("../services/adjustmentService", () => ({ + getAllAdjustments, + getEntriesByAdjustmentId: vi.fn(), + createAdjustment: vi.fn(), + updateAdjustment: vi.fn(), + deleteAdjustment: vi.fn(), + createEntry: vi.fn(), + updateEntry: vi.fn(), + deleteEntry: vi.fn(), +})); +vi.mock("../services/db", () => ({ getDb: vi.fn(async () => ({ select })) })); + +import { hookHarness } from "../test/hookHarness"; +import { useAdjustments } from "./useAdjustments"; +import type { Category } from "../shared/types"; + +const harness = hookHarness(); + +const category = (id: number, name: string, isInputable: boolean): Category => ({ + id, + name, + type: "expense", + is_active: true, + is_inputable: isInputable, + sort_order: id, + created_at: "", +}); + +/** The Transactions list: active and open to input. */ +const INPUTABLE = [category(1, "Épicerie", true), category(2, "Loyer", true)]; +/** Every active category, a parent hidden from input included. */ +const ACTIVE = [category(3, "Alimentation", false), ...INPUTABLE]; + +/** Lets the loaders' promises settle. */ +const flush = async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); +}; + +beforeEach(() => { + harness.clear(); + getAllCategories.mockReset(); + getAllCategories.mockResolvedValue(INPUTABLE); + getAllAdjustments.mockReset(); + getAllAdjustments.mockResolvedValue([]); + select.mockReset(); + select.mockResolvedValue(ACTIVE); +}); + +describe("useAdjustments — categories of a line (#429, D2)", () => { + it("offers the Transactions list, active and open to input, not every active category", async () => { + harness.render(useAdjustments); + harness.commit(); + await flush(); + const { state } = harness.render(useAdjustments); + expect(state.categories).toBe(INPUTABLE); + expect(getAllCategories).toHaveBeenCalledTimes(1); + expect(select).not.toHaveBeenCalled(); + }); + + it("reports a failed read as the page's error, with no category", async () => { + getAllCategories.mockRejectedValue(new Error("database is locked")); + harness.render(useAdjustments); + harness.commit(); + await flush(); + const { state } = harness.render(useAdjustments); + expect(state.categories).toEqual([]); + expect(state.error).toBe("database is locked"); + }); +}); diff --git a/src/hooks/useAdjustments.ts b/src/hooks/useAdjustments.ts index 8167f15..451d919 100644 --- a/src/hooks/useAdjustments.ts +++ b/src/hooks/useAdjustments.ts @@ -11,7 +11,7 @@ import { updateEntry, deleteEntry, } from "../services/adjustmentService"; -import { getDb } from "../services/db"; +import { getAllCategories } from "../services/transactionService"; export interface AdjustmentFormData { name: string; @@ -123,12 +123,14 @@ export function useAdjustments() { const [state, dispatch] = useReducer(reducer, initialState); const fetchIdRef = useRef(0); + // The categories a line can pick, the list of the Transactions page: active + // and open to input (#429, D2). The page passes it to the adjustment form and + // to the split window, both pickers; the detail view names a line's + // category from the entries' own join, so a line saved on a category hidden + // from input keeps its name there, and its value until changed by hand. const loadCategories = useCallback(async () => { try { - const db = await getDb(); - const rows = await db.select( - "SELECT * FROM categories WHERE is_active = 1 ORDER BY sort_order, name" - ); + const rows = await getAllCategories(); dispatch({ type: "SET_CATEGORIES", payload: rows }); } catch (e) { dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); -- 2.45.2 From aa229484f6b1c67386876280b9cbec00bb19d36a Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 18 Oct 2026 18:34:52 -0400 Subject: [PATCH 3/6] feat: filter the transfers to link by category with a combobox (#429) The category filter of the "Link transfers" window was a native select. It now takes the compact CategoryCombobox, modelled on the Transactions filter bar: "Any category" first as an extra option, active while the filter is empty, and picking it sets the filter back to null; the list of transactions reloads as before. Co-Authored-By: Claude Opus 5.5 --- .../LinkTransfersModal.render.test.tsx | 69 ++++++++++ src/components/balance/LinkTransfersModal.tsx | 36 ++--- .../balance/LinkTransfersModal.wiring.test.ts | 129 ++++++++++++++++++ 3 files changed, 218 insertions(+), 16 deletions(-) create mode 100644 src/components/balance/LinkTransfersModal.render.test.tsx create mode 100644 src/components/balance/LinkTransfersModal.wiring.test.ts diff --git a/src/components/balance/LinkTransfersModal.render.test.tsx b/src/components/balance/LinkTransfersModal.render.test.tsx new file mode 100644 index 0000000..392ff03 --- /dev/null +++ b/src/components/balance/LinkTransfersModal.render.test.tsx @@ -0,0 +1,69 @@ +// LinkTransfersModal — the category filter is a combobox (#429). No jsdom: +// react-dom/server renders the initial markup in vitest's node environment. +// It cannot render a portal, and the window is one: `createPortal` becomes +// the identity, and `document` a stub for the `document.body` it is given. +import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock()); +vi.mock("react-dom", async (orig) => ({ + ...(await orig()), + createPortal: (children: ReactNode) => children, +})); +vi.mock("../../services/transactionService", () => ({ + getTransactionPage: vi.fn(() => new Promise(() => undefined)), +})); +vi.mock("../../services/balance.service", () => ({ + linkTransfer: vi.fn(), + suggestTransferDirection: vi.fn(() => "in"), + BalanceServiceError: class extends Error {}, +})); + +import LinkTransfersModal from "./LinkTransfersModal"; +import type { Category } from "../../shared/types"; + +const TRANSFERS: Category = { + id: 7, + name: "Virements", + type: "transfer", + is_active: true, + is_inputable: true, + sort_order: 1, + created_at: "", +}; + +beforeAll(() => { + vi.stubGlobal("document", { body: {} }); +}); + +afterAll(() => { + vi.unstubAllGlobals(); +}); + +const markup = () => + renderToStaticMarkup( + , + ); + +describe("LinkTransfersModal — category filter (#429)", () => { + it("renders the filter as a combobox, and no select", () => { + const html = markup(); + expect(html.match(/]*role="combobox"[^>]*>/g) ?? []).toHaveLength(1); + expect(html).not.toContain(" { + const [input] = markup().match(/]*role="combobox"[^>]*>/g) ?? []; + expect(input).toContain('value="t(balance.transfers.modal.anyCategory)"'); + expect(input).toContain('aria-label="t(balance.transfers.modal.category)"'); + expect(input).toMatch(/class="w-full px-2 py-1 /); + }); +}); diff --git a/src/components/balance/LinkTransfersModal.tsx b/src/components/balance/LinkTransfersModal.tsx index 3a74b86..3157e78 100644 --- a/src/components/balance/LinkTransfersModal.tsx +++ b/src/components/balance/LinkTransfersModal.tsx @@ -3,7 +3,7 @@ // // Filters available: // - Period (from / to ISO dates) — default: last 90 days. -// - Category dropdown. +// - Category combobox, "any category" first (#429). // - Free-text search on description. // // Each row shows: date, description, amount, suggested direction @@ -29,6 +29,7 @@ import type { TransactionRow, BalanceTransferDirection, } from "../../shared/types"; +import CategoryCombobox from "../shared/CategoryCombobox"; const DEFAULT_PAGE_SIZE = 100; @@ -49,7 +50,7 @@ export interface LinkTransfersModalProps { /** Account that the selected transfers will be attached to. */ accountId: number; accountName: string; - /** Full category list for the filter dropdown. */ + /** Categories offered by the filter combobox. */ categories: Category[]; /** Optional pre-fill date bounds (defaults to last 90 days). */ initialFrom?: string; @@ -96,6 +97,13 @@ export default function LinkTransfersModal({ [i18n.language] ); + // "Any category" first: the filter's empty value (null), as on the + // Transactions page's filter bar. + const categoryExtras = useMemo( + () => [{ value: "", label: t("balance.transfers.modal.anyCategory") }], + [t] + ); + // Re-fetch whenever the filters change. Debounced via React's render cycle // — typing in the search box re-runs the SQL but at < 500 rows that's fine. useEffect(() => { @@ -256,20 +264,16 @@ export default function LinkTransfersModal({ {t("balance.transfers.modal.category")} - + setCategoryId(id)} + compact + ariaLabel={t("balance.transfers.modal.category")} + extraOptions={categoryExtras} + activeExtra={categoryId === null ? "" : null} + onExtraSelect={() => setCategoryId(null)} + />