feat: category combobox list in a portal, combobox in adjustment lines and the transfers filter (#429) #471
5 changed files with 305 additions and 16 deletions
87
src/components/adjustments/AdjustmentForm.render.test.tsx
Normal file
87
src/components/adjustments/AdjustmentForm.render.test.tsx
Normal 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)<");
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
116
src/components/adjustments/AdjustmentForm.wiring.test.ts
Normal file
116
src/components/adjustments/AdjustmentForm.wiring.test.ts
Normal 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: "" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
85
src/hooks/useAdjustments.test.ts
Normal file
85
src/hooks/useAdjustments.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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) });
|
||||
|
|
|
|||
Loading…
Reference in a new issue