Simpl-Resultat/src/components/shared/EmptyState.test.tsx
le king fu a060f0e0e0
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
feat(onboarding): dashboard empty state, getting-started checklist and re-show button (#352)
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>
2026-10-03 19:32:43 -04:00

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"));
});
});