feat: category combobox list in a portal, combobox in adjustment lines and the transfers filter (#429) #471
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#471
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-429-category-combobox-portal"
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?
Resolves #429
Generated autonomously by /autopilot run of 2026-10-18
Résumé
Maillon 4 sur 8 de la pile
planned-2026-10-16-lot-v018, basé surissue-372-systems-journal-first(PR #470).La liste ouverte de
CategoryComboboxétait enabsolutedans le conteneur de son champ : tout ancêtre défilant la coupait, le corps de la fenêtre de répartition (max-h-[60vh] overflow-y-auto) comme l'enveloppeoverflow-x-autodu tableau des transactions. Deux<select>natifs de catégories restaient aussi dans l'app. Désormais :CategoryCombobox.tsxrend son<ul role="listbox">parcreatePortal(…, document.body), enposition: fixed,z-[300](la fenêtre la plus haute de l'app est àz-[210]), à la largeur du champ, 4 px dessous. Elle est placée avant la première peinture (useLayoutEffect), puis suit son champ à chaquescroll(écouteur en capture surwindow, donc de tout conteneur) etresize, même quand le champ sort de la zone visible de son conteneur, sans jamais se fermer pour cela (décision du « Plan d'exécution »). Les écouteurs ne vivent que pendantopenet sont retirés dans le retour de l'effet. Le clic extérieur compte la liste comme intérieure (listRef) : elle n'est plus dans le DOM du conteneur.onMouseDown={e => e.preventDefault()}reste sur les options,aria-controls/aria-activedescendantrestent valides (ids globaux). Markup fermé inchangé :<div class="relative"><input …/></div>, sans portail.src/components/shared/placeListbox.ts(nouveau, pur) :placeListbox({ anchor, viewportHeight })→{ left, width, openUpward, top | bottom }; ouverture vers le haut quand la place sous le champ manque pour les 240 px de la liste et suffit au-dessus, enbottomCSS pour qu'une liste plus courte finisse quand même juste au-dessus du champ ; sous le champ quand la place manque des deux côtés.samePlacementévite un nouveau rendu quand un défilement ne déplace pas le champ.followAnchor(target, onMove, list)pose les deux écouteurs, ignore le défilement de la liste elle-même et rend le nettoyage, drapeau de capture compris.AdjustmentForm: le<select>de la catégorie d'une ligne devientCategoryCombobox(compact,ariaLabeladjustments.category) dans un conteneurflex-1 min-w-0, comme dans la fenêtre de répartition. Une nouvelle ligne prend toujours la première catégorie de la liste ; la sauvegarde ne change pas.useAdjustments(D2) : la liste vient detransactionService.getAllCategories()(actives et saisissables, la même requête que Transactions, plusis_inputable = 1) au lieu de sa propre requête sur toutes les actives. Lecture faite :state.categoriesn'alimente que deux sélecteurs,AdjustmentForm(parAdjustmentDetailPanel) etSplitAdjustmentModalouverte depuis la page Ajustements ; la vue de détail nomme la catégorie d'une ligne parcategory_name, tiré de la jointure degetEntriesByAdjustmentId;AdjustmentListPanelne lit aucune catégorie. Aucune liste complète n'est donc gardée pour l'affichage. Une ligne enregistrée sur une catégorie non saisissable garde sa valeur, champ vide tant qu'on ne la change pas (D2). Conséquence notée au CHANGELOG : la fenêtre de répartition ouverte depuis Ajustements propose la même liste que depuis Transactions.LinkTransfersModal: le filtre de catégorie du sélecteur de transactions devientCategoryCombobox(compact), sur le modèle deTransactionFilterBar: « Toutes les catégories » en option extra, active tant que le filtre est vide, qui le remet ànull; la liste des transactions se recharge comme avant.SplitAdjustmentModaletTransactionTable: aucun changement, le portail suffit.Fichiers
src/components/shared/CategoryCombobox.tsxopen, clic extérieur qui compte la listesrc/components/shared/placeListbox.tssrc/components/shared/placeListbox.test.tssrc/components/adjustments/AdjustmentForm.tsxsrc/components/adjustments/AdjustmentForm.render.test.tsxrenderToStaticMarkup)src/components/adjustments/AdjustmentForm.wiring.test.tshookHarness)src/hooks/useAdjustments.tssrc/hooks/useAdjustments.test.tssrc/components/balance/LinkTransfersModal.tsxsrc/components/balance/LinkTransfersModal.render.test.tsxcreatePortalremplacé par l'identité,documentfactice)src/components/balance/LinkTransfersModal.wiring.test.tssrc/i18n/locales/fr.json,en.jsondocs.adjustments.steps[1]: « tapez quelques lettres pour filtrer la liste des catégories »docs/guide-utilisateur.mddocs/architecture.mdCHANGELOG.md,CHANGELOG.fr.mdAucune clé i18n ajoutée, aucune migration, aucun fichier Rust, aucun appel réseau.
Décisions
Toutes consignées dans
reports/run-2026-10-18-lot-v018/decisions-log.md: 4 HIGH, 7 MEDIUM, 0 LOW.v0.17.2) ; sites repérés par leur contenu ; markup fermé relu sur la base (ringedCategorydeTransactionTable.render.test.tsx).z-[300]: la plus haute couche de l'app estz-[210](FeedbackConsentDialog), aucune couche en CSS ; la classe est bien produite par le build.LinkTransfersModaletAdjustmentFormsuivent les extraits de l'issue tels quels (aucunplaceholderpour la ligne d'ajustement : une catégorie absente de la liste s'affiche vide, D2) ; le<label>du filtre est gardé.bottomCSS (distance au bas de la fenêtre) : une liste de moins de 240 px finit quand même 4 px au-dessus du champ. Écartés :top = haut du champ − 244(une liste courte flotterait loin de son champ),translateY(-100%).useLayoutEffectplutôt queuseEffectpour le placement et les écouteurs : la liste est placée avant la première peinture, et une position laissée par une ouverture précédente n'est jamais peinte. Le nettoyage reste le retour de l'effet, conditionné paropen, comme le demande le plan. Conséquence : le composant ouvert ne tourne pas sous le harnais, qui refuseuseLayoutEffect(et déjàuseId).followAnchor), testée avec une cible factice qui ne retire un écouteur que par type, fonction et drapeau de capture, soit le cas des écouteurs orphelins relevé par la revue de sécurité du plan.maxHeighten style depuisLISTBOX_MAX_HEIGHTplutôt que la classemax-h-60: une seule source pour la règle de retournement et la hauteur rendue.getAllCategories()plutôt qu'une seconde copie de la requête dans le hook : une seule liste pour tous les sélecteurs de catégorie (Transactions, mot-clé #423, filtre des transferts, ajustements), et le hook ne lance plus de SQL lui-même.useAdjustments.test.ts,AdjustmentForm.wiring.test.tsetLinkTransfersModal.wiring.test.tssous le harnais, nécessaires pour rendre observables «onExtraSelectne remet pasnull», la catégorie par défaut d'une nouvelle ligne et la source de la liste. Les tests deLinkTransfersModalposent aussi undocumentfactice :createPortal(…, document.body)litdocument.bodyavant l'appel remplacé.architecture.mdsi la section composants décritCategoryCombobox» (elle ne le décrit pas) : sous-section « Portails » dans la section Tests (tester une fenêtre en portail ; ce qui du portail du combobox est épinglé et ce qui reste manuel ; la liste hors du DOM de son conteneur) ; guide §7 etdocs.adjustments.stepsFR/EN, comme #423 l'avait fait pour le champ parent.Preuves
src/zz_tmp_probe.test.tsavec unexpect(1).toBe(2)→npx vitest run src/zz_tmp_probe.test.tssonde rc=1, fichier supprimé.npm run buildvert (tsc + vite) ; la classez-[300]est dans la CSS produite.npm testsur la tête : 147 fichiers / 2375 tests verts dans le worktree, soit 6 fichiers et 27 tests de plus que la base (141 / 2348). Aucun fichier Rust touché.TransactionTable.render.test.tsx,AddKeywordDialog.render.test.tsx,CategoryForm.render.test.tsxetCategoryDetailPanel.render.test.tsxverts sans modification : le rendu fermé du combobox n'a pas bougé.3631ef0,619637e,aa22948) ; une à la fois, restauration pargit checkout --,git status --shortvide après chacune.placeListbox.tsif (false && …))placeListbox.tsplaceListbox.tsplaceListbox.tssamePlacementignoretopplaceListbox.tsscrollécouté hors phase de captureplaceListbox.tsplaceListbox.tsuseAdjustments.tsis_active = 1, toutes les actives)transactionService.tsAND is_inputable = 1retiré degetAllCategoriesLinkTransfersModal.tsxnull(setCategoryId(categoryId))LinkTransfersModal.tsxactiveExtra={null})LinkTransfersModal.tsxAdjustmentForm.tsxcompactretiréAdjustmentForm.tsx<select>de la base)AdjustmentForm.tsxAdjustmentForm.tsxamountCategoryCombobox.tsxlistRef(un clic sur une option fermerait la liste)CategoryCombobox.tsxfollowAnchorM1 à M9 ont tourné sur
placeListbox.test.ts, M2 suruseAdjustments.test.ts, M3, M12 et M13 sur les deux tests deLinkTransfersModal, M4, M5, M10 et M11 sur les deux tests d'AdjustmentForm; M2b, M16 et M17 sur toute la suite. M16 et M17 : le composant ouvert ne se rend pas sans DOM (renderToStaticMarkuple rend fermé, le harnais refuse sesuseIdetuseLayoutEffect) et jsdom est écarté par l'issue ; la logique qu'ils câblent est épinglée dansplaceListbox.test.ts, leur câblage relève des vérifications manuelles 6 (M16) et 8 (M17).Vérifications manuelles dues (prochaine release)
Dans l'app lancée (Linux), thèmes clair et sombre :
npm run tauri dev, inspecteur web, console) :getEventListeners(window).scroll?.lengthetgetEventListeners(window).resize?.lengthdonnent un de plus pendant qu'une liste est ouverte, et reviennent à leur valeur de départ après sa fermeture, y compris quand la fenêtre qui la contient se ferme liste ouverte. C'est la seule preuve de M17 : un écouteur orphelin ne se voit pas à l'écran.À regarder
PageHelp,TemplateActions,PeriodSelector,ProfileSwitcher,SecurityPickeretContextMenun'en contiennent pas). Les événements React, eux, remontent toujours par l'arbre du composant : lesstopPropagationdes fenêtres (AddKeywordDialog,LinkTransfersModal,MappingRow) continuent de s'appliquer.useIdetuseLayoutEffectdanssrc/test/hookHarness.tsrendrait le combobox ouvert testable sous le harnais (M16, M17, clavier).docs/architecture.mdau moins normal).🤖 Generated with Claude Code
Niveau : normal
Verdict : APPROVE
Auto-revue de l'auteur (worker
/autopilotdu 2026-10-18), faite avec la checklist de/pr-reviewsur le diff complet de la PR contre sa baseissue-372-systems-journal-first: ce n'est pas une revue indépendante,status:approvedn'est pas posé.Résumé
La liste ouverte de
CategoryComboboxpasse en portail surdocument.body(position: fixed,z-[300]), placée par une fonction pure (placeListbox) et gardée sur son champ par des écouteursscroll(capture) etresizequi ne vivent que pendantopen(followAnchor, nettoyage rendu par l'effet). La ligne d'ajustement et le filtre des transferts prennent le combobox ; les lignes d'ajustement ne proposent plus que les catégories actives et saisissables (D2, pargetAllCategories). Markup fermé inchangé, aucune clé i18n ajoutée, aucun appel réseau.Niveau (Step 0bis,
REVIEW.mddemain)CHANGELOG.md,CHANGELOG.fr.md,docs/guide-utilisateur.md: bas (**/*.md,docs/**).docs/architecture.md: au moins normal (exception de la liste).src/components/shared/CategoryCombobox.tsx,placeListbox.ts,src/components/adjustments/AdjustmentForm.tsx,src/components/balance/LinkTransfersModal.tsx,src/hooks/useAdjustments.tset les six fichiers de test : motifsrc/**(code livré), condition non remplie : aucun appel réseau ajouté ni modifié (getTransactionPageetgetAllCategorieslisent la base locale), aucune URL ouverte, aucun consentement touché. Normal.src/i18n/locales/*.json: normal (traductions).src-tauri/, aucun manifeste, lock ou workflow.PR : normal.
Checklist
scroll/resize, sans fermeture au défilement ; écouteurs retirés au nettoyage, drapeau de capture compris ; clic extérieur qui compte la liste ;onMouseDowndes options gardé ;aria-controls/aria-activedescendantvers des ids du même document. Les événements React d'une option remontent toujours par l'arbre du composant : lesstopPropagationdeAddKeywordDialog,LinkTransfersModaletMappingRows'appliquent comme avant ; aucun écouteur natif de clic extérieur n'entoure un combobox (PageHelp,TemplateActions,PeriodSelector,ProfileSwitcher,SecurityPicker,ContextMenurelus). Thème : variables sur:rootet.darksur<html>, donc héritées dansbody. D2 :state.categoriesn'alimente que deux sélecteurs ; la vue de détail litcategory_namede la jointure.hookHarnesspour tout ce qui passe par un effet ou un gestionnaire, rendu de nouveau après chaque setter ; fauxtmarqué ; assertions sur attributs (value="…",class="w-full px-2 py-1), pas sur des sous-chaînes ambiguës. Sonde rc=1.pull_refcloses, venant de cette PR, rien depuis la prose.getAllCategoriesqui passe par le verrou dedb.ts; aucun booléen lié ; aucune écriture.Mutations du relecteur (en plus des 18 de l'auteur, tableau du corps)
placeListbox.tsplaceListbox.tsresizejamais écoutéLinkTransfersModal.tsxcompactretiré du filtreAdjustmentForm.tsxcategories.slice(1))Deux sondes jetables (supprimées) vérifient l'affirmation de
architecture.md: sans DOM, le vraicreatePortallève « Target container is not a DOM element » ; avec un conteneur factice,renderToStaticMarkuplève « Portals are not currently supported by the server renderer ».Constats, tous corrigés
CHANGELOG.mdetCHANGELOG.fr.mddisaient que la liste s'ouvre vers le haut quand la place manque en dessous ; elle ne le fait que si elle tient au-dessus. Corrigé dans8745635.docs/architecture.md, « Portails » : les deux messages d'erreur cités, pour qu'un test qui les rencontre trouve la section (8745635) ;StarterAccountsModalse lisait comme déjà testé ainsi, reformulé (14ebf0f).getEventListeners(window)dans l'inspecteur web).Non bloquant
useIdetuseLayoutEffectrefusés par le harnais,renderToStaticMarkuple rend fermé). La logique est épinglée dansplaceListbox.test.ts, le câblage dans le composant tient en deux lignes lisibles ; les vérifications manuelles 6 et 8 le couvrent. Suggestion de suivi : couvriruseIdetuseLayoutEffectdanssrc/test/hookHarness.tsrendrait le composant ouvert testable (clavier, clic, portail).getEventListenersest une commande de la console de l'inspecteur WebKit ; si elle manque sous WebKitGTK, un point d'arrêt surfollowAnchoret sur son nettoyage donne la même preuve.check-frontenddoit tourner,check-rustnon (aucun cheminsrc-tauri/**).🤖 Generated with Claude Code
CI lue par l'orchestrateur du run
/autopilotdu 2026-10-18 :check-frontendvert sur la tête14ebf0f(run 579).placeListbox.test.ts(13),AdjustmentForm.render.test.tsx(5),AdjustmentForm.wiring.test.ts(3),LinkTransfersModal.render.test.tsx(2),LinkTransfersModal.wiring.test.ts(2) etuseAdjustments.test.ts(2).TransactionTable.render.test.tsx(6) est vert sans modification : le markup du combobox fermé n'a pas changé.locales.keys.test.ts(8) est verte.check-rustne s'applique pas à ce maillon (aucun chemin Rust touché).Niveau : normal
Verdict : APPROVE
Résumé : le portail tient ce que la PR annonce. Sous jsdom, dans une copie jetable, le combobox ouvert se rend sur
body, se place sous son champ ou au-dessus, suit un défilement de conteneur et unresize, garde la liste ouverte sur unmousedownd'option et retire ses écouteurs à la fermeture comme au démontage : M16 et M17, déclarées non observables, rougissent toutes deux sous mutation. D2, le câblage d'AdjustmentFormet le filtre des transferts sont épinglés. Trois suggestions, aucune bloquante.Ce qui a tourné
Copie hors dépôt (clone local, tête
14ebf0frécupérée sur Forgejo), supprimée en fin de revue.npm ci, sonde vitest rc=1,npm run buildvert (.z-\[300\]{z-index:300}présent dans la CSS produite),npm test: 147 fichiers, 2375 tests verts, les chiffres du corps et de la CI (check-frontendvert sur la tête, run 579).--no-save, rien de commité), 8 cas sur le combobox ouvert : liste enfant debodyet hors du conteneur, classesfixedetz-[300],top/left/widthsous le champ,bottomà 104 px près du bas de la fenêtre, suivi d'unscrollde conteneur et d'unresize,scrollde la liste ignoré,mousedownpuis clic sur une option qui sélectionne,mousedownextérieur qui ferme sans sélectionner, écouteursscrolletresizeà +1 pendant l'ouverture et revenus à 0 après Échap comme après un démontage liste ouverte, flèche bas puis Entrée. 8/8 verts sur la tête.LinkTransfersModal(fenêtre et liste toutes deux en portail) : choisir une catégorie recharge avec son id sans appeleronClose(lestopPropagationde la fenêtre s'applique par l'arbre React), « Toutes les catégories » remetnull, la saisie filtre (epar→ « Épargne »). 2/2 verts.placeListbox.tsplaceListbox.test.tsplaceListbox.tsspaceBelow < maxHeight→<=placeListbox.tsspaceAbove >= maxHeight→>placeListbox.tstopfixe (haut du champ − 244)placeListbox.tsplaceListbox.tssamePlacementignoreleftplaceListbox.tsresizeécouté en captureplaceListbox.tscontains(défilement d'une option compté)placeListbox.tsresizeAdjustmentForm.tsxAdjustmentForm.*AdjustmentForm.tsxvalue={null}LinkTransfersModal.tsxLinkTransfersModal.*LinkTransfersModal.tsxonChangequi remetnulluseAdjustments.tsuseAdjustments.test.tsCategoryCombobox.tsxlistRefCategoryCombobox.tsxCategoryCombobox.tsxdocument(aucunresizereçu)CategoryCombobox.tsxCategoryCombobox.tsxUne mutation à la fois, restauration par
git checkout --,git diff --statvide après chacune.Vérifié à la lecture
z-[210](FeedbackConsentDialog) ; aucunz-indexen CSS.:root, classe.darkposée sur<html>paruseTheme: le portail en hérite.PageHelpetSecurityPickerne citentCategoryComboboxqu'en commentaire ;AddKeywordDialog,LinkTransfersModaletMappingRowpassent par unstopPropagationReact, qui suit l'arbre du composant.getAllCategoriesfaitSELECT *avecis_active = 1 AND is_inputable = 1, mêmes colonnes que l'ancienne requête ;state.categoriesn'alimente qu'AdjustmentForm(parAdjustmentDetailPanel) etSplitAdjustmentModal; la vue de détail litcategory_namede la jointure degetEntriesByAdjustmentId. Le hook ne lance plus de SQL lui-même, conforme à la règle « logique métier dansservices/».AppShellest enh-screen overflow-hidden: le document ne défile pas,window.innerHeightvaut la hauteur de référence duposition: fixed.architecture.mdexistent dans react-dom 19.2.4 ;StarterAccountsModaletDetailAccountWizardrendent bien parcreatePortal.docs.adjustments.steps[1]modifiée dans les deux langues, guide §7 aligné ; aucune clé ajoutée.pull_refcloses, venant de cette PR, etResolves #429figure aussi dans les messages des trois derniers commits.Suggestions (non bloquantes)
placeListbox.test.ts:33(« stays below when the room below is exactly the list's maximum height ») place le champ à 100 px dans une fenêtre de 374 px : 96 px au-dessus, donc la liste reste dessous avec<comme avec<=. Effet nul pour l'utilisateur (un pixel), mais le test promet plus qu'il ne prouve. AvecanchorAt(300)etviewportHeight: 574(240 px dessous, 296 au-dessus), le cas est vert sur la tête et rouge sous R2 (vérifié dans la copie).scrollniresize. Observé sous jsdom : champ descendu de 200 px par un rendu du parent, liste restée àtop: 134px. L'ancienne listeabsolutesuivait dans ce cas. Cas réel :LinkTransfersModal.tsx:206etAddKeywordDialog.tsx:137sont centrées verticalement (items-center) et changent de hauteur quand leur contenu asynchrone arrive (lignes de transactions, aperçu des correspondances) ; une liste ouverte avant cette arrivée reste décalée de la moitié de l'écart jusqu'au prochain défilement. Fenêtre de quelques dizaines de millisecondes après l'ouverture, clic sur une option toujours correct : pas bloquant. Piste éprouvée dans la copie : unuseLayoutEffectsans tableau de dépendances qui appelleupdatePlacement()tant queopen. Le cas jsdom passe alors (liste à334px), les 9 autres cas restent verts, sans boucle de rendu (samePlacementrend l'état précédent) ; tout rendu du parent repasse par le combobox, qui n'est pas mémoïsé.AddKeywordDialog.tsx:149-151justifie la sortie des deux champs du bloc défilant par une liste que le scrollport couperait. Avec le portail, ce n'est plus la raison ; le reformuler évite qu'un lecteur croie la contrainte toujours active.Les trois tiennent dans un suivi, avec celui que propose le corps (
useIdetuseLayoutEffectdanshookHarness). Le maillon 5 (#393, PR #472) réutilise le combobox dans une fenêtre : la suggestion 2 le concerne aussi si cette fenêtre charge du contenu après son ouverture.Vérifications manuelles
Les 9 du corps restent dues sous WebKitGTK : peinture réelle, thèmes,
z-[300]au-dessus des fenêtres,getEventListeners. Les tests jetables prouvent la logique de 6 et 8 dans un DOM, pas le rendu de WebKitGTK.Actions faites
/tmp/pr471-*) ; dépôt de travail non touché.status:review→status:approved.🤖 Generated with Claude Code
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.Merge
Merge the changes and update on Forgejo.