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).
This commit is contained in:
parent
e45736bbde
commit
ce2793fc2c
4 changed files with 185 additions and 66 deletions
|
|
@ -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({
|
|||
>
|
||||
<div className="w-full max-w-[16rem]">
|
||||
<CategoryCombobox
|
||||
categories={targetCategories}
|
||||
categories={comboboxCategories}
|
||||
value={row.v1TargetId ?? null}
|
||||
onChange={handleTargetChange}
|
||||
compact
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { useMemo } from "react";
|
||||
import { useMemo, useCallback } from "react";
|
||||
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 {
|
||||
taxonomyToComboboxCategories,
|
||||
findTaxonomyCategory,
|
||||
} from "./migrationTargets";
|
||||
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
|
||||
import type {
|
||||
MigrationPlan,
|
||||
|
|
@ -40,15 +43,23 @@ export default function StepSimulate({
|
|||
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.
|
||||
// v1 LEAF catalogue adapted to the combobox `Category` shape. Computed once
|
||||
// here (not per row) and shared across every MappingRow's target picker —
|
||||
// only leaves are offered, so a transaction is never mapped to a grouping
|
||||
// bucket.
|
||||
const targetCategories = useMemo(
|
||||
() => 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<MappingRowType | null>(() => {
|
||||
if (selectedRowV2Id === null) return null;
|
||||
return (
|
||||
|
|
@ -162,6 +173,7 @@ export default function StepSimulate({
|
|||
transactionCountByV2Id.get(row.v2CategoryId) ?? 0
|
||||
}
|
||||
targetCategories={targetCategories}
|
||||
resolveTarget={resolveTarget}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue