feat(onboarding): Transactions page step card and guided entry #405

Merged
maximus merged 2 commits from issue-399-onboarding-transactions-card into main 2026-10-09 01:10:48 +00:00
12 changed files with 727 additions and 29 deletions
Showing only changes of commit fd44d8702b - Show all commits

View file

@ -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);

View file

@ -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<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");
});
});

View file

@ -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<number, LinkedTransferTooltipRow[]>;
/**
* 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<number | null>(null);
@ -144,7 +154,7 @@ export default function TransactionTable({
</tr>
</thead>
<tbody className="divide-y divide-[var(--border)]">
{rows.map((row) => (
{rows.map((row, index) => (
<Fragment key={row.id}>
<tr
onContextMenu={onRowContextMenu ? (e) => onRowContextMenu(e, row) : undefined}
@ -189,16 +199,29 @@ export default function TransactionTable({
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1">
<CategoryCombobox
categories={categories}
value={row.category_id}
onChange={(id) => 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.
*/}
<div
className="rounded-lg"
{...onboardingTargetAttributes(
TRANSACTION_CATEGORY_TARGET,
onboardingTarget && index === 0
)}
>
<CategoryCombobox
categories={categories}
value={row.category_id}
onChange={(id) => onCategoryChange(row.id, id)}
placeholder={t("transactions.table.noCategory")}
compact
extraOptions={noCategoryExtra}
activeExtra={row.category_id === null ? "" : null}
onExtraSelect={() => onCategoryChange(row.id, null)}
/>
</div>
{row.category_id !== null && (
<>
<button

View file

@ -1,4 +1,4 @@
// useTransactions — reducer unit tests (issue #353).
// useTransactions — reducer unit tests (issues #353, #399).
//
// The project has no jsdom / renderHook harness, so the EXPORTED reducer and
// its initial state are exercised directly (same approach as
@ -6,14 +6,19 @@
// profile-wide transaction count (`totalInDb`) is orthogonal to the paging
// state — setting it never touches `filters`, `sort` or `page` — and the
// 0 → N transition takes the page from the empty state back to the table.
// Then the start filters (#399): the guided entry, its single fallback when
// no transaction is left uncategorized, and the ordinary visit.
import { describe, it, expect } from "vitest";
import {
transactionsReducer,
initialTransactionsState,
initTransactionsState,
showsEmptyState,
GUIDED_ENTRY_FILTERS,
type TransactionsState,
} from "./useTransactions";
import type { TransactionPageResult } from "../shared/types";
const populated = (): TransactionsState =>
transactionsReducer(
@ -92,3 +97,118 @@ describe("transactionsReducer — SET_TOTAL_IN_DB (issue #353)", () => {
expect(after.totalInDb).toBe(5);
});
});
/** A page of `totalCount` transactions, as `getTransactionPage` returns it. */
const page = (totalCount: number): TransactionPageResult => ({
rows: [],
totalCount,
totalAmount: 0,
incomeTotal: 0,
expenseTotal: 0,
});
const result = (state: TransactionsState, totalCount: number) =>
transactionsReducer(state, { type: "SET_PAGE_RESULT", payload: page(totalCount) });
describe("initTransactionsState — the start filters (#399)", () => {
const JANUARY_FIRST = `${new Date().getFullYear()}-01-01`;
it("an ordinary visit keeps the default filters: since January 1st, every category", () => {
for (const state of [initTransactionsState(), initTransactionsState(undefined)]) {
expect(state).toEqual(initialTransactionsState);
expect(state.filters).toEqual({
search: "",
categoryId: null,
sourceId: null,
dateFrom: JANUARY_FIRST,
dateTo: null,
uncategorizedOnly: false,
});
expect(state.guidedFallback).toBe(false);
}
});
it("the guided entry starts on the uncategorized transactions, without a date bound", () => {
expect(GUIDED_ENTRY_FILTERS).toEqual({ dateFrom: null, uncategorizedOnly: true });
const guided = initTransactionsState(GUIDED_ENTRY_FILTERS);
expect(guided.filters).toEqual({
search: "",
categoryId: null,
sourceId: null,
dateFrom: null,
dateTo: null,
uncategorizedOnly: true,
});
expect(guided.guidedFallback).toBe(true);
// Nothing else moves: same sort, first page, nothing loaded yet.
expect({ ...guided, filters: initialTransactionsState.filters, guidedFallback: false }).toEqual(
initialTransactionsState,
);
});
it("applies any start filter over the defaults; only 'uncategorized only' arms the fallback", () => {
const searched = initTransactionsState({ search: "epicerie" });
expect(searched.filters).toEqual({ ...initialTransactionsState.filters, search: "epicerie" });
expect(searched.guidedFallback).toBe(false);
expect(initTransactionsState({ uncategorizedOnly: false }).guidedFallback).toBe(false);
});
});
describe("transactionsReducer — the guided entry's fallback (#399)", () => {
const guided = () => initTransactionsState(GUIDED_ENTRY_FILTERS);
it("an empty first page drops 'uncategorized only', keeps the other start filters, and reads again", () => {
const before = guided();
const after = result(before, 0);
expect(after.filters).toEqual({ ...before.filters, uncategorizedOnly: false });
expect(after.filters.dateFrom).toBeNull();
// A new filters object is what makes the hook read again.
expect(after.filters).not.toBe(before.filters);
expect(after.page).toBe(1);
// The loading placeholder, not an empty table, until that read.
expect(after.isLoading).toBe(true);
expect(after.guidedFallback).toBe(false);
});
it("falls back once: an empty page after the fallback keeps the filters, so nothing reads again", () => {
const fellBack = result(guided(), 0);
const again = result(fellBack, 0);
expect(again.filters).toBe(fellBack.filters);
expect(again.isLoading).toBe(false);
expect(again.guidedFallback).toBe(false);
expect(result(again, 0).filters).toBe(fellBack.filters);
});
it("a first page with uncategorized transactions keeps the filter, and a later empty page too", () => {
const before = guided();
const listed = result(before, 3);
expect(listed.filters).toBe(before.filters);
expect(listed.isLoading).toBe(false);
expect(listed.guidedFallback).toBe(false);
// Every one categorized, then a read comes back empty: the filter stays.
const emptied = result(listed, 0);
expect(emptied.filters).toBe(before.filters);
expect(emptied.filters.uncategorizedOnly).toBe(true);
});
it("a filter changed by the user before the first page is never dropped", () => {
const touched = transactionsReducer(guided(), {
type: "SET_FILTER",
payload: { key: "search", value: "epicerie" },
});
expect(touched.guidedFallback).toBe(false);
const after = result(touched, 0);
expect(after.filters).toBe(touched.filters);
expect(after.filters.uncategorizedOnly).toBe(true);
});
it("an ordinary visit never drops 'uncategorized only', even picked by hand and empty", () => {
const picked = transactionsReducer(initTransactionsState(), {
type: "SET_FILTER",
payload: { key: "uncategorizedOnly", value: true },
});
const after = result(picked, 0);
expect(after.filters).toBe(picked.filters);
expect(after.filters.uncategorizedOnly).toBe(true);
});
});

View file

@ -44,6 +44,13 @@ export interface TransactionsState {
* profile without any transaction from a year without any.
*/
totalInDb: number | null;
/**
* The start filters asked for the uncategorized transactions only (the
* guided entry, #399) and their first page has not come back yet. If it
* comes back empty, `SET_PAGE_RESULT` drops that filter, once. Off after
* that first page, after any filter change, and for an ordinary visit.
*/
guidedFallback: boolean;
}
export type TransactionsAction =
@ -60,7 +67,7 @@ export type TransactionsAction =
| { type: "UPDATE_ROW_NOTES"; payload: { txId: number; notes: string } }
| { type: "SET_AUTO_CATEGORIZING"; payload: boolean };
const initialFilters: TransactionFilters = {
const defaultFilters: TransactionFilters = {
search: "",
categoryId: null,
sourceId: null,
@ -69,13 +76,24 @@ const initialFilters: TransactionFilters = {
uncategorizedOnly: false,
};
/**
* The guided entry of the "categorize by hand" step (#399): the uncategorized
* transactions, whatever their date. No date bound, because the default
* filter starts on January 1st and, in January, the sample statements fall in
* the previous year.
*/
export const GUIDED_ENTRY_FILTERS: Partial<TransactionFilters> = {
dateFrom: null,
uncategorizedOnly: true,
};
export const initialTransactionsState: TransactionsState = {
rows: [],
totalCount: 0,
totalAmount: 0,
incomeTotal: 0,
expenseTotal: 0,
filters: initialFilters,
filters: defaultFilters,
sort: { column: "date", direction: "desc" },
page: 1,
pageSize: 50,
@ -85,8 +103,25 @@ export const initialTransactionsState: TransactionsState = {
isAutoCategorizing: false,
error: null,
totalInDb: null,
guidedFallback: false,
};
/**
* The hook's initial state (#399): the default filters with `initialFilters`
* over them. Start filters that ask for the uncategorized transactions only
* arm the fallback of their first page (`guidedFallback`). Without
* `initialFilters`, an ordinary visit: the default state.
*/
export function initTransactionsState(
initialFilters: Partial<TransactionFilters> = {}
): TransactionsState {
return {
...initialTransactionsState,
filters: { ...initialTransactionsState.filters, ...initialFilters },
guidedFallback: initialFilters.uncategorizedOnly === true,
};
}
export function transactionsReducer(
state: TransactionsState,
action: TransactionsAction
@ -96,8 +131,8 @@ export function transactionsReducer(
return { ...state, isLoading: action.payload };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
case "SET_PAGE_RESULT":
return {
case "SET_PAGE_RESULT": {
const next: TransactionsState = {
...state,
rows: action.payload.rows,
totalCount: action.payload.totalCount,
@ -105,12 +140,30 @@ export function transactionsReducer(
incomeTotal: action.payload.incomeTotal,
expenseTotal: action.payload.expenseTotal,
isLoading: false,
guidedFallback: false,
};
// The guided entry's first page is empty: no transaction is left
// uncategorized (#399). Drop that filter, once, so the page lists every
// transaction instead of an empty table. The new filters make the hook
// read again (the filters' 300 ms debounce); until then, the loading
// placeholder takes the place of the empty table.
if (state.guidedFallback && action.payload.totalCount === 0) {
return {
...next,
filters: { ...state.filters, uncategorizedOnly: false },
page: 1,
isLoading: true,
};
}
return next;
}
case "SET_FILTER":
return {
...state,
filters: { ...state.filters, [action.payload.key]: action.payload.value },
page: 1,
// The user's own filters: never dropped behind their back.
guidedFallback: false,
};
case "SET_SORT":
return { ...state, sort: action.payload };
@ -161,8 +214,13 @@ export function showsEmptyState(state: Pick<TransactionsState, "totalInDb" | "is
return state.totalInDb === 0 && !state.isLoading;
}
export function useTransactions() {
const [state, dispatch] = useReducer(transactionsReducer, initialTransactionsState);
/**
* The Transactions page's state. `initialFilters` go over the default filters
* (the guided entry passes `GUIDED_ENTRY_FILTERS`, #399); like any initial
* state, they are read once, at mount, and a later value changes nothing.
*/
export function useTransactions(initialFilters?: Partial<TransactionFilters>) {
const [state, dispatch] = useReducer(transactionsReducer, initialFilters, initTransactionsState);
const fetchIdRef = useRef(0);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedFiltersRef = useRef(state.filters);

View file

@ -15,4 +15,9 @@ i18n.use(initReactI18next).init({
},
});
// `{{value, lowercase}}`: a label named inside a sentence (the "Uncategorized"
// filter of the Transactions step card, #399) keeps its own key and loses its
// capital, in each language's own way. The sentence decides, not the code.
i18n.services.formatter?.add("lowercase", (value, lng) => String(value).toLocaleLowerCase(lng));
export default i18n;

View file

@ -89,6 +89,10 @@
"confirm": "Click {{button}}: the source is saved and your transactions come in.",
"importing": "Importing…",
"failed": "The import added no transaction; the report below says why."
},
"transactions": {
"uncategorized": "Only {{filter, lowercase}} transactions are shown. Click the category of one of them and pick one from the list. Simpl'Résultat keeps your choice, and automatic categorization will not replace it.",
"all": "Click a transaction's category and pick one from the list. Simpl'Résultat keeps your choice, and automatic categorization will not replace it."
}
},
"state": {

View file

@ -89,6 +89,10 @@
"confirm": "Cliquez {{button}} : la source est enregistrée et vos transactions arrivent.",
"importing": "Import en cours…",
"failed": "L'import n'a ajouté aucune transaction ; le rapport ci-dessous dit pourquoi."
},
"transactions": {
"uncategorized": "Seules les transactions {{filter, lowercase}} sont affichées. Cliquez sur la catégorie de l'une d'elles et choisissez-en une dans la liste. Simpl'Résultat retient ce choix, et la catégorisation automatique ne le remplacera pas.",
"all": "Cliquez sur la catégorie d'une transaction et choisissez-en une dans la liste. Simpl'Résultat retient ce choix, et la catégorisation automatique ne le remplacera pas."
}
},
"state": {

View file

@ -0,0 +1,215 @@
// TransactionsPage and its getting-started card (#399). No jsdom:
// react-dom/server renders in vitest's node environment. The page runs the
// real `useTransactions` (its reducer and initial state; no effect runs, so
// nothing is read) with the rows a first read would have brought, and the path
// is a fake `useOnboarding()` built from the real checklist. What is pinned is
// the page's wiring: the guided entry's start filters, where the card sits,
// what it says, and which control carries the one highlight.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import type { TransactionRow } from "../shared/types";
// The page's services pull the database: stub the connection.
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
i18n: { language: "fr" },
}),
}));
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
const harness = vi.hoisted(() => ({ onboarding: null as unknown, rows: [] as TransactionRow[] }));
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
vi.mock("../hooks/useTransactions", async (importOriginal) => {
const actual = await importOriginal<typeof import("../hooks/useTransactions")>();
return {
...actual,
useTransactions: (...args: Parameters<typeof actual.useTransactions>) => {
const hook = actual.useTransactions(...args);
return { ...hook, state: { ...hook.state, rows: harness.rows } };
},
};
});
import TransactionsPage from "./TransactionsPage";
import { TRANSACTION_CATEGORY_TARGET } from "../components/transactions/TransactionTable";
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
import { computeChecklist } from "../utils/onboardingChecklist";
import { nextStep, stepNavState } from "../utils/onboardingPath";
import type { OnboardingCounters } from "../shared/types";
/** Folder, source and import done: the next step is the manual category. */
const IMPORTED: OnboardingCounters = {
sources: 1,
imports: 1,
manualCategorized: 0,
budgetEntries: 0,
transactions: 41,
};
/** A transaction categorized by hand: the step is checked. */
const CATEGORIZED: OnboardingCounters = { ...IMPORTED, manualCategorized: 1 };
/** The path latched on /transactions (card mode), as the provider shares it. */
function path(
counters: OnboardingCounters,
overrides: Partial<OnboardingValue> = {},
budgetAllowed = true,
): OnboardingValue {
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed });
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
nextStep: nextStep(checklist),
mode: "card",
cardPage: "/transactions",
hide: vi.fn(async () => undefined),
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
...overrides,
};
}
const row = (id: number, description: string): TransactionRow => ({
id,
date: "2025-12-18",
description,
amount: -42.5,
category_id: null,
category_name: null,
category_color: null,
source_name: "Exemple Desjardins",
notes: null,
is_manually_categorized: false,
is_split: false,
});
/** What the link of the "categorize by hand" step carries. */
const GUIDED = stepNavState("manualCategory");
const render = (state: unknown = null) =>
renderToStaticMarkup(
<MemoryRouter initialEntries={[{ pathname: "/transactions", state }]}>
<TransactionsPage />
</MemoryRouter>,
);
const CARD = 'aria-label="t(onboarding.banner.label)"';
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const JANUARY_FIRST = `${new Date().getFullYear()}-01-01`;
const count = (html: string, needle: string) => html.split(needle).length - 1;
/**
* The card's sentence: the paragraph that follows its title. The card always
* hands `t` its interpolations, so a sentence without any reads `t(key[])`.
*/
const sentence = (html: string) => html.match(/<\/h2><p [^>]*>(.*?)<\/p>/)?.[1] ?? null;
/** The filter bar's start date, "" without a bound (React writes `value` last). */
const dateFrom = (html: string) =>
html.match(/<input type="date" placeholder="t\(transactions\.filters\.dateFrom\)"[^>]* value="([^"]*)"/)?.[1];
/** The period button shown as active. */
const activePeriod = (html: string) =>
html.match(/<button class="[^"]*bg-\[var\(--primary\)\] text-white">t\(dashboard\.period\.(\w+)\)<\/button>/)?.[1];
beforeEach(() => {
harness.onboarding = path(IMPORTED);
harness.rows = [];
});
describe("TransactionsPage — the guided entry (#399)", () => {
it("arriving by the step's link: the uncategorized transactions, without a date bound", () => {
const html = render(GUIDED);
expect(dateFrom(html)).toBe("");
expect(activePeriod(html)).toBe("all");
expect(html).toContain('value="t(transactions.filters.uncategorized)"');
});
it("an ordinary visit keeps the default filters: since January 1st, every category", () => {
const html = render();
expect(dateFrom(html)).toBe(JANUARY_FIRST);
expect(html).toContain('value="t(transactions.filters.allCategories)"');
expect(html).not.toContain('value="t(transactions.filters.uncategorized)"');
});
it("reads a malformed navigation state as an ordinary visit (CWE-476)", () => {
for (const state of ["guided", { guided: true }, { onboarding: null }, { onboarding: { guided: "yes" } }]) {
expect(dateFrom(render(state)), JSON.stringify(state)).toBe(JANUARY_FIRST);
}
});
});
describe("TransactionsPage — the step card (#399)", () => {
it("renders the card first, before the page's heading, at its place in the path", () => {
const html = render(GUIDED);
expect(count(html, CARD)).toBe(1);
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
expect(html).toContain("t(onboarding.card.stepOf[n=5,total=6])");
expect(html).toContain("t(onboarding.checklist.items.manualCategory)");
});
it("the guided entry: only the uncategorized transactions, the filter named by its own label", () => {
expect(sentence(render(GUIDED))).toBe(
"t(onboarding.card.transactions.uncategorized[filter=t(transactions.filters.uncategorized)])",
);
});
it("an ordinary visit: the sentence for every transaction", () => {
expect(sentence(render())).toBe("t(onboarding.card.transactions.all[])");
});
it("the first row's category is the one target", () => {
harness.rows = [row(1, "IGA 123"), row(2, "METRO 456")];
const html = render(GUIDED);
expect(count(html, HIGHLIGHT)).toBe(1);
expect(html).toContain(`data-onboarding-target="${TRANSACTION_CATEGORY_TARGET}" ${HIGHLIGHT}`);
const ring = html.indexOf(HIGHLIGHT);
expect(ring).toBeGreaterThan(html.indexOf("IGA 123"));
expect(ring).toBeLessThan(html.indexOf("METRO 456"));
});
it("without the card on screen, no control is highlighted", () => {
harness.rows = [row(1, "IGA 123"), row(2, "METRO 456")];
for (const overrides of [
{ mode: "banner" as const, cardPage: null },
{ mode: "none" as const, cardPage: null },
{ mode: "card" as const, cardPage: "/import" },
]) {
harness.onboarding = path(IMPORTED, overrides);
const html = render(GUIDED);
expect(html).not.toContain(CARD);
expect(count(html, HIGHLIGHT)).toBe(0);
expect(html).toContain("IGA 123");
}
});
it("a transaction categorized by hand: the card stays, says 'Done.' and offers the Budget step", () => {
harness.rows = [row(1, "IGA 123"), row(2, "METRO 456")];
harness.onboarding = path(CATEGORIZED);
const html = render();
expect(count(html, CARD)).toBe(1);
expect(html).toMatch(/<p role="status"[^>]*>.*t\(onboarding\.card\.done\)/);
expect(html).toMatch(
/<a [^>]*href="\/budget"[^>]*>t\(onboarding\.card\.next\[step=t\(onboarding\.checklist\.items\.budget\)\]\)/,
);
expect(sentence(html)).toBeNull();
expect(html).not.toContain("t(onboarding.card.skip)");
expect(count(html, HIGHLIGHT)).toBe(0);
});
it("in the Free edition, the step checked ends the path", () => {
harness.onboarding = path(CATEGORIZED, {}, false);
const html = render();
expect(html).toContain("t(onboarding.card.stepOf[n=5,total=5])");
expect(html).toContain("t(onboarding.card.done)");
expect(html).toContain("t(onboarding.banner.complete)");
expect(html).not.toContain('href="/budget"');
});
});

View file

@ -1,9 +1,21 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { Wand2, Tag, Receipt } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp";
import EmptyState from "../components/shared/EmptyState";
import { useTransactions, showsEmptyState } from "../hooks/useTransactions";
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
import { useOnboarding } from "../contexts/OnboardingContext";
import {
readOnboardingNavState,
showsStepCard,
transactionsCardModel,
} from "../utils/onboardingPath";
import {
useTransactions,
showsEmptyState,
GUIDED_ENTRY_FILTERS,
} from "../hooks/useTransactions";
import TransactionFilterBar from "../components/transactions/TransactionFilterBar";
import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar";
import TransactionTable from "../components/transactions/TransactionTable";
@ -18,8 +30,16 @@ import type { TransactionRow } from "../shared/types";
export default function TransactionsPage() {
const { t } = useTranslation();
const location = useLocation();
// The link of the "categorize by hand" step opens the guided entry (#399),
// read like any navigation state through `readOnboardingNavState` (CWE-476).
// The hook takes its start filters at mount only: the card consuming the
// navigation state afterwards changes nothing.
const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory, loadSplitChildren, saveSplit, deleteSplit } =
useTransactions();
useTransactions(
readOnboardingNavState(location.state).guided ? GUIDED_ENTRY_FILTERS : undefined
);
const onboarding = useOnboarding();
const [resultMessage, setResultMessage] = useState<string | null>(null);
const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null);
const [pending, setPending] = useState<TransactionRow | null>(null);
@ -58,8 +78,18 @@ export default function TransactionsPage() {
// can still sit on a populated profile.
const isEmptyProfile = showsEmptyState(state);
// The getting-started card follows the list's filter (#399). Its target is
// highlighted only while the card is on screen: a ring needs the card that
// explains it.
const cardModel = transactionsCardModel(state.filters, onboarding.checklist);
const target = showsStepCard(onboarding.mode, onboarding.cardPage, location.pathname)
? cardModel.target
: null;
return (
<div>
<OnboardingStepCard model={cardModel} />
<div className="relative flex items-center gap-3 mb-6">
<h1 className="text-2xl font-bold">{t("transactions.title")}</h1>
<PageHelp helpKey="transactions" />
@ -128,6 +158,7 @@ export default function TransactionsPage() {
onDeleteSplit={deleteSplit}
onRowContextMenu={handleRowContextMenu}
linkedTransfersByTxId={linkedTransfersByTxId}
onboardingTarget={target === "firstRowCategory"}
/>
<TransactionPagination

View file

@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import i18n from "../i18n/config";
import { computeChecklist, type Checklist } from "./onboardingChecklist";
import {
CARD_PAGES,
@ -13,8 +14,11 @@ import {
showsStepCard,
stepNavState,
stepsOfPage,
transactionsCardModel,
type BannerModeInput,
type ImportCardModel,
type StepCardText,
type TransactionsCardModel,
} from "./onboardingPath";
import {
CHECKLIST_ITEM_KEYS,
@ -342,10 +346,14 @@ describe("stepNavState (#397)", () => {
});
});
describe("CARD_PAGES (#398)", () => {
describe("CARD_PAGES (#398, #399)", () => {
it("carries the Import page", () => {
expect(CARD_PAGES.has("/import")).toBe(true);
});
it("carries the Transactions page", () => {
expect(CARD_PAGES.has("/transactions")).toBe(true);
});
});
describe("showsStepCard (#398)", () => {
@ -569,6 +577,84 @@ describe("importCardModel (#398)", () => {
});
});
describe("transactionsCardModel (#399)", () => {
/** Folder, source and import done: the next step is the manual category. */
const imported = checklistOf({ ...zero, sources: 1, imports: 1, transactions: 41 }, { importFolderSet: true });
const UNCATEGORIZED: TransactionsCardModel = {
text: {
key: "onboarding.card.transactions.uncategorized",
labels: { filter: "transactions.filters.uncategorized" },
},
done: false,
target: "firstRowCategory",
};
const ALL: TransactionsCardModel = {
text: { key: "onboarding.card.transactions.all" },
done: false,
target: "firstRowCategory",
};
/** A sentence of the card as StepCard renders it, through the app's real i18n. */
const render = (text: StepCardText | null, lng: "fr" | "en") =>
text &&
i18n.t(text.key, {
lng,
...text.values,
...Object.fromEntries(
Object.entries(text.labels ?? {}).map(([name, key]) => [name, i18n.t(key, { lng })]),
),
});
it("only the uncategorized transactions listed: says so, the filter named by its own label", () => {
expect(transactionsCardModel({ uncategorizedOnly: true }, imported)).toEqual(UNCATEGORIZED);
});
it("every transaction listed: the sentence without the filter", () => {
expect(transactionsCardModel({ uncategorizedOnly: false }, imported)).toEqual(ALL);
});
it("points at the first row's category until the step is checked", () => {
expect(transactionsCardModel({ uncategorizedOnly: true }, null).target).toBe("firstRowCategory");
});
it("the step checked: 'Done.', nothing to point at, whatever the filter", () => {
const categorized = checklistOf(upToManual, { importFolderSet: true });
for (const uncategorizedOnly of [true, false]) {
expect(transactionsCardModel({ uncategorizedOnly }, categorized)).toEqual({
text: null,
done: true,
target: null,
});
}
});
it("a skipped step is not a checked one", () => {
const skipped = checklistOf(
{ ...zero, sources: 1, imports: 1, transactions: 41 },
{ importFolderSet: true, skipped: ["manualCategory"] },
);
expect(transactionsCardModel({ uncategorizedOnly: false }, skipped)).toEqual(ALL);
});
it("reads the two sentences in French, the filter's label in lowercase", () => {
expect(render(UNCATEGORIZED.text, "fr")).toBe(
"Seules les transactions non catégorisées sont affichées. Cliquez sur la catégorie de l'une d'elles et choisissez-en une dans la liste. Simpl'Résultat retient ce choix, et la catégorisation automatique ne le remplacera pas.",
);
expect(render(ALL.text, "fr")).toBe(
"Cliquez sur la catégorie d'une transaction et choisissez-en une dans la liste. Simpl'Résultat retient ce choix, et la catégorisation automatique ne le remplacera pas.",
);
});
it("reads the two sentences in English, the filter's label in lowercase", () => {
expect(render(UNCATEGORIZED.text, "en")).toBe(
"Only uncategorized transactions are shown. Click the category of one of them and pick one from the list. Simpl'Résultat keeps your choice, and automatic categorization will not replace it.",
);
expect(render(ALL.text, "en")).toBe(
"Click a transaction's category and pick one from the list. Simpl'Résultat keeps your choice, and automatic categorization will not replace it.",
);
});
});
describe("onboardingTargetAttributes (#398)", () => {
it("marks the target and its highlight when on, nothing otherwise", () => {
expect(onboardingTargetAttributes("wizard-next", true)).toEqual({

View file

@ -4,10 +4,11 @@ import type {
ChecklistItemKey,
ImportCardInput,
OnboardingStatus,
TransactionFilters,
} from "../shared/types";
/**
* Pure rules of the guided getting-started path (#396, #398).
* Pure rules of the guided getting-started path (#396, #398, #399).
*
* The provider (`useOnboardingChecklist`) feeds them its state; the banner,
* the step cards and the pages that carry a card only read what they decide.
@ -24,10 +25,10 @@ export function nextStep(checklist: Checklist): ChecklistItem | null {
/**
* Routes whose page carries a step card. Each card issue adds its page (#398:
* Import), and a page without a delivered card keeps the banner, so a partly
* merged stack never removes any guidance.
* Import, #399: Transactions), and a page without a delivered card keeps the
* banner, so a partly merged stack never removes any guidance.
*/
export const CARD_PAGES: ReadonlySet<string> = new Set<string>(["/import"]);
export const CARD_PAGES: ReadonlySet<string> = new Set<string>(["/import", "/transactions"]);
const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = {
"/import": ["importFolder", "source", "import"],
@ -332,6 +333,41 @@ export function importCardModel(
};
}
/** The control of the Transactions page that the card points at. */
export type TransactionsCardTarget = "firstRowCategory";
export interface TransactionsCardModel extends StepCardModel {
/** The control to highlight, null for none. */
target: TransactionsCardTarget | null;
}
/**
* The Transactions page's card (#399), read from the page's filters and the
* checklist:
* - the "categorize by hand" step checked: "Done.", nothing to point at;
* - otherwise, the sentence that matches the list: the uncategorized
* transactions only (the guided entry, or the filter picked by hand), the
* filter named by its own label, or every transaction (an ordinary visit,
* or a guided entry that found none uncategorized); and the category of the
* first row shown.
*/
export function transactionsCardModel(
filters: Pick<TransactionFilters, "uncategorizedOnly">,
checklist: Checklist | null,
): TransactionsCardModel {
if (checked(checklist, "manualCategory")) return { text: null, done: true, target: null };
return {
text: filters.uncategorizedOnly
? {
key: "onboarding.card.transactions.uncategorized",
labels: { filter: "transactions.filters.uncategorized" },
}
: { key: "onboarding.card.transactions.all" },
done: false,
target: "firstRowCategory",
};
}
/** The attributes of a highlighted control; `styles.css` draws the ring on the second one. */
export interface OnboardingTargetAttributes {
"data-onboarding-target"?: string;