diff --git a/src/components/adjustments/AdjustmentForm.render.test.tsx b/src/components/adjustments/AdjustmentForm.render.test.tsx new file mode 100644 index 0000000..d29e233 --- /dev/null +++ b/src/components/adjustments/AdjustmentForm.render.test.tsx @@ -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 => ({ + 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( + , + ); + +/** The comboboxes' inputs, in the order of the lines. */ +const comboboxes = (html: string) => html.match(/]*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(" { + 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)<"); + }); +}); diff --git a/src/components/adjustments/AdjustmentForm.tsx b/src/components/adjustments/AdjustmentForm.tsx index ab052f1..7befe4f 100644 --- a/src/components/adjustments/AdjustmentForm.tsx +++ b/src/components/adjustments/AdjustmentForm.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Plus, Trash2 } from "lucide-react"; import type { Category } from "../../shared/types"; import type { AdjustmentFormData, EntryFormData } from "../../hooks/useAdjustments"; +import CategoryCombobox from "../shared/CategoryCombobox"; interface Props { initialData: AdjustmentFormData; @@ -120,17 +121,15 @@ export default function AdjustmentForm({
{entries.map((entry, index) => (
- +
+ updateEntryField(index, "category_id", id)} + compact + ariaLabel={t("adjustments.category")} + /> +
(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 & { children?: ReactNode }; + +function find(node: ReactNode, match: (el: ReactElement) => boolean): ReactElement[] { + if (Array.isArray(node)) return node.flatMap((n) => find(n, match)); + if (!isValidElement(node)) return []; + const el = node as ReactElement; + 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: "" }, + ]); + }); +}); diff --git a/src/hooks/useAdjustments.test.ts b/src/hooks/useAdjustments.test.ts new file mode 100644 index 0000000..038c8f6 --- /dev/null +++ b/src/hooks/useAdjustments.test.ts @@ -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"); + }); +}); diff --git a/src/hooks/useAdjustments.ts b/src/hooks/useAdjustments.ts index 8167f15..451d919 100644 --- a/src/hooks/useAdjustments.ts +++ b/src/hooks/useAdjustments.ts @@ -11,7 +11,7 @@ import { updateEntry, deleteEntry, } from "../services/adjustmentService"; -import { getDb } from "../services/db"; +import { getAllCategories } from "../services/transactionService"; export interface AdjustmentFormData { name: string; @@ -123,12 +123,14 @@ export function useAdjustments() { const [state, dispatch] = useReducer(reducer, initialState); 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 () => { try { - const db = await getDb(); - const rows = await db.select( - "SELECT * FROM categories WHERE is_active = 1 ORDER BY sort_order, name" - ); + const rows = await getAllCategories(); dispatch({ type: "SET_CATEGORIES", payload: rows }); } catch (e) { dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });