feat(categories): type-ahead combobox for the parent and keyword category fields #428
No reviewers
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/Simpl-Resultat#428
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-423-category-combobox-parent-keyword"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes #423
What changes
The « Catégorie parente » field of
CategoryFormand the « Catégorie » field ofAddKeywordDialog(right-click on a transaction) were native<select>s. Both now useCategoryCombobox, like the category cell of the Transactions page: typing filters the list, which is ordered hierarchically with indentation and shows seed categories translated throughi18n_key(both selects showed the rawnamebefore).CategoryCombobox.tsx: input type widened to a structuralCategoryLike(id,name,parent_id?,sort_order,i18n_key?);sortHierarchicalis generic over it.CategoryTreeNode[]passes without conversion, every existing caller compiles unchanged,CategoryCombobox.test.tsuntouched (option A of the issue).parentOptions.ts(new, pure): roots and level-1 categories; when editing, the edited category is out, and the allowed parent depth shrinks by the height of its sub-tree (04ac2e9, after the review): with children, only roots remain; with grandchildren, no parent at all. No separate descent walk: that rule alone leaves the descent out (a descendant sits at least one level below the edited category, deeper than any depth still allowed). The parent currently stored on the form is always kept, so a legacy or inconsistent parent never leaves the field blank (keeping it changes nothing on save). This list is the only guard today:updateCategorychecks neither depth nor cycle, #393 will add that (D1).CategoryForm.tsx: propeditingId(passed byCategoryDetailPanelin edit mode), combobox with « Aucun parent » as the extra option that resets tonull,aria-labelon the field.keywordCategory.ts(new):loadKeywordCategories()readstransactionService.getAllCategories()(active and inputable, same list as Transactions, D2) andreconcileInitialCategory()keeps the pre-filled category only when it is in that list.AddKeywordDialogholdscategoryIdatnulluntil the list is in, which keeps the apply button disabled meanwhile (no window where an id absent from the list is applicable), also on a failed load. The redundantcategoriesLoadedflag of04ac2e9is gone (8898213).AddKeywordDialoglayout: the two fields moved out of theoverflow-y-autoblock into a fixed block above it. The old body scrolled as a whole, so theabsolutelistbox was clipped by its scrollport (confirmed by the plan check;SplitAdjustmentModalhas the same clipping, not fixed here). The matches block still scrolls on its own.docs.*mirror infr.json/en.json. No new i18n key:categories.parent,categories.noParent,transactions.table.noCategoryreused.Tests
npm run buildgreenNew, 31 tests measured on the five files alone:
parentOptions.test.ts(14),keywordCategory.test.ts(5),CategoryForm.render.test.tsx(6),AddKeywordDialog.render.test.tsx(4),CategoryDetailPanel.render.test.tsx(2). The mutation counts below come from a run filtered onsrc/components/categories, which also covers the 14 tests ofcategories-migration, all green under every mutation. Static renders use thek[...]faketofTransactionTable.render.test.tsx; the seed-parent expectation is written against the exactcategoriesSeed.housing[defaultValue=Logement]string (#389).Mutations (author, one at a time, restored from scratchpad copies,
git diff --statidentical before/after)parentOptions.ts:<= maxDepth→<= maxDepth + 1(level 2 enters; the issue'sparent.parent_id === nullliteral no longer exists)parentOptions.ts: remove the descent exclusionMAX_PARENT_DEPTH - height→MAX_PARENT_DEPTH(height ignored)parentOptions.ts: back to the children-only rule of4bdb004(height > 0 ? 0 : MAX_PARENT_DEPTH), the review's blockerkeywordCategory.ts:reconcileInitialCategoryreturnsinitialId ?? null(fallback removed)keywordCategory.ts:loadKeywordCategoriescallsgetAllCategoriesWithCountsAddKeywordDialog.tsx:overflow-y-autoback on the fields blockparentOptions.ts: cycle guard (seen) of the height walk removed; the step bound turns the endless loop into a wrong heightparentOptions.ts: step bound of the height walk removed (defense behindseen, expected green)CategoryDetailPanel.tsx:editingIdno longer passedCategoryForm.tsx:editingIdnot handed tobuildParentOptionsMutations 3 and 4 are caught because the component consumes exactly the two helpers; a mutation applied inside the component's
useEffectis not observable underrenderToStaticMarkup(no effect runs). The render tests pin the component to the helpers only through the mocked import.Notes for review
sortHierarchicaland show un-indented at the end, exactly as in the Transactions combobox.max-h-[220px]). On a window under roughly 750 px tall the footer may leave the card; acceptable for the desktop window, said here rather than hidden.AdjustmentForm.tsx:124andLinkTransfersModal.tsx:260keep their<select>; service-side cycle guard in #393.🤖 Generated with Claude Code
Revue adversariale — REQUEST_CHANGES
Exécuté : suite ciblée rejouée sur une copie hors dépôt de
4bdb004(git archive, 46 testscategories+CategoryComboboxverts), sept mutations une à la fois, copie restaurée et comparée àHEAD(diff -rqvide), puis supprimée. CI run 526 lue dans le log : build 11 s, 114 fichiers, 1957 tests. Le statmain..HEADqui montredocs/adr/0024-serveur-mcp.mdsupprimé est un artefact :maina avancé de8b5b3b1après le point de branche, le diff contre la base de fusion fait 15 fichiers.Mutations
[1, 10→1, 100→10, 2],editingId: 1) doit rendre[][2]categoriesLoaded &&decanApplyuseState(initialCategoryId)au lieu denulloverflow-y-autoremis sur le bloc des champs<= maxDepth + 1currentParentIdreconcilerendinitialId ?? nullIssue bloquante
src/components/categories/parentOptions.ts:721-723— la règle D1 ne compte que les enfants directs, pas la descendance.editedHasChildrenpassemaxDepthà 0 : les racines restent offertes. Éditer une racine1qui porte10 → 100(trois niveaux) offre la racine2comme parent ; sauver donne2 → 1 → 10 → 100, quatre niveaux, que le CHANGELOG de cette PR dit désormais impossible (« ni un parent qui créerait un quatrième niveau »). D1 (#423) : « les parents qui porteraient la hiérarchie au-delà de 3 niveaux en comptant la descendance de la catégorie éditée ». Rien ne rattrape :updateCategory(categoryService.ts:80-96) n'a aucune garde de profondeur (#393 pas livré),moveCategory(useCategories.ts) non plus ; seulcreateCategoryen a une. Le testleaves out the whole descent(parentOptions.test.ts:617,toEqual([2])) consacre le mauvais résultat.maxDepth = MAX_PARENT_DEPTH - hauteur; négatif, seulcurrentParentIdsurvit au filtre (la liste ne montre plus que « Aucun parent », plus le parent déjà enregistré s'il y en a un). Le test deparentOptions.test.ts:617passe à[].Suggestions (non bloquantes)
AddKeywordDialog.tsx: les gardes «categoryIdtenu ànullavant la liste » etcategoriesLoadedse couvrent l'une l'autre, aucune ne rougit un test prise seule (deux mutations à 0). Le corps de la PR annonce « no window where an id absent from the list is applicable » sans test qui l'épingle. ExtrairecanApplyen fonction pure testée, ou dire dans la PR que l'invariant tient par lecture.AddKeywordDialog.tsx:69:[initialCategoryId]en dépendance de l'effet recharge la liste et écrase le choix de l'utilisateur si la prop change pendant que le dialogue est ouvert. Stable chez les trois appelants aujourd'hui ;[]avec la valeur lue une fois serait plus sûr, ou un commentaire pour le dire.categoriesvient degetAllCategoriesWithCounts(actives seulement), le parent n'est pas dans la liste, le champ s'affiche vide alors queparent_idest posé. Préexistant (l'ancien<select>montrait « Aucun parent », plus trompeur), à verser dans #393.Vérifié sans remarque
Pas de secret ni de
.env; aucune chaîne en dur (transactions.table.noCategory,categories.noParent,categories.parentréutilisées) ;CategoryLikecompatible avecCategory(parent_id?: number) etCategoryTreeNode;activeExtra=""→ libellé « Aucun parent » ("" != null) ;cancelledsur l'effet de chargement ; rognage réglé (carte sansoverflow-hidden, listeabsolute z-50hors du bloc défilant, bloc des champs àmin-height: autodonc jamais écrasé) ;Entersur le combobox fermé ouvre la liste au lieu de soumettre le formulaire (voulu) ; timeline de #423 :pull_ref closesdepuis #428 ; commit au format conventionnel ; aucune migration.Label de #423 :
status:review→status:needs-fix.Correctif du bloquant poussé en
04ac2e9(tête non relue).MAX_PARENT_DEPTH - hauteur(sous-arbre édité); hauteur 0 (feuille) → racines et niveau 1, hauteur 1 → racines seules, hauteur 2 → aucun parent, seul le parent courant survit.[1, 10→1, 100→10, 2],editingId: 1) écrite en test avant le correctif :[2]obtenu,[]attendu, 1 rouge sur 13.diffvide après restauration) : retour à la règle « enfants directs seulement » de4bdb004→ 1 rouge ; hauteur ignorée (MAX_PARENT_DEPTHconstant) → 3 rouges.tscpropre.initialCategoryIdcomme valeur initiale) ;[initialCategoryId]en dépendance reste, les trois appelants remontent le dialogue à chaque ouverture ; parent inactif absent de la liste → #393.Revue — APPROVE
Résumé. Les deux
<select>passent auCategoryComboboxsans toucher aux appelants existants (généricité structurelleCategoryLike), la règle des parents est isolée dans une fonction pure bien testée, et le dialogue mot-clé ne peut plus appliquer un identifiant absent de sa liste. Suite rejouée sur une copie hors dépôt (supprimée en fin de revue) : 1958 verts (114 fichiers), CIcheck-frontendverte sur04ac2e9(run 527). Timeline de #423 :pull_ref closesdepuis cette PR. Commits au format conventionnel. Aucune migration, aucun secret, i18n par clés existantes.Mutations du relecteur (une à la fois, copie restaurée après chacune, arbre réel inchangé)
parentOptions.ts: la catégorie éditée n'est plus exclueparentOptions.ts: le parent courant n'est plus conservéparentOptions.ts: gardeseendesubtreeHeightretiréeparentOptions.ts: orphelin compté en profondeur 1parentOptions.ts: hauteur ignorée (MAX_PARENT_DEPTHseul)CategoryForm.tsx:activeExtra={null}CategoryForm.tsx:editingId: nullpassé àbuildParentOptionsAddKeywordDialog.tsx:categoriesLoadedretiré decanApplyAddKeywordDialog.tsx:categoryIddémarre àinitialCategoryIdkeywordCategory.ts:reconcileInitialCategoryrend toujoursinitialIdAucune survivante ne cache un défaut de comportement ; elles marquent des épingles manquantes.
Suggestions (non bloquantes)
parentOptions.ts(« Saving an invalid parent is refused by the service-side validation of #393 ») et le corps de la PR renvoient à une validation côté service. #393 est ouverte (status:ready) etupdateCategory(categoryService.ts) fait unUPDATEsans contrôle de profondeur ni de cycle ; seulcreateCategoryvérifie la profondeur. Ce qui protège aujourd'hui, c'est que la combobox n'offre jamais un parent nouveau invalide (le parent conservé est toujours celui déjà enregistré). Reformuler en « serait le rôle de #393, ouverte », pour ne pas laisser croire à un filet qui n'est pas posé.seendesubtreeHeightn'est pas épinglée. Le test de cycle édite3, hors du cycle. Sans la garde, éditer un membre du cycle boucle sans fin : sonde[1→2, 2→1, 3]aveceditingId: 1, 1 vert avec la garde (rend[3]),timeoutà 40 s sans. Ajouter ce cas au test existant.editingId→buildParentOptionsn'est pas épinglé. Le rendu statique ne montre pas la liste fermée ; unvi.mock("./parentOptions")avec assertion sur les arguments d'appel suffirait. Même angle mort pourCategoryDetailPanel.tsx(editingId={selectedCategory.id}), sans test.categoriesLoadedet lenullinitial se couvrent mutuellement ; le test « keeps the apply button disabled » est vert parce que la prévisualisation mockée ne se résout jamais, pas grâce àcategoriesLoaded. Acceptable en défense en profondeur ; à savoir si l'une des deux est retirée un jour.ReportsCategoryPage, une catégorie zoomée non saisissable (parent) perd son pré-remplissage. Couvert par la phrase du changelog, rien à changer.Actions faites
status:review→status:approved.scratchpad/pr428etmut.shsupprimées ;git statusdu dépôt vide.Suites de la revue 23121 appliquées en
8898213(tête non relue, merge demandé par Max : « corrige les 4 points et merge »).parentOptions.tset le corps de la PR disent maintenant que cette liste est la seule garde (updateCategoryne vérifie ni profondeur ni cycle), au lieu d'une validation côté service qui n'existe pas.[1→2, 2→1, 3],editingId: 1→[3]). La marche de hauteur est aussi bornée par le nombre de catégories, ce qui rend la mutation observable : sansseen, hauteur fausse et 1 rouge au lieu d'une boucle sans fin.editingId: espion passe-plat surbuildParentOptionsdansCategoryForm.render.test.tsxet un nouveauCategoryDetailPanel.render.test.tsx; retirereditingIddu panneau → 1 rouge, ne pas le transmettre depuis le formulaire → 2 rouges.categoriesLoadedretiré, lenullinitial suffit (le bouton reste désactivé tant que la liste n'est pas là, y compris sur un chargement en échec).Suite : 1963 vitest verts (115 fichiers),
tscpropre. Mutations reportées dans le tableau du corps (lignes 6 à 9).Mergée en avance rapide par l'API sur
8898213(main= tête de la PR, branche supprimée), sur la demande de Max « corrige les 4 points et merge ».4bdb004(23095), APPROVE sur04ac2e9(23121).8898213ajouté après la revue, non relu : les quatre suivis de 23121 (commentaires sur #393, test et borne de la marche de hauteur, espions surbuildParentOptions, retrait decategoriesLoaded). Seul changement de comportement : la garde redondante retirée du dialogue, lenullinitial garde le bouton désactivé.npm cipropre : sonde rouge rc=1, build, 1963 vitest (115 fichiers).<select>de catégories et le rognage identique deSplitAdjustmentModal.