diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 2ced1e7..c1bae37 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -2,6 +2,10 @@ ## [Non publié] +### Modifié + +- Migration des catégories (Paramètres → Catégories → migrer vers le jeu standard) : la catégorie cible de l'étape d'aperçu est désormais modifiable sur **chaque** ligne, et non plus seulement celles « à réviser ». Chaque ligne offre un champ de recherche avec autocomplétion (insensible aux accents), pour corriger une cible détectée automatiquement aussi facilement que résoudre une ligne sans correspondance (#246). + ### Sécurité - Mise à jour de dépendances pour corriger quatre advisories signalés par `npm audit` : `react-router-dom` 7.13 → 7.18.1 (ce qui tire `react-router` au-delà des advisories RCE turbo-stream, DoS `__manifest`/single-fetch et XSS de redirection RSC), plus l'outillage de développement `vite` 6.4.2 → 6.4.3 et `vitest` 4.0.18 → 4.1.9. Aucun changement de comportement. L'app est livrée comme un client de bureau local : les advisories runtime (qui nécessitent un serveur SSR/RSC react-router) ne s'appliquaient pas au produit livré ; ce sont des mises à jour de durcissement (#235, #236, #237, #238). diff --git a/CHANGELOG.md b/CHANGELOG.md index 9442e91..d3488b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Categories migration (Settings → Categories → migrate to the standard set): the target category on the preview step is now editable on **every** row, not only the "needs review" ones. Each row carries a type-ahead picker (accent-insensitive search) so you can override an auto-detected target as easily as resolving an unmatched one (#246). + ### Security - Updated dependencies to clear four advisories reported by `npm audit`: `react-router-dom` 7.13 → 7.18.1 (which pulls `react-router` past the turbo-stream RCE, `__manifest`/single-fetch DoS, and RSC-redirect XSS advisories), plus the dev toolchain `vite` 6.4.2 → 6.4.3 and `vitest` 4.0.18 → 4.1.9. No behaviour change. The app ships as a local desktop client, so the runtime advisories (which require a react-router SSR/RSC server) did not apply to the shipped product; these are hardening bumps (#235, #236, #237, #238). diff --git a/src/components/categories-migration/MappingRow.tsx b/src/components/categories-migration/MappingRow.tsx index 0a0550a..b770b04 100644 --- a/src/components/categories-migration/MappingRow.tsx +++ b/src/components/categories-migration/MappingRow.tsx @@ -1,6 +1,6 @@ -import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy"; +import CategoryCombobox from "../shared/CategoryCombobox"; +import type { Category } from "../../shared/types"; import type { MappingRow as MappingRowType, ConfidenceBadge, @@ -13,13 +13,19 @@ interface MappingRowProps { /** Callback fired when the row is clicked — opens the preview panel. */ onSelect: (v2CategoryId: number) => void; /** - * Called with the new v1 target id + name when the user resolves the row - * via the inline dropdown. The dropdown is only rendered for unresolved - * ("🟠 needs review") rows — resolved rows just show the target name. + * Called with the new v1 target id + name when the user picks a target via + * the inline type-ahead combobox. Editable on EVERY row (resolved or not), + * so a user can override an auto-detected target as well as resolve a + * "needs review" one. */ onResolve: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void; /** Number of transactions currently attached to this v2 category. */ transactionCount: number; + /** + * v1 leaf catalogue adapted to the `Category` shape the combobox expects. + * Computed ONCE by StepSimulate and shared across rows (perf). + */ + targetCategories: Category[]; } function badgeClass(confidence: ConfidenceBadge): string { @@ -41,14 +47,9 @@ export default function MappingRow({ onSelect, onResolve, transactionCount, + targetCategories, }: MappingRowProps) { const { t } = useTranslation(); - const { getLeaves } = useCategoryTaxonomy(); - - // For the resolve dropdown: all v1 leaves (terminal categories). We keep the - // list flat because the simulate row is narrow; the search box in step 2 - // already helps users find a target by keyword. - const v1Leaves = useMemo(() => getLeaves(), [getLeaves]); const badgeLabel = t( `categoriesSeed.migration.simulate.confidence.${row.confidence}`, @@ -59,12 +60,18 @@ export default function MappingRow({ const isUnresolved = row.v1TargetId === null || row.v1TargetId === undefined; - const handleResolveChange = (ev: React.ChangeEvent) => { - const v1TargetId = Number(ev.target.value); - if (!Number.isFinite(v1TargetId) || v1TargetId <= 0) return; - const leaf = v1Leaves.find((l) => l.id === v1TargetId); - if (!leaf) return; - const name = t(leaf.i18n_key, { defaultValue: leaf.name }); + // Fired when the user picks a v1 leaf in the type-ahead combobox. The + // combobox only emits ids that exist in `targetCategories` (never null with + // our config), but we guard defensively. Resolving a "none" row bumps its + // confidence to "medium" via the reducer; editing an already-resolved row + // keeps its confidence unchanged (see RESOLVE_ROW). + const handleTargetChange = (v1TargetId: number | null) => { + if (v1TargetId === null) return; + const target = targetCategories.find((c) => c.id === v1TargetId); + if (!target) return; + const name = target.i18n_key + ? t(target.i18n_key, { defaultValue: target.name }) + : target.name; onResolve(row.v2CategoryId, v1TargetId, name); }; @@ -119,32 +126,26 @@ export default function MappingRow({ - {/* v1 target (or picker) */} + {/* v1 target — editable type-ahead combobox on EVERY row. Stop click + + keydown from bubbling so interacting with the picker (typing spaces, + Enter to select) never triggers the row's select/preview handler. */}
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} > - {isUnresolved ? ( - - ) : ( - - {targetDisplayName} - - )} +
+ +
); diff --git a/src/components/categories-migration/StepSimulate.tsx b/src/components/categories-migration/StepSimulate.tsx index 64f7d54..e88c31b 100644 --- a/src/components/categories-migration/StepSimulate.tsx +++ b/src/components/categories-migration/StepSimulate.tsx @@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next"; import { ArrowLeft, ArrowRight, AlertTriangle, FolderHeart } from "lucide-react"; import MappingRow from "./MappingRow"; import TransactionPreviewPanel from "./TransactionPreviewPanel"; +import { taxonomyToComboboxCategories } from "./migrationTargets"; +import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy"; import type { MigrationPlan, MappingRow as MappingRowType, @@ -36,6 +38,16 @@ export default function StepSimulate({ onBack, }: StepSimulateProps) { const { t } = useTranslation(); + const { taxonomy } = useCategoryTaxonomy(); + + // Full v1 taxonomy adapted to the combobox `Category` shape (hierarchical). + // Computed once here (not per row) and shared across every MappingRow's + // target picker. Includes non-leaf parents so mid-tree default targets like + // "Divertissement" (id 1710) display and can be re-picked. + const targetCategories = useMemo( + () => taxonomyToComboboxCategories(taxonomy.roots), + [taxonomy], + ); const selectedRow = useMemo(() => { if (selectedRowV2Id === null) return null; @@ -149,6 +161,7 @@ export default function StepSimulate({ transactionCount={ transactionCountByV2Id.get(row.v2CategoryId) ?? 0 } + targetCategories={targetCategories} /> ))} diff --git a/src/components/categories-migration/migrationTargets.test.ts b/src/components/categories-migration/migrationTargets.test.ts new file mode 100644 index 0000000..de0c854 --- /dev/null +++ b/src/components/categories-migration/migrationTargets.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { taxonomyToComboboxCategories } from "./migrationTargets"; +import { + getTaxonomyV1, + type TaxonomyNode, +} from "../../services/categoryTaxonomyService"; + +function node( + id: number, + name: string, + children: TaxonomyNode[] = [], + type: "expense" | "income" | "transfer" = "expense", +): TaxonomyNode { + return { + id, + name, + i18n_key: `categoriesSeed.test.${id}`, + type, + color: "#123456", + sort_order: 1, + children, + }; +} + +describe("taxonomyToComboboxCategories", () => { + it("returns [] for empty input", () => { + expect(taxonomyToComboboxCategories([])).toEqual([]); + }); + + it("flattens the tree in parent-before-children (DFS) order", () => { + const roots = [ + node(1000, "Revenus", [node(1010, "Emploi", [node(1011, "Paie")])]), + node(1100, "Alimentation", [node(1111, "Épicerie")]), + ]; + const out = taxonomyToComboboxCategories(roots); + expect(out.map((c) => c.id)).toEqual([1000, 1010, 1011, 1100, 1111]); + }); + + it("wires real parent_id (undefined for roots) so the combobox can nest", () => { + const roots = [node(1000, "Revenus", [node(1010, "Emploi", [node(1011, "Paie")])])]; + const byId = new Map(taxonomyToComboboxCategories(roots).map((c) => [c.id, c])); + expect(byId.get(1000)!.parent_id).toBeUndefined(); + expect(byId.get(1010)!.parent_id).toBe(1000); + expect(byId.get(1011)!.parent_id).toBe(1010); + }); + + it("marks only leaves inputable; parents are selectable but non-inputable", () => { + const roots = [node(1700, "Loisirs", [node(1710, "Divertissement", [node(1711, "Cinéma")])])]; + const byId = new Map(taxonomyToComboboxCategories(roots).map((c) => [c.id, c])); + expect(byId.get(1700)!.is_inputable).toBe(false); + expect(byId.get(1710)!.is_inputable).toBe(false); // intermediate parent + expect(byId.get(1711)!.is_inputable).toBe(true); // leaf + }); + + it("copies id/name/type/color/i18n_key and defaults is_active + created_at", () => { + const [c] = taxonomyToComboboxCategories([node(1111, "Épicerie", [], "expense")]); + expect(c).toMatchObject({ + id: 1111, + name: "Épicerie", + type: "expense", + color: "#123456", + i18n_key: "categoriesSeed.test.1111", + is_active: true, + is_inputable: true, + sort_order: 1, + }); + expect(typeof c.created_at).toBe("string"); + }); + + it("adapts the real bundled v1 taxonomy: every node present, ids unique, non-leaf target 1710 offered", () => { + const out = taxonomyToComboboxCategories(getTaxonomyV1().roots); + expect(out.length).toBe(150); // 11 roots + 27 intermediates + 112 leaves + const ids = new Set(out.map((c) => c.id)); + expect(ids.size).toBe(out.length); + // The migration default maps v2 cat 26 → v1 1710 (a non-leaf parent), so it + // must be selectable/displayable in the picker. + const divertissement = out.find((c) => c.id === 1710); + expect(divertissement).toBeDefined(); + expect(divertissement!.is_inputable).toBe(false); + // Leaves stay inputable — a random known leaf. + expect(out.find((c) => c.id === 1111)!.is_inputable).toBe(true); + }); +}); diff --git a/src/components/categories-migration/migrationTargets.ts b/src/components/categories-migration/migrationTargets.ts new file mode 100644 index 0000000..f021b0e --- /dev/null +++ b/src/components/categories-migration/migrationTargets.ts @@ -0,0 +1,43 @@ +import type { Category } from "../../shared/types"; +import type { TaxonomyNode } from "../../services/categoryTaxonomyService"; + +/** + * Flatten the v1 taxonomy tree into `Category`-shaped rows that + * `CategoryCombobox` can render (hierarchical, accent-insensitive type-ahead). + * + * We map EVERY node — roots, intermediate parents AND leaves — not only the + * leaves: the migration's default mapping table can point a v2 category at a + * non-leaf parent (e.g. v2 "Jeux, Films & Livres" → v1 "Divertissement", + * id 1710), so the picker must be able to both display and offer those nodes. + * Feeding the full tree also lets the combobox reproduce the taxonomy hierarchy + * (parents before children, siblings by sort order) — the same shape the + * by-category report picker already uses. + * + * Each row carries its real `parent_id` and `sort_order`; `is_inputable` + * mirrors the DB (leaves only), matching what the migration writer inserts via + * `listAllV1Rows()`. Any id the user can pick here therefore exists in the DB + * after the migration, so the resulting mapping is always FK-safe. + * + * Pure helper — no i18n, no DB, no React. The display name is resolved by the + * combobox at render time via `i18n_key` (falling back to `name`). + */ +export function taxonomyToComboboxCategories(roots: TaxonomyNode[]): Category[] { + const out: Category[] = []; + const walk = (node: TaxonomyNode, parentId: number | null): void => { + out.push({ + id: node.id, + name: node.name, + parent_id: parentId ?? undefined, + color: node.color, + type: node.type, + is_active: true, + is_inputable: node.children.length === 0, + sort_order: node.sort_order, + i18n_key: node.i18n_key, + created_at: "", + }); + for (const child of node.children) walk(child, node.id); + }; + for (const root of roots) walk(root, null); + return out; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2610644..da043aa 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1435,6 +1435,7 @@ "loadError": "Failed to load profile data: {{error}}", "needsReview": "Needs review", "chooseTarget": "Choose a target...", + "editTargetAria": "Change the target for {{category}}", "txCount_one": "{{count}} transaction", "txCount_other": "{{count}} transactions", "unresolvedWarning_one": "You have {{count}} decision to make before you can continue.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 5e8d2a2..27133ff 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1435,6 +1435,7 @@ "loadError": "Impossible de charger les données du profil : {{error}}", "needsReview": "À réviser", "chooseTarget": "Choisir une cible...", + "editTargetAria": "Modifier la cible pour {{category}}", "txCount_one": "{{count}} transaction", "txCount_other": "{{count}} transactions", "unresolvedWarning_one": "Vous avez {{count}} décision à prendre avant de pouvoir continuer.",