From e45736bbdea09c03a0153b2dd70b53feaa8a1caf Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 4 Jul 2026 18:17:10 -0400 Subject: [PATCH 1/3] 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.", From ce2793fc2c7e969f8118106e5d50b2f51bf35421 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 4 Jul 2026 20:40:29 -0400 Subject: [PATCH 2/3] refactor(categories): migration target picker lists leaves only The v1 target combobox in the migration preview now offers only leaf categories (the inputable end-categories), not intermediate parents, so a transaction is never mapped to a grouping bucket. Leaves render as a flat, un-indented list in taxonomy order. A low-confidence default can still point a v2 category at a non-leaf parent (e.g. 'Divertissement' #1710); MappingRow re-injects that current target for its single row via findTaxonomyCategory so the input stays populated, while new picks remain leaves-only. Adds a findTaxonomyCategory pure helper + tests (adapter now returns 112 leaves, not the 150-node full tree). --- .../categories-migration/MappingRow.tsx | 29 ++++- .../categories-migration/StepSimulate.tsx | 24 +++-- .../migrationTargets.test.ts | 100 +++++++++++++----- .../categories-migration/migrationTargets.ts | 98 +++++++++++------ 4 files changed, 185 insertions(+), 66 deletions(-) diff --git a/src/components/categories-migration/MappingRow.tsx b/src/components/categories-migration/MappingRow.tsx index b770b04..8642f77 100644 --- a/src/components/categories-migration/MappingRow.tsx +++ b/src/components/categories-migration/MappingRow.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import CategoryCombobox from "../shared/CategoryCombobox"; import type { Category } from "../../shared/types"; @@ -22,10 +23,18 @@ interface MappingRowProps { /** 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). + * v1 LEAF catalogue adapted to the `Category` shape the combobox expects. + * Computed ONCE by StepSimulate and shared across rows (perf). Leaves only — + * a transaction is never mapped to a grouping bucket. */ targetCategories: Category[]; + /** + * Resolves a target id (possibly a non-leaf parent absent from the leaves-only + * `targetCategories`) to a display `Category`. Used to keep a low-confidence + * parent default like "Divertissement" (#1710) visible on its row without + * offering parents as new picks. + */ + resolveTarget: (id: number) => Category | null; } function badgeClass(confidence: ConfidenceBadge): string { @@ -48,9 +57,23 @@ export default function MappingRow({ onResolve, transactionCount, targetCategories, + resolveTarget, }: MappingRowProps) { const { t } = useTranslation(); + // The picker lists leaves only. If this row's current target is a non-leaf + // parent (a low-confidence default like "Divertissement" #1710), it is absent + // from that list and would blank the combobox input, so re-inject it for this + // row only — shown as the current value, never offered as a new pick. + const comboboxCategories = useMemo(() => { + const id = row.v1TargetId; + if (id == null || targetCategories.some((c) => c.id === id)) { + return targetCategories; + } + const current = resolveTarget(id); + return current ? [...targetCategories, current] : targetCategories; + }, [targetCategories, row.v1TargetId, resolveTarget]); + const badgeLabel = t( `categoriesSeed.migration.simulate.confidence.${row.confidence}`, ); @@ -136,7 +159,7 @@ export default function MappingRow({ >
taxonomyToComboboxCategories(taxonomy.roots), [taxonomy], ); + // Resolve a target id (possibly a non-leaf parent absent from the leaves-only + // list) to a display Category, so a row keeps showing a low-confidence parent + // default like "Divertissement" (#1710) without offering parents as picks. + const resolveTarget = useCallback( + (id: number) => findTaxonomyCategory(taxonomy.roots, id), + [taxonomy], + ); + const selectedRow = useMemo(() => { if (selectedRowV2Id === null) return null; return ( @@ -162,6 +173,7 @@ export default function StepSimulate({ transactionCountByV2Id.get(row.v2CategoryId) ?? 0 } targetCategories={targetCategories} + resolveTarget={resolveTarget} /> ))} diff --git a/src/components/categories-migration/migrationTargets.test.ts b/src/components/categories-migration/migrationTargets.test.ts index de0c854..8842e90 100644 --- a/src/components/categories-migration/migrationTargets.test.ts +++ b/src/components/categories-migration/migrationTargets.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from "vitest"; -import { taxonomyToComboboxCategories } from "./migrationTargets"; +import { + taxonomyToComboboxCategories, + findTaxonomyCategory, +} from "./migrationTargets"; import { getTaxonomyV1, type TaxonomyNode, @@ -22,38 +25,50 @@ function node( }; } -describe("taxonomyToComboboxCategories", () => { +describe("taxonomyToComboboxCategories (leaves only)", () => { it("returns [] for empty input", () => { expect(taxonomyToComboboxCategories([])).toEqual([]); }); - it("flattens the tree in parent-before-children (DFS) order", () => { + it("keeps only leaves, dropping every intermediate parent, in DFS order", () => { const roots = [ - node(1000, "Revenus", [node(1010, "Emploi", [node(1011, "Paie")])]), + node(1000, "Revenus", [ + node(1010, "Emploi", [node(1011, "Paie"), node(1012, "Prime")]), + ]), node(1100, "Alimentation", [node(1111, "Épicerie")]), ]; const out = taxonomyToComboboxCategories(roots); - expect(out.map((c) => c.id)).toEqual([1000, 1010, 1011, 1100, 1111]); + // parents 1000/1010/1100 dropped; leaves kept in depth-first reading order + expect(out.map((c) => c.id)).toEqual([1011, 1012, 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("flattens leaves: parent_id undefined + running sort_order (flat list)", () => { + const roots = [ + node(1000, "Revenus", [ + node(1010, "Emploi", [node(1011, "Paie"), node(1012, "Prime")]), + ]), + node(1100, "Alimentation", [node(1111, "Épicerie")]), + ]; + const out = taxonomyToComboboxCategories(roots); + expect(out.every((c) => c.parent_id === undefined)).toBe(true); + expect(out.map((c) => c.sort_order)).toEqual([0, 1, 2]); }); - 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("marks every returned row inputable (all leaves)", () => { + const roots = [ + node(1700, "Loisirs", [ + node(1710, "Divertissement", [node(1711, "Cinéma")]), + ]), + ]; + const out = taxonomyToComboboxCategories(roots); + expect(out.map((c) => c.id)).toEqual([1711]); // 1700 + 1710 parents dropped + expect(out.every((c) => c.is_inputable)).toBe(true); }); it("copies id/name/type/color/i18n_key and defaults is_active + created_at", () => { - const [c] = taxonomyToComboboxCategories([node(1111, "Épicerie", [], "expense")]); + const [c] = taxonomyToComboboxCategories([ + node(1111, "Épicerie", [], "expense"), + ]); expect(c).toMatchObject({ id: 1111, name: "Épicerie", @@ -62,22 +77,53 @@ describe("taxonomyToComboboxCategories", () => { 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", () => { + it("adapts the real v1 taxonomy: 112 leaves, ids unique, NO non-leaf parent offered", () => { const out = taxonomyToComboboxCategories(getTaxonomyV1().roots); - expect(out.length).toBe(150); // 11 roots + 27 intermediates + 112 leaves + expect(out.length).toBe(112); // leaves only (was 150 incl. 38 parents) 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. + // The non-leaf parent 1710 "Divertissement" must NOT be a selectable option. + expect(out.find((c) => c.id === 1710)).toBeUndefined(); + // A known leaf is present and inputable. expect(out.find((c) => c.id === 1111)!.is_inputable).toBe(true); + expect(out.every((c) => c.is_inputable)).toBe(true); + }); +}); + +describe("findTaxonomyCategory", () => { + const roots = [ + node(1700, "Loisirs", [ + node(1710, "Divertissement", [node(1711, "Cinéma")]), + ]), + ]; + + it("resolves a non-leaf parent by id (to display a current target)", () => { + const c = findTaxonomyCategory(roots, 1710); + expect(c).toMatchObject({ + id: 1710, + name: "Divertissement", + is_inputable: false, + }); + expect(c!.parent_id).toBeUndefined(); + }); + + it("resolves a leaf too", () => { + expect(findTaxonomyCategory(roots, 1711)).toMatchObject({ + id: 1711, + is_inputable: true, + }); + }); + + it("returns null for an unknown id", () => { + expect(findTaxonomyCategory(roots, 9999)).toBeNull(); + }); + + it("finds the real low-confidence default target 1710 in the bundled taxonomy", () => { + const c = findTaxonomyCategory(getTaxonomyV1().roots, 1710); + expect(c).toMatchObject({ id: 1710, is_inputable: false }); }); }); diff --git a/src/components/categories-migration/migrationTargets.ts b/src/components/categories-migration/migrationTargets.ts index f021b0e..e92df70 100644 --- a/src/components/categories-migration/migrationTargets.ts +++ b/src/components/categories-migration/migrationTargets.ts @@ -2,42 +2,80 @@ 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). + * Flatten the v1 taxonomy into `Category`-shaped rows for the migration target + * picker, keeping ONLY the leaves — the inputable end-categories a transaction + * can actually be filed under. Intermediate parents (grouping buckets) are + * dropped so the type-ahead offers real targets only, never a bucket. * - * 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. + * Leaves are emitted in depth-first reading order and carry a running + * `sort_order` with `parent_id` undefined, so `CategoryCombobox` renders them as + * one flat, un-indented list in taxonomy order (leaves stay grouped under their + * original parent) instead of re-scrambling them by the per-parent sort_order. * - * 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. + * Every leaf id is `is_inputable` in the DB after the migration, so any pick is + * 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`). + * Edge case: a low-confidence default can point a v2 category at a NON-leaf + * parent (e.g. v2 "Jeux, Films & Livres" -> v1 "Divertissement" #1710). Such a + * value is absent from this leaves-only list; `MappingRow` re-injects it for that + * single row (via `findTaxonomyCategory`) so the input still shows the current + * suggestion, while new picks stay leaves-only. + * + * Pure helper — no i18n, no DB, no React. */ 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); + let order = 0; + const walk = (node: TaxonomyNode): void => { + if (node.children.length === 0) { + out.push({ + id: node.id, + name: node.name, + parent_id: undefined, + color: node.color, + type: node.type, + is_active: true, + is_inputable: true, + sort_order: order++, + i18n_key: node.i18n_key, + created_at: "", + }); + return; + } + for (const child of node.children) walk(child); }; - for (const root of roots) walk(root, null); + for (const root of roots) walk(root); return out; } + +/** + * Resolve any taxonomy node (leaf OR parent) by id into the `Category` shape, + * for DISPLAY of a current non-leaf target that the leaves-only picker omits. + * `sort_order` is pushed to the end so, when injected, the node sorts last. + * Returns null when the id is absent from the taxonomy. Pure helper. + */ +export function findTaxonomyCategory( + roots: TaxonomyNode[], + id: number, +): Category | null { + const stack: TaxonomyNode[] = [...roots]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.id === id) { + return { + id: node.id, + name: node.name, + parent_id: undefined, + color: node.color, + type: node.type, + is_active: true, + is_inputable: node.children.length === 0, + sort_order: Number.MAX_SAFE_INTEGER, + i18n_key: node.i18n_key, + created_at: "", + }; + } + for (const child of node.children) stack.push(child); + } + return null; +} From 4f87ce329cda18b4bac1b4fbca74cac434ddd263 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 5 Jul 2026 16:53:27 -0400 Subject: [PATCH 3/3] polish(categories): honest doc + extract/test picker-options glue (#246 review) Address the two non-blocking review notes on PR #252: - The re-injected non-leaf parent (e.g. #1710) is technically clickable, not 'never offered'; correct the MappingRow comment to state selecting it is a no-op (only leaves resolve). - Extract the per-row option-building glue into a pure, unit-tested helper comboboxCategoriesForTarget (4 cases: null/leaf pass-through by reference, non-leaf append, unresolvable stale id). --- .../categories-migration/MappingRow.tsx | 17 ++++--- .../migrationTargets.test.ts | 48 +++++++++++++++++++ .../categories-migration/migrationTargets.ts | 29 +++++++++++ 3 files changed, 85 insertions(+), 9 deletions(-) diff --git a/src/components/categories-migration/MappingRow.tsx b/src/components/categories-migration/MappingRow.tsx index 8642f77..79d2580 100644 --- a/src/components/categories-migration/MappingRow.tsx +++ b/src/components/categories-migration/MappingRow.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import CategoryCombobox from "../shared/CategoryCombobox"; +import { comboboxCategoriesForTarget } from "./migrationTargets"; import type { Category } from "../../shared/types"; import type { MappingRow as MappingRowType, @@ -64,15 +65,13 @@ export default function MappingRow({ // The picker lists leaves only. If this row's current target is a non-leaf // parent (a low-confidence default like "Divertissement" #1710), it is absent // from that list and would blank the combobox input, so re-inject it for this - // row only — shown as the current value, never offered as a new pick. - const comboboxCategories = useMemo(() => { - const id = row.v1TargetId; - if (id == null || targetCategories.some((c) => c.id === id)) { - return targetCategories; - } - const current = resolveTarget(id); - return current ? [...targetCategories, current] : targetCategories; - }, [targetCategories, row.v1TargetId, resolveTarget]); + // row so it stays visible. It remains clickable, but selecting it is a no-op — + // handleTargetChange only resolves ids present in `targetCategories` (leaves) — + // so any pick that takes effect is a leaf. See comboboxCategoriesForTarget. + const comboboxCategories = useMemo( + () => comboboxCategoriesForTarget(targetCategories, row.v1TargetId ?? null, resolveTarget), + [targetCategories, row.v1TargetId, resolveTarget], + ); const badgeLabel = t( `categoriesSeed.migration.simulate.confidence.${row.confidence}`, diff --git a/src/components/categories-migration/migrationTargets.test.ts b/src/components/categories-migration/migrationTargets.test.ts index 8842e90..2f2c2da 100644 --- a/src/components/categories-migration/migrationTargets.test.ts +++ b/src/components/categories-migration/migrationTargets.test.ts @@ -2,7 +2,9 @@ import { describe, it, expect } from "vitest"; import { taxonomyToComboboxCategories, findTaxonomyCategory, + comboboxCategoriesForTarget, } from "./migrationTargets"; +import type { Category } from "../../shared/types"; import { getTaxonomyV1, type TaxonomyNode, @@ -127,3 +129,49 @@ describe("findTaxonomyCategory", () => { expect(c).toMatchObject({ id: 1710, is_inputable: false }); }); }); + +describe("comboboxCategoriesForTarget", () => { + const leaf = (id: number): Category => ({ + id, + name: `Leaf ${id}`, + parent_id: undefined, + type: "expense", + is_active: true, + is_inputable: true, + sort_order: id, + created_at: "", + }); + const leaves = [leaf(1111), leaf(1121), leaf(1131)]; + const parent1710: Category = { + id: 1710, + name: "Divertissement", + parent_id: undefined, + type: "expense", + is_active: true, + is_inputable: false, + sort_order: Number.MAX_SAFE_INTEGER, + created_at: "", + }; + const resolveTarget = (id: number) => (id === 1710 ? parent1710 : null); + + it("returns the leaves list unchanged (same reference) when target is null", () => { + expect(comboboxCategoriesForTarget(leaves, null, resolveTarget)).toBe(leaves); + }); + + it("returns the leaves list unchanged (same reference) when target is already a leaf", () => { + expect(comboboxCategoriesForTarget(leaves, 1121, resolveTarget)).toBe(leaves); + }); + + it("appends the resolved non-leaf parent when the target is a parent absent from leaves", () => { + const out = comboboxCategoriesForTarget(leaves, 1710, resolveTarget); + expect(out).toHaveLength(leaves.length + 1); + expect(out[out.length - 1]).toMatchObject({ id: 1710, is_inputable: false }); + // originals preserved, in order + expect(out.slice(0, 3).map((c) => c.id)).toEqual([1111, 1121, 1131]); + }); + + it("returns the leaves list unchanged when the non-leaf target cannot be resolved", () => { + // targetId absent from leaves AND resolver returns null (stale id) -> no injection + expect(comboboxCategoriesForTarget(leaves, 9999, resolveTarget)).toBe(leaves); + }); +}); diff --git a/src/components/categories-migration/migrationTargets.ts b/src/components/categories-migration/migrationTargets.ts index e92df70..31fe9d3 100644 --- a/src/components/categories-migration/migrationTargets.ts +++ b/src/components/categories-migration/migrationTargets.ts @@ -79,3 +79,32 @@ export function findTaxonomyCategory( } return null; } + +/** + * Build the target-picker option list for one migration row. + * + * Returns the leaves-only `leafOptions` unchanged when the row's current target + * is null or already a leaf (the common case — same array reference, so the + * combobox never re-renders needlessly). + * + * When the target is a NON-leaf parent absent from the leaves list (a + * low-confidence default like "Divertissement" #1710), the resolved parent is + * appended so the combobox can DISPLAY it as the current value. It stays visible + * (and, since `CategoryCombobox` renders every option, clickable) but selecting + * it is a no-op: the row's resolve handler only accepts ids present in the + * leaves list, so re-picking the parent changes nothing and the user is steered + * to a leaf. New picks that take effect are therefore always leaves. + * + * Pure helper — no React, no i18n, no DB. + */ +export function comboboxCategoriesForTarget( + leafOptions: Category[], + targetId: number | null, + resolveTarget: (id: number) => Category | null, +): Category[] { + if (targetId == null || leafOptions.some((c) => c.id === targetId)) { + return leafOptions; + } + const current = resolveTarget(targetId); + return current ? [...leafOptions, current] : leafOptions; +}