fix: onboarding phase 1 follow-ups: pins, Transactions card, Import messages, docs (#384) #474

Open
maximus wants to merge 7 commits from issue-384-onboarding-followups into issue-437-test-pins
6 changed files with 331 additions and 29 deletions
Showing only changes of commit 3d917ed803 - Show all commits

View file

@ -428,17 +428,23 @@ export function useBudget() {
// Load once on mount: the import-source list for the FilterPanel's account // Load once on mount: the import-source list for the FilterPanel's account
// checkboxes (same query as the Trends/Compare source filters — see // checkboxes (same query as the Trends/Compare source filters — see
// useTrends.ts) and the profile-wide transaction count (#353). // useTrends.ts) and the profile-wide transaction count (#353). Each read
// stands alone (#384): a failed count no longer leaves the account filter
// empty, and leaves `totalInDb` null, so the grid, never the empty state.
// The first failure is the page's error.
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { const [accounts, total] = await Promise.allSettled([
const [accounts, total] = await Promise.all([ getAllImportSources(),
getAllImportSources(), getTransactionCount(),
getTransactionCount(), ]);
]); if (accounts.status === "fulfilled") dispatch({ type: "SET_ACCOUNTS", payload: accounts.value });
dispatch({ type: "SET_ACCOUNTS", payload: accounts }); if (total.status === "fulfilled") dispatch({ type: "SET_TOTAL_IN_DB", payload: total.value });
dispatch({ type: "SET_TOTAL_IN_DB", payload: total }); const failed = [accounts, total].find(
} catch (e) { (r): r is PromiseRejectedResult => r.status === "rejected"
);
if (failed) {
const e: unknown = failed.reason;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
} }
})(); })();

View file

@ -0,0 +1,97 @@
// useBudget — the reads of the mount stand alone (#384).
//
// React's hooks come from the shared harness (src/test/hookHarness.ts, #409):
// the hook runs as a plain function, its reducer is applied, and `commit()`
// runs its effects (the year's budget, then the accounts and the count). The
// services and the reports period are mocked. What is pinned: a failed count
// no longer drops the accounts of the filter, leaves `totalInDb` unknown (the
// grid, never the "import first" card), and the first failure, in the order
// accounts, count, is the page's error.
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
const services = vi.hoisted(() => ({
getAllImportSources: vi.fn(),
getTransactionCount: vi.fn(),
}));
vi.mock("../services/transactionService", () => services);
vi.mock("../services/budgetService", () => ({
getAllActiveCategories: vi.fn(async () => []),
getBudgetEntriesForYear: vi.fn(async () => []),
getActualTotalsForYear: vi.fn(async () => []),
getAllTemplates: vi.fn(async () => []),
upsertBudgetEntry: vi.fn(),
upsertBudgetEntriesForYear: vi.fn(),
saveAsTemplate: vi.fn(),
applyTemplate: vi.fn(),
deleteTemplate: vi.fn(),
}));
// One array for every render: a new one would read as a changed filter.
const { NO_ACCOUNT } = vi.hoisted(() => ({ NO_ACCOUNT: [] as number[] }));
vi.mock("./useReportsPeriod", () => ({
useReportsPeriod: () => ({ accountIds: NO_ACCOUNT, setAccountIds: () => undefined }),
}));
import { hookHarness } from "../test/hookHarness";
import { useBudget, showsEmptyState } from "./useBudget";
import type { ImportSource } from "../shared/types";
const harness = hookHarness();
const ACCOUNTS = [{ id: 7, name: "Desjardins" }] as ImportSource[];
/** Lets the mount reads settle. */
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
/** Mounts the hook, runs its effects, lets them settle, and renders again. */
async function mount() {
harness.render(useBudget);
harness.commit();
await flush();
return harness.render(useBudget).state;
}
beforeEach(() => {
harness.clear();
services.getAllImportSources.mockReset().mockResolvedValue(ACCOUNTS);
services.getTransactionCount.mockReset().mockResolvedValue(0);
});
describe("useBudget — the reads of the mount (#384)", () => {
it("both succeed: the accounts and the count, no error", async () => {
const state = await mount();
expect(state.accounts).toBe(ACCOUNTS);
expect(state.totalInDb).toBe(0);
expect(state.error).toBeNull();
expect(showsEmptyState(state)).toBe(true);
});
it("a failed count still loads the accounts, and leaves the count unknown", async () => {
services.getTransactionCount.mockRejectedValue(new Error("count failed"));
const state = await mount();
expect(state.accounts).toBe(ACCOUNTS);
expect(state.totalInDb).toBeNull();
expect(state.error).toBe("count failed");
// Unknown count: the grid, never the "import first" card.
expect(showsEmptyState(state)).toBe(false);
});
it("failed accounts still load the count", async () => {
services.getAllImportSources.mockRejectedValue(new Error("accounts failed"));
services.getTransactionCount.mockResolvedValue(41);
const state = await mount();
expect(state.accounts).toEqual([]);
expect(state.totalInDb).toBe(41);
expect(state.error).toBe("accounts failed");
});
it("both fail: the accounts' failure, the first one, is the error", async () => {
services.getAllImportSources.mockRejectedValue(new Error("accounts failed"));
services.getTransactionCount.mockRejectedValue(new Error("count failed"));
const state = await mount();
expect(state.totalInDb).toBeNull();
expect(state.error).toBe("accounts failed");
});
});

View file

@ -67,14 +67,19 @@ describe("transactionsReducer — SET_TOTAL_IN_DB (issue #353)", () => {
expect(showsEmptyState(filled)).toBe(false); expect(showsEmptyState(filled)).toBe(false);
}); });
it("keeps the regular layout while a page is loading, even at 0", () => { it("shows the empty state as soon as the count is 0, even while the first page loads (#384, D1)", () => {
// The loading placeholder stays in place of the table so the card does not // Same rule as Budget: the count alone decides. On an empty profile the
// flash during the first fetch of a profile whose count is already known. // card comes up as soon as the count is known, instead of the filter bar,
// the totals and the loading line until the first page is back.
const loadingEmpty = transactionsReducer( const loadingEmpty = transactionsReducer(
transactionsReducer(initialTransactionsState, { type: "SET_TOTAL_IN_DB", payload: 0 }), transactionsReducer(initialTransactionsState, { type: "SET_TOTAL_IN_DB", payload: 0 }),
{ type: "SET_LOADING", payload: true }, { type: "SET_LOADING", payload: true },
); );
expect(showsEmptyState(loadingEmpty)).toBe(false); expect(loadingEmpty.isLoading).toBe(true);
expect(showsEmptyState(loadingEmpty)).toBe(true);
// And while the count is unknown, a loading page keeps the regular layout.
const loadingUnknown = transactionsReducer(initialTransactionsState, { type: "SET_LOADING", payload: true });
expect(showsEmptyState(loadingUnknown)).toBe(false);
}); });
it("an empty page on a populated profile is NOT the empty state (default filter is 'since Jan 1st')", () => { it("an empty page on a populated profile is NOT the empty state (default filter is 'since Jan 1st')", () => {

View file

@ -207,11 +207,13 @@ export function transactionsReducer(
/** /**
* Whether the Transactions page shows the "import first" empty state (#353) * Whether the Transactions page shows the "import first" empty state (#353)
* instead of the filter bar, table and pagination. Pure, so the 0 → N * instead of the filter bar, table and pagination. Pure, so the 0 → N
* transition is unit-testable without a renderer. `null` (count not loaded) * transition is unit-testable without a renderer. `null` (count not loaded,
* and a loading page keep the regular layout. * or its read failed) keeps the regular layout. A 0 shows the card at once,
* even while the first page is still loading: the same rule as Budget, so an
* empty profile no longer gets the table's layout first (#384, D1).
*/ */
export function showsEmptyState(state: Pick<TransactionsState, "totalInDb" | "isLoading">): boolean { export function showsEmptyState(state: Pick<TransactionsState, "totalInDb">): boolean {
return state.totalInDb === 0 && !state.isLoading; return state.totalInDb === 0;
} }
/** /**
@ -225,19 +227,26 @@ export function useTransactions(initialFilters?: Partial<TransactionFilters>) {
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedFiltersRef = useRef(state.filters); const debouncedFiltersRef = useRef(state.filters);
// Load categories, sources and the profile-wide transaction count once on mount // Load categories, sources and the profile-wide transaction count once on
// mount. Each read stands alone (#384): a failed count no longer leaves the
// category and source filters empty, and leaves `totalInDb` null, so the
// regular layout, never the empty state. The first failure is the page's
// error.
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { const [cats, srcs, total] = await Promise.allSettled([
const [cats, srcs, total] = await Promise.all([ getAllCategories(),
getAllCategories(), getAllImportSources(),
getAllImportSources(), getTransactionCount(),
getTransactionCount(), ]);
]); if (cats.status === "fulfilled") dispatch({ type: "SET_CATEGORIES", payload: cats.value });
dispatch({ type: "SET_CATEGORIES", payload: cats }); if (srcs.status === "fulfilled") dispatch({ type: "SET_SOURCES", payload: srcs.value });
dispatch({ type: "SET_SOURCES", payload: srcs }); if (total.status === "fulfilled") dispatch({ type: "SET_TOTAL_IN_DB", payload: total.value });
dispatch({ type: "SET_TOTAL_IN_DB", payload: total }); const failed = [cats, srcs, total].find(
} catch (e) { (r): r is PromiseRejectedResult => r.status === "rejected"
);
if (failed) {
const e: unknown = failed.reason;
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e), payload: e instanceof Error ? e.message : String(e),

View file

@ -0,0 +1,121 @@
// useTransactions — the reads of the mount stand alone (#384).
//
// React's hooks come from the shared harness (src/test/hookHarness.ts, #409):
// the hook runs as a plain function, its reducer is applied, and `commit()`
// runs its effects (the mount reads, the first page, the search debounce,
// whose timer `unmount()` clears). The services are mocked. What is pinned:
// one failed read no longer drops the others, a failed count leaves
// `totalInDb` unknown (the regular layout, never the "import first" card),
// and the first failure, in the order categories, sources, count, is the
// page's error.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
const services = vi.hoisted(() => ({
getAllCategories: vi.fn(),
getAllImportSources: vi.fn(),
getTransactionCount: vi.fn(),
getTransactionPage: vi.fn(),
}));
vi.mock("../services/transactionService", () => ({
...services,
updateTransactionCategory: vi.fn(),
updateTransactionNotes: vi.fn(),
autoCategorizeTransactions: vi.fn(),
getSplitChildren: vi.fn(),
saveSplitAdjustment: vi.fn(),
deleteSplitAdjustment: vi.fn(),
}));
vi.mock("../services/categoryService", () => ({ createKeyword: vi.fn() }));
import { hookHarness } from "../test/hookHarness";
import { useTransactions, showsEmptyState } from "./useTransactions";
import type { Category, ImportSource, TransactionPageResult } from "../shared/types";
const harness = hookHarness();
const CATEGORIES = [{ id: 1, name: "Épicerie" }] as Category[];
const SOURCES = [{ id: 7, name: "Desjardins" }] as ImportSource[];
const PAGE: TransactionPageResult = {
rows: [],
totalCount: 0,
totalAmount: 0,
incomeTotal: 0,
expenseTotal: 0,
};
/** Lets the mount reads and the first page settle. */
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
/** Mounts the hook, runs its effects, lets them settle, and renders again. */
async function mount() {
harness.render(() => useTransactions());
harness.commit();
await flush();
return harness.render(() => useTransactions()).state;
}
/** The actions of one type the hook dispatched, in order. */
const sent = (type: string) =>
harness.dispatch.mock.calls
.map(([action]) => action as { type: string; payload?: unknown })
.filter((a) => a.type === type);
beforeEach(() => {
harness.clear();
services.getAllCategories.mockReset().mockResolvedValue(CATEGORIES);
services.getAllImportSources.mockReset().mockResolvedValue(SOURCES);
services.getTransactionCount.mockReset().mockResolvedValue(0);
services.getTransactionPage.mockReset().mockResolvedValue(PAGE);
});
afterEach(() => {
harness.unmount();
});
describe("useTransactions — the reads of the mount (#384)", () => {
it("all three succeed: categories, sources and the count, no error", async () => {
const state = await mount();
expect(state.categories).toBe(CATEGORIES);
expect(state.sources).toBe(SOURCES);
expect(state.totalInDb).toBe(0);
expect(state.error).toBeNull();
expect(showsEmptyState(state)).toBe(true);
});
it("a failed count still loads the categories and the sources, and leaves the count unknown", async () => {
services.getTransactionCount.mockRejectedValue(new Error("count failed"));
const state = await mount();
expect(state.categories).toBe(CATEGORIES);
expect(state.sources).toBe(SOURCES);
expect(state.totalInDb).toBeNull();
expect(sent("SET_TOTAL_IN_DB")).toEqual([]);
expect(state.error).toBe("count failed");
// Unknown count: the regular layout, never the "import first" card.
expect(showsEmptyState(state)).toBe(false);
});
it("failed categories still load the sources and the count", async () => {
services.getAllCategories.mockRejectedValue(new Error("categories failed"));
services.getTransactionCount.mockResolvedValue(12);
const state = await mount();
expect(state.categories).toEqual([]);
expect(state.sources).toBe(SOURCES);
expect(state.totalInDb).toBe(12);
expect(state.error).toBe("categories failed");
});
it("several failures: the first one, in the order categories, sources, count, is the error", async () => {
services.getAllImportSources.mockRejectedValue("sources failed");
services.getTransactionCount.mockRejectedValue(new Error("count failed"));
const state = await mount();
expect(state.categories).toBe(CATEGORIES);
expect(state.sources).toEqual([]);
expect(state.totalInDb).toBeNull();
// A rejection that is not an Error is shown as its text.
expect(state.error).toBe("sources failed");
expect(sent("SET_ERROR").filter((a) => a.payload !== null)).toHaveLength(1);
});
});

View file

@ -20,6 +20,8 @@ const harness = vi.hoisted(() => ({
rows: [] as TransactionRow[], rows: [] as TransactionRow[],
/** Filters a later dispatch would have set (the fallback, a filter picked by hand). */ /** Filters a later dispatch would have set (the fallback, a filter picked by hand). */
filters: null as Partial<TransactionFilters> | null, filters: null as Partial<TransactionFilters> | null,
/** Other fields a read would have set: the profile-wide count, a page loading (#384). */
state: {} as { totalInDb?: number | null; isLoading?: boolean },
/** The license gate the page reads (#369), and the features it asked for. */ /** The license gate the page reads (#369), and the features it asked for. */
entitlement: { allowed: true, ready: true }, entitlement: { allowed: true, ready: true },
entitlementCalls: [] as string[], entitlementCalls: [] as string[],
@ -39,7 +41,7 @@ vi.mock("../hooks/useTransactions", async (importOriginal) => {
useTransactions: (...args: Parameters<typeof actual.useTransactions>) => { useTransactions: (...args: Parameters<typeof actual.useTransactions>) => {
const hook = actual.useTransactions(...args); const hook = actual.useTransactions(...args);
const filters = harness.filters ? { ...hook.state.filters, ...harness.filters } : hook.state.filters; const filters = harness.filters ? { ...hook.state.filters, ...harness.filters } : hook.state.filters;
return { ...hook, state: { ...hook.state, rows: harness.rows, filters } }; return { ...hook, state: { ...hook.state, ...harness.state, rows: harness.rows, filters } };
}, },
}; };
}); });
@ -140,6 +142,7 @@ beforeEach(() => {
harness.onboarding = path(IMPORTED); harness.onboarding = path(IMPORTED);
harness.rows = []; harness.rows = [];
harness.filters = null; harness.filters = null;
harness.state = {};
harness.entitlement = { allowed: true, ready: true }; harness.entitlement = { allowed: true, ready: true };
harness.entitlementCalls = []; harness.entitlementCalls = [];
}); });
@ -305,3 +308,64 @@ describe("TransactionsPage — the Répartition button follows the edition (#369
} }
}); });
}); });
describe("TransactionsPage — a profile without transactions (#353, #384)", () => {
/** An empty profile: the path's next step is elsewhere, so no step card here. */
const EMPTY: OnboardingCounters = {
sources: 0,
imports: 0,
manualCategorized: 0,
budgetEntries: 0,
transactions: 0,
};
const CTA = /<a [^>]*href="\/import"[^>]*>t\(transactions\.empty\.cta\)<\/a>/;
/** What only the regular layout renders: filter bar, totals, table, pagination, loading line. */
const REGULAR = [
"t(transactions.filters.searchPlaceholder)",
"t(transactions.summary.count)",
"t(transactions.noTransactions)",
"t(transactions.pagination.showing)",
"t(common.loading)",
];
beforeEach(() => {
harness.onboarding = path(EMPTY, { mode: "banner", cardPage: null });
});
it("the 'import first' card in place of the filter bar, the table and the pagination", () => {
harness.state = { totalInDb: 0 };
const html = render();
expect(html).toContain("t(transactions.empty.title)");
expect(html).toContain("t(transactions.empty.description)");
expect(html).toMatch(CTA);
for (const marker of REGULAR) expect(html, marker).not.toContain(marker);
expect(html).not.toContain("<table");
// The page's heading stays above the card.
expect(html.indexOf("t(transactions.title)")).toBeLessThan(html.indexOf("t(transactions.empty.title)"));
});
it("as soon as the count is known, while the first page still loads (D1)", () => {
harness.state = { totalInDb: 0, isLoading: true };
const html = render();
expect(html).toContain("t(transactions.empty.title)");
expect(html).not.toContain("t(common.loading)");
expect(html).not.toContain("t(transactions.filters.searchPlaceholder)");
});
it("count not loaded yet, or its read failed: the regular layout, never the card", () => {
for (const state of [{ totalInDb: null }, { totalInDb: null, isLoading: true }]) {
harness.state = state;
const html = render();
expect(html, JSON.stringify(state)).not.toContain("transactions.empty");
expect(html, JSON.stringify(state)).toContain("t(transactions.filters.searchPlaceholder)");
}
});
it("a populated profile with an empty page keeps the table's layout (default filter: since January 1st)", () => {
harness.state = { totalInDb: 7 };
const html = render();
expect(html).not.toContain("transactions.empty");
expect(html).toContain("t(transactions.noTransactions)");
});
});