From e45736bbdea09c03a0153b2dd70b53feaa8a1caf Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 4 Jul 2026 18:17:10 -0400 Subject: [PATCH] feat(categories): editable migration target with type-ahead (#246) Make the v1 target editable on EVERY row of the categories-migration preview (StepSimulate), not only the "needs review" ones. Each row now renders a reused CategoryCombobox (accent-insensitive type-ahead, keyboard, hierarchical) in place of the read-only text / flat - - {v1Leaves.map((leaf) => ( - - ))} - - ) : ( - - {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.",