Catégorie parent et choix de catégorie du mot-clé : menu déroulant avec saisie (CategoryCombobox) #423

Closed
opened 2026-10-12 21:05:09 +00:00 by maximus · 0 comments
Owner

Contexte

Deux formulaires choisissent une catégorie dans un <select> natif, sans saisie au clavier :

  • src/components/categories/CategoryForm.tsx:44-66, 133-146 — champ « Catégorie parente » (création et édition). Options : racines et catégories de niveau 1, indentées par espaces insécables, plus « Aucun parent ». Le nom brut (c.name) est affiché, alors que l'arbre de la même page traduit les catégories du seed par i18n_key (CategoryTree.tsx:149).
  • src/components/categories/AddKeywordDialog.tsx:160-175 — champ « Catégorie » du dialogue « Ajouter un mot-clé », ouvert par le clic droit sur une transaction (TransactionsPage.tsx:192, ReportsHighlightsPage.tsx:124, ReportsCategoryPage.tsx:102). Options : toutes les catégories actives, à plat, sans hiérarchie, nom brut.

La page Transactions utilise CategoryCombobox (src/components/shared/CategoryCombobox.tsx) : saisie libre, filtrage insensible aux accents, ordre hiérarchique DFS avec indentation (sortHierarchical), navigation clavier, option « extra » en tête de liste (extraOptions / onExtraSelect / activeExtra), nom traduit par i18n_key. Déjà réutilisé dans TransactionTable, TransactionFilterBar, SplitAdjustmentModal (dans un modal), MappingRow, SecurityPicker et CategoryZoomHeader.

Analyse du 2026-10-12 (/analyze, lu sur main 8b5b3b1). Prémisse vérifiée : les deux <select> existent tels que décrits.

Constats de l'analyse

  • Types : CategoryCombobox exige Category[] (shared/types/index.ts:54, parent_id?: number, created_at obligatoire). CategoryForm reçoit des CategoryTreeNode[] (index.ts:611, parent_id: number | null, pas de created_at, plus children). Les deux types ne sont pas assignables l'un à l'autre.
  • Le formulaire ne connaît pas l'id de la catégorie éditée : CategoryFormData (index.ts:626) n'a pas d'id. Exclure la catégorie éditée de la liste des parents demande une prop de plus, fournie par CategoryDetailPanel.tsx:90-98 qui tient selectedCategory.
  • Deux listes différentes aujourd'hui : le combobox des Transactions reçoit transactionService.getAllCategories() (is_active = 1 AND is_inputable = 1, transactionService.ts:328-331) ; le dialogue mot-clé reçoit getAllCategoriesWithCounts() (categoryService.ts:18-28, actives seulement, saisissables ou non, déjà c.* donc avec parent_id, sort_order, i18n_key : la note d'origine « il faudra charger les colonnes » était fausse, seul le type local CategoryOption les perd).
  • Le combobox n'émet jamais null : la remise à « aucun » passe par une option extra (modèle TransactionTable.tsx:214-222 : extraOptions, activeExtra={value === null ? "" : null}, onExtraSelect → null).
  • computeDepths et sortHierarchical lisent parent_id sur la liste reçue : passer une liste déjà filtrée (racines + niveau 1) garde l'indentation, les parents des niveaux 1 étant présents.
  • Chevauchement avec #393 : son plan prévoit « Sélecteur de parent (CategoryForm.tsx) : exclure la catégorie éditée et toute sa descendance, et les parents qui dépasseraient la profondeur ». updateCategory (categoryService.ts:80-96) n'a aucune garde de cycle.
  • Historique des fichiers : CategoryForm inchangé depuis le 3ᵉ niveau (a04813c) ; AddKeywordDialog depuis #74 ; combobox depuis #126.

Décisions (Max, 2026-10-12)

  • D1 — #423 porte l'exclusion dans le sélecteur de parent (catégorie éditée, sa descendance, et les parents qui porteraient la hiérarchie au-delà de 3 niveaux en comptant la descendance de la catégorie éditée). #393 garde la validation côté service (updateCategory), la garde de getCategoryDepth et l'outil de réparation. Commentaire posé sur #393.
  • D2 — Le dialogue mot-clé prend la même liste que les Transactions : catégories actives et saisie autorisée (transactionService.getAllCategories()), cohérent avec l'aide du drapeau (« masquer du budget et des listes de catégories »). Conséquence acceptée : depuis ReportsCategoryPage, un initialCategoryId non saisissable n'est pas dans la liste ; le dialogue le remet alors à null (champ vide, bouton d'application désactivé jusqu'au choix), plutôt que d'envoyer un id que l'utilisateur ne voit pas.

Travail à faire

  • Assouplir le type d'entrée de CategoryCombobox : accepter un type structurel minimal (id, name, parent_id?: number | null, sort_order, i18n_key?) plutôt que Category complet, pour que CategoryTreeNode[] passe sans cast. computeDepths, sortHierarchical et displayName n'utilisent que ces champs. SecurityPicker et les autres appelants ne bougent pas.
  • Extraire un module pur buildParentOptions(categories, editingId) (par exemple src/components/categories/parentOptions.ts) : racines + niveau 1, moins la catégorie éditée, moins sa descendance, moins les niveaux 1 si la catégorie éditée a elle-même des enfants ; ordre laissé au combobox (sortHierarchical). Remplace les lignes 44-66 de CategoryForm.tsx.
  • CategoryForm : prop editingId?: number | null (passée par CategoryDetailPanel en édition, absente en création) ; remplacer le <select> par CategoryCombobox avec extraOptions=[{ value: "", label: t("categories.noParent") }], activeExtra={form.parent_id === null ? "" : null}, onExtraSelect → parent_id: null, ariaLabel={t("categories.parent")} (le <label> actuel n'a pas de htmlFor).
  • AddKeywordDialog : charger par transactionService.getAllCategories() (D2), supprimer CategoryOption ; remplacer le <select> par CategoryCombobox avec une option extra « — » (ou le libellé « Aucune catégorie » existant) qui remet categoryId à null ; à la réception de la liste, si initialCategoryId n'y figure pas, setCategoryId(null). canApply reste categoryId !== null.
  • Vérifier le rendu dans le modal : la liste du combobox est absolute z-50 dans un conteneur overflow-y-auto (AddKeywordDialog.tsx:138) ; SplitAdjustmentModal est le précédent. Si la liste est rognée, la placer en bas du bloc ou retirer overflow-y-auto du conteneur.
  • Tests :
    • parentOptions.test.ts : racines seules ; niveau 1 inclus ; niveau 2 exclu ; en édition, soi-même exclu, descendance exclue, niveaux 1 exclus quand la catégorie éditée a des enfants ; création (editingId absent) = liste d'aujourd'hui.
    • Rendu statique (renderToStaticMarkup, faux t au format k[...] de TransactionTable.render.test.tsx, voir #389) de CategoryForm et d'AddKeywordDialog : présence d'un role="combobox", absence de <select>, libellé affiché de la valeur pré-remplie. Pour AddKeywordDialog, le chargement est dans un useEffect que le rendu statique n'exécute pas : mocker transactionService et tester le rendu initial, puis la logique « initialCategoryId hors liste → null » dans une fonction pure testée à part.
    • CategoryCombobox.test.ts : inchangé si le type d'entrée est seulement élargi.
  • i18n : categories.parent, categories.noParent, reports.highlights.category existent ; ajouter au besoin un libellé pour l'option vide du mot-clé, dans fr.json et en.json.
  • CHANGELOG : entrée Changed/Modifié dans CHANGELOG.md et CHANGELOG.fr.md : saisie dans les deux champs, ordre hiérarchique dans le mot-clé, noms des catégories du seed traduits dans ces deux champs, liste du mot-clé limitée aux catégories saisissables, parents impossibles retirés du formulaire.
  • Docs : docs/guide-utilisateur.md et clés docs.* si le guide décrit l'un des deux champs ; pas d'ADR (réutilisation d'un composant existant).

Fichiers concernés

  • src/components/shared/CategoryCombobox.tsx — type d'entrée structurel
  • src/components/categories/CategoryForm.tsx — combobox, prop editingId, appel de buildParentOptions
  • src/components/categories/parentOptions.ts (nouveau) — module pur
  • src/components/categories/CategoryDetailPanel.tsx — passer editingId={selectedCategory.id}
  • src/components/categories/AddKeywordDialog.tsx — combobox, source de données, repli de initialCategoryId
  • src/i18n/locales/fr.json, src/i18n/locales/en.json
  • CHANGELOG.md, CHANGELOG.fr.md
  • Tests : src/components/categories/parentOptions.test.ts, CategoryForm.render.test.tsx, AddKeywordDialog.render.test.tsx (nouveaux)

Surface de test

  • Tests existants : src/components/shared/CategoryCombobox.test.ts (7 cas sur sortHierarchical) ; aucun test sur CategoryForm, AddKeywordDialog ni CategoryDetailPanel.
  • Tests manquants : ci-dessus.
  • Mutations à passer par /fix-issue (déduites à la lecture) : inverser parent.parent_id === null dans le filtre de niveau (les niveaux 2 entrent) ; retirer l'exclusion de la descendance ; retirer le repli initialCategoryId hors liste → null ; remettre getAllCategoriesWithCounts. Chaque mutation doit rougir au moins un test.

Options

Option A — élargir le type d'entrée du combobox (retenue)

  • Pour : aucun cast, aucune conversion CategoryTreeNode → Category, les appelants existants compilent tels quels.
  • Contre : un type de plus à nommer dans CategoryCombobox.tsx.

Option B — convertir dans CategoryForm ({ ...node, parent_id: node.parent_id ?? undefined, created_at: "" })

  • Pour : combobox intact.
  • Contre : valeur factice pour created_at, bruit à chaque rendu.

Hors portée

  • Validation de cycle et de profondeur côté service, garde de getCategoryDepth, outil « Vérifier la hiérarchie » et budget : #393.
  • Les autres <select> de catégories de l'app : AdjustmentForm.tsx:124 (ajustements) et LinkTransfersModal.tsx:260 (bilan). Candidats à une issue de suite si le résultat convient.
  • Un mot-clé déjà posé sur une catégorie non saisissable reste en place (D2 ne change que la liste proposée).

Critères d'acceptation

  • Catégorie parente (création et édition) : taper filtre la liste, avec indentation et ordre hiérarchique ; « Aucun parent » en tête remet la valeur à null et reste la valeur par défaut à la création ; les niveaux 2 ne sont jamais proposés.
  • En édition : la catégorie elle-même et sa descendance ne sont pas proposées ; si elle a des enfants, seules les racines le sont.
  • Mot-clé : la liste est celle des Transactions (actives et saisie autorisée), en ordre hiérarchique avec indentation ; taper filtre ; la catégorie pré-remplie par le clic droit reste sélectionnée si elle est dans la liste, sinon le champ est vide ; sans catégorie, le bouton d'application reste désactivé.
  • Les catégories du seed s'affichent traduites (FR/EN) dans les deux champs.
  • La liste déroulante n'est pas rognée dans le modal du mot-clé.
  • Aucune chaîne en dur ; npm run build et npm test verts ; tests listés ci-dessus présents et les quatre mutations rouges.
  • Entrées Changed/Modifié dans les deux changelogs.

Complexité estimée

Medium : trois composants, un type élargi, une prop ajoutée, un module pur et trois fichiers de tests. Aucune migration, aucun changement Rust.

## Contexte Deux formulaires choisissent une catégorie dans un `<select>` natif, sans saisie au clavier : - `src/components/categories/CategoryForm.tsx:44-66, 133-146` — champ « Catégorie parente » (création et édition). Options : racines et catégories de niveau 1, indentées par espaces insécables, plus « Aucun parent ». Le nom brut (`c.name`) est affiché, alors que l'arbre de la même page traduit les catégories du seed par `i18n_key` (`CategoryTree.tsx:149`). - `src/components/categories/AddKeywordDialog.tsx:160-175` — champ « Catégorie » du dialogue « Ajouter un mot-clé », ouvert par le clic droit sur une transaction (`TransactionsPage.tsx:192`, `ReportsHighlightsPage.tsx:124`, `ReportsCategoryPage.tsx:102`). Options : toutes les catégories actives, à plat, sans hiérarchie, nom brut. La page Transactions utilise `CategoryCombobox` (`src/components/shared/CategoryCombobox.tsx`) : saisie libre, filtrage insensible aux accents, ordre hiérarchique DFS avec indentation (`sortHierarchical`), navigation clavier, option « extra » en tête de liste (`extraOptions` / `onExtraSelect` / `activeExtra`), nom traduit par `i18n_key`. Déjà réutilisé dans `TransactionTable`, `TransactionFilterBar`, `SplitAdjustmentModal` (dans un modal), `MappingRow`, `SecurityPicker` et `CategoryZoomHeader`. Analyse du 2026-10-12 (`/analyze`, lu sur `main` `8b5b3b1`). Prémisse vérifiée : les deux `<select>` existent tels que décrits. ### Constats de l'analyse - **Types** : `CategoryCombobox` exige `Category[]` (`shared/types/index.ts:54`, `parent_id?: number`, `created_at` obligatoire). `CategoryForm` reçoit des `CategoryTreeNode[]` (`index.ts:611`, `parent_id: number | null`, pas de `created_at`, plus `children`). Les deux types ne sont pas assignables l'un à l'autre. - **Le formulaire ne connaît pas l'id de la catégorie éditée** : `CategoryFormData` (`index.ts:626`) n'a pas d'`id`. Exclure la catégorie éditée de la liste des parents demande une prop de plus, fournie par `CategoryDetailPanel.tsx:90-98` qui tient `selectedCategory`. - **Deux listes différentes aujourd'hui** : le combobox des Transactions reçoit `transactionService.getAllCategories()` (`is_active = 1 AND is_inputable = 1`, `transactionService.ts:328-331`) ; le dialogue mot-clé reçoit `getAllCategoriesWithCounts()` (`categoryService.ts:18-28`, actives seulement, saisissables ou non, déjà `c.*` donc avec `parent_id`, `sort_order`, `i18n_key` : la note d'origine « il faudra charger les colonnes » était fausse, seul le type local `CategoryOption` les perd). - **Le combobox n'émet jamais `null`** : la remise à « aucun » passe par une option extra (modèle `TransactionTable.tsx:214-222` : `extraOptions`, `activeExtra={value === null ? "" : null}`, `onExtraSelect` → `null`). - **`computeDepths` et `sortHierarchical` lisent `parent_id` sur la liste reçue** : passer une liste déjà filtrée (racines + niveau 1) garde l'indentation, les parents des niveaux 1 étant présents. - **Chevauchement avec #393** : son plan prévoit « Sélecteur de parent (`CategoryForm.tsx`) : exclure la catégorie éditée et toute sa descendance, et les parents qui dépasseraient la profondeur ». `updateCategory` (`categoryService.ts:80-96`) n'a aucune garde de cycle. - Historique des fichiers : `CategoryForm` inchangé depuis le 3ᵉ niveau (`a04813c`) ; `AddKeywordDialog` depuis #74 ; combobox depuis #126. ## Décisions (Max, 2026-10-12) - **D1 — #423 porte l'exclusion dans le sélecteur de parent** (catégorie éditée, sa descendance, et les parents qui porteraient la hiérarchie au-delà de 3 niveaux en comptant la descendance de la catégorie éditée). #393 garde la validation côté service (`updateCategory`), la garde de `getCategoryDepth` et l'outil de réparation. Commentaire posé sur #393. - **D2 — Le dialogue mot-clé prend la même liste que les Transactions** : catégories actives et saisie autorisée (`transactionService.getAllCategories()`), cohérent avec l'aide du drapeau (« masquer du budget et des listes de catégories »). Conséquence acceptée : depuis `ReportsCategoryPage`, un `initialCategoryId` non saisissable n'est pas dans la liste ; le dialogue le remet alors à `null` (champ vide, bouton d'application désactivé jusqu'au choix), plutôt que d'envoyer un id que l'utilisateur ne voit pas. ## Travail à faire - [ ] **Assouplir le type d'entrée de `CategoryCombobox`** : accepter un type structurel minimal (`id`, `name`, `parent_id?: number | null`, `sort_order`, `i18n_key?`) plutôt que `Category` complet, pour que `CategoryTreeNode[]` passe sans cast. `computeDepths`, `sortHierarchical` et `displayName` n'utilisent que ces champs. `SecurityPicker` et les autres appelants ne bougent pas. - [ ] **Extraire un module pur `buildParentOptions(categories, editingId)`** (par exemple `src/components/categories/parentOptions.ts`) : racines + niveau 1, moins la catégorie éditée, moins sa descendance, moins les niveaux 1 si la catégorie éditée a elle-même des enfants ; ordre laissé au combobox (`sortHierarchical`). Remplace les lignes 44-66 de `CategoryForm.tsx`. - [ ] **`CategoryForm`** : prop `editingId?: number | null` (passée par `CategoryDetailPanel` en édition, absente en création) ; remplacer le `<select>` par `CategoryCombobox` avec `extraOptions=[{ value: "", label: t("categories.noParent") }]`, `activeExtra={form.parent_id === null ? "" : null}`, `onExtraSelect` → `parent_id: null`, `ariaLabel={t("categories.parent")}` (le `<label>` actuel n'a pas de `htmlFor`). - [ ] **`AddKeywordDialog`** : charger par `transactionService.getAllCategories()` (D2), supprimer `CategoryOption` ; remplacer le `<select>` par `CategoryCombobox` avec une option extra « — » (ou le libellé « Aucune catégorie » existant) qui remet `categoryId` à `null` ; à la réception de la liste, si `initialCategoryId` n'y figure pas, `setCategoryId(null)`. `canApply` reste `categoryId !== null`. - [ ] **Vérifier le rendu dans le modal** : la liste du combobox est `absolute z-50` dans un conteneur `overflow-y-auto` (`AddKeywordDialog.tsx:138`) ; `SplitAdjustmentModal` est le précédent. Si la liste est rognée, la placer en bas du bloc ou retirer `overflow-y-auto` du conteneur. - [ ] **Tests** : - `parentOptions.test.ts` : racines seules ; niveau 1 inclus ; niveau 2 exclu ; en édition, soi-même exclu, descendance exclue, niveaux 1 exclus quand la catégorie éditée a des enfants ; création (`editingId` absent) = liste d'aujourd'hui. - Rendu statique (`renderToStaticMarkup`, faux `t` au format `k[...]` de `TransactionTable.render.test.tsx`, voir #389) de `CategoryForm` et d'`AddKeywordDialog` : présence d'un `role="combobox"`, absence de `<select>`, libellé affiché de la valeur pré-remplie. Pour `AddKeywordDialog`, le chargement est dans un `useEffect` que le rendu statique n'exécute pas : mocker `transactionService` et tester le rendu initial, puis la logique « `initialCategoryId` hors liste → `null` » dans une fonction pure testée à part. - `CategoryCombobox.test.ts` : inchangé si le type d'entrée est seulement élargi. - [ ] **i18n** : `categories.parent`, `categories.noParent`, `reports.highlights.category` existent ; ajouter au besoin un libellé pour l'option vide du mot-clé, dans `fr.json` et `en.json`. - [ ] **CHANGELOG** : entrée Changed/Modifié dans `CHANGELOG.md` et `CHANGELOG.fr.md` : saisie dans les deux champs, ordre hiérarchique dans le mot-clé, noms des catégories du seed traduits dans ces deux champs, liste du mot-clé limitée aux catégories saisissables, parents impossibles retirés du formulaire. - [ ] **Docs** : `docs/guide-utilisateur.md` et clés `docs.*` si le guide décrit l'un des deux champs ; pas d'ADR (réutilisation d'un composant existant). ## Fichiers concernés - `src/components/shared/CategoryCombobox.tsx` — type d'entrée structurel - `src/components/categories/CategoryForm.tsx` — combobox, prop `editingId`, appel de `buildParentOptions` - `src/components/categories/parentOptions.ts` (nouveau) — module pur - `src/components/categories/CategoryDetailPanel.tsx` — passer `editingId={selectedCategory.id}` - `src/components/categories/AddKeywordDialog.tsx` — combobox, source de données, repli de `initialCategoryId` - `src/i18n/locales/fr.json`, `src/i18n/locales/en.json` - `CHANGELOG.md`, `CHANGELOG.fr.md` - Tests : `src/components/categories/parentOptions.test.ts`, `CategoryForm.render.test.tsx`, `AddKeywordDialog.render.test.tsx` (nouveaux) ## Surface de test - Tests existants : `src/components/shared/CategoryCombobox.test.ts` (7 cas sur `sortHierarchical`) ; aucun test sur `CategoryForm`, `AddKeywordDialog` ni `CategoryDetailPanel`. - Tests manquants : ci-dessus. - Mutations à passer par `/fix-issue` (déduites à la lecture) : inverser `parent.parent_id === null` dans le filtre de niveau (les niveaux 2 entrent) ; retirer l'exclusion de la descendance ; retirer le repli `initialCategoryId` hors liste → `null` ; remettre `getAllCategoriesWithCounts`. Chaque mutation doit rougir au moins un test. ## Options **Option A — élargir le type d'entrée du combobox (retenue)** - Pour : aucun cast, aucune conversion `CategoryTreeNode` → `Category`, les appelants existants compilent tels quels. - Contre : un type de plus à nommer dans `CategoryCombobox.tsx`. **Option B — convertir dans `CategoryForm`** (`{ ...node, parent_id: node.parent_id ?? undefined, created_at: "" }`) - Pour : combobox intact. - Contre : valeur factice pour `created_at`, bruit à chaque rendu. ## Hors portée - Validation de cycle et de profondeur côté service, garde de `getCategoryDepth`, outil « Vérifier la hiérarchie » et budget : **#393**. - Les autres `<select>` de catégories de l'app : `AdjustmentForm.tsx:124` (ajustements) et `LinkTransfersModal.tsx:260` (bilan). Candidats à une issue de suite si le résultat convient. - Un mot-clé déjà posé sur une catégorie non saisissable reste en place (D2 ne change que la liste proposée). ## Critères d'acceptation - [ ] Catégorie parente (création et édition) : taper filtre la liste, avec indentation et ordre hiérarchique ; « Aucun parent » en tête remet la valeur à `null` et reste la valeur par défaut à la création ; les niveaux 2 ne sont jamais proposés. - [ ] En édition : la catégorie elle-même et sa descendance ne sont pas proposées ; si elle a des enfants, seules les racines le sont. - [ ] Mot-clé : la liste est celle des Transactions (actives et saisie autorisée), en ordre hiérarchique avec indentation ; taper filtre ; la catégorie pré-remplie par le clic droit reste sélectionnée si elle est dans la liste, sinon le champ est vide ; sans catégorie, le bouton d'application reste désactivé. - [ ] Les catégories du seed s'affichent traduites (FR/EN) dans les deux champs. - [ ] La liste déroulante n'est pas rognée dans le modal du mot-clé. - [ ] Aucune chaîne en dur ; `npm run build` et `npm test` verts ; tests listés ci-dessus présents et les quatre mutations rouges. - [ ] Entrées Changed/Modifié dans les deux changelogs. ## Complexité estimée Medium : trois composants, un type élargi, une prop ajoutée, un module pur et trois fichiers de tests. Aucune migration, aucun changement Rust.
maximus added the
status:triage
type:feature
source:human
labels 2026-10-12 21:05:09 +00:00
maximus added
status:ready
and removed
status:triage
labels 2026-10-12 21:09:53 +00:00
maximus added
status:review
and removed
status:ready
labels 2026-10-12 21:23:09 +00:00
maximus added
status:needs-fix
and removed
status:review
labels 2026-10-12 22:13:49 +00:00
maximus added
status:review
and removed
status:needs-fix
labels 2026-10-12 22:16:25 +00:00
maximus added
status:approved
and removed
status:review
labels 2026-10-12 22:39:54 +00:00
maximus added
status:review
and removed
status:approved
labels 2026-10-12 22:45:29 +00:00
maximus removed the
status:review
label 2026-10-12 22:48:24 +00:00
maximus referenced this issue from a commit 2026-10-12 22:49:07 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#423
No description provided.