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é]
|
## [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é
|
### 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).
|
- 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]
|
## [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
|
### 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).
|
- 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 { useTranslation } from "react-i18next";
|
||||||
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
|
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||||
|
import type { Category } from "../../shared/types";
|
||||||
import type {
|
import type {
|
||||||
MappingRow as MappingRowType,
|
MappingRow as MappingRowType,
|
||||||
ConfidenceBadge,
|
ConfidenceBadge,
|
||||||
|
|
@ -13,13 +13,19 @@ interface MappingRowProps {
|
||||||
/** Callback fired when the row is clicked — opens the preview panel. */
|
/** Callback fired when the row is clicked — opens the preview panel. */
|
||||||
onSelect: (v2CategoryId: number) => void;
|
onSelect: (v2CategoryId: number) => void;
|
||||||
/**
|
/**
|
||||||
* Called with the new v1 target id + name when the user resolves the row
|
* Called with the new v1 target id + name when the user picks a target via
|
||||||
* via the inline dropdown. The dropdown is only rendered for unresolved
|
* the inline type-ahead combobox. Editable on EVERY row (resolved or not),
|
||||||
* ("🟠 needs review") rows — resolved rows just show the target name.
|
* 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;
|
onResolve: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void;
|
||||||
/** 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.
|
||||||
|
* Computed ONCE by StepSimulate and shared across rows (perf).
|
||||||
|
*/
|
||||||
|
targetCategories: Category[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function badgeClass(confidence: ConfidenceBadge): string {
|
function badgeClass(confidence: ConfidenceBadge): string {
|
||||||
|
|
@ -41,14 +47,9 @@ export default function MappingRow({
|
||||||
onSelect,
|
onSelect,
|
||||||
onResolve,
|
onResolve,
|
||||||
transactionCount,
|
transactionCount,
|
||||||
|
targetCategories,
|
||||||
}: MappingRowProps) {
|
}: MappingRowProps) {
|
||||||
const { t } = useTranslation();
|
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(
|
const badgeLabel = t(
|
||||||
`categoriesSeed.migration.simulate.confidence.${row.confidence}`,
|
`categoriesSeed.migration.simulate.confidence.${row.confidence}`,
|
||||||
|
|
@ -59,12 +60,18 @@ export default function MappingRow({
|
||||||
|
|
||||||
const isUnresolved = row.v1TargetId === null || row.v1TargetId === undefined;
|
const isUnresolved = row.v1TargetId === null || row.v1TargetId === undefined;
|
||||||
|
|
||||||
const handleResolveChange = (ev: React.ChangeEvent<HTMLSelectElement>) => {
|
// Fired when the user picks a v1 leaf in the type-ahead combobox. The
|
||||||
const v1TargetId = Number(ev.target.value);
|
// combobox only emits ids that exist in `targetCategories` (never null with
|
||||||
if (!Number.isFinite(v1TargetId) || v1TargetId <= 0) return;
|
// our config), but we guard defensively. Resolving a "none" row bumps its
|
||||||
const leaf = v1Leaves.find((l) => l.id === v1TargetId);
|
// confidence to "medium" via the reducer; editing an already-resolved row
|
||||||
if (!leaf) return;
|
// keeps its confidence unchanged (see RESOLVE_ROW).
|
||||||
const name = t(leaf.i18n_key, { defaultValue: leaf.name });
|
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);
|
onResolve(row.v2CategoryId, v1TargetId, name);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -119,32 +126,26 @@ export default function MappingRow({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{isUnresolved ? (
|
<div className="w-full max-w-[16rem]">
|
||||||
<select
|
<CategoryCombobox
|
||||||
value=""
|
categories={targetCategories}
|
||||||
onChange={handleResolveChange}
|
value={row.v1TargetId ?? null}
|
||||||
aria-label={t("categoriesSeed.migration.simulate.chooseTarget")}
|
onChange={handleTargetChange}
|
||||||
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"
|
compact
|
||||||
>
|
placeholder={t("categoriesSeed.migration.simulate.chooseTarget")}
|
||||||
<option value="" disabled>
|
ariaLabel={t("categoriesSeed.migration.simulate.editTargetAria", {
|
||||||
{t("categoriesSeed.migration.simulate.chooseTarget")}
|
category: row.v2CategoryName,
|
||||||
</option>
|
})}
|
||||||
{v1Leaves.map((leaf) => (
|
/>
|
||||||
<option key={leaf.id} value={leaf.id}>
|
</div>
|
||||||
{t(leaf.i18n_key, { defaultValue: leaf.name })}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<span className="truncate text-[var(--foreground)]">
|
|
||||||
{targetDisplayName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ 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 { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
|
||||||
import type {
|
import type {
|
||||||
MigrationPlan,
|
MigrationPlan,
|
||||||
MappingRow as MappingRowType,
|
MappingRow as MappingRowType,
|
||||||
|
|
@ -36,6 +38,16 @@ export default function StepSimulate({
|
||||||
onBack,
|
onBack,
|
||||||
}: StepSimulateProps) {
|
}: StepSimulateProps) {
|
||||||
const { t } = useTranslation();
|
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>(() => {
|
const selectedRow = useMemo<MappingRowType | null>(() => {
|
||||||
if (selectedRowV2Id === null) return null;
|
if (selectedRowV2Id === null) return null;
|
||||||
|
|
@ -149,6 +161,7 @@ export default function StepSimulate({
|
||||||
transactionCount={
|
transactionCount={
|
||||||
transactionCountByV2Id.get(row.v2CategoryId) ?? 0
|
transactionCountByV2Id.get(row.v2CategoryId) ?? 0
|
||||||
}
|
}
|
||||||
|
targetCategories={targetCategories}
|
||||||
/>
|
/>
|
||||||
</li>
|
</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}}",
|
"loadError": "Failed to load profile data: {{error}}",
|
||||||
"needsReview": "Needs review",
|
"needsReview": "Needs review",
|
||||||
"chooseTarget": "Choose a target...",
|
"chooseTarget": "Choose a target...",
|
||||||
|
"editTargetAria": "Change the target for {{category}}",
|
||||||
"txCount_one": "{{count}} transaction",
|
"txCount_one": "{{count}} transaction",
|
||||||
"txCount_other": "{{count}} transactions",
|
"txCount_other": "{{count}} transactions",
|
||||||
"unresolvedWarning_one": "You have {{count}} decision to make before you can continue.",
|
"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}}",
|
"loadError": "Impossible de charger les données du profil : {{error}}",
|
||||||
"needsReview": "À réviser",
|
"needsReview": "À réviser",
|
||||||
"chooseTarget": "Choisir une cible...",
|
"chooseTarget": "Choisir une cible...",
|
||||||
|
"editTargetAria": "Modifier la cible pour {{category}}",
|
||||||
"txCount_one": "{{count}} transaction",
|
"txCount_one": "{{count}} transaction",
|
||||||
"txCount_other": "{{count}} transactions",
|
"txCount_other": "{{count}} transactions",
|
||||||
"unresolvedWarning_one": "Vous avez {{count}} décision à prendre avant de pouvoir continuer.",
|
"unresolvedWarning_one": "Vous avez {{count}} décision à prendre avant de pouvoir continuer.",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue