feat: pick an adjustment line's category in a combobox (#429)

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>
This commit is contained in:
le king fu 2026-10-18 18:34:49 -04:00
parent 3631ef0b64
commit 619637e91b
5 changed files with 305 additions and 16 deletions

View file

@ -0,0 +1,87 @@
// 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)<");
});
});

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import type { Category } from "../../shared/types"; import type { Category } from "../../shared/types";
import type { AdjustmentFormData, EntryFormData } from "../../hooks/useAdjustments"; import type { AdjustmentFormData, EntryFormData } from "../../hooks/useAdjustments";
import CategoryCombobox from "../shared/CategoryCombobox";
interface Props { interface Props {
initialData: AdjustmentFormData; initialData: AdjustmentFormData;
@ -120,17 +121,15 @@ export default function AdjustmentForm({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{entries.map((entry, index) => ( {entries.map((entry, index) => (
<div key={index} className="flex items-center gap-2"> <div key={index} className="flex items-center gap-2">
<select <div className="flex-1 min-w-0">
value={entry.category_id} <CategoryCombobox
onChange={(e) => updateEntryField(index, "category_id", Number(e.target.value))} categories={categories}
className="flex-1 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" value={entry.category_id}
> onChange={(id) => updateEntryField(index, "category_id", id)}
{categories.map((c) => ( compact
<option key={c.id} value={c.id}> ariaLabel={t("adjustments.category")}
{c.name} />
</option> </div>
))}
</select>
<input <input
type="number" type="number"
step="0.01" step="0.01"

View file

@ -0,0 +1,116 @@
// AdjustmentForm — what a line's combobox changes (#429).
//
// A static render clicks nothing. React's hooks come from the shared harness
// (src/test/hookHarness.ts, #409): the form runs as a plain function, its
// state survives from one call to the next as between two renders of a
// mounted form, and the test calls the props the form hands to each
// CategoryCombobox and to its buttons, then renders again to read the lines.
import { describe, it, expect, vi, 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());
import { hookHarness } from "../../test/hookHarness";
import AdjustmentForm from "./AdjustmentForm";
import CategoryCombobox from "../shared/CategoryCombobox";
import type { Category } from "../../shared/types";
import type { AdjustmentFormData, EntryFormData } from "../../hooks/useAdjustments";
const harness = hookHarness();
type Props = Record<string, unknown> & { children?: ReactNode };
function find(node: ReactNode, match: (el: ReactElement<Props>) => boolean): ReactElement<Props>[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, match));
if (!isValidElement(node)) return [];
const el = node as ReactElement<Props>;
return [...(match(el) ? [el] : []), ...find(el.props.children, match)];
}
/** Text of an element's children, elements left out. */
const text = (node: ReactNode): string =>
Array.isArray(node) ? node.map(text).join("") : typeof node === "string" ? node : "";
const category = (id: number, name: string): Category => ({
id,
name,
type: "expense",
is_active: true,
is_inputable: true,
sort_order: id,
created_at: "",
});
const RENT = category(30, "Loyer");
const CABIN = category(20, "Chalet");
/** In the list's order: a new line takes the first one. */
const CATEGORIES = [RENT, CABIN];
const DATA: AdjustmentFormData = { name: "Chalet", description: "", date: "2026-10-01", is_recurring: false };
let onSave = vi.fn();
/** One render of the mounted form; the props keep their identity from one render to the next. */
const mount = (entries: EntryFormData[]) => {
const props = {
initialData: DATA,
initialEntries: entries,
categories: CATEGORIES,
isCreating: false,
isSaving: false,
onSave,
onCancel: vi.fn(),
};
return () => harness.render(() => AdjustmentForm(props));
};
const comboboxes = (tree: ReactNode) => find(tree, (el) => el.type === CategoryCombobox);
const addButton = (tree: ReactNode) =>
find(tree, (el) => el.type === "button" && text(el.props.children) === "t(adjustments.addEntry)")[0];
const form = (tree: ReactNode) => find(tree, (el) => el.type === "form")[0];
beforeEach(() => {
harness.clear();
onSave = vi.fn();
});
describe("AdjustmentForm — a line's category (#429)", () => {
it("gives a new line the first category of the list", () => {
const render = mount([]);
let tree = render();
expect(comboboxes(tree)).toHaveLength(0);
(addButton(tree).props.onClick as () => void)();
tree = render();
const [combobox] = comboboxes(tree);
expect(combobox.props.value).toBe(RENT.id);
expect(combobox.props.categories).toBe(CATEGORIES);
expect(combobox.props.compact).toBe(true);
expect(combobox.props.ariaLabel).toBe("t(adjustments.category)");
});
it("changes the category of the line picked, and of that line only", () => {
const render = mount([
{ id: 1, category_id: RENT.id, amount: 1200, description: "" },
{ id: 2, category_id: RENT.id, amount: 80, description: "Électricité" },
]);
let tree = render();
(comboboxes(tree)[1].props.onChange as (id: number) => void)(CABIN.id);
tree = render();
expect(comboboxes(tree).map((c) => c.props.value)).toEqual([RENT.id, CABIN.id]);
});
it("saves every line with its category, its amount and its description, as before", () => {
const render = mount([{ id: 1, category_id: RENT.id, amount: 1200, description: "" }]);
let tree = render();
(addButton(tree).props.onClick as () => void)();
tree = render();
(comboboxes(tree)[1].props.onChange as (id: number) => void)(CABIN.id);
tree = render();
(form(tree).props.onSubmit as (e: { preventDefault: () => void }) => void)({ preventDefault: vi.fn() });
expect(onSave).toHaveBeenCalledWith(DATA, [
{ id: 1, category_id: RENT.id, amount: 1200, description: "" },
{ category_id: CABIN.id, amount: 0, description: "" },
]);
});
});

View file

@ -0,0 +1,85 @@
// useAdjustments — the categories an adjustment line can pick (#429, D2).
//
// React's hooks come from the shared harness (src/test/hookHarness.ts, #409):
// the hook runs as a plain function, its reducer is applied, and `commit()`
// runs its mount effect. The services are mocked; the base answers a raw
// query with every active category, so a hook that read the base itself,
// instead of the Transactions list, would show it.
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
const { getAllCategories, getAllAdjustments, select } = vi.hoisted(() => ({
getAllCategories: vi.fn(),
getAllAdjustments: vi.fn(),
select: vi.fn(),
}));
vi.mock("../services/transactionService", () => ({ getAllCategories }));
vi.mock("../services/adjustmentService", () => ({
getAllAdjustments,
getEntriesByAdjustmentId: vi.fn(),
createAdjustment: vi.fn(),
updateAdjustment: vi.fn(),
deleteAdjustment: vi.fn(),
createEntry: vi.fn(),
updateEntry: vi.fn(),
deleteEntry: vi.fn(),
}));
vi.mock("../services/db", () => ({ getDb: vi.fn(async () => ({ select })) }));
import { hookHarness } from "../test/hookHarness";
import { useAdjustments } from "./useAdjustments";
import type { Category } from "../shared/types";
const harness = hookHarness();
const category = (id: number, name: string, isInputable: boolean): Category => ({
id,
name,
type: "expense",
is_active: true,
is_inputable: isInputable,
sort_order: id,
created_at: "",
});
/** The Transactions list: active and open to input. */
const INPUTABLE = [category(1, "Épicerie", true), category(2, "Loyer", true)];
/** Every active category, a parent hidden from input included. */
const ACTIVE = [category(3, "Alimentation", false), ...INPUTABLE];
/** Lets the loaders' promises settle. */
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
beforeEach(() => {
harness.clear();
getAllCategories.mockReset();
getAllCategories.mockResolvedValue(INPUTABLE);
getAllAdjustments.mockReset();
getAllAdjustments.mockResolvedValue([]);
select.mockReset();
select.mockResolvedValue(ACTIVE);
});
describe("useAdjustments — categories of a line (#429, D2)", () => {
it("offers the Transactions list, active and open to input, not every active category", async () => {
harness.render(useAdjustments);
harness.commit();
await flush();
const { state } = harness.render(useAdjustments);
expect(state.categories).toBe(INPUTABLE);
expect(getAllCategories).toHaveBeenCalledTimes(1);
expect(select).not.toHaveBeenCalled();
});
it("reports a failed read as the page's error, with no category", async () => {
getAllCategories.mockRejectedValue(new Error("database is locked"));
harness.render(useAdjustments);
harness.commit();
await flush();
const { state } = harness.render(useAdjustments);
expect(state.categories).toEqual([]);
expect(state.error).toBe("database is locked");
});
});

View file

@ -11,7 +11,7 @@ import {
updateEntry, updateEntry,
deleteEntry, deleteEntry,
} from "../services/adjustmentService"; } from "../services/adjustmentService";
import { getDb } from "../services/db"; import { getAllCategories } from "../services/transactionService";
export interface AdjustmentFormData { export interface AdjustmentFormData {
name: string; name: string;
@ -123,12 +123,14 @@ export function useAdjustments() {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
// The categories a line can pick, the list of the Transactions page: active
// and open to input (#429, D2). The page passes it to the adjustment form and
// to the split window, both pickers; the detail view names a line's
// category from the entries' own join, so a line saved on a category hidden
// from input keeps its name there, and its value until changed by hand.
const loadCategories = useCallback(async () => { const loadCategories = useCallback(async () => {
try { try {
const db = await getDb(); const rows = await getAllCategories();
const rows = await db.select<Category[]>(
"SELECT * FROM categories WHERE is_active = 1 ORDER BY sort_order, name"
);
dispatch({ type: "SET_CATEGORIES", payload: rows }); dispatch({ type: "SET_CATEGORIES", payload: rows });
} catch (e) { } catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });