Two visit steps join the getting-started path: "Browse categories and keywords" (5th) and "Browse the reports" (8th). Nothing in the database counts them: they are done by "Got it" on their card, stored as onboarding_acknowledged_<key> in user_preferences (no migration). - computeChecklist: eight rows; the visit rows are locked until the profile's visit steps are initialized, so a finished path does not flash a "Next step" before they are marked done. - initializeVisitSteps: once per profile, after the first successful load with the Budget entitlement known; acknowledges both steps when every other step is done or skipped, then sets the flag. No transaction (D16): idempotent writes, the flag last. - Reducer: ACKNOWLEDGE wins over an older snapshot like SKIP but keeps the card latch, so the card says "Done." and offers the next step. - StepCard: "Got it" before "Skip this step" when onAcknowledge is given; OnboardingStepCard passes it for a visit step. - Banner counter, card rank and end of path wait for budgetReady && visitStepsInitialized; the list shows the visit rows "pending" until then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
165 lines
6.3 KiB
TypeScript
165 lines
6.3 KiB
TypeScript
// DashboardPage reads the shared getting-started path (#396). No jsdom:
|
|
// react-dom/server renders in vitest's node environment. The path comes from a
|
|
// fake `useOnboarding()`, and the dashboard's data hook and widgets are stubbed
|
|
// to markers: what is pinned is the page's branching on the path. Since #397
|
|
// the page has no list of its own once transactions exist: the banner of
|
|
// AppShell replaced the compact bar (D7).
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
|
|
|
|
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
|
|
|
|
let onboarding: OnboardingValue;
|
|
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => onboarding }));
|
|
|
|
vi.mock("../hooks/useDashboard", () => ({
|
|
useDashboard: () => ({
|
|
state: {
|
|
categoryBreakdown: [],
|
|
categoryOverTime: { categories: [], data: [] },
|
|
cartesSnapshot: null,
|
|
period: "year",
|
|
customDateFrom: "2026-01-01",
|
|
customDateTo: "2026-10-08",
|
|
referenceYear: 2026,
|
|
referenceMonth: 9,
|
|
accountIds: [],
|
|
accounts: [],
|
|
netWorth: null,
|
|
isLoading: false,
|
|
error: null,
|
|
},
|
|
setPeriod: vi.fn(),
|
|
setCustomDates: vi.fn(),
|
|
setReferencePeriod: vi.fn(),
|
|
setAccountIds: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
// Hoisted with the mocks below, which run before the imports.
|
|
const { marker, emptyStateProps } = vi.hoisted(() => ({
|
|
marker: (name: string) => ({ default: () => <i data-marker={name} /> }),
|
|
emptyStateProps: { current: null as Record<string, unknown> | null },
|
|
}));
|
|
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
|
|
vi.mock("../components/dashboard/CategoriesV1DiscoveryBanner", () => marker("discovery"));
|
|
vi.mock("../components/dashboard/DashboardEmptyState", () => ({
|
|
default: (props: Record<string, unknown>) => {
|
|
emptyStateProps.current = props;
|
|
return <i data-marker="empty-state" />;
|
|
},
|
|
}));
|
|
// The page no longer imports the list; the marker would show a compact bar
|
|
// that came back.
|
|
vi.mock("../components/dashboard/OnboardingChecklist", () => marker("checklist"));
|
|
vi.mock("../components/dashboard/PeriodSelector", () => marker("period"));
|
|
vi.mock("../components/dashboard/NetWorthTile", () => marker("net-worth"));
|
|
vi.mock("../components/reports/CategoryBarChart", () => marker("bar-chart"));
|
|
vi.mock("../components/reports/CategoryOverTimeChart", () => marker("over-time"));
|
|
vi.mock("../components/reports/FilterPanel", () => marker("filters"));
|
|
vi.mock("../components/reports/cards/KpiCard", () => marker("kpi"));
|
|
vi.mock("../components/reports/cards/IncomeExpenseOverlayChart", () => marker("overlay"));
|
|
vi.mock("../components/reports/cards/TopMoversList", () => marker("movers"));
|
|
vi.mock("../components/reports/cards/BudgetAdherenceCard", () => marker("adherence"));
|
|
vi.mock("../components/shared/TransactionDetailModal", () => marker("detail"));
|
|
|
|
import DashboardPage from "./DashboardPage";
|
|
import { computeChecklist } from "../utils/onboardingChecklist";
|
|
import type { OnboardingCounters } from "../shared/types";
|
|
|
|
const counters = (transactions: number): OnboardingCounters => ({
|
|
sources: transactions > 0 ? 1 : 0,
|
|
imports: transactions > 0 ? 1 : 0,
|
|
manualCategorized: 0,
|
|
budgetEntries: 0,
|
|
transactions,
|
|
});
|
|
|
|
function path(overrides: Partial<OnboardingValue> & { transactions?: number } = {}): OnboardingValue {
|
|
const { transactions = 25, ...rest } = overrides;
|
|
const c = counters(transactions);
|
|
return {
|
|
status: "ready",
|
|
checklist: computeChecklist(c, {
|
|
importFolderSet: true,
|
|
budgetAllowed: true,
|
|
visitStepsInitialized: true,
|
|
acknowledged: c.imports >= 1 ? ["categories", "reports"] : [],
|
|
}),
|
|
counters: c,
|
|
hidden: false,
|
|
budgetReady: true,
|
|
visitStepsInitialized: true,
|
|
nextStep: null,
|
|
mode: "banner",
|
|
cardPage: null,
|
|
hide: vi.fn(async () => undefined),
|
|
show: vi.fn(async () => undefined),
|
|
skip: vi.fn(async () => undefined),
|
|
resume: vi.fn(async () => undefined),
|
|
acknowledge: vi.fn(async () => undefined),
|
|
refresh: vi.fn(async () => undefined),
|
|
...rest,
|
|
};
|
|
}
|
|
|
|
const render = () =>
|
|
renderToStaticMarkup(
|
|
<MemoryRouter>
|
|
<DashboardPage />
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
beforeEach(() => {
|
|
onboarding = path();
|
|
emptyStateProps.current = null;
|
|
});
|
|
|
|
describe("DashboardPage on the shared path (#352, #396, #397)", () => {
|
|
it("renders only its header while the path first loads", () => {
|
|
onboarding = path({ status: "loading", checklist: null, counters: null });
|
|
const html = render();
|
|
expect(html).toContain("t(dashboard.title)");
|
|
expect(html).not.toContain('data-marker="empty-state"');
|
|
expect(html).not.toContain('data-marker="filters"');
|
|
});
|
|
|
|
it("shows the welcome card on a profile without any transaction", () => {
|
|
onboarding = path({ transactions: 0 });
|
|
const html = render();
|
|
expect(html).toContain('data-marker="empty-state"');
|
|
expect(html).not.toContain('data-marker="filters"');
|
|
});
|
|
|
|
it("wires the welcome card's list to the path: hidden state, 'Hide' and 'Resume'", () => {
|
|
onboarding = path({ transactions: 0, hidden: true });
|
|
render();
|
|
const props = emptyStateProps.current!;
|
|
expect(props.checklist).toBe(onboarding.checklist);
|
|
expect(props.hidden).toBe(true);
|
|
expect(props.budgetReady).toBe(true);
|
|
expect(props.onHide).toBe(onboarding.hide);
|
|
expect(props.onResume).toBe(onboarding.resume);
|
|
});
|
|
|
|
it.each([
|
|
["shown", false],
|
|
["hidden", true],
|
|
])("has no list of its own on a profile with transactions, path %s: the banner replaced the compact bar (D7)", (_, hidden) => {
|
|
onboarding = path({ hidden, mode: hidden ? "none" : "banner" });
|
|
const html = render();
|
|
expect(html).not.toContain('data-marker="checklist"');
|
|
expect(html).not.toContain('data-marker="empty-state"');
|
|
expect(html).toContain('data-marker="filters"');
|
|
});
|
|
|
|
it("renders the page without the list when the first load failed", () => {
|
|
onboarding = path({ status: "error", checklist: null, counters: null, mode: "none" });
|
|
const html = render();
|
|
expect(html).not.toContain('data-marker="checklist"');
|
|
expect(html).not.toContain('data-marker="empty-state"');
|
|
expect(html).toContain('data-marker="filters"');
|
|
});
|
|
});
|