feat(categories): type-ahead combobox for the parent and keyword category fields #428

Merged
maximus merged 3 commits from issue-423-category-combobox-parent-keyword into main 2026-10-12 22:47:34 +00:00
16 changed files with 664 additions and 92 deletions

View file

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

View file

@ -0,0 +1,84 @@
// CategoryDetailPanel — the edited category's id reaches the parent options (#423).
// No jsdom: react-dom/server renders 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("./parentOptions", async (importOriginal) => {
const actual = await importOriginal<typeof import("./parentOptions")>();
return { ...actual, buildParentOptions: vi.fn(actual.buildParentOptions) };
});
import CategoryDetailPanel from "./CategoryDetailPanel";
import { buildParentOptions } from "./parentOptions";
import type { CategoryFormData, CategoryTreeNode } from "../../shared/types";
const node = (id: number, parent_id: number | null, name: string): 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: [],
});
const HOUSING = node(1, null, "Logement");
const RENT = node(10, 1, "Loyer");
const CATEGORIES = [HOUSING, RENT];
const formOf = (c: CategoryTreeNode): CategoryFormData => ({
name: c.name,
type: c.type,
color: "#4A90A4",
parent_id: c.parent_id,
is_inputable: c.is_inputable,
sort_order: c.sort_order,
});
const markup = (selected: CategoryTreeNode | null, editing: CategoryFormData | null, isCreating: boolean) =>
renderToStaticMarkup(
<CategoryDetailPanel
categories={CATEGORIES}
selectedCategory={selected}
keywords={[]}
editingCategory={editing}
isCreating={isCreating}
isSaving={false}
onStartEditing={vi.fn()}
onCancelEditing={vi.fn()}
onSave={vi.fn()}
onDelete={vi.fn(async () => ({ blocked: false, count: 0 }))}
onAddKeyword={vi.fn()}
onUpdateKeyword={vi.fn()}
onRemoveKeyword={vi.fn()}
/>,
);
describe("CategoryDetailPanel — editingId wiring (#423)", () => {
it("passes the selected category's id when editing", () => {
vi.mocked(buildParentOptions).mockClear();
const html = markup(RENT, formOf(RENT), false);
expect(html).toContain('role="combobox"');
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: 10, currentParentId: 1 });
});
it("passes no id when creating", () => {
vi.mocked(buildParentOptions).mockClear();
const html = markup(null, formOf(node(0, null, "")), true);
expect(html).toContain('role="combobox"');
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: null, currentParentId: null });
});
});

View file

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

View file

@ -0,0 +1,114 @@
// 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" },
}),
}));
// Pass-through spy on the pure module, to pin what the form hands it.
vi.mock("./parentOptions", async (importOriginal) => {
const actual = await importOriginal<typeof import("./parentOptions")>();
return { ...actual, buildParentOptions: vi.fn(actual.buildParentOptions) };
});
import CategoryForm from "./CategoryForm";
import { buildParentOptions } from "./parentOptions";
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"');
});
it("hands the edited id and the stored parent to buildParentOptions", () => {
vi.mocked(buildParentOptions).mockClear();
markup(form(1), 10);
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: 10, currentParentId: 1 });
});
it("hands no edited id when creating", () => {
vi.mocked(buildParentOptions).mockClear();
markup(form(null));
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: null, currentParentId: null });
});
});

View file

@ -1,7 +1,9 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Trash2 } from "lucide-react";
import type { CategoryFormData, CategoryTreeNode } from "../../shared/types";
import CategoryCombobox from "../shared/CategoryCombobox";
import { buildParentOptions } from "./parentOptions";
const PRESET_COLORS = [
"#4A90A4",
@ -18,6 +20,8 @@ const PRESET_COLORS = [
interface Props {
initialData: CategoryFormData;
categories: CategoryTreeNode[];
/** Id of the category being edited; omitted when creating. */
editingId?: number | null;
isCreating: boolean;
isSaving: boolean;
onSave: (data: CategoryFormData) => void;
@ -28,6 +32,7 @@ interface Props {
export default function CategoryForm({
initialData,
categories,
editingId = null,
isCreating,
isSaving,
onSave,
@ -41,36 +46,16 @@ export default function CategoryForm({
setForm(initialData);
}, [initialData]);
// Allow level 0 and level 1 categories as parents (but not level 2, which would create a 4th level)
// Also build indentation info
const parentOptions: Array<CategoryTreeNode & { indent: number }> = [];
for (const cat of categories) {
if (cat.parent_id === null) {
// Level 0 — always allowed as parent
parentOptions.push({ ...cat, indent: 0 });
}
}
for (const cat of categories) {
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;
});
// Roots and level-1 categories, minus the edited category and its descent;
// the combobox orders them hierarchically. See `buildParentOptions`.
const parentOptions = useMemo(
() => buildParentOptions(categories, { editingId, currentParentId: form.parent_id }),
[categories, editingId, form.parent_id],
);
// "No parent" sits first in the list and is how the field goes back to null:
// the combobox itself never emits null.
const noParentExtra = useMemo(() => [{ value: "", label: t("categories.noParent") }], [t]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@ -130,20 +115,15 @@ export default function CategoryForm({
<div>
<label className="block text-sm font-medium mb-1">{t("categories.parent")}</label>
<select
value={form.parent_id ?? ""}
onChange={(e) =>
setForm({ ...form, parent_id: e.target.value ? Number(e.target.value) : null })
}
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)]"
>
<option value="">{t("categories.noParent")}</option>
{parentOptions.map((c) => (
<option key={c.id} value={c.id}>
{c.indent > 0 ? "\u00A0\u00A0\u00A0\u00A0" : ""}{c.name}
</option>
))}
</select>
<CategoryCombobox
categories={parentOptions}
value={form.parent_id}
onChange={(id) => setForm({ ...form, parent_id: id })}
extraOptions={noParentExtra}
activeExtra={form.parent_id === null ? "" : null}
onExtraSelect={() => setForm({ ...form, parent_id: null })}
ariaLabel={t("categories.parent")}
/>
</div>
<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,106 @@
// 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("offers nothing when the edited category has grandchildren", () => {
// Root 1 carries 10 -> 100: under any parent, 100 would sit at level 3.
// Probe of the review of PR #428: `[1, 10->1, 100->10, 2]`, editing 1.
expect(ids(buildParentOptions([node(1, null), node(10, 1), node(100, 10), node(2, null)], { editingId: 1 }))).toEqual([]);
expect(ids(buildParentOptions(TREE, { editingId: 1 }))).toEqual([]);
});
it("leaves out the whole descent of the edited category", () => {
// Editing 10 (child 100): a level-1 category with a child; 100 stays out.
expect(ids(buildParentOptions(TREE, { editingId: 10 }))).not.toContain(100);
// Editing root 2 (child 20): only roots remain, so 20 stays out.
expect(ids(buildParentOptions(TREE, { editingId: 2 }))).not.toContain(20);
});
it("keeps only roots when the edited category has children but no grandchildren", () => {
// 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]);
expect(ids(buildParentOptions(TREE, { editingId: 2 }))).toEqual([1]);
});
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);
});
it("terminates when the edited category is itself a member of a cycle", () => {
// 1 <-> 2: the height walk under 1 meets 1 again and stops (height 1, not
// unbounded). Only roots remain, so the root outside the cycle is offered.
const cyclic = [node(1, 2), node(2, 1), node(3, null)];
expect(ids(buildParentOptions(cyclic, { editingId: 1 }))).toEqual([3]);
});
});

View file

@ -0,0 +1,109 @@
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;
}
/**
* Height of the sub-tree under `rootId`: 0 for a leaf, 1 with children, 2 with
* grandchildren. Walks `parent_id` links; a cycle is not walked twice, and the
* walk can never take more steps than there are categories.
*/
function subtreeHeight(categories: CategoryTreeNode[], rootId: number): number {
const childrenOf = new Map<number, number[]>();
for (const c of categories) {
if (c.parent_id === null) continue;
const bucket = childrenOf.get(c.parent_id) ?? [];
bucket.push(c.id);
childrenOf.set(c.parent_id, bucket);
}
let height = 0;
const seen = new Set<number>([rootId]);
let frontier = [rootId];
while (frontier.length > 0 && height < categories.length) {
const next: number[] = [];
for (const id of frontier) {
for (const child of childrenOf.get(id) ?? []) {
if (seen.has(child)) continue;
seen.add(child);
next.push(child);
}
}
if (next.length === 0) break;
height += 1;
frontier = next;
}
return height;
}
/**
* 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 the allowed parent depth
* shrinks by the height of its sub-tree: with children, only roots remain
* (the children would otherwise land at level 3); with grandchildren, no
* parent at all (they would land at level 4). This alone also leaves out its
* whole descent, which would create a cycle that hides the branch everywhere
* (#393): a descendant sits at least one level below the edited category,
* deeper than any depth still allowed.
* - The parent currently set on the form is always kept when it exists.
*
* This list is the only guard: `updateCategory` writes any `parent_id` it is
* given, without a depth or cycle check (#393 will add one). The form therefore
* never offers a new parent that the rules above reject.
*
* 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 height = editingId !== null ? subtreeHeight(categories, editingId) : 0;
// Negative when the edited category has grandchildren: only the current parent survives.
const maxDepth = MAX_PARENT_DEPTH - height;
return categories.filter((c) => {
if (c.id === editingId) return false;
// Kept even when the depth rule would drop it: the field must show what is
// stored. Keeping it changes nothing on save (it is already the parent);
// repairing such data is the job of #393.
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 { 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 {
categories: Category[];
interface CategoryComboboxProps<T extends CategoryLike = CategoryLike> {
categories: T[];
value: number | null;
onChange: (id: number | null) => void;
placeholder?: string;
@ -23,8 +35,8 @@ function normalize(s: string): string {
}
// Compute depth of each category based on parent_id chain
function computeDepths(categories: Category[]): Map<number, number> {
const byId = new Map<number, Category>();
function computeDepths(categories: CategoryLike[]): Map<number, number> {
const byId = new Map<number, CategoryLike>();
for (const c of categories) byId.set(c.id, c);
const depths = new Map<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
* emitted at the end, each treated as a pseudo-root, so nothing disappears.
*/
export function sortHierarchical(
categories: Category[],
resolveName: (cat: Category) => string,
): Category[] {
export function sortHierarchical<T extends CategoryLike>(
categories: T[],
resolveName: (cat: T) => string,
): T[] {
if (categories.length === 0) return [];
const ids = new Set<number>();
for (const c of categories) ids.add(c.id);
// Group by parent bucket: root (`null`) or parent id.
const childrenByParent = new Map<number | null, Category[]>();
const orphans: Category[] = [];
const childrenByParent = new Map<number | null, T[]>();
const orphans: T[] = [];
for (const c of categories) {
if (c.parent_id == 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;
return resolveName(a).localeCompare(resolveName(b));
};
for (const bucket of childrenByParent.values()) bucket.sort(compare);
orphans.sort(compare);
const out: Category[] = [];
const out: T[] = [];
const visited = new Set<number>();
const visit = (cat: Category) => {
const visit = (cat: T) => {
if (visited.has(cat.id)) return; // defensive against cycles
visited.add(cat.id);
out.push(cat);
@ -108,7 +120,7 @@ export function sortHierarchical(
return out;
}
export default function CategoryCombobox({
export default function CategoryCombobox<T extends CategoryLike = CategoryLike>({
categories,
value,
onChange,
@ -118,7 +130,7 @@ export default function CategoryCombobox({
extraOptions,
onExtraSelect,
activeExtra,
}: CategoryComboboxProps) {
}: CategoryComboboxProps<T>) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
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
// we translate with name as defaultValue; user-created rows just use name.
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]
);

View file

@ -1125,7 +1125,7 @@
"Re-initialize categories to defaults"
],
"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",
"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",

View file

@ -1125,7 +1125,7 @@
"Réinitialiser les catégories aux valeurs par défaut"
],
"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",
"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",