All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
A profile without any transaction now opens a dashboard that says what to do: a welcome card with the expanded getting-started checklist and one primary action towards the import page, instead of widgets showing 0 $. With transactions, the compact checklist sits between the title and the filter panel until it is complete or dismissed. - `EmptyState` (pure, dashed card from the import source list; a `to` action renders a react-router `<Link>`, an `onClick` action a button) - `OnboardingChecklist` (pure, expanded and compact modes; undone rows link to their route, the locked Budget row shows a lock, the "Base and up" label and links to the license card; neutral budget row and hidden counter until the entitlement is known) - `DashboardEmptyState` composes both - `DashboardPage` branches on the hook `status`: loading renders the title, help and banner only; error falls back to the v0.15.0 page without the list and logs a warning; ready with zero transactions renders the empty state outside the `isLoading` veil - `useOnboardingChecklist` exposes `counters` and `error` for the page - Users settings: "Show the getting-started checklist" button in the Guide section, visible only when the preference is set, with an inline confirmation after the click - FR + EN keys `dashboard.empty.*`, `onboarding.checklist.*`, `settings.users.onboarding.*` Tests: `EmptyState.test.tsx` and `OnboardingChecklist.test.tsx` by direct call (FilterPanel pattern). No DB migration. CHANGELOG left to #357. Resolves #352 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
111 lines
4.1 KiB
TypeScript
111 lines
4.1 KiB
TypeScript
// EmptyState — unit tests (#352).
|
|
//
|
|
// No jsdom / testing-library in this project (see FilterPanel.test.tsx): the
|
|
// component is pure (no hook), so it is called as a plain function and its
|
|
// React-element tree is walked directly. `Link` is react-router's real
|
|
// component: creating the element never needs a router, only rendering would.
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
|
import { Link } from "react-router-dom";
|
|
|
|
import EmptyState from "./EmptyState";
|
|
|
|
function find(node: ReactNode, pred: (el: ReactElement) => boolean): ReactElement[] {
|
|
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
|
|
if (!isValidElement(node)) return [];
|
|
const el = node as ReactElement<{ children?: ReactNode }>;
|
|
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
|
|
}
|
|
|
|
function texts(node: ReactNode): string[] {
|
|
if (Array.isArray(node)) return node.flatMap(texts);
|
|
if (typeof node === "string") return [node];
|
|
if (!isValidElement(node)) return [];
|
|
return texts((node as ReactElement<{ children?: ReactNode }>).props.children);
|
|
}
|
|
|
|
const ICON = "ICON_MARKER";
|
|
|
|
describe("EmptyState", () => {
|
|
it("renders the icon, the title and the description", () => {
|
|
const el = EmptyState({ icon: ICON, title: "Title", description: "Desc" });
|
|
const text = texts(el);
|
|
expect(text).toContain(ICON);
|
|
expect(text).toContain("Title");
|
|
expect(text).toContain("Desc");
|
|
});
|
|
|
|
it("renders no action container when no action is given", () => {
|
|
const el = EmptyState({ icon: ICON, title: "Title" });
|
|
expect(find(el, (e) => e.type === Link)).toHaveLength(0);
|
|
expect(find(el, (e) => e.type === "button")).toHaveLength(0);
|
|
});
|
|
|
|
it("renders a `to` action as a react-router Link, never a button", () => {
|
|
const el = EmptyState({
|
|
icon: ICON,
|
|
title: "Title",
|
|
primaryAction: { label: "Go", to: "/import" },
|
|
});
|
|
const links = find(el, (e) => e.type === Link) as ReactElement<{ to: string; children: ReactNode }>[];
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0].props.to).toBe("/import");
|
|
expect(texts(links[0].props.children)).toContain("Go");
|
|
expect(find(el, (e) => e.type === "button")).toHaveLength(0);
|
|
});
|
|
|
|
it("renders an `onClick` action as a type=button that calls the handler", () => {
|
|
const onClick = vi.fn();
|
|
const el = EmptyState({
|
|
icon: ICON,
|
|
title: "Title",
|
|
primaryAction: { label: "Do", onClick },
|
|
});
|
|
const buttons = find(el, (e) => e.type === "button") as ReactElement<{
|
|
type: string;
|
|
disabled: boolean;
|
|
onClick: () => void;
|
|
}>[];
|
|
expect(buttons).toHaveLength(1);
|
|
expect(buttons[0].props.type).toBe("button");
|
|
expect(buttons[0].props.disabled).toBe(false);
|
|
buttons[0].props.onClick();
|
|
expect(onClick).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("propagates `disabled` on the button variant", () => {
|
|
const el = EmptyState({
|
|
icon: ICON,
|
|
title: "Title",
|
|
primaryAction: { label: "Do", onClick: vi.fn(), disabled: true },
|
|
});
|
|
const [button] = find(el, (e) => e.type === "button") as ReactElement<{ disabled: boolean }>[];
|
|
expect(button.props.disabled).toBe(true);
|
|
});
|
|
|
|
it("renders both actions, primary first", () => {
|
|
const el = EmptyState({
|
|
icon: ICON,
|
|
title: "Title",
|
|
primaryAction: { label: "Primary", to: "/a" },
|
|
secondaryAction: { label: "Secondary", onClick: vi.fn() },
|
|
});
|
|
const actions = find(el, (e) => e.type === Link || e.type === "button");
|
|
expect(actions).toHaveLength(2);
|
|
expect(actions[0].type).toBe(Link);
|
|
expect(actions[1].type).toBe("button");
|
|
});
|
|
|
|
it("renders children between the description and the actions", () => {
|
|
const el = EmptyState({
|
|
icon: ICON,
|
|
title: "Title",
|
|
description: "Desc",
|
|
primaryAction: { label: "Go", to: "/a" },
|
|
children: "CHILD_MARKER",
|
|
});
|
|
const text = texts(el);
|
|
expect(text.indexOf("Desc")).toBeLessThan(text.indexOf("CHILD_MARKER"));
|
|
expect(text.indexOf("CHILD_MARKER")).toBeLessThan(text.indexOf("Go"));
|
|
});
|
|
});
|