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 <noreply@anthropic.com>
87 lines
3.1 KiB
TypeScript
87 lines
3.1 KiB
TypeScript
// 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> = {}): 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(
|
|
<AdjustmentForm
|
|
initialData={{ name: "Chalet", description: "", date: "2026-10-01", is_recurring: false }}
|
|
initialEntries={entries}
|
|
categories={[GROCERIES, CABIN]}
|
|
isCreating={false}
|
|
isSaving={false}
|
|
onSave={vi.fn()}
|
|
onCancel={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
/** The comboboxes' inputs, in the order of the lines. */
|
|
const comboboxes = (html: string) => html.match(/<input [^>]*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("<select");
|
|
});
|
|
|
|
it("shows each line's category, a seed one through its key, a user one by its name", () => {
|
|
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)<");
|
|
});
|
|
});
|