feat: category combobox list in a portal, combobox in adjustment lines and the transfers filter (#429) #471
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:
|
||||
// - 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">
|
||||
|
|
|
|||
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