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 <noreply@anthropic.com>
108 lines
4 KiB
TypeScript
108 lines
4 KiB
TypeScript
// 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<string, unknown>) =>
|
|
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(
|
|
<TransactionTable
|
|
rows={rows}
|
|
sort={{ column: "date", direction: "desc" }}
|
|
categories={[GROCERIES]}
|
|
onSort={vi.fn()}
|
|
onCategoryChange={vi.fn()}
|
|
onNotesChange={vi.fn()}
|
|
onAddKeyword={vi.fn(async () => 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("<tbody")[1].split("<tr").slice(1);
|
|
|
|
/** The highlighted wrapper around a closed combobox, whose input shows `value`. */
|
|
const ringedCategory = (value: string) =>
|
|
new RegExp(
|
|
`<div class="rounded-lg" data-onboarding-target="${TRANSACTION_CATEGORY_TARGET}" ${HIGHLIGHT}>` +
|
|
`<div class="relative"><input [^>]*role="combobox"[^>]*value="${value}"[^>]*/></div></div>`,
|
|
);
|
|
|
|
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('<div class="rounded-lg"><div class="relative"><input');
|
|
}
|
|
});
|
|
|
|
it("rings the category only: a categorized first row keeps its keyword and split buttons outside", () => {
|
|
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}<button`));
|
|
});
|
|
|
|
it("highlights nothing without the property", () => {
|
|
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");
|
|
});
|
|
});
|