feat(categories): editable migration target with type-ahead (#246)
Make the v1 target editable on EVERY row of the categories-migration preview (StepSimulate), not only the "needs review" ones. Each row now renders a reused CategoryCombobox (accent-insensitive type-ahead, keyboard, hierarchical) in place of the read-only text / flat <select>. A pure adapter (taxonomyToComboboxCategories) flattens the full v1 taxonomy into Category-shaped rows once in StepSimulate and passes it as a prop to the rows. The full tree (not just leaves) is fed so mid-tree default targets such as "Divertissement" (id 1710, the default for "Jeux, Films & Livres") both display and can be re-picked. The target cell stops click/keydown propagation so picker interaction never toggles the row preview. Reducer/guard behavior unchanged: resolving a "none" row still bumps confidence to medium, and the unresolved-count guard stays functional. Resolves #246
This commit is contained in:
parent
110ad57e1b
commit
e45736bbde
8 changed files with 190 additions and 40 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Non publié]
|
||||
|
||||
### Modifié
|
||||
|
||||
- Migration des catégories (Paramètres → Catégories → migrer vers le jeu standard) : la catégorie cible de l'étape d'aperçu est désormais modifiable sur **chaque** ligne, et non plus seulement celles « à réviser ». Chaque ligne offre un champ de recherche avec autocomplétion (insensible aux accents), pour corriger une cible détectée automatiquement aussi facilement que résoudre une ligne sans correspondance (#246).
|
||||
|
||||
### Sécurité
|
||||
|
||||
- Mise à jour de dépendances pour corriger quatre advisories signalés par `npm audit` : `react-router-dom` 7.13 → 7.18.1 (ce qui tire `react-router` au-delà des advisories RCE turbo-stream, DoS `__manifest`/single-fetch et XSS de redirection RSC), plus l'outillage de développement `vite` 6.4.2 → 6.4.3 et `vitest` 4.0.18 → 4.1.9. Aucun changement de comportement. L'app est livrée comme un client de bureau local : les advisories runtime (qui nécessitent un serveur SSR/RSC react-router) ne s'appliquaient pas au produit livré ; ce sont des mises à jour de durcissement (#235, #236, #237, #238).
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Categories migration (Settings → Categories → migrate to the standard set): the target category on the preview step is now editable on **every** row, not only the "needs review" ones. Each row carries a type-ahead picker (accent-insensitive search) so you can override an auto-detected target as easily as resolving an unmatched one (#246).
|
||||
|
||||
### Security
|
||||
|
||||
- Updated dependencies to clear four advisories reported by `npm audit`: `react-router-dom` 7.13 → 7.18.1 (which pulls `react-router` past the turbo-stream RCE, `__manifest`/single-fetch DoS, and RSC-redirect XSS advisories), plus the dev toolchain `vite` 6.4.2 → 6.4.3 and `vitest` 4.0.18 → 4.1.9. No behaviour change. The app ships as a local desktop client, so the runtime advisories (which require a react-router SSR/RSC server) did not apply to the shipped product; these are hardening bumps (#235, #236, #237, #238).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
|
||||
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||
import type { Category } from "../../shared/types";
|
||||
import type {
|
||||
MappingRow as MappingRowType,
|
||||
ConfidenceBadge,
|
||||
|
|
@ -13,13 +13,19 @@ interface MappingRowProps {
|
|||
/** Callback fired when the row is clicked — opens the preview panel. */
|
||||
onSelect: (v2CategoryId: number) => void;
|
||||
/**
|
||||
* Called with the new v1 target id + name when the user resolves the row
|
||||
* via the inline dropdown. The dropdown is only rendered for unresolved
|
||||
* ("🟠 needs review") rows — resolved rows just show the target name.
|
||||
* Called with the new v1 target id + name when the user picks a target via
|
||||
* the inline type-ahead combobox. Editable on EVERY row (resolved or not),
|
||||
* so a user can override an auto-detected target as well as resolve a
|
||||
* "needs review" one.
|
||||
*/
|
||||
onResolve: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void;
|
||||
/** 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).
|
||||
*/
|
||||
targetCategories: Category[];
|
||||
}
|
||||
|
||||
function badgeClass(confidence: ConfidenceBadge): string {
|
||||
|
|
@ -41,14 +47,9 @@ export default function MappingRow({
|
|||
onSelect,
|
||||
onResolve,
|
||||
transactionCount,
|
||||
targetCategories,
|
||||
}: MappingRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getLeaves } = useCategoryTaxonomy();
|
||||
|
||||
// For the resolve dropdown: all v1 leaves (terminal categories). We keep the
|
||||
// list flat because the simulate row is narrow; the search box in step 2
|
||||
// already helps users find a target by keyword.
|
||||
const v1Leaves = useMemo(() => getLeaves(), [getLeaves]);
|
||||
|
||||
const badgeLabel = t(
|
||||
`categoriesSeed.migration.simulate.confidence.${row.confidence}`,
|
||||
|
|
@ -59,12 +60,18 @@ export default function MappingRow({
|
|||
|
||||
const isUnresolved = row.v1TargetId === null || row.v1TargetId === undefined;
|
||||
|
||||
const handleResolveChange = (ev: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const v1TargetId = Number(ev.target.value);
|
||||
if (!Number.isFinite(v1TargetId) || v1TargetId <= 0) return;
|
||||
const leaf = v1Leaves.find((l) => l.id === v1TargetId);
|
||||
if (!leaf) return;
|
||||
const name = t(leaf.i18n_key, { defaultValue: leaf.name });
|
||||
// Fired when the user picks a v1 leaf in the type-ahead combobox. The
|
||||
// combobox only emits ids that exist in `targetCategories` (never null with
|
||||
// our config), but we guard defensively. Resolving a "none" row bumps its
|
||||
// confidence to "medium" via the reducer; editing an already-resolved row
|
||||
// keeps its confidence unchanged (see RESOLVE_ROW).
|
||||
const handleTargetChange = (v1TargetId: number | null) => {
|
||||
if (v1TargetId === null) return;
|
||||
const target = targetCategories.find((c) => c.id === v1TargetId);
|
||||
if (!target) return;
|
||||
const name = target.i18n_key
|
||||
? t(target.i18n_key, { defaultValue: target.name })
|
||||
: target.name;
|
||||
onResolve(row.v2CategoryId, v1TargetId, name);
|
||||
};
|
||||
|
||||
|
|
@ -119,32 +126,26 @@ export default function MappingRow({
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* v1 target (or picker) */}
|
||||
{/* v1 target — editable type-ahead combobox on EVERY row. Stop click +
|
||||
keydown from bubbling so interacting with the picker (typing spaces,
|
||||
Enter to select) never triggers the row's select/preview handler. */}
|
||||
<div
|
||||
className="col-span-5 flex items-center justify-end gap-2 min-w-0"
|
||||
className="col-span-5 flex items-center justify-end min-w-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isUnresolved ? (
|
||||
<select
|
||||
value=""
|
||||
onChange={handleResolveChange}
|
||||
aria-label={t("categoriesSeed.migration.simulate.chooseTarget")}
|
||||
className="max-w-full truncate rounded-md border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-sm text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("categoriesSeed.migration.simulate.chooseTarget")}
|
||||
</option>
|
||||
{v1Leaves.map((leaf) => (
|
||||
<option key={leaf.id} value={leaf.id}>
|
||||
{t(leaf.i18n_key, { defaultValue: leaf.name })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="truncate text-[var(--foreground)]">
|
||||
{targetDisplayName}
|
||||
</span>
|
||||
)}
|
||||
<div className="w-full max-w-[16rem]">
|
||||
<CategoryCombobox
|
||||
categories={targetCategories}
|
||||
value={row.v1TargetId ?? null}
|
||||
onChange={handleTargetChange}
|
||||
compact
|
||||
placeholder={t("categoriesSeed.migration.simulate.chooseTarget")}
|
||||
ariaLabel={t("categoriesSeed.migration.simulate.editTargetAria", {
|
||||
category: row.v2CategoryName,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ 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 { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
|
||||
import type {
|
||||
MigrationPlan,
|
||||
MappingRow as MappingRowType,
|
||||
|
|
@ -36,6 +38,16 @@ export default function StepSimulate({
|
|||
onBack,
|
||||
}: StepSimulateProps) {
|
||||
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.
|
||||
const targetCategories = useMemo(
|
||||
() => taxonomyToComboboxCategories(taxonomy.roots),
|
||||
[taxonomy],
|
||||
);
|
||||
|
||||
const selectedRow = useMemo<MappingRowType | null>(() => {
|
||||
if (selectedRowV2Id === null) return null;
|
||||
|
|
@ -149,6 +161,7 @@ export default function StepSimulate({
|
|||
transactionCount={
|
||||
transactionCountByV2Id.get(row.v2CategoryId) ?? 0
|
||||
}
|
||||
targetCategories={targetCategories}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
83
src/components/categories-migration/migrationTargets.test.ts
Normal file
83
src/components/categories-migration/migrationTargets.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { taxonomyToComboboxCategories } from "./migrationTargets";
|
||||
import {
|
||||
getTaxonomyV1,
|
||||
type TaxonomyNode,
|
||||
} from "../../services/categoryTaxonomyService";
|
||||
|
||||
function node(
|
||||
id: number,
|
||||
name: string,
|
||||
children: TaxonomyNode[] = [],
|
||||
type: "expense" | "income" | "transfer" = "expense",
|
||||
): TaxonomyNode {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
i18n_key: `categoriesSeed.test.${id}`,
|
||||
type,
|
||||
color: "#123456",
|
||||
sort_order: 1,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
describe("taxonomyToComboboxCategories", () => {
|
||||
it("returns [] for empty input", () => {
|
||||
expect(taxonomyToComboboxCategories([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("flattens the tree in parent-before-children (DFS) order", () => {
|
||||
const roots = [
|
||||
node(1000, "Revenus", [node(1010, "Emploi", [node(1011, "Paie")])]),
|
||||
node(1100, "Alimentation", [node(1111, "Épicerie")]),
|
||||
];
|
||||
const out = taxonomyToComboboxCategories(roots);
|
||||
expect(out.map((c) => c.id)).toEqual([1000, 1010, 1011, 1100, 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("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("copies id/name/type/color/i18n_key and defaults is_active + created_at", () => {
|
||||
const [c] = taxonomyToComboboxCategories([node(1111, "Épicerie", [], "expense")]);
|
||||
expect(c).toMatchObject({
|
||||
id: 1111,
|
||||
name: "Épicerie",
|
||||
type: "expense",
|
||||
color: "#123456",
|
||||
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", () => {
|
||||
const out = taxonomyToComboboxCategories(getTaxonomyV1().roots);
|
||||
expect(out.length).toBe(150); // 11 roots + 27 intermediates + 112 leaves
|
||||
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.
|
||||
expect(out.find((c) => c.id === 1111)!.is_inputable).toBe(true);
|
||||
});
|
||||
});
|
||||
43
src/components/categories-migration/migrationTargets.ts
Normal file
43
src/components/categories-migration/migrationTargets.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
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).
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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`).
|
||||
*/
|
||||
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);
|
||||
};
|
||||
for (const root of roots) walk(root, null);
|
||||
return out;
|
||||
}
|
||||
|
|
@ -1435,6 +1435,7 @@
|
|||
"loadError": "Failed to load profile data: {{error}}",
|
||||
"needsReview": "Needs review",
|
||||
"chooseTarget": "Choose a target...",
|
||||
"editTargetAria": "Change the target for {{category}}",
|
||||
"txCount_one": "{{count}} transaction",
|
||||
"txCount_other": "{{count}} transactions",
|
||||
"unresolvedWarning_one": "You have {{count}} decision to make before you can continue.",
|
||||
|
|
|
|||
|
|
@ -1435,6 +1435,7 @@
|
|||
"loadError": "Impossible de charger les données du profil : {{error}}",
|
||||
"needsReview": "À réviser",
|
||||
"chooseTarget": "Choisir une cible...",
|
||||
"editTargetAria": "Modifier la cible pour {{category}}",
|
||||
"txCount_one": "{{count}} transaction",
|
||||
"txCount_other": "{{count}} transactions",
|
||||
"unresolvedWarning_one": "Vous avez {{count}} décision à prendre avant de pouvoir continuer.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue