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 { 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"
|
||||||
|
|
|
||||||
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,
|
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) });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue