From fd44d8702bd4e97b5069782fd992f02a12a4bdef Mon Sep 17 00:00:00 2001 From: le king fu Date: Thu, 8 Oct 2026 08:56:44 -0400 Subject: [PATCH] feat(onboarding): the Transactions page's step card and its guided entry The link of the "categorize by hand" step carries `guided`, which the Transactions page reads through `readOnboardingNavState` and turns into start filters: `useTransactions(initialFilters?)` puts them over the defaults through `initTransactionsState`, at mount only, so the card consuming the navigation state changes nothing. The guided entry lists the uncategorized transactions whatever their date (the default filter starts on January 1st, and in January the sample statements fall in the previous year). When its first page comes back empty, the reducer drops "uncategorized only" once: a new filters object, so the existing filter effect reads again, and the loading placeholder meanwhile. The first page and any filter the user sets disarm the fallback, so it never loops and never drops a filter behind the user's back. `transactionsCardModel(filters, checklist)` says which sentence matches the list, names the filter by its own label, points at the first row's category, and says "Done." once the step is checked, which the card needs to offer the Budget step or the end of the path. The label loses its capital through an i18next `lowercase` formatter used by the sentence itself, `{{filter, lowercase}}`. TransactionTable wraps every row's category in the same element and puts the ring on the first one only, so the ring coming or going never remounts the combobox; CategoryCombobox is unchanged. CARD_PAGES gets /transactions, and the page renders the card first and highlights its target only while the card shows. Co-Authored-By: Claude Opus 5.5 --- .../layout/AppShell.render.test.tsx | 16 +- .../TransactionTable.render.test.tsx | 108 +++++++++ .../transactions/TransactionTable.tsx | 45 +++- src/hooks/useTransactions.test.ts | 122 +++++++++- src/hooks/useTransactions.ts | 70 +++++- src/i18n/config.ts | 5 + src/i18n/locales/en.json | 4 + src/i18n/locales/fr.json | 4 + src/pages/TransactionsPage.render.test.tsx | 215 ++++++++++++++++++ src/pages/TransactionsPage.tsx | 35 ++- src/utils/onboardingPath.test.ts | 88 ++++++- src/utils/onboardingPath.ts | 44 +++- 12 files changed, 727 insertions(+), 29 deletions(-) create mode 100644 src/components/transactions/TransactionTable.render.test.tsx create mode 100644 src/pages/TransactionsPage.render.test.tsx diff --git a/src/components/layout/AppShell.render.test.tsx b/src/components/layout/AppShell.render.test.tsx index e486f3f..c458582 100644 --- a/src/components/layout/AppShell.render.test.tsx +++ b/src/components/layout/AppShell.render.test.tsx @@ -150,10 +150,7 @@ describe("AppShell — the getting-started banner (#397)", () => { } }); - it.each([ - ["/transactions", IMPORTED], - ["/budget", BUDGET_LEFT], - ] as const)( + it.each([["/budget", BUDGET_LEFT]] as const)( "keeps the banner on %s, the page of the next step, while no step card is delivered", (pathname, snapshot) => { expect(CARD_PAGES.has(pathname)).toBe(false); @@ -176,6 +173,17 @@ describe("AppShell — the getting-started banner (#397)", () => { expect(renderAt("/reports", loaded(NEW_PROFILE))).toContain('href="/import"'); }); + it("gives /transactions, the page of the next step, its step card instead of the banner (#399)", () => { + expect(CARD_PAGES.has("/transactions")).toBe(true); + const value = pathAt("/transactions", loaded(IMPORTED)); + expect(value.nextStep?.to).toBe("/transactions"); + expect(value.mode).toBe("card"); + expect(value.cardPage).toBe("/transactions"); + expect(renderWith("/transactions", value)).not.toContain(BANNER); + // Anywhere else, the same profile keeps the banner and its link to /transactions. + expect(renderAt("/reports", loaded(IMPORTED))).toContain('href="/transactions"'); + }); + it("shows nothing on a page that carries a step card", () => { const value = { ...pathAt("/import", loaded(NEW_PROFILE)), mode: "card" as const, cardPage: "/import" }; expect(renderWith("/import", value)).not.toContain(BANNER); diff --git a/src/components/transactions/TransactionTable.render.test.tsx b/src/components/transactions/TransactionTable.render.test.tsx new file mode 100644 index 0000000..d36696b --- /dev/null +++ b/src/components/transactions/TransactionTable.render.test.tsx @@ -0,0 +1,108 @@ +// TransactionTable — the getting-started target (#399). No jsdom: +// react-dom/server renders in vitest's node environment, and the markup says +// which element carries the highlight: a wrapper around the category of the +// first row shown, CategoryCombobox itself left as it is. +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string, o?: Record) => + o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k, + i18n: { language: "fr" }, + }), +})); + +import TransactionTable, { TRANSACTION_CATEGORY_TARGET } from "./TransactionTable"; +import type { Category, TransactionRow } from "../../shared/types"; + +const HIGHLIGHT = 'data-onboarding-highlight="true"'; +const count = (html: string, needle: string) => html.split(needle).length - 1; + +const GROCERIES: Category = { + id: 7, + name: "Épicerie", + type: "expense", + is_active: true, + is_inputable: true, + sort_order: 1, + created_at: "", +}; + +const row = (id: number, description: string, category: Category | null = null): TransactionRow => ({ + id, + date: "2026-09-12", + description, + amount: -42.5, + category_id: category?.id ?? null, + category_name: category?.name ?? null, + category_color: null, + source_name: "Exemple Desjardins", + notes: null, + is_manually_categorized: false, + is_split: false, +}); + +/** The guided entry: uncategorized rows only. */ +const UNCATEGORIZED = [row(1, "IGA 123"), row(2, "METRO 456"), row(3, "SAQ 789")]; + +const markup = (rows: TransactionRow[], onboardingTarget?: boolean) => + renderToStaticMarkup( + undefined)} + onLoadSplitChildren={vi.fn(async () => [])} + onSaveSplit={vi.fn(async () => undefined)} + onDeleteSplit={vi.fn(async () => undefined)} + onboardingTarget={onboardingTarget} + />, + ); + +/** The rows of the table's body, in order. */ +const bodyRows = (html: string) => html.split(" + new RegExp( + `
` + + `
]*role="combobox"[^>]*value="${value}"[^>]*/>
`, + ); + +describe("TransactionTable — getting-started target (#399)", () => { + it("highlights exactly one element: a wrapper around the first row's category", () => { + const html = markup(UNCATEGORIZED, true); + expect(count(html, HIGHLIGHT)).toBe(1); + const [first, ...others] = bodyRows(html); + expect(first).toContain("IGA 123"); + expect(first).toMatch(ringedCategory("transactions.table.noCategory")); + expect(others).toHaveLength(2); + for (const other of others) { + expect(other).not.toContain("data-onboarding"); + // Every row has the same wrapper, without the ring. + expect(other).toContain('
{ + const html = markup([row(1, "IGA 123", GROCERIES), row(2, "METRO 456")], true); + expect(count(html, HIGHLIGHT)).toBe(1); + const [first] = bodyRows(html); + expect(first).toMatch(new RegExp(`${ringedCategory("Épicerie").source} { + expect(markup(UNCATEGORIZED)).not.toContain("data-onboarding"); + expect(markup(UNCATEGORIZED, false)).not.toContain("data-onboarding"); + }); + + it("highlights nothing without a row: the card stays, without a ring", () => { + const html = markup([], true); + expect(html).toContain("transactions.noTransactions"); + expect(html).not.toContain("data-onboarding"); + }); +}); diff --git a/src/components/transactions/TransactionTable.tsx b/src/components/transactions/TransactionTable.tsx index 74b1263..a51d965 100644 --- a/src/components/transactions/TransactionTable.tsx +++ b/src/components/transactions/TransactionTable.tsx @@ -8,6 +8,7 @@ import type { SplitChild, } from "../../shared/types"; import type { LinkedTransferTooltipRow } from "../../services/balance.service"; +import { onboardingTargetAttributes } from "../../utils/onboardingPath"; import CategoryCombobox from "../shared/CategoryCombobox"; import SplitAdjustmentModal from "./SplitAdjustmentModal"; @@ -31,8 +32,16 @@ interface TransactionTableProps { * to avoid an N+1 hit on the table render. */ linkedTransfersByTxId?: Map; + /** + * Highlight the category of the first row for the getting-started card + * (#399). The ring sits on a wrapper: `CategoryCombobox` stays as it is. + */ + onboardingTarget?: boolean; } +/** `data-onboarding-target` of the highlighted category. */ +export const TRANSACTION_CATEGORY_TARGET = "transaction-category"; + function SortIcon({ column, sort, @@ -62,6 +71,7 @@ export default function TransactionTable({ onDeleteSplit, onRowContextMenu, linkedTransfersByTxId, + onboardingTarget, }: TransactionTableProps) { const { t } = useTranslation(); const [expandedId, setExpandedId] = useState(null); @@ -144,7 +154,7 @@ export default function TransactionTable({ - {rows.map((row) => ( + {rows.map((row, index) => ( onRowContextMenu(e, row) : undefined} @@ -189,16 +199,29 @@ export default function TransactionTable({
- onCategoryChange(row.id, id)} - placeholder={t("transactions.table.noCategory")} - compact - extraOptions={noCategoryExtra} - activeExtra={row.category_id === null ? "" : null} - onExtraSelect={() => onCategoryChange(row.id, null)} - /> + {/* + Every row has the wrapper and only the first one's carries + the ring (#399): the ring coming or going never remounts + the combobox, so it never drops the focus. + */} +
+ onCategoryChange(row.id, id)} + placeholder={t("transactions.table.noCategory")} + compact + extraOptions={noCategoryExtra} + activeExtra={row.category_id === null ? "" : null} + onExtraSelect={() => onCategoryChange(row.id, null)} + /> +
{row.category_id !== null && ( <>