fix: onboarding phase 1 follow-ups: pins, Transactions card, Import messages, docs (#384) #474
6 changed files with 331 additions and 29 deletions
|
|
@ -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) });
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
97
src/hooks/useBudget.wiring.test.ts
Normal file
97
src/hooks/useBudget.wiring.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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')", () => {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
121
src/hooks/useTransactions.wiring.test.ts
Normal file
121
src/hooks/useTransactions.wiring.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue