feat(categories): type-ahead combobox for the parent and keyword category fields
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m57s

Fixes #423

The "Parent category" field of CategoryForm and the "Category" field of
AddKeywordDialog were native selects with no typing. Both now use
CategoryCombobox, like the category cell of the Transactions page: typed
letters filter the list, which is ordered hierarchically with indentation
and shows seed categories through their i18n_key.

- CategoryCombobox accepts a minimal structural type (CategoryLike), so
  CategoryTreeNode rows pass without conversion; sortHierarchical is
  generic over it. Callers are unchanged.
- buildParentOptions (pure, tested) keeps roots and level-1 categories,
  leaves out the edited category, and keeps only roots when the edited
  category has children. That rule alone also leaves out its descent (a
  descendant is never a root), so no separate subtree walk is needed. The
  parent currently stored is always kept so the field never looks blank.
  CategoryForm gets an editingId prop, passed by CategoryDetailPanel.
- AddKeywordDialog reads the Transactions list (active and inputable,
  decision D2) through loadKeywordCategories, keeps the pre-filled
  category only when it is in that list (reconcileInitialCategory), and
  holds the apply button until the list is in. Its two fields sit above
  the scrolling block of matches, so the open list is not clipped by the
  scrollport.
- Tests: parentOptions, keywordCategory, and static renders of both
  components. Changelogs and the user guide (with its i18n mirror).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-12 17:22:31 -04:00
parent 1954502d10
commit 4bdb00402e
15 changed files with 512 additions and 92 deletions

View file

@ -2,6 +2,10 @@
## [Non publié] ## [Non publié]
### Modifié
- Le champ « Catégorie parente » du formulaire de catégorie et le champ « Catégorie » du dialogue « Nouveau mot-clé » (clic droit sur une transaction) acceptent 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 avec son indentation et les noms des catégories standard dans votre langue. La liste des parents ne propose plus la catégorie en cours d'édition, ses propres sous-catégories, ni un parent qui créerait un quatrième niveau ; un parent déjà enregistré reste affiché. Le dialogue de mot-clé propose les mêmes catégories que la page Transactions (actives et autorisées à la saisie) et ne garde la catégorie pré-remplie que si elle en fait partie ; ses deux champs sont placés au-dessus de la liste défilante des correspondances, pour que la liste de catégories ouverte ne soit jamais coupée (#423).
### Corrigé ### Corrigé
- Les installations faites depuis le paquet `.rpm` reçoivent désormais les mises à jour automatiques. Le manifeste de mise à jour ne listait que le `.deb`, qu'une installation `.rpm` téléchargeait puis refusait ; il liste maintenant chaque paquet Linux sous sa propre clé. Les installations `.rpm` existantes n'ont rien à réinstaller : elles recevront la prochaine version (vérifié sur le paquet v0.17.1 publié). Les instructions de téléchargement de la page de release ne mentionnent plus d'AppImage, qui n'est pas construite, et nomment désormais le `.rpm`. Le manifeste n'a plus d'entrée Linux générique : les AppImage des versions 0.4.3 à 0.4.5, qui ne savaient pas installer le `.deb` qu'elle désignait et en étaient cassées, affichent désormais une erreur à la vérification des mises à jour ; installez plutôt le paquet `.deb` ou `.rpm` (#320). - Les installations faites depuis le paquet `.rpm` reçoivent désormais les mises à jour automatiques. Le manifeste de mise à jour ne listait que le `.deb`, qu'une installation `.rpm` téléchargeait puis refusait ; il liste maintenant chaque paquet Linux sous sa propre clé. Les installations `.rpm` existantes n'ont rien à réinstaller : elles recevront la prochaine version (vérifié sur le paquet v0.17.1 publié). Les instructions de téléchargement de la page de release ne mentionnent plus d'AppImage, qui n'est pas construite, et nomment désormais le `.rpm`. Le manifeste n'a plus d'entrée Linux générique : les AppImage des versions 0.4.3 à 0.4.5, qui ne savaient pas installer le `.deb` qu'elle désignait et en étaient cassées, affichent désormais une erreur à la vérification des mises à jour ; installez plutôt le paquet `.deb` ou `.rpm` (#320).

View file

@ -2,6 +2,10 @@
## [Unreleased] ## [Unreleased]
### Changed
- The "Parent category" field of the category form and the "Category" field of the "New keyword" dialog (right-click on a transaction) now take typing, like the category cell of the Transactions page: type a few letters to filter the list, which shows the hierarchy with indentation and the names of the standard categories in your language. The parent list no longer offers the category being edited, its own sub-categories, or a parent that would create a fourth level; a parent already stored stays shown. The keyword dialog offers the same categories as the Transactions page (active and allowed for input) and keeps the pre-filled category only when it is among them; its two fields sit above the scrolling list of matches, so the open category list is never cut (#423).
### Fixed ### Fixed
- Installs from the `.rpm` package now receive automatic updates. The update manifest only listed the `.deb`, which an `.rpm` install downloaded and then refused; it now lists each Linux package under its own key. Existing `.rpm` installs need no reinstall: they will receive the next version (checked on the published v0.17.1 package). The download instructions of the release page no longer mention an AppImage, which is not built, and now name the `.rpm`. The manifest no longer has a generic Linux entry: the AppImage builds of 0.4.3 to 0.4.5, which could not install the `.deb` it pointed to and were broken by it, now report an error when checking for updates instead; install the `.deb` or `.rpm` package (#320). - Installs from the `.rpm` package now receive automatic updates. The update manifest only listed the `.deb`, which an `.rpm` install downloaded and then refused; it now lists each Linux package under its own key. Existing `.rpm` installs need no reinstall: they will receive the next version (checked on the published v0.17.1 package). The download instructions of the release page no longer mention an AppImage, which is not built, and now name the `.rpm`. The manifest no longer has a generic Linux entry: the AppImage builds of 0.4.3 to 0.4.5, which could not install the `.deb` it pointed to and were broken by it, now report an error when checking for updates instead; install the `.deb` or `.rpm` package (#320).

View file

@ -275,7 +275,7 @@ Gérez votre arbre de catégories avec des sous-catégories, des règles de mots
### Comment faire ### Comment faire
1. Cliquez sur Ajouter une catégorie pour créer une nouvelle catégorie — choisissez un nom, un type et un parent optionnel 1. Cliquez sur Ajouter une catégorie pour créer une nouvelle catégorie — choisissez un nom, un type et un parent optionnel (tapez quelques lettres pour filtrer la liste des parents)
2. Sélectionnez une catégorie dans l'arbre pour voir ses détails et sa liste de mots-clés 2. Sélectionnez une catégorie dans l'arbre pour voir ses détails et sa liste de mots-clés
3. Glissez-déposez une catégorie dans l'arbre pour la réordonner ou la déplacer sous un autre parent 3. Glissez-déposez une catégorie dans l'arbre pour la réordonner ou la déplacer sous un autre parent
4. Ajoutez des mots-clés correspondant aux descriptions des transactions pour l'auto-catégorisation 4. Ajoutez des mots-clés correspondant aux descriptions des transactions pour l'auto-catégorisation
@ -450,7 +450,7 @@ Choisissez une catégorie dans la combobox en haut. Par défaut le rapport inclu
**Clic droit** sur n'importe quelle transaction (dans le zoom catégorie, la liste des faits saillants, ou la page Transactions) ouvre un menu *Ajouter comme mot-clé*. Un dialog affiche : **Clic droit** sur n'importe quelle transaction (dans le zoom catégorie, la liste des faits saillants, ou la page Transactions) ouvre un menu *Ajouter comme mot-clé*. Un dialog affiche :
1. Une **prévisualisation** des transactions qui seront recatégorisées (jusqu'à 50 visibles avec cases à cocher individuelles — les matches au-delà de 50 peuvent être appliqués via une case explicite) 1. Une **prévisualisation** des transactions qui seront recatégorisées (jusqu'à 50 visibles avec cases à cocher individuelles — les matches au-delà de 50 peuvent être appliqués via une case explicite)
2. Un sélecteur de catégorie cible 2. Un sélecteur de catégorie cible, où taper quelques lettres filtre la liste
3. Un bouton **Appliquer et recatégoriser** 3. Un bouton **Appliquer et recatégoriser**
L'application est atomique : soit toutes les transactions cochées sont recatégorisées et le mot-clé enregistré, soit rien n'est fait. Si le mot-clé existait déjà pour une autre catégorie, un prompt vous demande si vous voulez le réassigner — cela ne touche **pas** l'historique, seulement les transactions visibles cochées. L'application est atomique : soit toutes les transactions cochées sont recatégorisées et le mot-clé enregistré, soit rien n'est fait. Si le mot-clé existait déjà pour une autre catégorie, un prompt vous demande si vous voulez le réassigner — cela ne touche **pas** l'historique, seulement les transactions visibles cochées.

View file

@ -0,0 +1,72 @@
// AddKeywordDialog — the category field is a combobox placed outside the
// scrolling block (#423). No jsdom: react-dom/server renders the initial
// markup, before any effect runs, in vitest's node environment.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
i18n: { language: "fr" },
}),
}));
vi.mock("./keywordCategory", () => ({
loadKeywordCategories: vi.fn(() => new Promise(() => undefined)),
reconcileInitialCategory: vi.fn(() => null),
}));
vi.mock("../../services/categorizationService", () => ({
KEYWORD_MAX_LENGTH: 50,
KEYWORD_MIN_LENGTH: 3,
KEYWORD_PREVIEW_LIMIT: 50,
applyKeywordWithReassignment: vi.fn(),
previewKeywordMatches: vi.fn(() => new Promise(() => undefined)),
validateKeyword: (raw: string) => {
const value = raw.trim();
return value.length >= 3 ? { ok: true, value } : { ok: false, reason: "tooShort" };
},
}));
import AddKeywordDialog from "./AddKeywordDialog";
const markup = (initialCategoryId: number | null = null) =>
renderToStaticMarkup(
<AddKeywordDialog
initialKeyword="METRO"
initialCategoryId={initialCategoryId}
onClose={vi.fn()}
onApplied={vi.fn()}
/>,
);
describe("AddKeywordDialog — category field (#423)", () => {
it("renders the category as a combobox and no select", () => {
const html = markup();
expect(html.match(/<input [^>]*role="combobox"[^>]*>/g) ?? []).toHaveLength(1);
expect(html).not.toContain("<select");
});
it("starts empty, with the 'no category' placeholder, until the list is in", () => {
const [input] = markup(7).match(/<input [^>]*role="combobox"[^>]*>/g) ?? [];
expect(input).toContain('value=""');
expect(input).toContain('placeholder="transactions.table.noCategory"');
});
it("keeps the apply button disabled before the list is in", () => {
const html = markup(7);
const [apply] = html.match(/<button [^>]*>reports\.keyword\.applyAndRecategorize<\/button>/g) ?? [];
expect(apply).toBeDefined();
expect(apply).toContain("disabled");
});
it("places the combobox before any scrolling block, so its list is not clipped", () => {
const html = markup();
const at = html.indexOf('role="combobox"');
expect(at).toBeGreaterThan(0);
expect(html.slice(0, at)).not.toContain("overflow-y-auto");
// The preview block below still scrolls on its own.
expect(html.slice(at)).toContain("overflow-y-auto");
});
});

View file

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { RecentTransaction } from "../../shared/types"; import type { Category, RecentTransaction } from "../../shared/types";
import { import {
KEYWORD_MAX_LENGTH, KEYWORD_MAX_LENGTH,
KEYWORD_MIN_LENGTH, KEYWORD_MIN_LENGTH,
@ -9,12 +9,8 @@ import {
previewKeywordMatches, previewKeywordMatches,
validateKeyword, validateKeyword,
} from "../../services/categorizationService"; } from "../../services/categorizationService";
import { getAllCategoriesWithCounts } from "../../services/categoryService"; import CategoryCombobox from "../shared/CategoryCombobox";
import { loadKeywordCategories, reconcileInitialCategory } from "./keywordCategory";
interface CategoryOption {
id: number;
name: string;
}
export interface AddKeywordDialogProps { export interface AddKeywordDialogProps {
initialKeyword: string; initialKeyword: string;
@ -37,8 +33,11 @@ export default function AddKeywordDialog({
}: AddKeywordDialogProps) { }: AddKeywordDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [keyword, setKeyword] = useState(initialKeyword); const [keyword, setKeyword] = useState(initialKeyword);
const [categoryId, setCategoryId] = useState<number | null>(initialCategoryId); // Held at null until the list is in: the pre-filled category is only kept
const [categories, setCategories] = useState<CategoryOption[]>([]); // when it is among the categories the dialog offers (`reconcileInitialCategory`).
const [categoryId, setCategoryId] = useState<number | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [categoriesLoaded, setCategoriesLoaded] = useState(false);
const [checked, setChecked] = useState<Set<number>>(new Set()); const [checked, setChecked] = useState<Set<number>>(new Set());
const [applyToHidden, setApplyToHidden] = useState(false); const [applyToHidden, setApplyToHidden] = useState(false);
const [preview, setPreview] = useState<PreviewState>({ kind: "idle" }); const [preview, setPreview] = useState<PreviewState>({ kind: "idle" });
@ -50,10 +49,24 @@ export default function AddKeywordDialog({
const validation = useMemo(() => validateKeyword(keyword), [keyword]); const validation = useMemo(() => validateKeyword(keyword), [keyword]);
useEffect(() => { useEffect(() => {
getAllCategoriesWithCounts() let cancelled = false;
.then((rows) => setCategories(rows.map((r) => ({ id: r.id, name: r.name })))) loadKeywordCategories()
.catch(() => setCategories([])); .then((rows) => {
}, []); if (cancelled) return;
setCategories(rows);
setCategoryId(reconcileInitialCategory(initialCategoryId, rows));
setCategoriesLoaded(true);
})
.catch(() => {
if (cancelled) return;
setCategories([]);
setCategoryId(null);
setCategoriesLoaded(true);
});
return () => {
cancelled = true;
};
}, [initialCategoryId]);
useEffect(() => { useEffect(() => {
if (!validation.ok) { if (!validation.ok) {
@ -88,6 +101,7 @@ export default function AddKeywordDialog({
const canApply = const canApply =
validation.ok && validation.ok &&
categoriesLoaded &&
categoryId !== null && categoryId !== null &&
preview.kind === "ready" && preview.kind === "ready" &&
!isApplying && !isApplying &&
@ -135,7 +149,10 @@ export default function AddKeywordDialog({
<h2 className="text-base font-semibold">{t("reports.keyword.dialogTitle")}</h2> <h2 className="text-base font-semibold">{t("reports.keyword.dialogTitle")}</h2>
</header> </header>
<div className="px-5 py-4 flex flex-col gap-4 overflow-y-auto"> {/* The two fields sit outside the scrolling block, so the open category
list is drawn over the preview below instead of being cut by its
scrollport. */}
<div className="px-5 pt-4 flex flex-col gap-4">
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide"> <span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.keyword.dialogTitle")} {t("reports.keyword.dialogTitle")}
@ -160,19 +177,16 @@ export default function AddKeywordDialog({
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide"> <span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.highlights.category")} {t("reports.highlights.category")}
</span> </span>
<select <CategoryCombobox
value={categoryId ?? ""} categories={categories}
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : null)} value={categoryId}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm" onChange={setCategoryId}
> placeholder={t("transactions.table.noCategory")}
<option value="">—</option> />
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label> </label>
</div>
<div className="px-5 pb-4 pt-4 flex flex-col gap-4 overflow-y-auto min-h-0">
<section> <section>
<h3 className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide mb-2"> <h3 className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide mb-2">

View file

@ -90,6 +90,7 @@ export default function CategoryDetailPanel({
<CategoryForm <CategoryForm
initialData={editingCategory} initialData={editingCategory}
categories={categories} categories={categories}
editingId={selectedCategory.id}
isCreating={false} isCreating={false}
isSaving={isSaving} isSaving={isSaving}
onSave={onSave} onSave={onSave}

View file

@ -0,0 +1,95 @@
// CategoryForm — the parent field is a combobox, not a select (#423). No jsdom:
// react-dom/server renders in vitest's node environment; the markup says what
// the closed combobox shows as its value.
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
i18n: { language: "fr" },
}),
}));
import CategoryForm from "./CategoryForm";
import type { CategoryFormData, CategoryTreeNode } from "../../shared/types";
const node = (
id: number,
parent_id: number | null,
name: string,
i18n_key: string | null = null,
): CategoryTreeNode => ({
id,
name,
parent_id,
color: null,
icon: null,
type: "expense",
is_active: true,
is_inputable: true,
sort_order: id,
keyword_count: 0,
i18n_key,
children: [],
});
const HOUSING = node(1, null, "Logement", "categoriesSeed.housing");
const RENT = node(10, 1, "Loyer");
const CATEGORIES = [HOUSING, RENT];
const form = (parent_id: number | null): CategoryFormData => ({
name: "Taxes",
type: "expense",
color: "#4A90A4",
parent_id,
is_inputable: true,
sort_order: 0,
});
const markup = (data: CategoryFormData, editingId?: number) =>
renderToStaticMarkup(
<CategoryForm
initialData={data}
categories={CATEGORIES}
editingId={editingId}
isCreating={editingId === undefined}
isSaving={false}
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
);
/** The parent combobox input (the only `role="combobox"` in the form). */
const comboboxInput = (html: string) => {
const inputs = html.match(/<input [^>]*role="combobox"[^>]*>/g) ?? [];
expect(inputs).toHaveLength(1);
return inputs[0];
};
describe("CategoryForm — parent field (#423)", () => {
it("renders the parent as a combobox labelled by the field name, with no parent select left", () => {
const html = markup(form(null));
const input = comboboxInput(html);
expect(input).toContain('aria-label="categories.parent"');
// Only the type select remains.
expect(html.match(/<select/g) ?? []).toHaveLength(1);
expect(html).not.toContain("categories.noParent</option>");
});
it("shows the 'no parent' option as the value when creating without a parent", () => {
expect(comboboxInput(markup(form(null)))).toContain('value="categories.noParent"');
});
it("shows the parent's translated name as the value when editing a category with a parent", () => {
// Seed rows translate through their i18n_key with the name as default value.
expect(comboboxInput(markup(form(1), 10))).toContain(
'value="categoriesSeed.housing[defaultValue=Logement]"',
);
});
it("shows a user-created parent by its name", () => {
expect(comboboxInput(markup(form(10), 42))).toContain('value="Loyer"');
});
});

View file

@ -1,7 +1,9 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import type { CategoryFormData, CategoryTreeNode } from "../../shared/types"; import type { CategoryFormData, CategoryTreeNode } from "../../shared/types";
import CategoryCombobox from "../shared/CategoryCombobox";
import { buildParentOptions } from "./parentOptions";
const PRESET_COLORS = [ const PRESET_COLORS = [
"#4A90A4", "#4A90A4",
@ -18,6 +20,8 @@ const PRESET_COLORS = [
interface Props { interface Props {
initialData: CategoryFormData; initialData: CategoryFormData;
categories: CategoryTreeNode[]; categories: CategoryTreeNode[];
/** Id of the category being edited; omitted when creating. */
editingId?: number | null;
isCreating: boolean; isCreating: boolean;
isSaving: boolean; isSaving: boolean;
onSave: (data: CategoryFormData) => void; onSave: (data: CategoryFormData) => void;
@ -28,6 +32,7 @@ interface Props {
export default function CategoryForm({ export default function CategoryForm({
initialData, initialData,
categories, categories,
editingId = null,
isCreating, isCreating,
isSaving, isSaving,
onSave, onSave,
@ -41,36 +46,16 @@ export default function CategoryForm({
setForm(initialData); setForm(initialData);
}, [initialData]); }, [initialData]);
// Allow level 0 and level 1 categories as parents (but not level 2, which would create a 4th level) // Roots and level-1 categories, minus the edited category and its descent;
// Also build indentation info // the combobox orders them hierarchically. See `buildParentOptions`.
const parentOptions: Array<CategoryTreeNode & { indent: number }> = []; const parentOptions = useMemo(
for (const cat of categories) { () => buildParentOptions(categories, { editingId, currentParentId: form.parent_id }),
if (cat.parent_id === null) { [categories, editingId, form.parent_id],
// Level 0 — always allowed as parent );
parentOptions.push({ ...cat, indent: 0 });
} // "No parent" sits first in the list and is how the field goes back to null:
} // the combobox itself never emits null.
for (const cat of categories) { const noParentExtra = useMemo(() => [{ value: "", label: t("categories.noParent") }], [t]);
if (cat.parent_id !== null) {
// Check if this category's parent is a root (making this level 1)
const parent = categories.find((c) => c.id === cat.parent_id);
if (parent && parent.parent_id === null) {
// Level 1 — allowed as parent (would create level 3 children)
parentOptions.push({ ...cat, indent: 1 });
}
// Level 2 categories are NOT shown (would create level 4)
}
}
// Sort to keep hierarchy order: group by root parent sort_order
parentOptions.sort((a, b) => {
const rootA = a.indent === 0 ? a : categories.find((c) => c.id === a.parent_id);
const rootB = b.indent === 0 ? b : categories.find((c) => c.id === b.parent_id);
const orderA = rootA?.sort_order ?? 999;
const orderB = rootB?.sort_order ?? 999;
if (orderA !== orderB) return orderA - orderB;
if (a.indent !== b.indent) return a.indent - b.indent;
return a.sort_order - b.sort_order;
});
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -130,20 +115,15 @@ export default function CategoryForm({
<div> <div>
<label className="block text-sm font-medium mb-1">{t("categories.parent")}</label> <label className="block text-sm font-medium mb-1">{t("categories.parent")}</label>
<select <CategoryCombobox
value={form.parent_id ?? ""} categories={parentOptions}
onChange={(e) => value={form.parent_id}
setForm({ ...form, parent_id: e.target.value ? Number(e.target.value) : null }) onChange={(id) => setForm({ ...form, parent_id: id })}
} extraOptions={noParentExtra}
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" activeExtra={form.parent_id === null ? "" : null}
> onExtraSelect={() => setForm({ ...form, parent_id: null })}
<option value="">{t("categories.noParent")}</option> ariaLabel={t("categories.parent")}
{parentOptions.map((c) => ( />
<option key={c.id} value={c.id}>
{c.indent > 0 ? "\u00A0\u00A0\u00A0\u00A0" : ""}{c.name}
</option>
))}
</select>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -0,0 +1,50 @@
// The category list of the "add a keyword" dialog and its pre-filled value (#423, D2).
import { describe, it, expect, vi, beforeEach } from "vitest";
// `vi.mock` is hoisted above the imports: the mocks must come from `vi.hoisted`.
const { getAllCategories, getAllCategoriesWithCounts } = vi.hoisted(() => ({
getAllCategories: vi.fn(),
getAllCategoriesWithCounts: vi.fn(),
}));
vi.mock("../../services/transactionService", () => ({ getAllCategories }));
vi.mock("../../services/categoryService", () => ({ getAllCategoriesWithCounts }));
import { loadKeywordCategories, reconcileInitialCategory } from "./keywordCategory";
const cats = [
{ id: 1, name: "Logement", sort_order: 1 },
{ id: 7, name: "Épicerie", sort_order: 2 },
];
describe("loadKeywordCategories", () => {
beforeEach(() => {
getAllCategories.mockReset();
getAllCategoriesWithCounts.mockReset();
});
it("reads the Transactions list (active and inputable), not the full category list", async () => {
getAllCategories.mockResolvedValue(cats);
await expect(loadKeywordCategories()).resolves.toBe(cats);
expect(getAllCategories).toHaveBeenCalledTimes(1);
expect(getAllCategoriesWithCounts).not.toHaveBeenCalled();
});
});
describe("reconcileInitialCategory", () => {
it("keeps the pre-filled category when it is in the list", () => {
expect(reconcileInitialCategory(7, cats)).toBe(7);
});
it("drops a pre-filled category that is not in the list", () => {
expect(reconcileInitialCategory(42, cats)).toBeNull();
});
it("returns null without a pre-filled category", () => {
expect(reconcileInitialCategory(null, cats)).toBeNull();
expect(reconcileInitialCategory(undefined, cats)).toBeNull();
});
it("returns null when the list is empty", () => {
expect(reconcileInitialCategory(7, [])).toBeNull();
});
});

View file

@ -0,0 +1,25 @@
import type { Category } from "../../shared/types";
import { getAllCategories } from "../../services/transactionService";
import type { CategoryLike } from "../shared/CategoryCombobox";
/**
* Categories offered by the "add a keyword" dialog: the same list as the
* category cell of the Transactions page (active and inputable), so a keyword
* can only target a category the user can also pick by hand (#423, D2).
*/
export function loadKeywordCategories(): Promise<Category[]> {
return getAllCategories();
}
/**
* The category pre-filled by the right-click menu is kept only when it is in
* the list; a category the user cannot see (hidden from input, inactive) is
* dropped so the dialog never applies an id that is not shown.
*/
export function reconcileInitialCategory(
initialId: number | null | undefined,
categories: CategoryLike[],
): number | null {
if (initialId == null) return null;
return categories.some((c) => c.id === initialId) ? initialId : null;
}

View file

@ -0,0 +1,92 @@
// buildParentOptions — which categories the category form offers as a parent (#423).
import { describe, it, expect } from "vitest";
import { buildParentOptions } from "./parentOptions";
import type { CategoryTreeNode } from "../../shared/types";
const node = (id: number, parent_id: number | null, name = `c${id}`): CategoryTreeNode => ({
id,
name,
parent_id,
color: null,
icon: null,
type: "expense",
is_active: true,
is_inputable: true,
sort_order: id,
keyword_count: 0,
i18n_key: null,
children: [],
});
// Two roots. Root 1 has a child (10) with its own child (100), and a leaf child (11).
// Root 2 has a leaf child (20).
const TREE = [node(1, null), node(10, 1), node(100, 10), node(11, 1), node(2, null), node(20, 2)];
const ids = (list: CategoryTreeNode[]) => list.map((c) => c.id).sort((a, b) => a - b);
describe("buildParentOptions — creating a category", () => {
it("offers roots and level-1 categories, never level 2", () => {
expect(ids(buildParentOptions(TREE))).toEqual([1, 2, 10, 11, 20]);
});
it("offers roots alone when there is nothing deeper", () => {
expect(ids(buildParentOptions([node(1, null), node(2, null)]))).toEqual([1, 2]);
});
it("treats a category whose parent is missing as a root", () => {
// 10's parent (1) is not in the list: 10 is a root, 100 a level-1 child.
expect(ids(buildParentOptions([node(10, 1), node(100, 10), node(1000, 100)]))).toEqual([10, 100]);
});
it("returns [] for an empty list", () => {
expect(buildParentOptions([])).toEqual([]);
});
});
describe("buildParentOptions — editing a category", () => {
it("leaves out the edited category itself", () => {
expect(ids(buildParentOptions(TREE, { editingId: 11 }))).toEqual([1, 2, 10, 20]);
});
it("leaves out the whole descent of the edited category", () => {
// Editing root 1, which has children: only roots remain, so none of its
// descent (10, 11, 100) can be offered, whatever its depth.
expect(ids(buildParentOptions(TREE, { editingId: 1 }))).toEqual([2]);
// Editing 10 (child 100): a level-1 category with a child; 100 stays out.
expect(ids(buildParentOptions(TREE, { editingId: 10 }))).not.toContain(100);
});
it("keeps only roots when the edited category has children", () => {
// 10 has a child (100): under a level-1 parent its child would sit at level 3.
expect(ids(buildParentOptions(TREE, { editingId: 10 }))).toEqual([1, 2]);
});
it("keeps level-1 categories when the edited category is a leaf", () => {
expect(ids(buildParentOptions(TREE, { editingId: 20 }))).toEqual([1, 2, 10, 11]);
});
it("always keeps the parent currently set on the form, even one the rules would drop", () => {
// Legacy data: 1000 sits under 100 (level 3). Editing 1000, its parent 100 is
// level 2 and would be dropped; it stays so the field shows what is stored.
const deep = [...TREE, node(1000, 100)];
expect(ids(buildParentOptions(deep, { editingId: 1000, currentParentId: 100 }))).toEqual([
1, 2, 10, 11, 20, 100,
]);
});
it("does not re-add the edited category as its own current parent", () => {
expect(ids(buildParentOptions(TREE, { editingId: 11, currentParentId: 11 }))).toEqual([1, 2, 10, 20]);
});
it("ignores a current parent that is not in the list", () => {
expect(ids(buildParentOptions(TREE, { editingId: 11, currentParentId: 999 }))).toEqual([1, 2, 10, 20]);
});
it("terminates on a cycle and never offers the edited category", () => {
// Which members of the cycle are offered is not specified (#393 repairs
// cycles); the walk must end, and 3 must stay out.
const cyclic = [node(1, 2), node(2, 1), node(3, null)];
const offered = ids(buildParentOptions(cyclic, { editingId: 3 }));
expect(offered).not.toContain(3);
expect(offered.every((id) => id === 1 || id === 2)).toBe(true);
});
});

View file

@ -0,0 +1,71 @@
import type { CategoryTreeNode } from "../../shared/types";
/** Deepest level a category may sit at: root (0), child (1), grandchild (2). */
const MAX_PARENT_DEPTH = 1;
export interface ParentOptionsContext {
/** Id of the category being edited; absent or null when creating. */
editingId?: number | null;
/**
* Parent currently stored on the form. Always kept in the options when it
* exists, so a legacy or inconsistent parent never leaves the field blank
* while `parent_id` is set.
*/
currentParentId?: number | null;
}
function depthsOf(categories: CategoryTreeNode[]): Map<number, number> {
const byId = new Map<number, CategoryTreeNode>();
for (const c of categories) byId.set(c.id, c);
const depths = new Map<number, number>();
const depthOf = (id: number, seen: Set<number>): number => {
const known = depths.get(id);
if (known !== undefined) return known;
if (seen.has(id)) return 0; // cycle: treat as a root rather than loop
seen.add(id);
const cat = byId.get(id);
const parentId = cat?.parent_id ?? null;
// A missing parent (orphan) counts as a root, like the tree and the combobox do.
const d = parentId === null || !byId.has(parentId) ? 0 : depthOf(parentId, seen) + 1;
depths.set(id, d);
return d;
};
for (const c of categories) depthOf(c.id, new Set());
return depths;
}
/**
* Categories that may be chosen as the parent in the category form.
*
* - Roots and level-1 categories only: a level-2 parent would create a fourth
* level, which nothing else in the app knows how to display.
* - When editing, the edited category is left out, and when it has children of
* its own, only roots remain (its children would otherwise land at level 3).
* This alone also leaves out its whole descent, which would create a cycle
* that hides the branch everywhere (#393): a category with descendants has
* children, so only roots are offered, and a descendant is never a root.
* - The parent currently set on the form is always kept when it exists.
*
* Ordering is left to the caller (`CategoryCombobox` sorts hierarchically).
*/
export function buildParentOptions(
categories: CategoryTreeNode[],
ctx: ParentOptionsContext = {},
): CategoryTreeNode[] {
const editingId = ctx.editingId ?? null;
const currentParentId = ctx.currentParentId ?? null;
const depths = depthsOf(categories);
const editedHasChildren =
editingId !== null && categories.some((c) => c.parent_id === editingId);
const maxDepth = editedHasChildren ? 0 : MAX_PARENT_DEPTH;
return categories.filter((c) => {
if (c.id === editingId) return false;
// Kept even when the depth rule or the descent rule would drop it: the
// field must show what is stored. Saving an invalid parent is refused by
// the service-side validation of #393, not here.
if (c.id === currentParentId) return true;
return (depths.get(c.id) ?? 0) <= maxDepth;
});
}

View file

@ -1,9 +1,21 @@
import { useState, useRef, useEffect, useCallback, useId, useMemo } from "react"; import { useState, useRef, useEffect, useCallback, useId, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Category } from "../../shared/types"; /**
* Minimal shape the combobox needs. `Category` (transactions, reports) and
* `CategoryTreeNode` (categories page) both satisfy it, so callers pass their
* own rows without converting: `parent_id` may be `null` or absent for a root,
* `i18n_key` names the seed translation when the row comes from the seed.
*/
export interface CategoryLike {
id: number;
name: string;
parent_id?: number | null;
sort_order: number;
i18n_key?: string | null;
}
interface CategoryComboboxProps { interface CategoryComboboxProps<T extends CategoryLike = CategoryLike> {
categories: Category[]; categories: T[];
value: number | null; value: number | null;
onChange: (id: number | null) => void; onChange: (id: number | null) => void;
placeholder?: string; placeholder?: string;
@ -23,8 +35,8 @@ function normalize(s: string): string {
} }
// Compute depth of each category based on parent_id chain // Compute depth of each category based on parent_id chain
function computeDepths(categories: Category[]): Map<number, number> { function computeDepths(categories: CategoryLike[]): Map<number, number> {
const byId = new Map<number, Category>(); const byId = new Map<number, CategoryLike>();
for (const c of categories) byId.set(c.id, c); for (const c of categories) byId.set(c.id, c);
const depths = new Map<number, number>(); const depths = new Map<number, number>();
function depthOf(id: number, seen: Set<number>): number { function depthOf(id: number, seen: Set<number>): number {
@ -58,18 +70,18 @@ function computeDepths(categories: Category[]): Map<number, number> {
* Orphans (category whose parent is missing or inactive / filtered out) are * Orphans (category whose parent is missing or inactive / filtered out) are
* emitted at the end, each treated as a pseudo-root, so nothing disappears. * emitted at the end, each treated as a pseudo-root, so nothing disappears.
*/ */
export function sortHierarchical( export function sortHierarchical<T extends CategoryLike>(
categories: Category[], categories: T[],
resolveName: (cat: Category) => string, resolveName: (cat: T) => string,
): Category[] { ): T[] {
if (categories.length === 0) return []; if (categories.length === 0) return [];
const ids = new Set<number>(); const ids = new Set<number>();
for (const c of categories) ids.add(c.id); for (const c of categories) ids.add(c.id);
// Group by parent bucket: root (`null`) or parent id. // Group by parent bucket: root (`null`) or parent id.
const childrenByParent = new Map<number | null, Category[]>(); const childrenByParent = new Map<number | null, T[]>();
const orphans: Category[] = []; const orphans: T[] = [];
for (const c of categories) { for (const c of categories) {
if (c.parent_id == null) { if (c.parent_id == null) {
const bucket = childrenByParent.get(null) ?? []; const bucket = childrenByParent.get(null) ?? [];
@ -84,16 +96,16 @@ export function sortHierarchical(
} }
} }
const compare = (a: Category, b: Category) => { const compare = (a: T, b: T) => {
if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order; if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order;
return resolveName(a).localeCompare(resolveName(b)); return resolveName(a).localeCompare(resolveName(b));
}; };
for (const bucket of childrenByParent.values()) bucket.sort(compare); for (const bucket of childrenByParent.values()) bucket.sort(compare);
orphans.sort(compare); orphans.sort(compare);
const out: Category[] = []; const out: T[] = [];
const visited = new Set<number>(); const visited = new Set<number>();
const visit = (cat: Category) => { const visit = (cat: T) => {
if (visited.has(cat.id)) return; // defensive against cycles if (visited.has(cat.id)) return; // defensive against cycles
visited.add(cat.id); visited.add(cat.id);
out.push(cat); out.push(cat);
@ -108,7 +120,7 @@ export function sortHierarchical(
return out; return out;
} }
export default function CategoryCombobox({ export default function CategoryCombobox<T extends CategoryLike = CategoryLike>({
categories, categories,
value, value,
onChange, onChange,
@ -118,7 +130,7 @@ export default function CategoryCombobox({
extraOptions, extraOptions,
onExtraSelect, onExtraSelect,
activeExtra, activeExtra,
}: CategoryComboboxProps) { }: CategoryComboboxProps<T>) {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@ -135,7 +147,7 @@ export default function CategoryCombobox({
// Resolve the display name for a category: seed rows carry an i18n_key that // Resolve the display name for a category: seed rows carry an i18n_key that
// we translate with name as defaultValue; user-created rows just use name. // we translate with name as defaultValue; user-created rows just use name.
const displayName = useCallback( const displayName = useCallback(
(c: Category) => (c.i18n_key ? t(c.i18n_key, { defaultValue: c.name }) : c.name), (c: T) => (c.i18n_key ? t(c.i18n_key, { defaultValue: c.name }) : c.name),
[t] [t]
); );

View file

@ -1125,7 +1125,7 @@
"Re-initialize categories to defaults" "Re-initialize categories to defaults"
], ],
"steps": [ "steps": [
"Click Add Category to create a new category — choose a name, type, and optional parent", "Click Add Category to create a new category — choose a name, type, and optional parent (type a few letters to filter the parent list)",
"Select a category in the tree to view its details and keyword list", "Select a category in the tree to view its details and keyword list",
"Drag and drop a category in the tree to reorder it or move it under a different parent", "Drag and drop a category in the tree to reorder it or move it under a different parent",
"Add keywords that match transaction descriptions for auto-categorization", "Add keywords that match transaction descriptions for auto-categorization",

View file

@ -1125,7 +1125,7 @@
"Réinitialiser les catégories aux valeurs par défaut" "Réinitialiser les catégories aux valeurs par défaut"
], ],
"steps": [ "steps": [
"Cliquez sur Ajouter une catégorie pour créer une nouvelle catégorie — choisissez un nom, un type et un parent optionnel", "Cliquez sur Ajouter une catégorie pour créer une nouvelle catégorie — choisissez un nom, un type et un parent optionnel (tapez quelques lettres pour filtrer la liste des parents)",
"Sélectionnez une catégorie dans l'arbre pour voir ses détails et sa liste de mots-clés", "Sélectionnez une catégorie dans l'arbre pour voir ses détails et sa liste de mots-clés",
"Glissez-déposez une catégorie dans l'arbre pour la réordonner ou la déplacer sous un autre parent", "Glissez-déposez une catégorie dans l'arbre pour la réordonner ou la déplacer sous un autre parent",
"Ajoutez des mots-clés correspondant aux descriptions des transactions pour l'auto-catégorisation", "Ajoutez des mots-clés correspondant aux descriptions des transactions pour l'auto-catégorisation",