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