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 && ( <>