diff --git a/src/hooks/useBudget.ts b/src/hooks/useBudget.ts index 80499b5..76787be 100644 --- a/src/hooks/useBudget.ts +++ b/src/hooks/useBudget.ts @@ -428,17 +428,23 @@ export function useBudget() { // Load once on mount: the import-source list for the FilterPanel's account // 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(() => { (async () => { - try { - const [accounts, total] = await Promise.all([ - getAllImportSources(), - getTransactionCount(), - ]); - dispatch({ type: "SET_ACCOUNTS", payload: accounts }); - dispatch({ type: "SET_TOTAL_IN_DB", payload: total }); - } catch (e) { + const [accounts, total] = await Promise.allSettled([ + getAllImportSources(), + getTransactionCount(), + ]); + if (accounts.status === "fulfilled") dispatch({ type: "SET_ACCOUNTS", payload: accounts.value }); + if (total.status === "fulfilled") dispatch({ type: "SET_TOTAL_IN_DB", payload: total.value }); + const failed = [accounts, total].find( + (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) }); } })(); diff --git a/src/hooks/useBudget.wiring.test.ts b/src/hooks/useBudget.wiring.test.ts new file mode 100644 index 0000000..7469a10 --- /dev/null +++ b/src/hooks/useBudget.wiring.test.ts @@ -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"); + }); +}); diff --git a/src/hooks/useTransactions.test.ts b/src/hooks/useTransactions.test.ts index 8f6df11..a351415 100644 --- a/src/hooks/useTransactions.test.ts +++ b/src/hooks/useTransactions.test.ts @@ -67,14 +67,19 @@ describe("transactionsReducer — SET_TOTAL_IN_DB (issue #353)", () => { expect(showsEmptyState(filled)).toBe(false); }); - it("keeps the regular layout while a page is loading, even at 0", () => { - // The loading placeholder stays in place of the table so the card does not - // flash during the first fetch of a profile whose count is already known. + it("shows the empty state as soon as the count is 0, even while the first page loads (#384, D1)", () => { + // Same rule as Budget: the count alone decides. On an empty profile the + // 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( transactionsReducer(initialTransactionsState, { type: "SET_TOTAL_IN_DB", payload: 0 }), { 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')", () => { diff --git a/src/hooks/useTransactions.ts b/src/hooks/useTransactions.ts index 01aaee3..f854a15 100644 --- a/src/hooks/useTransactions.ts +++ b/src/hooks/useTransactions.ts @@ -207,11 +207,13 @@ export function transactionsReducer( /** * Whether the Transactions page shows the "import first" empty state (#353) * instead of the filter bar, table and pagination. Pure, so the 0 → N - * transition is unit-testable without a renderer. `null` (count not loaded) - * and a loading page keep the regular layout. + * transition is unit-testable without a renderer. `null` (count not loaded, + * 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): boolean { - return state.totalInDb === 0 && !state.isLoading; +export function showsEmptyState(state: Pick): boolean { + return state.totalInDb === 0; } /** @@ -225,19 +227,26 @@ export function useTransactions(initialFilters?: Partial) { const searchTimerRef = useRef | null>(null); 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(() => { (async () => { - try { - const [cats, srcs, total] = await Promise.all([ - getAllCategories(), - getAllImportSources(), - getTransactionCount(), - ]); - dispatch({ type: "SET_CATEGORIES", payload: cats }); - dispatch({ type: "SET_SOURCES", payload: srcs }); - dispatch({ type: "SET_TOTAL_IN_DB", payload: total }); - } catch (e) { + const [cats, srcs, total] = await Promise.allSettled([ + getAllCategories(), + getAllImportSources(), + getTransactionCount(), + ]); + if (cats.status === "fulfilled") dispatch({ type: "SET_CATEGORIES", payload: cats.value }); + if (srcs.status === "fulfilled") dispatch({ type: "SET_SOURCES", payload: srcs.value }); + if (total.status === "fulfilled") dispatch({ type: "SET_TOTAL_IN_DB", payload: total.value }); + const failed = [cats, srcs, total].find( + (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), diff --git a/src/hooks/useTransactions.wiring.test.ts b/src/hooks/useTransactions.wiring.test.ts new file mode 100644 index 0000000..2997eea --- /dev/null +++ b/src/hooks/useTransactions.wiring.test.ts @@ -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); + }); +}); diff --git a/src/pages/TransactionsPage.render.test.tsx b/src/pages/TransactionsPage.render.test.tsx index eed30c3..8878cde 100644 --- a/src/pages/TransactionsPage.render.test.tsx +++ b/src/pages/TransactionsPage.render.test.tsx @@ -20,6 +20,8 @@ const harness = vi.hoisted(() => ({ rows: [] as TransactionRow[], /** Filters a later dispatch would have set (the fallback, a filter picked by hand). */ filters: null as Partial | 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. */ entitlement: { allowed: true, ready: true }, entitlementCalls: [] as string[], @@ -39,7 +41,7 @@ vi.mock("../hooks/useTransactions", async (importOriginal) => { useTransactions: (...args: Parameters) => { const hook = actual.useTransactions(...args); 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.rows = []; harness.filters = null; + harness.state = {}; harness.entitlement = { allowed: true, ready: true }; 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 = /]*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(" { + 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)"); + }); +});