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 <noreply@anthropic.com>
This commit is contained in:
parent
619637e91b
commit
aa229484f6
3 changed files with 218 additions and 16 deletions
69
src/components/balance/LinkTransfersModal.render.test.tsx
Normal file
69
src/components/balance/LinkTransfersModal.render.test.tsx
Normal 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 /);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
//
|
//
|
||||||
// Filters available:
|
// Filters available:
|
||||||
// - Period (from / to ISO dates) — default: last 90 days.
|
// - Period (from / to ISO dates) — default: last 90 days.
|
||||||
// - Category dropdown.
|
// - Category combobox, "any category" first (#429).
|
||||||
// - Free-text search on description.
|
// - Free-text search on description.
|
||||||
//
|
//
|
||||||
// Each row shows: date, description, amount, suggested direction
|
// Each row shows: date, description, amount, suggested direction
|
||||||
|
|
@ -29,6 +29,7 @@ import type {
|
||||||
TransactionRow,
|
TransactionRow,
|
||||||
BalanceTransferDirection,
|
BalanceTransferDirection,
|
||||||
} from "../../shared/types";
|
} from "../../shared/types";
|
||||||
|
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 100;
|
const DEFAULT_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
|
@ -49,7 +50,7 @@ export interface LinkTransfersModalProps {
|
||||||
/** Account that the selected transfers will be attached to. */
|
/** Account that the selected transfers will be attached to. */
|
||||||
accountId: number;
|
accountId: number;
|
||||||
accountName: string;
|
accountName: string;
|
||||||
/** Full category list for the filter dropdown. */
|
/** Categories offered by the filter combobox. */
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
/** Optional pre-fill date bounds (defaults to last 90 days). */
|
/** Optional pre-fill date bounds (defaults to last 90 days). */
|
||||||
initialFrom?: string;
|
initialFrom?: string;
|
||||||
|
|
@ -96,6 +97,13 @@ export default function LinkTransfersModal({
|
||||||
[i18n.language]
|
[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
|
// 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.
|
// — typing in the search box re-runs the SQL but at < 500 rows that's fine.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -256,20 +264,16 @@ export default function LinkTransfersModal({
|
||||||
<span className="block text-[var(--muted-foreground)] mb-1">
|
<span className="block text-[var(--muted-foreground)] mb-1">
|
||||||
{t("balance.transfers.modal.category")}
|
{t("balance.transfers.modal.category")}
|
||||||
</span>
|
</span>
|
||||||
<select
|
<CategoryCombobox
|
||||||
value={categoryId === null ? "" : String(categoryId)}
|
categories={categories}
|
||||||
onChange={(e) =>
|
value={categoryId}
|
||||||
setCategoryId(e.target.value === "" ? null : Number(e.target.value))
|
onChange={(id) => setCategoryId(id)}
|
||||||
}
|
compact
|
||||||
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
|
ariaLabel={t("balance.transfers.modal.category")}
|
||||||
>
|
extraOptions={categoryExtras}
|
||||||
<option value="">{t("balance.transfers.modal.anyCategory")}</option>
|
activeExtra={categoryId === null ? "" : null}
|
||||||
{categories.map((c) => (
|
onExtraSelect={() => setCategoryId(null)}
|
||||||
<option key={c.id} value={c.id}>
|
/>
|
||||||
{c.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="text-xs">
|
<label className="text-xs">
|
||||||
<span className="block text-[var(--muted-foreground)] mb-1">
|
<span className="block text-[var(--muted-foreground)] mb-1">
|
||||||
|
|
|
||||||
129
src/components/balance/LinkTransfersModal.wiring.test.ts
Normal file
129
src/components/balance/LinkTransfersModal.wiring.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue