feat(onboarding): Transactions page step card and guided entry #405
12 changed files with 727 additions and 29 deletions
|
|
@ -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);
|
||||
|
|
|
|||
108
src/components/transactions/TransactionTable.render.test.tsx
Normal file
108
src/components/transactions/TransactionTable.render.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,6 +199,18 @@ export default function TransactionTable({
|
|||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{/*
|
||||
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}
|
||||
|
|
@ -199,6 +221,7 @@ export default function TransactionTable({
|
|||
activeExtra={row.category_id === null ? "" : null}
|
||||
onExtraSelect={() => onCategoryChange(row.id, null)}
|
||||
/>
|
||||
</div>
|
||||
{row.category_id !== null && (
|
||||
<>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
215
src/pages/TransactionsPage.render.test.tsx
Normal file
215
src/pages/TransactionsPage.render.test.tsx
Normal 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"');
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue