polish(categories): honest doc + extract/test picker-options glue (#246 review)
Address the two non-blocking review notes on PR #252: - The re-injected non-leaf parent (e.g. #1710) is technically clickable, not 'never offered'; correct the MappingRow comment to state selecting it is a no-op (only leaves resolve). - Extract the per-row option-building glue into a pure, unit-tested helper comboboxCategoriesForTarget (4 cases: null/leaf pass-through by reference, non-leaf append, unresolvable stale id).
This commit is contained in:
parent
ce2793fc2c
commit
4f87ce329c
3 changed files with 85 additions and 9 deletions
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue