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 { useTranslation } from "react-i18next";
|
||||||
import CategoryCombobox from "../shared/CategoryCombobox";
|
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||||
import type { Category } from "../../shared/types";
|
import type { Category } from "../../shared/types";
|
||||||
|
|
@ -22,10 +23,18 @@ interface MappingRowProps {
|
||||||
/** Number of transactions currently attached to this v2 category. */
|
/** Number of transactions currently attached to this v2 category. */
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
/**
|
/**
|
||||||
* v1 leaf catalogue adapted to the `Category` shape the combobox expects.
|
* v1 LEAF catalogue adapted to the `Category` shape the combobox expects.
|
||||||
* Computed ONCE by StepSimulate and shared across rows (perf).
|
* Computed ONCE by StepSimulate and shared across rows (perf). Leaves only —
|
||||||
|
* a transaction is never mapped to a grouping bucket.
|
||||||
*/
|
*/
|
||||||
targetCategories: Category[];
|
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 {
|
function badgeClass(confidence: ConfidenceBadge): string {
|
||||||
|
|
@ -48,9 +57,23 @@ export default function MappingRow({
|
||||||
onResolve,
|
onResolve,
|
||||||
transactionCount,
|
transactionCount,
|
||||||
targetCategories,
|
targetCategories,
|
||||||
|
resolveTarget,
|
||||||
}: MappingRowProps) {
|
}: MappingRowProps) {
|
||||||
const { t } = useTranslation();
|
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(
|
const badgeLabel = t(
|
||||||
`categoriesSeed.migration.simulate.confidence.${row.confidence}`,
|
`categoriesSeed.migration.simulate.confidence.${row.confidence}`,
|
||||||
);
|
);
|
||||||
|
|
@ -136,7 +159,7 @@ export default function MappingRow({
|
||||||
>
|
>
|
||||||
<div className="w-full max-w-[16rem]">
|
<div className="w-full max-w-[16rem]">
|
||||||
<CategoryCombobox
|
<CategoryCombobox
|
||||||
categories={targetCategories}
|
categories={comboboxCategories}
|
||||||
value={row.v1TargetId ?? null}
|
value={row.v1TargetId ?? null}
|
||||||
onChange={handleTargetChange}
|
onChange={handleTargetChange}
|
||||||
compact
|
compact
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowLeft, ArrowRight, AlertTriangle, FolderHeart } from "lucide-react";
|
import { ArrowLeft, ArrowRight, AlertTriangle, FolderHeart } from "lucide-react";
|
||||||
import MappingRow from "./MappingRow";
|
import MappingRow from "./MappingRow";
|
||||||
import TransactionPreviewPanel from "./TransactionPreviewPanel";
|
import TransactionPreviewPanel from "./TransactionPreviewPanel";
|
||||||
import { taxonomyToComboboxCategories } from "./migrationTargets";
|
import {
|
||||||
|
taxonomyToComboboxCategories,
|
||||||
|
findTaxonomyCategory,
|
||||||
|
} from "./migrationTargets";
|
||||||
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
|
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
|
||||||
import type {
|
import type {
|
||||||
MigrationPlan,
|
MigrationPlan,
|
||||||
|
|
@ -40,15 +43,23 @@ export default function StepSimulate({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { taxonomy } = useCategoryTaxonomy();
|
const { taxonomy } = useCategoryTaxonomy();
|
||||||
|
|
||||||
// Full v1 taxonomy adapted to the combobox `Category` shape (hierarchical).
|
// v1 LEAF catalogue adapted to the combobox `Category` shape. Computed once
|
||||||
// Computed once here (not per row) and shared across every MappingRow's
|
// here (not per row) and shared across every MappingRow's target picker —
|
||||||
// target picker. Includes non-leaf parents so mid-tree default targets like
|
// only leaves are offered, so a transaction is never mapped to a grouping
|
||||||
// "Divertissement" (id 1710) display and can be re-picked.
|
// bucket.
|
||||||
const targetCategories = useMemo(
|
const targetCategories = useMemo(
|
||||||
() => taxonomyToComboboxCategories(taxonomy.roots),
|
() => taxonomyToComboboxCategories(taxonomy.roots),
|
||||||
[taxonomy],
|
[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>(() => {
|
const selectedRow = useMemo<MappingRowType | null>(() => {
|
||||||
if (selectedRowV2Id === null) return null;
|
if (selectedRowV2Id === null) return null;
|
||||||
return (
|
return (
|
||||||
|
|
@ -162,6 +173,7 @@ export default function StepSimulate({
|
||||||
transactionCountByV2Id.get(row.v2CategoryId) ?? 0
|
transactionCountByV2Id.get(row.v2CategoryId) ?? 0
|
||||||
}
|
}
|
||||||
targetCategories={targetCategories}
|
targetCategories={targetCategories}
|
||||||
|
resolveTarget={resolveTarget}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { taxonomyToComboboxCategories } from "./migrationTargets";
|
import {
|
||||||
|
taxonomyToComboboxCategories,
|
||||||
|
findTaxonomyCategory,
|
||||||
|
} from "./migrationTargets";
|
||||||
import {
|
import {
|
||||||
getTaxonomyV1,
|
getTaxonomyV1,
|
||||||
type TaxonomyNode,
|
type TaxonomyNode,
|
||||||
|
|
@ -22,38 +25,50 @@ function node(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("taxonomyToComboboxCategories", () => {
|
describe("taxonomyToComboboxCategories (leaves only)", () => {
|
||||||
it("returns [] for empty input", () => {
|
it("returns [] for empty input", () => {
|
||||||
expect(taxonomyToComboboxCategories([])).toEqual([]);
|
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 = [
|
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")]),
|
node(1100, "Alimentation", [node(1111, "Épicerie")]),
|
||||||
];
|
];
|
||||||
const out = taxonomyToComboboxCategories(roots);
|
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", () => {
|
it("flattens leaves: parent_id undefined + running sort_order (flat list)", () => {
|
||||||
const roots = [node(1000, "Revenus", [node(1010, "Emploi", [node(1011, "Paie")])])];
|
const roots = [
|
||||||
const byId = new Map(taxonomyToComboboxCategories(roots).map((c) => [c.id, c]));
|
node(1000, "Revenus", [
|
||||||
expect(byId.get(1000)!.parent_id).toBeUndefined();
|
node(1010, "Emploi", [node(1011, "Paie"), node(1012, "Prime")]),
|
||||||
expect(byId.get(1010)!.parent_id).toBe(1000);
|
]),
|
||||||
expect(byId.get(1011)!.parent_id).toBe(1010);
|
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", () => {
|
it("marks every returned row inputable (all leaves)", () => {
|
||||||
const roots = [node(1700, "Loisirs", [node(1710, "Divertissement", [node(1711, "Cinéma")])])];
|
const roots = [
|
||||||
const byId = new Map(taxonomyToComboboxCategories(roots).map((c) => [c.id, c]));
|
node(1700, "Loisirs", [
|
||||||
expect(byId.get(1700)!.is_inputable).toBe(false);
|
node(1710, "Divertissement", [node(1711, "Cinéma")]),
|
||||||
expect(byId.get(1710)!.is_inputable).toBe(false); // intermediate parent
|
]),
|
||||||
expect(byId.get(1711)!.is_inputable).toBe(true); // leaf
|
];
|
||||||
|
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", () => {
|
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({
|
expect(c).toMatchObject({
|
||||||
id: 1111,
|
id: 1111,
|
||||||
name: "Épicerie",
|
name: "Épicerie",
|
||||||
|
|
@ -62,22 +77,53 @@ describe("taxonomyToComboboxCategories", () => {
|
||||||
i18n_key: "categoriesSeed.test.1111",
|
i18n_key: "categoriesSeed.test.1111",
|
||||||
is_active: true,
|
is_active: true,
|
||||||
is_inputable: true,
|
is_inputable: true,
|
||||||
sort_order: 1,
|
|
||||||
});
|
});
|
||||||
expect(typeof c.created_at).toBe("string");
|
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);
|
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));
|
const ids = new Set(out.map((c) => c.id));
|
||||||
expect(ids.size).toBe(out.length);
|
expect(ids.size).toBe(out.length);
|
||||||
// The migration default maps v2 cat 26 → v1 1710 (a non-leaf parent), so it
|
// The non-leaf parent 1710 "Divertissement" must NOT be a selectable option.
|
||||||
// must be selectable/displayable in the picker.
|
expect(out.find((c) => c.id === 1710)).toBeUndefined();
|
||||||
const divertissement = out.find((c) => c.id === 1710);
|
// A known leaf is present and inputable.
|
||||||
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);
|
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";
|
import type { TaxonomyNode } from "../../services/categoryTaxonomyService";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flatten the v1 taxonomy tree into `Category`-shaped rows that
|
* Flatten the v1 taxonomy into `Category`-shaped rows for the migration target
|
||||||
* `CategoryCombobox` can render (hierarchical, accent-insensitive type-ahead).
|
* 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 are emitted in depth-first reading order and carry a running
|
||||||
* leaves: the migration's default mapping table can point a v2 category at a
|
* `sort_order` with `parent_id` undefined, so `CategoryCombobox` renders them as
|
||||||
* non-leaf parent (e.g. v2 "Jeux, Films & Livres" → v1 "Divertissement",
|
* one flat, un-indented list in taxonomy order (leaves stay grouped under their
|
||||||
* id 1710), so the picker must be able to both display and offer those nodes.
|
* original parent) instead of re-scrambling them by the per-parent sort_order.
|
||||||
* 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`
|
* Every leaf id is `is_inputable` in the DB after the migration, so any pick is
|
||||||
* mirrors the DB (leaves only), matching what the migration writer inserts via
|
* FK-safe.
|
||||||
* `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
|
* Edge case: a low-confidence default can point a v2 category at a NON-leaf
|
||||||
* combobox at render time via `i18n_key` (falling back to `name`).
|
* 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[] {
|
export function taxonomyToComboboxCategories(roots: TaxonomyNode[]): Category[] {
|
||||||
const out: Category[] = [];
|
const out: Category[] = [];
|
||||||
const walk = (node: TaxonomyNode, parentId: number | null): void => {
|
let order = 0;
|
||||||
out.push({
|
const walk = (node: TaxonomyNode): void => {
|
||||||
id: node.id,
|
if (node.children.length === 0) {
|
||||||
name: node.name,
|
out.push({
|
||||||
parent_id: parentId ?? undefined,
|
id: node.id,
|
||||||
color: node.color,
|
name: node.name,
|
||||||
type: node.type,
|
parent_id: undefined,
|
||||||
is_active: true,
|
color: node.color,
|
||||||
is_inputable: node.children.length === 0,
|
type: node.type,
|
||||||
sort_order: node.sort_order,
|
is_active: true,
|
||||||
i18n_key: node.i18n_key,
|
is_inputable: true,
|
||||||
created_at: "",
|
sort_order: order++,
|
||||||
});
|
i18n_key: node.i18n_key,
|
||||||
for (const child of node.children) walk(child, node.id);
|
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;
|
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