reorderRows: a collapsed sub-group (a subtotal with no child row) followed by a sibling leaf gives Vide, z, A, the order read by running the case first; then the same block followed by a sibling root, a group (Vide, z, A, b, B) or a leaf (Vide, z, A, R). OnboardingBanner: the decorative h-1 w-1 dot is absent while the visit steps are not initialized and while the Budget entitlement is unknown, and present, between the counter and the next step, once the total is known. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
// OnboardingBanner — render tests (#397). No jsdom: react-dom/server renders
|
|
// in vitest's node environment for the markup (region, aria-expanded, hrefs),
|
|
// and the component is called as a function for what the markup does not
|
|
// carry (a link's navigation state, the handlers).
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { Link, MemoryRouter } from "react-router-dom";
|
|
|
|
// The shared fake `t` (src/test/i18nMock.ts) marks what it translates, `t(key[options])`: a string that
|
|
// skipped `t` would show unmarked.
|
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
|
|
|
import OnboardingBanner, {
|
|
GETTING_STARTED_GUIDE,
|
|
ONBOARDING_BANNER_LIST_ID,
|
|
type OnboardingBannerProps,
|
|
} from "./OnboardingBanner";
|
|
import OnboardingChecklist from "../dashboard/OnboardingChecklist";
|
|
import { computeChecklist } from "../../utils/onboardingChecklist";
|
|
import { nextStep } from "../../utils/onboardingPath";
|
|
import type { OnboardingCounters } from "../../shared/types";
|
|
|
|
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
|
|
|
|
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
|
|
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
|
|
if (!isValidElement(node)) return [];
|
|
const el = node as AnyElement;
|
|
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 AnyElement).props.children);
|
|
}
|
|
|
|
const ZERO: OnboardingCounters = {
|
|
sources: 0,
|
|
imports: 0,
|
|
manualCategorized: 0,
|
|
budgetEntries: 0,
|
|
transactions: 0,
|
|
};
|
|
/** Profile, folder and source done: 3/8, the next step is "import" on /import. */
|
|
const THREE_DONE: OnboardingCounters = { ...ZERO, sources: 1 };
|
|
/** Up to the import: the next step is "manualCategory" on /transactions. */
|
|
const FOUR_DONE: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 40 };
|
|
const ALL_DONE: OnboardingCounters = {
|
|
sources: 1,
|
|
imports: 1,
|
|
manualCategorized: 1,
|
|
budgetEntries: 1,
|
|
transactions: 40,
|
|
};
|
|
|
|
function bannerProps(
|
|
overrides: Partial<OnboardingBannerProps> = {},
|
|
counters: OnboardingCounters = THREE_DONE,
|
|
): OnboardingBannerProps {
|
|
// A template stopped after the import has its visit steps acknowledged (#425).
|
|
const checklist = computeChecklist(counters, {
|
|
importFolderSet: true,
|
|
budgetAllowed: true,
|
|
visitStepsInitialized: true,
|
|
acknowledged: counters.imports >= 1 ? ["categories", "reports"] : [],
|
|
});
|
|
return {
|
|
mode: checklist.complete ? "complete" : "banner",
|
|
checklist,
|
|
nextStep: nextStep(checklist),
|
|
budgetReady: true,
|
|
visitStepsInitialized: true,
|
|
listOpen: false,
|
|
onToggleList: vi.fn(),
|
|
onHide: vi.fn(),
|
|
onResume: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const markup = (props: OnboardingBannerProps) =>
|
|
renderToStaticMarkup(
|
|
<MemoryRouter>
|
|
<OnboardingBanner {...props} />
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
const tree = (props: OnboardingBannerProps) => OnboardingBanner(props) as ReactNode;
|
|
|
|
const buttonNamed = (node: ReactNode, key: string) => {
|
|
const [button] = find(node, (e) => e.type === "button" && texts(e.props.children).includes(`t(${key})`));
|
|
expect(button, `button ${key}`).toBeDefined();
|
|
return button;
|
|
};
|
|
|
|
/** The dot between the counter and the next step: a decorative `h-1 w-1` span. */
|
|
const isDot = (e: AnyElement) => {
|
|
const classes = String(e.props.className).split(/\s+/);
|
|
return e.type === "span" && classes.includes("h-1") && classes.includes("w-1");
|
|
};
|
|
const dots = (props: OnboardingBannerProps) => find(tree(props), isDot);
|
|
|
|
describe("OnboardingBanner — banner mode (#397)", () => {
|
|
it("is a region named for the path, with the counter, the next step and its link", () => {
|
|
const html = markup(bannerProps());
|
|
expect(html).toContain('role="region"');
|
|
expect(html).toContain('aria-label="t(onboarding.banner.label)"');
|
|
expect(html).toContain("t(onboarding.banner.progress[done=3,total=8])");
|
|
expect(html).toContain("t(onboarding.banner.next)");
|
|
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(onboarding\.checklist\.items\.import\)/);
|
|
expect(html).toContain("t(onboarding.checklist.hide)");
|
|
});
|
|
|
|
it("links the next step to its page with the step's navigation state", () => {
|
|
const [toImport] = find(tree(bannerProps()), (e) => e.type === Link);
|
|
expect(toImport.props.to).toBe("/import");
|
|
expect(toImport.props.state).toEqual({ onboarding: { focus: true } });
|
|
|
|
// The manual category opens Transactions in its guided entry too.
|
|
const [toTransactions] = find(tree(bannerProps({}, FOUR_DONE)), (e) => e.type === Link);
|
|
expect(toTransactions.props.to).toBe("/transactions");
|
|
expect(toTransactions.props.state).toEqual({ onboarding: { focus: true, guided: true } });
|
|
});
|
|
|
|
it("the counter waits for the Budget entitlement, the next step does not", () => {
|
|
const props = bannerProps({ budgetReady: false });
|
|
const html = markup(props);
|
|
expect(html).not.toContain("onboarding.banner.progress");
|
|
expect(html).toMatch(/href="\/import"/);
|
|
// The dot waits with the counter it follows (#446).
|
|
expect(dots(props)).toHaveLength(0);
|
|
});
|
|
|
|
it("the counter waits for the visit steps' initialization too, the next step does not (#425)", () => {
|
|
const props = bannerProps({ visitStepsInitialized: false });
|
|
const html = markup(props);
|
|
expect(html).not.toContain("onboarding.banner.progress");
|
|
expect(html).toMatch(/href="\/import"/);
|
|
// No dot either: it would stand alone before the next step (#446).
|
|
expect(dots(props)).toHaveLength(0);
|
|
});
|
|
|
|
it("once the total is known, a decorative dot separates the counter from the next step (#446)", () => {
|
|
const props = bannerProps();
|
|
const [dot] = dots(props);
|
|
expect(dots(props)).toHaveLength(1);
|
|
expect(dot.props["aria-hidden"]).toBe("true");
|
|
const html = markup(props);
|
|
const counter = html.indexOf("t(onboarding.banner.progress");
|
|
const dotAt = html.search(/<span aria-hidden="true" class="h-1 w-1 /);
|
|
const next = html.indexOf("t(onboarding.banner.next)");
|
|
expect(counter).toBeGreaterThan(-1);
|
|
expect(dotAt).toBeGreaterThan(counter);
|
|
expect(next).toBeGreaterThan(dotAt);
|
|
});
|
|
|
|
it("folded: 'Show the list' with aria-expanded false, and no list", () => {
|
|
const html = markup(bannerProps());
|
|
expect(html).toMatch(
|
|
new RegExp(`<button [^>]*aria-expanded="false"[^>]*aria-controls="${ONBOARDING_BANNER_LIST_ID}"[^>]*>t\\(onboarding\\.banner\\.showList\\)`),
|
|
);
|
|
expect(html).toContain(`id="${ONBOARDING_BANNER_LIST_ID}"`);
|
|
expect(html).not.toContain("onboarding.checklist.title");
|
|
expect(html).not.toContain("onboarding.banner.hideList");
|
|
});
|
|
|
|
it("unfolded: 'Close the list' with aria-expanded true, and the detailed list in place", () => {
|
|
const html = markup(bannerProps({ listOpen: true }));
|
|
expect(html).toMatch(/<button [^>]*aria-expanded="true"[^>]*>t\(onboarding\.banner\.hideList\)/);
|
|
expect(html).not.toContain("onboarding.banner.showList");
|
|
const container = html.indexOf(`id="${ONBOARDING_BANNER_LIST_ID}"`);
|
|
expect(container).toBeGreaterThan(-1);
|
|
expect(html.indexOf("t(onboarding.checklist.title)")).toBeGreaterThan(container);
|
|
for (const key of [
|
|
"profile",
|
|
"importFolder",
|
|
"source",
|
|
"import",
|
|
"categories",
|
|
"manualCategory",
|
|
"budget",
|
|
"reports",
|
|
]) {
|
|
expect(html).toContain(`t(onboarding.checklist.items.${key})`);
|
|
}
|
|
});
|
|
|
|
it("the unfolded list is the detailed list, wired to the path", () => {
|
|
const props = bannerProps({ listOpen: true });
|
|
const [list] = find(tree(props), (e) => e.type === OnboardingChecklist);
|
|
expect(list.props.items).toBe(props.checklist!.items);
|
|
expect(list.props.done).toBe(3);
|
|
expect(list.props.total).toBe(8);
|
|
expect(list.props.budgetReady).toBe(true);
|
|
expect(list.props.visitStepsInitialized).toBe(true);
|
|
// Each flag reaches the list on its own: the Budget row's pending state follows budgetReady alone.
|
|
const [waiting] = find(
|
|
tree(bannerProps({ listOpen: true, visitStepsInitialized: false })),
|
|
(e) => e.type === OnboardingChecklist,
|
|
);
|
|
expect(waiting.props.budgetReady).toBe(true);
|
|
expect(waiting.props.visitStepsInitialized).toBe(false);
|
|
expect(list.props.onHide).toBe(props.onHide);
|
|
expect(list.props.onResume).toBe(props.onResume);
|
|
expect(find(tree(bannerProps()), (e) => e.type === OnboardingChecklist)).toHaveLength(0);
|
|
});
|
|
|
|
it("'Show the list' toggles the list and 'Hide' hides the path", () => {
|
|
const props = bannerProps();
|
|
const node = tree(props);
|
|
(buttonNamed(node, "onboarding.banner.showList").props.onClick as () => void)();
|
|
expect(props.onToggleList).toHaveBeenCalledTimes(1);
|
|
expect(props.onHide).not.toHaveBeenCalled();
|
|
(buttonNamed(node, "onboarding.checklist.hide").props.onClick as () => void)();
|
|
expect(props.onHide).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("sits in the page flow, never fixed over the page (D1)", () => {
|
|
const root = tree(bannerProps()) as AnyElement;
|
|
expect(root.props.role).toBe("region");
|
|
expect(String(root.props.className)).not.toMatch(/\b(fixed|sticky|absolute)\b/);
|
|
});
|
|
});
|
|
|
|
describe("OnboardingBanner — end of the path (D4)", () => {
|
|
it("says the path is complete, opens the guide's getting-started section and offers 'Close'", () => {
|
|
const props = bannerProps({}, ALL_DONE);
|
|
expect(props.mode).toBe("complete");
|
|
const html = markup(props);
|
|
expect(html).toContain('aria-label="t(onboarding.banner.label)"');
|
|
expect(html).toContain("t(onboarding.banner.complete)");
|
|
expect(GETTING_STARTED_GUIDE).toBe("/settings/users#gettingStarted");
|
|
expect(html).toMatch(/<a [^>]*href="\/settings\/users#gettingStarted"[^>]*>t\(onboarding\.banner\.openGuide\)/);
|
|
expect(html).toContain("t(common.close)");
|
|
expect(html).not.toContain("onboarding.banner.progress");
|
|
expect(html).not.toContain("onboarding.banner.next");
|
|
expect(html).not.toContain("aria-expanded");
|
|
expect(html).not.toContain("onboarding.checklist.hide");
|
|
});
|
|
|
|
it("'Close' hides the path", () => {
|
|
const props = bannerProps({}, ALL_DONE);
|
|
(buttonNamed(tree(props), "common.close").props.onClick as () => void)();
|
|
expect(props.onHide).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("shows no list at the end, even if it was unfolded", () => {
|
|
const html = markup(bannerProps({ listOpen: true }, ALL_DONE));
|
|
expect(html).not.toContain("onboarding.checklist.title");
|
|
});
|
|
});
|
|
|
|
describe("OnboardingBanner — nothing to show", () => {
|
|
it("renders nothing in mode none, nor on a page that carries a step card", () => {
|
|
expect(markup(bannerProps({ mode: "none" }))).toBe("");
|
|
expect(markup(bannerProps({ mode: "card" }))).toBe("");
|
|
});
|
|
|
|
it("renders nothing without a checklist", () => {
|
|
expect(markup(bannerProps({ checklist: null, nextStep: null }))).toBe("");
|
|
});
|
|
});
|