feat: category combobox list in a portal, combobox in adjustment lines and the transfers filter (#429) #471
17 changed files with 863 additions and 40 deletions
|
|
@ -5,6 +5,7 @@
|
||||||
### Modifié
|
### 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).
|
- 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 elle ne tient pas en dessous mais tient au-dessus (#429).
|
||||||
|
|
||||||
## [0.17.2] - 2026-10-18
|
## [0.17.2] - 2026-10-18
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
### Changed
|
### 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).
|
- 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 it does not fit below but fits above (#429).
|
||||||
|
|
||||||
## [0.17.2] - 2026-10-18
|
## [0.17.2] - 2026-10-18
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
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 (« Portals are not currently supported by the server renderer »), et l'environnement node de vitest n'a pas de `document` (sans élément DOM, `createPortal` lève « Target container is not a DOM element »). Une fenêtre rendue par `createPortal(…, document.body)` (`LinkTransfersModal`, et de même `StarterAccountsModal`, `DetailAccountWizard`…) ne se teste donc qu'en remplaçant `createPortal` par l'identité (`vi.mock("react-dom", …)`, le reste du module gardé) et en posant 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
|
## 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.
|
Quatre workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** : les jobs se suivent, ils ne tournent pas en parallèle.
|
||||||
|
|
|
||||||
|
|
@ -309,7 +309,7 @@ Ajoutez des entrées manuelles non issues de vos relevés bancaires, et consulte
|
||||||
### Comment faire
|
### Comment faire
|
||||||
|
|
||||||
1. Cliquez sur Nouvel ajustement pour créer un groupe d'ajustement
|
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
|
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
|
4. Consultez la section Répartitions de transactions pour voir les splits créés depuis la page Transactions
|
||||||
|
|
||||||
|
|
|
||||||
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: "" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
69
src/components/balance/LinkTransfersModal.render.test.tsx
Normal file
69
src/components/balance/LinkTransfersModal.render.test.tsx
Normal 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 /);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
//
|
//
|
||||||
// Filters available:
|
// Filters available:
|
||||||
// - Period (from / to ISO dates) — default: last 90 days.
|
// - Period (from / to ISO dates) — default: last 90 days.
|
||||||
// - Category dropdown.
|
// - Category combobox, "any category" first (#429).
|
||||||
// - Free-text search on description.
|
// - Free-text search on description.
|
||||||
//
|
//
|
||||||
// Each row shows: date, description, amount, suggested direction
|
// Each row shows: date, description, amount, suggested direction
|
||||||
|
|
@ -29,6 +29,7 @@ import type {
|
||||||
TransactionRow,
|
TransactionRow,
|
||||||
BalanceTransferDirection,
|
BalanceTransferDirection,
|
||||||
} from "../../shared/types";
|
} from "../../shared/types";
|
||||||
|
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 100;
|
const DEFAULT_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
|
@ -49,7 +50,7 @@ export interface LinkTransfersModalProps {
|
||||||
/** Account that the selected transfers will be attached to. */
|
/** Account that the selected transfers will be attached to. */
|
||||||
accountId: number;
|
accountId: number;
|
||||||
accountName: string;
|
accountName: string;
|
||||||
/** Full category list for the filter dropdown. */
|
/** Categories offered by the filter combobox. */
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
/** Optional pre-fill date bounds (defaults to last 90 days). */
|
/** Optional pre-fill date bounds (defaults to last 90 days). */
|
||||||
initialFrom?: string;
|
initialFrom?: string;
|
||||||
|
|
@ -96,6 +97,13 @@ export default function LinkTransfersModal({
|
||||||
[i18n.language]
|
[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
|
// 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.
|
// — typing in the search box re-runs the SQL but at < 500 rows that's fine.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -256,20 +264,16 @@ export default function LinkTransfersModal({
|
||||||
<span className="block text-[var(--muted-foreground)] mb-1">
|
<span className="block text-[var(--muted-foreground)] mb-1">
|
||||||
{t("balance.transfers.modal.category")}
|
{t("balance.transfers.modal.category")}
|
||||||
</span>
|
</span>
|
||||||
<select
|
<CategoryCombobox
|
||||||
value={categoryId === null ? "" : String(categoryId)}
|
categories={categories}
|
||||||
onChange={(e) =>
|
value={categoryId}
|
||||||
setCategoryId(e.target.value === "" ? null : Number(e.target.value))
|
onChange={(id) => setCategoryId(id)}
|
||||||
}
|
compact
|
||||||
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
|
ariaLabel={t("balance.transfers.modal.category")}
|
||||||
>
|
extraOptions={categoryExtras}
|
||||||
<option value="">{t("balance.transfers.modal.anyCategory")}</option>
|
activeExtra={categoryId === null ? "" : null}
|
||||||
{categories.map((c) => (
|
onExtraSelect={() => setCategoryId(null)}
|
||||||
<option key={c.id} value={c.id}>
|
/>
|
||||||
{c.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="text-xs">
|
<label className="text-xs">
|
||||||
<span className="block text-[var(--muted-foreground)] mb-1">
|
<span className="block text-[var(--muted-foreground)] mb-1">
|
||||||
|
|
|
||||||
129
src/components/balance/LinkTransfersModal.wiring.test.ts
Normal file
129
src/components/balance/LinkTransfersModal.wiring.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
LISTBOX_MAX_HEIGHT,
|
||||||
|
followAnchor,
|
||||||
|
placeListbox,
|
||||||
|
samePlacement,
|
||||||
|
type ListboxPlacement,
|
||||||
|
} from "./placeListbox";
|
||||||
/**
|
/**
|
||||||
* Minimal shape the combobox needs. `Category` (transactions, reports) and
|
* Minimal shape the combobox needs. `Category` (transactions, reports) and
|
||||||
* `CategoryTreeNode` (categories page) both satisfy it, so callers pass their
|
* `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 [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [highlightIndex, setHighlightIndex] = useState(0);
|
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||||
|
const [placement, setPlacement] = useState<ListboxPlacement | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const listRef = useRef<HTMLUListElement>(null);
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -187,10 +196,36 @@ export default function CategoryCombobox<T extends CategoryLike = CategoryLike>(
|
||||||
}
|
}
|
||||||
}, [highlightIndex, open]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const handler = (e: MouseEvent) => {
|
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);
|
setOpen(false);
|
||||||
setQuery("");
|
setQuery("");
|
||||||
}
|
}
|
||||||
|
|
@ -277,12 +312,20 @@ export default function CategoryCombobox<T extends CategoryLike = CategoryLike>(
|
||||||
onKeyDown={handleKeyDown}
|
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)]`}
|
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
|
<ul
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
id={listboxId}
|
id={listboxId}
|
||||||
role="listbox"
|
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) => (
|
{filteredExtras.map((opt, i) => (
|
||||||
<li
|
<li
|
||||||
|
|
@ -327,7 +370,8 @@ export default function CategoryCombobox<T extends CategoryLike = CategoryLike>(
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
175
src/components/shared/placeListbox.test.ts
Normal file
175
src/components/shared/placeListbox.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
105
src/components/shared/placeListbox.ts
Normal file
105
src/components/shared/placeListbox.ts
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
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) });
|
||||||
|
|
|
||||||
|
|
@ -1184,7 +1184,7 @@
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Click New Adjustment to create an adjustment group",
|
"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",
|
"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 the Transaction Splits section to see splits created from the Transactions page"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1184,7 +1184,7 @@
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Cliquez sur Nouvel ajustement pour créer un groupe d'ajustement",
|
"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",
|
"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"
|
"Consultez la section Répartitions de transactions pour voir les splits créés depuis la page Transactions"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue