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)} + />