feat: category combobox list in a portal, combobox in adjustment lines and the transfers filter (#429) #471

Open
maximus wants to merge 6 commits from issue-429-category-combobox-portal into issue-372-systems-journal-first
5 changed files with 305 additions and 16 deletions
Showing only changes of commit 619637e91b - Show all commits

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 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({
<div className="flex flex-col gap-2">
{entries.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<select
value={entry.category_id}
onChange={(e) => updateEntryField(index, "category_id", Number(e.target.value))}
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)]"
>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<div className="flex-1 min-w-0">
<CategoryCombobox
categories={categories}
value={entry.category_id}
onChange={(id) => updateEntryField(index, "category_id", id)}
compact
ariaLabel={t("adjustments.category")}
/>
</div>
<input
type="number"
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,
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<Category[]>(
"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) });