feat: category combobox list in a portal, combobox in adjustment lines and the transfers filter (#429) #471

Open
maximus wants to merge 6 commits from issue-429-category-combobox-portal into issue-372-systems-journal-first
3 changed files with 218 additions and 16 deletions
Showing only changes of commit aa229484f6 - Show all commits

View file

@ -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<typeof import("react-dom")>()),
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(
<LinkTransfersModal
accountId={1}
accountName="CELI"
categories={[TRANSFERS]}
initialFrom="2026-07-01"
initialTo="2026-09-30"
onClose={vi.fn()}
/>,
);
describe("LinkTransfersModal — category filter (#429)", () => {
it("renders the filter as a combobox, and no select", () => {
const html = markup();
expect(html.match(/<input [^>]*role="combobox"[^>]*>/g) ?? []).toHaveLength(1);
expect(html).not.toContain("<select");
});
it("opens on 'any category', named, compact like the other filters", () => {
const [input] = markup().match(/<input [^>]*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 /);
});
});

View file

@ -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({
<span className="block text-[var(--muted-foreground)] mb-1">
{t("balance.transfers.modal.category")}
</span>
<select
value={categoryId === null ? "" : String(categoryId)}
onChange={(e) =>
setCategoryId(e.target.value === "" ? null : Number(e.target.value))
}
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
>
<option value="">{t("balance.transfers.modal.anyCategory")}</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<CategoryCombobox
categories={categories}
value={categoryId}
onChange={(id) => setCategoryId(id)}
compact
ariaLabel={t("balance.transfers.modal.category")}
extraOptions={categoryExtras}
activeExtra={categoryId === null ? "" : null}
onExtraSelect={() => setCategoryId(null)}
/>
</label>
<label className="text-xs">
<span className="block text-[var(--muted-foreground)] mb-1">

View file

@ -0,0 +1,129 @@
// LinkTransfersModal — what the category filter changes (#429).
//
// A static render runs no effect and clicks nothing. React's hooks come from
// the shared harness (src/test/hookHarness.ts, #409): the window runs as a
// plain function, its state survives from one call to the next, the test
// calls the props it hands to its CategoryCombobox, renders again to read the
// filter, and `commit()` runs the effect that reloads the transactions.
// `createPortal` is the identity and `document` a stub: the window is a portal.
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
vi.mock("react-dom", async (orig) => ({
...(await orig<typeof import("react-dom")>()),
createPortal: (children: ReactNode) => children,
}));
const { getTransactionPage } = vi.hoisted(() => ({ getTransactionPage: vi.fn() }));
vi.mock("../../services/transactionService", () => ({ getTransactionPage }));
vi.mock("../../services/balance.service", () => ({
linkTransfer: vi.fn(),
suggestTransferDirection: vi.fn(() => "in"),
BalanceServiceError: class extends Error {},
}));
import { hookHarness } from "../../test/hookHarness";
import LinkTransfersModal from "./LinkTransfersModal";
import CategoryCombobox from "../shared/CategoryCombobox";
import type { Category } from "../../shared/types";
const harness = hookHarness();
type Props = Record<string, unknown> & { children?: ReactNode };
function find(node: ReactNode, type: unknown): ReactElement<Props>[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, type));
if (!isValidElement(node)) return [];
const el = node as ReactElement<Props>;
return [...(el.type === type ? [el] : []), ...find(el.props.children, type)];
}
const TRANSFERS: Category = {
id: 7,
name: "Virements",
type: "transfer",
is_active: true,
is_inputable: true,
sort_order: 1,
created_at: "",
};
const CATEGORIES = [TRANSFERS];
const PROPS = {
accountId: 1,
accountName: "CELI",
categories: CATEGORIES,
initialFrom: "2026-07-01",
initialTo: "2026-09-30",
onClose: vi.fn(),
};
/** One render of the mounted window; its category filter. */
const filter = () => {
const [combobox] = find(harness.render(() => LinkTransfersModal(PROPS)), CategoryCombobox);
return combobox.props;
};
/** The category of the last transaction query. */
const queriedCategory = () => {
const calls = getTransactionPage.mock.calls;
return (calls[calls.length - 1][0] as { categoryId: number | null }).categoryId;
};
/** Lets the query's promise settle. */
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
beforeAll(() => {
vi.stubGlobal("document", { body: {} });
});
afterAll(() => {
vi.unstubAllGlobals();
});
beforeEach(() => {
harness.clear();
getTransactionPage.mockReset();
getTransactionPage.mockResolvedValue({ rows: [], total: 0 });
});
describe("LinkTransfersModal — category filter (#429)", () => {
it("opens on 'any category', first of the list, with the categories it is given", () => {
const props = filter();
expect(props.extraOptions).toEqual([{ value: "", label: "t(balance.transfers.modal.anyCategory)" }]);
expect(props.activeExtra).toBe("");
expect(props.value).toBeNull();
expect(props.categories).toBe(CATEGORIES);
expect(props.compact).toBe(true);
expect(props.ariaLabel).toBe("t(balance.transfers.modal.category)");
});
it("filters on the category picked, then 'any category' empties the filter again", async () => {
filter();
harness.commit();
await flush();
expect(getTransactionPage).toHaveBeenCalledTimes(1);
expect(queriedCategory()).toBeNull();
(filter().onChange as (id: number) => void)(TRANSFERS.id);
let props = filter();
expect(props.value).toBe(TRANSFERS.id);
expect(props.activeExtra).toBeNull();
harness.commit();
await flush();
expect(getTransactionPage).toHaveBeenCalledTimes(2);
expect(queriedCategory()).toBe(TRANSFERS.id);
(props.onExtraSelect as (value: string) => void)("");
props = filter();
expect(props.value).toBeNull();
expect(props.activeExtra).toBe("");
harness.commit();
await flush();
expect(getTransactionPage).toHaveBeenCalledTimes(3);
expect(queriedCategory()).toBeNull();
});
});