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