Compare commits

..

4 commits

Author SHA1 Message Date
le king fu
7053ae5fc6 docs: describe the category combobox in a portal and its new places (#429)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m55s
CHANGELOG (EN and FR, Changed): typing in the category of an adjustment
line and in the category filter of the "Link transfers" window, the
adjustment lines limited to the categories open to input, and the open
list no longer cut by the edge of a window or of the Transactions table.

Guide, section 7, and its in-app twin (docs.adjustments.steps, FR and
EN): type a few letters to filter the category list of a line.

architecture.md, Tests section: how a test renders a window drawn by
createPortal (identity mock, document stub), what of the combobox's
portal is pinned without a DOM (placeListbox.ts) and what is checked in
the app, and that the list no longer sits in its container's DOM.

Resolves #429

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:41:37 -04:00
le king fu
aa229484f6 feat: filter the transfers to link by category with a combobox (#429)
The category filter of the "Link transfers" window was a native select.
It now takes the compact CategoryCombobox, modelled on the Transactions
filter bar: "Any category" first as an extra option, active while the
filter is empty, and picking it sets the filter back to null; the list
of transactions reloads as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:34:52 -04:00
le king fu
619637e91b 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>
2026-10-18 18:34:49 -04:00
le king fu
3631ef0b64 feat: render the category combobox's open list in a portal (#429)
The open list of CategoryCombobox was positioned `absolute` inside its
input's container, so a scrolling ancestor clipped it: the body of the
split window, the `overflow-x-auto` wrapper of the Transactions table.
It is now rendered in a portal on document.body, `position: fixed`, at
z-[300] (above every window, the highest at z-[210]).

The pure rule lives in placeListbox.ts: under the input, as wide as it,
4 px below; above it when the room below is short of the list's 240 px
and the room above has them; below when neither side has them. The
list is placed in a layout effect before the first paint, then follows
the input on every scroll (capture phase, any scrolling container) and
resize while it is open, skipping scrolls of the list itself; the
listeners are removed with the list (followAnchor returns the cleanup).
The outside press counts the list as inside, since it no longer sits in
the container's DOM. The closed markup is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:34:44 -04:00
17 changed files with 863 additions and 40 deletions

View file

@ -5,6 +5,7 @@
### Modifié
- Paramètres → Système : « Journaux et commentaires » vient désormais juste après « Mise à jour », avant l'historique des versions, qui ne repousse plus le Journal en bas de la page. L'historique des versions s'ouvre sur les notes de la version installée, les autres versions repliées : cliquez sur un numéro de version pour lire ses notes, ou sur « Tout déplier » pour ouvrir tout l'historique (#372).
- Ajustements : la catégorie d'une ligne d'ajustement accepte désormais la saisie, comme la cellule de catégorie de la page Transactions : tapez quelques lettres pour filtrer la liste, qui montre la hiérarchie et les noms des catégories standard dans votre langue. Elle propose les mêmes catégories que la page Transactions, actives et autorisées à la saisie, comme la fenêtre de répartition ouverte depuis la page Ajustements ; une ligne déjà enregistrée sur une catégorie masquée de la saisie la garde, son champ vide tant que vous n'en choisissez pas une autre. Le filtre de catégorie de la fenêtre « Lier transferts » du Bilan accepte aussi la saisie, avec « Toutes les catégories » en tête. Une liste de catégories ouverte n'est plus coupée par le bord d'une fenêtre ou du tableau des transactions, dans la fenêtre de répartition comme sur les dernières lignes du tableau, et elle s'ouvre vers le haut quand la place manque en dessous (#429).
## [0.17.2] - 2026-10-18

View file

@ -5,6 +5,7 @@
### Changed
- Settings → System: “Logs and feedback” now comes right after “Update”, before the version history, which no longer pushes the Journal to the bottom of the page. The version history opens on the notes of the installed version, with every other version folded: click a version number to read its notes, or “Expand all” to open the whole history (#372).
- Adjustments: the category of an adjustment line now takes typing, like the category cell of the Transactions page: type a few letters to filter the list, which shows the hierarchy and the names of the standard categories in your language. It offers the same categories as the Transactions page, active and allowed for input, as does the split window opened from the Adjustments page; a line already saved on a category hidden from input keeps it, its field empty until you choose another one. The category filter of the “Link transfers” window of the Balance sheet takes typing too, with “Any category” first. An open category list is no longer cut by the edge of a window or of the Transactions table, in the split window as on the last rows of the table, and it opens upward when there is not enough room below (#429).
## [0.17.2] - 2026-10-18

View file

@ -641,6 +641,12 @@ Les assertions qui nomment un texte affiché s'écrivent sous la forme marquée
Garde : `src/__integration__/i18n-mock-guard.test.ts` lit tous les `*.test.ts(x)` de `src/` et échoue, hors de `src/test/i18nMock.ts`, sur une propriété `useTranslation:`, sur un faux `t` en propriété (`t: (`, `t: vi.fn(`) ou en liaison (`t = (`, `t = vi.fn(`), tous ancrés sur une frontière de mot, ou sur un `vi.mock("react-i18next", …)` sans appel à `i18nMock(` (le nom seul, dans un commentaire par exemple, ne compte pas : les commentaires sont retirés avant la recherche).
### Portails (#429)
`renderToStaticMarkup` ne rend pas un portail, et l'environnement node de vitest n'a pas de `document`. Le test d'une fenêtre rendue par `createPortal(…, document.body)` (`LinkTransfersModal`, `StarterAccountsModal`…) remplace `createPortal` par l'identité (`vi.mock("react-dom", …)`, le reste du module gardé) et pose un `document` factice (`vi.stubGlobal`), puisque `document.body` est lu avant l'appel : modèle dans `LinkTransfersModal.render.test.tsx` et, sous le harnais, `LinkTransfersModal.wiring.test.ts`.
`CategoryCombobox` rend sa liste ouverte dans un portail sur `document.body`, en position fixe au-dessus de toutes les fenêtres (`z-[300]`) : aucun conteneur défilant ne la coupe. Fermé, il ne rend que `<div class="relative"><input …/></div>`, sans portail, et son rendu statique n'en demande rien. La règle de placement (sous le champ, au-dessus quand la place manque en dessous et suffit au-dessus) et les écouteurs qui gardent la liste sur son champ pendant qu'elle est ouverte (`scroll` en capture, `resize`, retirés avec elle) sont des fonctions pures de `src/components/shared/placeListbox.ts`, testées sans DOM. Le composant ouvert, lui, ne se rend pas sans DOM : `renderToStaticMarkup` le rend fermé et le harnais refuse ses `useId` et `useLayoutEffect` ; le reste (portail, clic dans la liste, clavier) se vérifie dans l'application. La liste n'étant plus dans le DOM de son conteneur, un écouteur natif de clic extérieur posé par un conteneur la verrait dehors : celui du combobox la compte comme intérieure.
## CI/CD
Quatre workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** : les jobs se suivent, ils ne tournent pas en parallèle.

View file

@ -309,7 +309,7 @@ Ajoutez des entrées manuelles non issues de vos relevés bancaires, et consulte
### Comment faire
1. Cliquez sur Nouvel ajustement pour créer un groupe d'ajustement
2. Ajoutez des entrées avec une description, un montant, une date et une catégorie
2. Ajoutez des entrées avec une description, un montant, une date et une catégorie (tapez quelques lettres pour filtrer la liste des catégories)
3. Activez le drapeau récurrent si l'ajustement doit se répéter à chaque période
4. Consultez la section Répartitions de transactions pour voir les splits créés depuis la page Transactions

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,69 @@
// LinkTransfersModal — the category filter is a combobox (#429). No jsdom:
// react-dom/server renders the initial markup in vitest's node environment.
// It cannot render a portal, and the window is one: `createPortal` becomes
// the identity, and `document` a stub for the `document.body` it is given.
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
vi.mock("react-dom", async (orig) => ({
...(await orig<typeof import("react-dom")>()),
createPortal: (children: ReactNode) => children,
}));
vi.mock("../../services/transactionService", () => ({
getTransactionPage: vi.fn(() => new Promise(() => undefined)),
}));
vi.mock("../../services/balance.service", () => ({
linkTransfer: vi.fn(),
suggestTransferDirection: vi.fn(() => "in"),
BalanceServiceError: class extends Error {},
}));
import LinkTransfersModal from "./LinkTransfersModal";
import type { Category } from "../../shared/types";
const TRANSFERS: Category = {
id: 7,
name: "Virements",
type: "transfer",
is_active: true,
is_inputable: true,
sort_order: 1,
created_at: "",
};
beforeAll(() => {
vi.stubGlobal("document", { body: {} });
});
afterAll(() => {
vi.unstubAllGlobals();
});
const markup = () =>
renderToStaticMarkup(
<LinkTransfersModal
accountId={1}
accountName="CELI"
categories={[TRANSFERS]}
initialFrom="2026-07-01"
initialTo="2026-09-30"
onClose={vi.fn()}
/>,
);
describe("LinkTransfersModal — category filter (#429)", () => {
it("renders the filter as a combobox, and no select", () => {
const html = markup();
expect(html.match(/<input [^>]*role="combobox"[^>]*>/g) ?? []).toHaveLength(1);
expect(html).not.toContain("<select");
});
it("opens on 'any category', named, compact like the other filters", () => {
const [input] = markup().match(/<input [^>]*role="combobox"[^>]*>/g) ?? [];
expect(input).toContain('value="t(balance.transfers.modal.anyCategory)"');
expect(input).toContain('aria-label="t(balance.transfers.modal.category)"');
expect(input).toMatch(/class="w-full px-2 py-1 /);
});
});

View file

@ -3,7 +3,7 @@
//
// Filters available:
// - Period (from / to ISO dates) — default: last 90 days.
// - Category dropdown.
// - Category combobox, "any category" first (#429).
// - Free-text search on description.
//
// Each row shows: date, description, amount, suggested direction
@ -29,6 +29,7 @@ import type {
TransactionRow,
BalanceTransferDirection,
} from "../../shared/types";
import CategoryCombobox from "../shared/CategoryCombobox";
const DEFAULT_PAGE_SIZE = 100;
@ -49,7 +50,7 @@ export interface LinkTransfersModalProps {
/** Account that the selected transfers will be attached to. */
accountId: number;
accountName: string;
/** Full category list for the filter dropdown. */
/** Categories offered by the filter combobox. */
categories: Category[];
/** Optional pre-fill date bounds (defaults to last 90 days). */
initialFrom?: string;
@ -96,6 +97,13 @@ export default function LinkTransfersModal({
[i18n.language]
);
// "Any category" first: the filter's empty value (null), as on the
// Transactions page's filter bar.
const categoryExtras = useMemo(
() => [{ value: "", label: t("balance.transfers.modal.anyCategory") }],
[t]
);
// Re-fetch whenever the filters change. Debounced via React's render cycle
// — typing in the search box re-runs the SQL but at < 500 rows that's fine.
useEffect(() => {
@ -256,20 +264,16 @@ export default function LinkTransfersModal({
<span className="block text-[var(--muted-foreground)] mb-1">
{t("balance.transfers.modal.category")}
</span>
<select
value={categoryId === null ? "" : String(categoryId)}
onChange={(e) =>
setCategoryId(e.target.value === "" ? null : Number(e.target.value))
}
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
>
<option value="">{t("balance.transfers.modal.anyCategory")}</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<CategoryCombobox
categories={categories}
value={categoryId}
onChange={(id) => setCategoryId(id)}
compact
ariaLabel={t("balance.transfers.modal.category")}
extraOptions={categoryExtras}
activeExtra={categoryId === null ? "" : null}
onExtraSelect={() => setCategoryId(null)}
/>
</label>
<label className="text-xs">
<span className="block text-[var(--muted-foreground)] mb-1">

View file

@ -0,0 +1,129 @@
// LinkTransfersModal — what the category filter changes (#429).
//
// A static render runs no effect and clicks nothing. React's hooks come from
// the shared harness (src/test/hookHarness.ts, #409): the window runs as a
// plain function, its state survives from one call to the next, the test
// calls the props it hands to its CategoryCombobox, renders again to read the
// filter, and `commit()` runs the effect that reloads the transactions.
// `createPortal` is the identity and `document` a stub: the window is a portal.
import { describe, it, expect, vi, beforeAll, afterAll, 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());
vi.mock("react-dom", async (orig) => ({
...(await orig<typeof import("react-dom")>()),
createPortal: (children: ReactNode) => children,
}));
const { getTransactionPage } = vi.hoisted(() => ({ getTransactionPage: vi.fn() }));
vi.mock("../../services/transactionService", () => ({ getTransactionPage }));
vi.mock("../../services/balance.service", () => ({
linkTransfer: vi.fn(),
suggestTransferDirection: vi.fn(() => "in"),
BalanceServiceError: class extends Error {},
}));
import { hookHarness } from "../../test/hookHarness";
import LinkTransfersModal from "./LinkTransfersModal";
import CategoryCombobox from "../shared/CategoryCombobox";
import type { Category } from "../../shared/types";
const harness = hookHarness();
type Props = Record<string, unknown> & { children?: ReactNode };
function find(node: ReactNode, type: unknown): ReactElement<Props>[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, type));
if (!isValidElement(node)) return [];
const el = node as ReactElement<Props>;
return [...(el.type === type ? [el] : []), ...find(el.props.children, type)];
}
const TRANSFERS: Category = {
id: 7,
name: "Virements",
type: "transfer",
is_active: true,
is_inputable: true,
sort_order: 1,
created_at: "",
};
const CATEGORIES = [TRANSFERS];
const PROPS = {
accountId: 1,
accountName: "CELI",
categories: CATEGORIES,
initialFrom: "2026-07-01",
initialTo: "2026-09-30",
onClose: vi.fn(),
};
/** One render of the mounted window; its category filter. */
const filter = () => {
const [combobox] = find(harness.render(() => LinkTransfersModal(PROPS)), CategoryCombobox);
return combobox.props;
};
/** The category of the last transaction query. */
const queriedCategory = () => {
const calls = getTransactionPage.mock.calls;
return (calls[calls.length - 1][0] as { categoryId: number | null }).categoryId;
};
/** Lets the query's promise settle. */
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
beforeAll(() => {
vi.stubGlobal("document", { body: {} });
});
afterAll(() => {
vi.unstubAllGlobals();
});
beforeEach(() => {
harness.clear();
getTransactionPage.mockReset();
getTransactionPage.mockResolvedValue({ rows: [], total: 0 });
});
describe("LinkTransfersModal — category filter (#429)", () => {
it("opens on 'any category', first of the list, with the categories it is given", () => {
const props = filter();
expect(props.extraOptions).toEqual([{ value: "", label: "t(balance.transfers.modal.anyCategory)" }]);
expect(props.activeExtra).toBe("");
expect(props.value).toBeNull();
expect(props.categories).toBe(CATEGORIES);
expect(props.compact).toBe(true);
expect(props.ariaLabel).toBe("t(balance.transfers.modal.category)");
});
it("filters on the category picked, then 'any category' empties the filter again", async () => {
filter();
harness.commit();
await flush();
expect(getTransactionPage).toHaveBeenCalledTimes(1);
expect(queriedCategory()).toBeNull();
(filter().onChange as (id: number) => void)(TRANSFERS.id);
let props = filter();
expect(props.value).toBe(TRANSFERS.id);
expect(props.activeExtra).toBeNull();
harness.commit();
await flush();
expect(getTransactionPage).toHaveBeenCalledTimes(2);
expect(queriedCategory()).toBe(TRANSFERS.id);
(props.onExtraSelect as (value: string) => void)("");
props = filter();
expect(props.value).toBeNull();
expect(props.activeExtra).toBe("");
harness.commit();
await flush();
expect(getTransactionPage).toHaveBeenCalledTimes(3);
expect(queriedCategory()).toBeNull();
});
});

View file

@ -1,5 +1,13 @@
import { useState, useRef, useEffect, useCallback, useId, useMemo } from "react";
import { useState, useRef, useEffect, useLayoutEffect, useCallback, useId, useMemo } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
LISTBOX_MAX_HEIGHT,
followAnchor,
placeListbox,
samePlacement,
type ListboxPlacement,
} from "./placeListbox";
/**
* Minimal shape the combobox needs. `Category` (transactions, reports) and
* `CategoryTreeNode` (categories page) both satisfy it, so callers pass their
@ -135,6 +143,7 @@ export default function CategoryCombobox<T extends CategoryLike = CategoryLike>(
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [highlightIndex, setHighlightIndex] = useState(0);
const [placement, setPlacement] = useState<ListboxPlacement | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@ -187,10 +196,36 @@ export default function CategoryCombobox<T extends CategoryLike = CategoryLike>(
}
}, [highlightIndex, open]);
// The open list lives in a portal on document.body (#429): no scrolling
// container clips it. It is placed on its input before the first paint,
// then follows the input on every scroll and resize, even out of the
// visible part of its container; the listeners go with the list.
const updatePlacement = useCallback(() => {
const input = inputRef.current;
if (!input) return;
const next = placeListbox({
anchor: input.getBoundingClientRect(),
viewportHeight: window.innerHeight,
});
setPlacement((prev) => (samePlacement(prev, next) ? prev : next));
}, []);
useLayoutEffect(() => {
if (!open) return;
updatePlacement();
return followAnchor(window, updatePlacement, () => listRef.current);
}, [open, updatePlacement]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
// The list is outside the container in the DOM: a press on an option
// counts as inside, or the list would close before the click selects.
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node) &&
!listRef.current?.contains(e.target as Node)
) {
setOpen(false);
setQuery("");
}
@ -277,12 +312,20 @@ export default function CategoryCombobox<T extends CategoryLike = CategoryLike>(
onKeyDown={handleKeyDown}
className={`w-full ${px} ${py} text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]`}
/>
{open && totalItems > 0 && (
{open && totalItems > 0 && placement && createPortal(
<ul
ref={listRef}
id={listboxId}
role="listbox"
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-lg"
// z-[300]: above every window of the app, the highest at z-[210].
className="fixed z-[300] overflow-auto rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-lg"
style={{
top: placement.top,
bottom: placement.bottom,
left: placement.left,
width: placement.width,
maxHeight: LISTBOX_MAX_HEIGHT,
}}
>
{filteredExtras.map((opt, i) => (
<li
@ -327,7 +370,8 @@ export default function CategoryCombobox<T extends CategoryLike = CategoryLike>(
</li>
);
})}
</ul>
</ul>,
document.body,
)}
</div>
);

View file

@ -0,0 +1,175 @@
// placeListbox — where the open list of a category combobox goes (#429).
// The portal itself needs a DOM (manual check); its placement rule and the
// listeners that keep it on its input do not, and are pinned here.
import { describe, it, expect, vi } from "vitest";
import {
LISTBOX_GAP,
LISTBOX_MAX_HEIGHT,
followAnchor,
placeListbox,
samePlacement,
type ListenerTarget,
type ListboxPlacement,
} from "./placeListbox";
/** An input 30 px high and 200 px wide, its top at `top`. */
const anchorAt = (top: number) => ({ top, bottom: top + 30, left: 50, width: 200 });
describe("placeListbox", () => {
it("keeps the list's size of max-h-60 and the gap of mt-1", () => {
expect(LISTBOX_MAX_HEIGHT).toBe(240);
expect(LISTBOX_GAP).toBe(4);
});
it("places the list under the input, as wide as it, at the input's left edge", () => {
expect(placeListbox({ anchor: anchorAt(100), viewportHeight: 800 })).toEqual({
left: 50,
width: 200,
openUpward: false,
top: 134,
});
});
it("stays below when the room below is exactly the list's maximum height", () => {
// 130 (input's bottom) + 4 + 240 = 374.
const placement = placeListbox({ anchor: anchorAt(100), viewportHeight: 374 });
expect(placement.openUpward).toBe(false);
expect(placement.top).toBe(134);
});
it("opens upward when the room below is short and the room above suffices", () => {
const placement = placeListbox({ anchor: anchorAt(700), viewportHeight: 800 });
expect(placement).toEqual({ left: 50, width: 200, openUpward: true, bottom: 104 });
// Its bottom edge sits the gap above the input, whatever its height.
expect(800 - placement.bottom!).toBe(700 - LISTBOX_GAP);
});
it("opens upward when the room above is exactly the list's maximum height", () => {
// Room above: 244 - 4 = 240; room below: 300 - 274 - 4 = 22.
expect(placeListbox({ anchor: anchorAt(244), viewportHeight: 300 }).openUpward).toBe(true);
});
it("stays below when room is short on both sides, even with more room above", () => {
// Room above: 200 - 4 = 196; room below: 400 - 230 - 4 = 166.
expect(placeListbox({ anchor: anchorAt(200), viewportHeight: 400 })).toEqual({
left: 50,
width: 200,
openUpward: false,
top: 234,
});
// One pixel short above: still below.
expect(placeListbox({ anchor: anchorAt(243), viewportHeight: 300 }).openUpward).toBe(false);
});
it("reads the maximum height and the gap it is given", () => {
// Room below: 800 - 730 - 10 = 60, under 100; room above: 700 - 10 = 690.
expect(
placeListbox({ anchor: anchorAt(700), viewportHeight: 800, maxHeight: 100, gap: 10 }),
).toEqual({ left: 50, width: 200, openUpward: true, bottom: 110 });
// Room below: 60, enough for a list of 50 px.
expect(
placeListbox({ anchor: anchorAt(700), viewportHeight: 800, maxHeight: 50, gap: 10 }),
).toEqual({ left: 50, width: 200, openUpward: false, top: 740 });
});
});
describe("samePlacement", () => {
const below: ListboxPlacement = { left: 50, width: 200, openUpward: false, top: 134 };
it("tells an unchanged position apart from a moved one", () => {
expect(samePlacement({ ...below }, below)).toBe(true);
expect(samePlacement(null, below)).toBe(false);
expect(samePlacement({ ...below, top: 135 }, below)).toBe(false);
expect(samePlacement({ ...below, left: 51 }, below)).toBe(false);
expect(samePlacement({ ...below, width: 201 }, below)).toBe(false);
expect(
samePlacement({ left: 50, width: 200, openUpward: true, bottom: 134 }, below),
).toBe(false);
});
});
/**
* A fake `window` with the DOM's rule: a listener is removed only by the same
* type, the same function and the same capture flag.
*/
function fakeTarget() {
const listeners: Array<{ type: string; fn: (event: Event) => void; capture: boolean }> = [];
const target: ListenerTarget = {
addEventListener: (type, fn, capture = false) => {
listeners.push({ type, fn, capture });
},
removeEventListener: (type, fn, capture = false) => {
const at = listeners.findIndex((l) => l.type === type && l.fn === fn && l.capture === capture);
if (at >= 0) listeners.splice(at, 1);
},
};
const fire = (type: string, eventTarget: unknown = null) => {
for (const l of [...listeners]) if (l.type === type) l.fn({ target: eventTarget } as unknown as Event);
};
return { target, listeners, fire };
}
/** A node of the list: it contains itself and its options. */
const option = { id: "option" };
const listNode = { contains: (node: unknown) => node === listNode || node === option } as unknown as Node;
const outside = { id: "a scrolling container" };
describe("followAnchor", () => {
it("listens to scroll in the capture phase, since scroll does not bubble, and to resize", () => {
const { target, listeners } = fakeTarget();
followAnchor(target, vi.fn(), () => listNode);
expect(listeners.map(({ type, capture }) => ({ type, capture }))).toEqual([
{ type: "scroll", capture: true },
{ type: "resize", capture: false },
]);
});
it("moves the list on a scroll of the page or of a container, and on a resize", () => {
const { target, fire } = fakeTarget();
const onMove = vi.fn();
followAnchor(target, onMove, () => listNode);
fire("scroll", outside);
expect(onMove).toHaveBeenCalledTimes(1);
fire("resize");
expect(onMove).toHaveBeenCalledTimes(2);
});
it("does not move the list on a scroll of the list itself", () => {
const { target, fire } = fakeTarget();
const onMove = vi.fn();
followAnchor(target, onMove, () => listNode);
fire("scroll", listNode);
fire("scroll", option);
expect(onMove).not.toHaveBeenCalled();
// Before the list is in the page, every scroll counts.
const early = fakeTarget();
const onEarlyMove = vi.fn();
followAnchor(early.target, onEarlyMove, () => null);
early.fire("scroll", outside);
expect(onEarlyMove).toHaveBeenCalledTimes(1);
});
it("removes exactly its own listeners, capture flag included", () => {
const { target, listeners, fire } = fakeTarget();
const onMove = vi.fn();
const stop = followAnchor(target, onMove, () => listNode);
stop();
expect(listeners).toEqual([]);
fire("scroll", outside);
fire("resize");
expect(onMove).not.toHaveBeenCalled();
});
it("leaves another open list's listeners in place", () => {
const { target, listeners, fire } = fakeTarget();
const first = vi.fn();
const second = vi.fn();
const stopFirst = followAnchor(target, first, () => listNode);
followAnchor(target, second, () => listNode);
stopFirst();
expect(listeners).toHaveLength(2);
fire("scroll", outside);
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,105 @@
// placeListbox — where the open list of a combobox goes (#429).
//
// `CategoryCombobox` renders its open list in a portal on `document.body`,
// with `position: fixed`, so no scrolling container clips it: the body of the
// split window, the wrapper of the Transactions table, a modal. What does not
// need a DOM lives here, so vitest's node environment can pin it: the
// placement rule, and the listeners that keep the list on its input.
/** Maximum height of the list, in pixels (Tailwind's `max-h-60`). */
export const LISTBOX_MAX_HEIGHT = 240;
/** Gap between the input and the list, in pixels (the former `mt-1`). */
export const LISTBOX_GAP = 4;
/** What the placement reads of the input: a `DOMRect` fits. */
export interface ListboxAnchor {
top: number;
bottom: number;
left: number;
width: number;
}
/**
* Fixed position of the list, in viewport pixels, as wide as the input.
* Below the input, `top` is the list's top edge. Above it, `bottom` is the
* distance from the viewport's bottom edge to the list's bottom edge, so a
* list shorter than its maximum height still ends right above the input.
*/
export interface ListboxPlacement {
left: number;
width: number;
openUpward: boolean;
top?: number;
bottom?: number;
}
export interface PlaceListboxInput {
anchor: ListboxAnchor;
viewportHeight: number;
maxHeight?: number;
gap?: number;
}
/**
* Under the input, unless the list does not fit below and does fit above:
* then above. With room on neither side, it stays below, its usual place.
*/
export function placeListbox({
anchor,
viewportHeight,
maxHeight = LISTBOX_MAX_HEIGHT,
gap = LISTBOX_GAP,
}: PlaceListboxInput): ListboxPlacement {
const spaceBelow = viewportHeight - anchor.bottom - gap;
const spaceAbove = anchor.top - gap;
const base = { left: anchor.left, width: anchor.width };
if (spaceBelow < maxHeight && spaceAbove >= maxHeight) {
return { ...base, openUpward: true, bottom: viewportHeight - anchor.top + gap };
}
return { ...base, openUpward: false, top: anchor.bottom + gap };
}
/** Same position: a scroll that does not move the input renders nothing again. */
export function samePlacement(a: ListboxPlacement | null, b: ListboxPlacement): boolean {
return (
a !== null &&
a.left === b.left &&
a.width === b.width &&
a.openUpward === b.openUpward &&
a.top === b.top &&
a.bottom === b.bottom
);
}
/** The part of `window` the listeners use, so a test can pass a fake. */
export interface ListenerTarget {
addEventListener(type: string, listener: (event: Event) => void, capture?: boolean): void;
removeEventListener(type: string, listener: (event: Event) => void, capture?: boolean): void;
}
/**
* Keeps the list on its input while it is open: `onMove` runs on every resize
* and on every scroll, of the page or of any scrolling container (a modal's
* body, the page's main area), hence the capture phase, since `scroll` does
* not bubble. A scroll of the list itself is skipped: its options moving do
* not move the input. Returns the cleanup, which removes exactly these
* listeners, capture flag included.
*/
export function followAnchor(
target: ListenerTarget,
onMove: () => void,
list: () => Pick<Node, "contains"> | null,
): () => void {
const onScroll = (event: Event) => {
if (list()?.contains(event.target as Node | null)) return;
onMove();
};
const onResize = () => onMove();
target.addEventListener("scroll", onScroll, true);
target.addEventListener("resize", onResize);
return () => {
target.removeEventListener("scroll", onScroll, true);
target.removeEventListener("resize", onResize);
};
}

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) });

View file

@ -1184,7 +1184,7 @@
],
"steps": [
"Click New Adjustment to create an adjustment group",
"Add entries with a description, amount, date, and category",
"Add entries with a description, amount, date, and category (type a few letters to filter the category list)",
"Toggle the recurring flag if the adjustment should repeat each period",
"View the Transaction Splits section to see splits created from the Transactions page"
],

View file

@ -1184,7 +1184,7 @@
],
"steps": [
"Cliquez sur Nouvel ajustement pour créer un groupe d'ajustement",
"Ajoutez des entrées avec une description, un montant, une date et une catégorie",
"Ajoutez des entrées avec une description, un montant, une date et une catégorie (tapez quelques lettres pour filtrer la liste des catégories)",
"Activez le drapeau récurrent si l'ajustement doit se répéter à chaque période",
"Consultez la section Répartitions de transactions pour voir les splits créés depuis la page Transactions"
],