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; +}