From 4bdb00402e17384fda8e269d100d2bd54f16c64a Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 12 Oct 2026 17:22:31 -0400 Subject: [PATCH 1/3] feat(categories): type-ahead combobox for the parent and keyword category fields 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 --- CHANGELOG.fr.md | 4 + CHANGELOG.md | 4 + docs/guide-utilisateur.md | 4 +- .../AddKeywordDialog.render.test.tsx | 72 ++++++++++++++ .../categories/AddKeywordDialog.tsx | 66 ++++++++----- .../categories/CategoryDetailPanel.tsx | 1 + .../categories/CategoryForm.render.test.tsx | 95 +++++++++++++++++++ src/components/categories/CategoryForm.tsx | 70 +++++--------- .../categories/keywordCategory.test.ts | 50 ++++++++++ src/components/categories/keywordCategory.ts | 25 +++++ .../categories/parentOptions.test.ts | 92 ++++++++++++++++++ src/components/categories/parentOptions.ts | 71 ++++++++++++++ src/components/shared/CategoryCombobox.tsx | 46 +++++---- src/i18n/locales/en.json | 2 +- src/i18n/locales/fr.json | 2 +- 15 files changed, 512 insertions(+), 92 deletions(-) create mode 100644 src/components/categories/AddKeywordDialog.render.test.tsx create mode 100644 src/components/categories/CategoryForm.render.test.tsx create mode 100644 src/components/categories/keywordCategory.test.ts create mode 100644 src/components/categories/keywordCategory.ts create mode 100644 src/components/categories/parentOptions.test.ts create mode 100644 src/components/categories/parentOptions.ts diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 3eb4636..d95aa6c 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -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). diff --git a/CHANGELOG.md b/CHANGELOG.md index 84d198d..67e939c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/docs/guide-utilisateur.md b/docs/guide-utilisateur.md index b5f6d36..4209f80 100644 --- a/docs/guide-utilisateur.md +++ b/docs/guide-utilisateur.md @@ -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. diff --git a/src/components/categories/AddKeywordDialog.render.test.tsx b/src/components/categories/AddKeywordDialog.render.test.tsx new file mode 100644 index 0000000..002351d --- /dev/null +++ b/src/components/categories/AddKeywordDialog.render.test.tsx @@ -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) => + 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( + , + ); + +describe("AddKeywordDialog — category field (#423)", () => { + it("renders the category as a combobox and no select", () => { + const html = markup(); + expect(html.match(/]*role="combobox"[^>]*>/g) ?? []).toHaveLength(1); + expect(html).not.toContain(" { + const [input] = markup(7).match(/]*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(/