feat(onboarding): show the getting-started banner on top of every page
The getting-started path now follows the user: AppShell mounts OnboardingBanner at the top of <main>, in the page flow, and the provider's mode keeps it off a hidden path, the dashboard of a profile without transactions, a page that carries a step card and the first load. The banner shows "Getting started n/N" once the Budget entitlement is known, the next step linked with its navigation state, "Show the list", which unfolds the detailed list in place, and "Hide"; at the end, the closing message, "Open the guide" and "Close". Each route mounts its own banner slot, so a page always starts folded. The dashboard's compact bar and the compact mode of OnboardingChecklist are gone (D7). The detailed list reads each state out to screen readers, shows a skipped step with its badge and "Resume", and gives every link to a step the same navigation state (stepNavState), with the guided entry for the manual category. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
21c46b6380
commit
75c1708bbe
15 changed files with 1127 additions and 164 deletions
64
src/components/dashboard/DashboardEmptyState.render.test.tsx
Normal file
64
src/components/dashboard/DashboardEmptyState.render.test.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// DashboardEmptyState — the welcome card of a profile without transactions
|
||||
// (#352). #397 leaves it as it was: the card keeps the detailed list, which
|
||||
// now also takes the "Resume" handler. No jsdom: react-dom/server for the
|
||||
// markup, a function call for the wiring.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (k: string, o?: Record<string, unknown>) =>
|
||||
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
|
||||
i18n: { language: "fr" },
|
||||
}),
|
||||
}));
|
||||
|
||||
import DashboardEmptyState from "./DashboardEmptyState";
|
||||
import OnboardingChecklist from "./OnboardingChecklist";
|
||||
import { computeChecklist } from "../../utils/onboardingChecklist";
|
||||
|
||||
const checklist = computeChecklist(
|
||||
{ sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 },
|
||||
{ importFolderSet: false, budgetAllowed: true },
|
||||
);
|
||||
|
||||
function cardProps(hidden = false) {
|
||||
return { checklist, budgetReady: true, hidden, onHide: vi.fn(), onResume: vi.fn() };
|
||||
}
|
||||
|
||||
const markup = (hidden = false) =>
|
||||
renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<DashboardEmptyState {...cardProps(hidden)} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
describe("DashboardEmptyState (#352, #397)", () => {
|
||||
it("shows the welcome card, its action towards Import and the detailed list", () => {
|
||||
const html = markup();
|
||||
expect(html).toContain("t(dashboard.empty.title)");
|
||||
expect(html).toContain("t(dashboard.empty.description)");
|
||||
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(dashboard\.empty\.cta\)/);
|
||||
expect(html).toContain('aria-label="t(onboarding.checklist.title)"');
|
||||
expect(html).toContain("t(onboarding.checklist.counter[done=1,total=6])");
|
||||
});
|
||||
|
||||
it("keeps the card and drops the list once the path is hidden", () => {
|
||||
const html = markup(true);
|
||||
expect(html).toContain("t(dashboard.empty.title)");
|
||||
expect(html).not.toContain("onboarding.checklist.title");
|
||||
});
|
||||
|
||||
it("hands its list the path's handlers", () => {
|
||||
const props = cardProps();
|
||||
const card = DashboardEmptyState(props) as ReactElement<{ children?: ReactNode }>;
|
||||
const list = card.props.children as ReactElement<Record<string, unknown>>;
|
||||
expect(list.type).toBe(OnboardingChecklist);
|
||||
expect(list.props.items).toBe(checklist.items);
|
||||
expect(list.props.budgetReady).toBe(true);
|
||||
expect(list.props.onHide).toBe(props.onHide);
|
||||
expect(list.props.onResume).toBe(props.onResume);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@ import { LayoutDashboard } from "lucide-react";
|
|||
import EmptyState from "../shared/EmptyState";
|
||||
import OnboardingChecklist from "./OnboardingChecklist";
|
||||
import type { Checklist } from "../../utils/onboardingChecklist";
|
||||
import type { ChecklistItemKey } from "../../shared/types";
|
||||
|
||||
interface DashboardEmptyStateProps {
|
||||
checklist: Checklist;
|
||||
|
|
@ -10,18 +11,22 @@ interface DashboardEmptyStateProps {
|
|||
/** The user dismissed the checklist: the welcome card stays, the list goes. */
|
||||
hidden: boolean;
|
||||
onHide: () => void;
|
||||
/** "Resume" on a skipped row of the list (#397). */
|
||||
onResume: (key: ChecklistItemKey) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard of a profile without any transaction (#352): a welcome card with
|
||||
* the expanded getting-started checklist and one primary action towards the
|
||||
* import page, in place of widgets showing 0 $.
|
||||
* the detailed getting-started checklist and one primary action towards the
|
||||
* import page, in place of widgets showing 0 $. The getting-started banner
|
||||
* stays off this page (D11): the card already holds the list.
|
||||
*/
|
||||
export default function DashboardEmptyState({
|
||||
checklist,
|
||||
budgetReady,
|
||||
hidden,
|
||||
onHide,
|
||||
onResume,
|
||||
}: DashboardEmptyStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -37,9 +42,9 @@ export default function DashboardEmptyState({
|
|||
items={checklist.items}
|
||||
done={checklist.done}
|
||||
total={checklist.total}
|
||||
mode="expanded"
|
||||
budgetReady={budgetReady}
|
||||
onHide={onHide}
|
||||
onResume={onResume}
|
||||
/>
|
||||
)}
|
||||
</EmptyState>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// OnboardingChecklist — unit tests (#352).
|
||||
// OnboardingChecklist — unit tests (#352, #397).
|
||||
//
|
||||
// FilterPanel.test.tsx pattern: no jsdom, `react-i18next` and `lucide-react`
|
||||
// mocked, the component called as a function and its element tree walked.
|
||||
|
|
@ -20,12 +20,13 @@ vi.mock("lucide-react", () => ({
|
|||
CheckCircle2: () => null,
|
||||
Circle: () => null,
|
||||
Lock: () => null,
|
||||
SkipForward: () => null,
|
||||
}));
|
||||
|
||||
import { CheckCircle2, Circle, Lock } from "lucide-react";
|
||||
import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react";
|
||||
import OnboardingChecklist, { type OnboardingChecklistProps } from "./OnboardingChecklist";
|
||||
import { computeChecklist } from "../../utils/onboardingChecklist";
|
||||
import type { OnboardingCounters } from "../../shared/types";
|
||||
import type { ChecklistItemKey, OnboardingCounters } from "../../shared/types";
|
||||
|
||||
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
|
||||
|
||||
|
|
@ -44,7 +45,7 @@ function texts(node: ReactNode): string[] {
|
|||
}
|
||||
|
||||
// Leaves of the walk: react-router's Link and the mocked icons keep their type.
|
||||
const LEAVES = new Set<unknown>([Link, CheckCircle2, Circle, Lock]);
|
||||
const LEAVES = new Set<unknown>([Link, CheckCircle2, Circle, Lock, SkipForward]);
|
||||
|
||||
/** Resolves the inner `RowIcon` component so the icon elements are visible. */
|
||||
function expand(node: ReactNode): ReactNode {
|
||||
|
|
@ -67,19 +68,25 @@ const ZERO: OnboardingCounters = {
|
|||
transactions: 0,
|
||||
};
|
||||
|
||||
function render(overrides: Partial<OnboardingChecklistProps> = {}, counters = ZERO, budgetAllowed = true) {
|
||||
const checklist = computeChecklist(counters, { importFolderSet: false, budgetAllowed });
|
||||
function render(
|
||||
overrides: Partial<OnboardingChecklistProps> = {},
|
||||
counters = ZERO,
|
||||
budgetAllowed = true,
|
||||
skipped: ChecklistItemKey[] = [],
|
||||
) {
|
||||
const checklist = computeChecklist(counters, { importFolderSet: false, budgetAllowed, skipped });
|
||||
const onHide = vi.fn();
|
||||
const onResume = vi.fn();
|
||||
const props: OnboardingChecklistProps = {
|
||||
items: checklist.items,
|
||||
done: checklist.done,
|
||||
total: checklist.total,
|
||||
mode: "expanded",
|
||||
budgetReady: true,
|
||||
onHide,
|
||||
onResume,
|
||||
...overrides,
|
||||
};
|
||||
return { element: expand(OnboardingChecklist(props)), onHide, checklist };
|
||||
return { element: expand(OnboardingChecklist(props)), onHide, onResume, checklist };
|
||||
}
|
||||
|
||||
function rowOf(element: ReactNode, key: string): AnyElement {
|
||||
|
|
@ -88,7 +95,14 @@ function rowOf(element: ReactNode, key: string): AnyElement {
|
|||
return row;
|
||||
}
|
||||
|
||||
describe("OnboardingChecklist — expanded", () => {
|
||||
/** The visually hidden texts of a row: what assistive technologies read after the label. */
|
||||
function stateTextsOf(row: AnyElement): string[] {
|
||||
return find(row, (e) => e.type === "span" && e.props.className === "sr-only").flatMap((e) =>
|
||||
texts(e.props.children).map((s) => s.trim()).filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
describe("OnboardingChecklist", () => {
|
||||
it("renders the title, the counter 1 / 6 and the hide button", () => {
|
||||
const { element, onHide } = render();
|
||||
const text = texts(element);
|
||||
|
|
@ -120,6 +134,15 @@ describe("OnboardingChecklist — expanded", () => {
|
|||
expect(find(profile, (e) => e.type === CheckCircle2)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("gives the link of a row to do the step's navigation state, guided on the manual category", () => {
|
||||
const { element } = render();
|
||||
const [folder] = find(rowOf(element, "importFolder"), (e) => e.type === Link);
|
||||
expect(folder.props.state).toEqual({ onboarding: { focus: true } });
|
||||
const [manual] = find(rowOf(element, "manualCategory"), (e) => e.type === Link);
|
||||
expect(manual.props.to).toBe("/transactions");
|
||||
expect(manual.props.state).toEqual({ onboarding: { focus: true, guided: true } });
|
||||
});
|
||||
|
||||
it("marks a row done when its counter is reached", () => {
|
||||
const { element } = render({}, { ...ZERO, sources: 1 });
|
||||
const source = rowOf(element, "source");
|
||||
|
|
@ -134,6 +157,8 @@ describe("OnboardingChecklist — expanded", () => {
|
|||
expect(texts(budget)).toContain("onboarding.checklist.lockedBudget");
|
||||
const [link] = find(budget, (e) => e.type === Link);
|
||||
expect(link.props.to).toBe("/settings/users");
|
||||
// The license card carries no step card: nothing to focus there.
|
||||
expect(link.props.state).toBeUndefined();
|
||||
expect(texts(element)).toContain("onboarding.checklist.counter[done=1,total=5]");
|
||||
});
|
||||
|
||||
|
|
@ -149,35 +174,84 @@ describe("OnboardingChecklist — expanded", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("OnboardingChecklist — compact", () => {
|
||||
it("renders the icons on one line with the counter, without the row sentences", () => {
|
||||
const { element } = render({ mode: "compact" }, { ...ZERO, sources: 1, imports: 1 });
|
||||
const text = texts(element);
|
||||
expect(text).toContain("onboarding.checklist.title");
|
||||
expect(text).toContain("onboarding.checklist.counter[done=3,total=6]");
|
||||
expect(text.some((s) => s.startsWith("onboarding.checklist.items."))).toBe(false);
|
||||
expect(find(element, (e) => e.type === "li")).toHaveLength(6);
|
||||
expect(find(element, (e) => e.type === CheckCircle2)).toHaveLength(3);
|
||||
expect(find(element, (e) => e.type === Circle)).toHaveLength(3);
|
||||
describe("OnboardingChecklist — states read out (#384, #397)", () => {
|
||||
it("reads done, skipped and locked after the label, and nothing for a row still to do", () => {
|
||||
const { element } = render({}, ZERO, false, ["source"]);
|
||||
expect(stateTextsOf(rowOf(element, "profile"))).toEqual(["onboarding.state.done"]);
|
||||
expect(stateTextsOf(rowOf(element, "source"))).toEqual(["onboarding.state.skipped"]);
|
||||
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["onboarding.state.locked"]);
|
||||
expect(stateTextsOf(rowOf(element, "importFolder"))).toEqual([]);
|
||||
expect(stateTextsOf(rowOf(element, "import"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("labels each icon for assistive technologies and links the undone rows", () => {
|
||||
const { element, checklist } = render({ mode: "compact" });
|
||||
const links = find(element, (e) => e.type === Link);
|
||||
const undone = checklist.items.filter((i) => !i.done);
|
||||
expect(links.map((l) => l.props.to)).toEqual(undone.map((i) => i.to));
|
||||
for (const link of links) {
|
||||
expect(String(link.props["aria-label"])).toMatch(/^onboarding\.checklist\.items\./);
|
||||
}
|
||||
const [profileIcon] = find(element, (e) => e.type === "span" && e.props.role === "img");
|
||||
expect(profileIcon.props["aria-label"]).toBe("onboarding.checklist.items.profile");
|
||||
it("reads the budget row as pending while the entitlement is unknown", () => {
|
||||
const { element } = render({ budgetReady: false }, ZERO, false);
|
||||
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["onboarding.state.pending"]);
|
||||
});
|
||||
|
||||
it("keeps the hide button and hides the counter until the entitlement is known", () => {
|
||||
const { element, onHide } = render({ mode: "compact", budgetReady: false });
|
||||
expect(texts(element).some((s) => s.startsWith("onboarding.checklist.counter"))).toBe(false);
|
||||
const [hide] = find(element, (e) => e.type === "button");
|
||||
(hide.props.onClick as () => void)();
|
||||
expect(onHide).toHaveBeenCalledTimes(1);
|
||||
it("puts the state text right after the label, inside the same element", () => {
|
||||
const { element } = render({}, { ...ZERO, sources: 1 });
|
||||
const [labelled] = find(
|
||||
rowOf(element, "source"),
|
||||
(e) => e.type === "span" && texts(e.props.children).includes("onboarding.checklist.items.source"),
|
||||
);
|
||||
expect(texts(labelled.props.children).map((s) => s.trim()).filter(Boolean)).toEqual([
|
||||
"onboarding.checklist.items.source",
|
||||
"onboarding.state.done",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OnboardingChecklist — skipped rows (#397)", () => {
|
||||
it("shows the skip icon, the 'Skipped' badge and 'Resume', and counts the row", () => {
|
||||
const { element } = render({}, ZERO, true, ["source"]);
|
||||
const source = rowOf(element, "source");
|
||||
expect(find(source, (e) => e.type === SkipForward)).toHaveLength(1);
|
||||
expect(find(source, (e) => e.type === Circle)).toHaveLength(0);
|
||||
const [badge] = find(
|
||||
source,
|
||||
(e) => e.type === "span" && texts(e.props.children).includes("onboarding.checklist.skippedBadge"),
|
||||
);
|
||||
// The hidden "(skipped)" text already says it: the badge is not read twice.
|
||||
expect(badge.props["aria-hidden"]).toBe("true");
|
||||
expect(texts(element)).toContain("onboarding.checklist.counter[done=2,total=6]");
|
||||
});
|
||||
|
||||
it("'Resume' takes the step back, then opens its page with the step's navigation state", () => {
|
||||
const { element, onResume } = render({}, ZERO, true, ["source", "manualCategory"]);
|
||||
const sourceLinks = find(rowOf(element, "source"), (e) => e.type === Link);
|
||||
// The label is no link: only "Resume" leads to the page, after taking the step back.
|
||||
expect(sourceLinks).toHaveLength(1);
|
||||
const [resume] = sourceLinks;
|
||||
expect(texts(resume.props.children)).toEqual(["onboarding.checklist.resume"]);
|
||||
expect(resume.props.to).toBe("/import");
|
||||
expect(resume.props.state).toEqual({ onboarding: { focus: true } });
|
||||
(resume.props.onClick as () => void)();
|
||||
expect(onResume).toHaveBeenCalledTimes(1);
|
||||
expect(onResume).toHaveBeenCalledWith("source");
|
||||
|
||||
const [manual] = find(rowOf(element, "manualCategory"), (e) => e.type === Link);
|
||||
expect(manual.props.state).toEqual({ onboarding: { focus: true, guided: true } });
|
||||
(manual.props.onClick as () => void)();
|
||||
expect(onResume).toHaveBeenLastCalledWith("manualCategory");
|
||||
});
|
||||
|
||||
it("a row done and skipped shows as done, without 'Resume'", () => {
|
||||
const { element } = render({}, { ...ZERO, sources: 1 }, true, ["source"]);
|
||||
const source = rowOf(element, "source");
|
||||
expect(find(source, (e) => e.type === CheckCircle2)).toHaveLength(1);
|
||||
expect(find(source, (e) => e.type === SkipForward)).toHaveLength(0);
|
||||
expect(find(source, (e) => e.type === Link)).toHaveLength(0);
|
||||
expect(texts(source)).not.toContain("onboarding.checklist.resume");
|
||||
});
|
||||
|
||||
it("a locked row stays locked even when skipped", () => {
|
||||
const { element } = render({}, ZERO, false, ["budget"]);
|
||||
const budget = rowOf(element, "budget");
|
||||
expect(find(budget, (e) => e.type === Lock)).toHaveLength(1);
|
||||
expect(find(budget, (e) => e.type === SkipForward)).toHaveLength(0);
|
||||
expect(texts(budget)).not.toContain("onboarding.checklist.resume");
|
||||
const [link] = find(budget, (e) => e.type === Link);
|
||||
expect(link.props.to).toBe("/settings/users");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,18 +1,15 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CheckCircle2, Circle, Lock } from "lucide-react";
|
||||
import type { ChecklistItem } from "../../shared/types";
|
||||
|
||||
export type OnboardingChecklistMode = "expanded" | "compact";
|
||||
import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react";
|
||||
import { stepNavState } from "../../utils/onboardingPath";
|
||||
import type { ChecklistItem, ChecklistItemKey } from "../../shared/types";
|
||||
|
||||
export interface OnboardingChecklistProps {
|
||||
items: ChecklistItem[];
|
||||
/** Number of unlocked rows that are done. */
|
||||
/** Number of unlocked rows that are done or skipped. */
|
||||
done: number;
|
||||
/** Number of unlocked rows (6, or 5 when Budget is locked). */
|
||||
total: number;
|
||||
/** `expanded` = one row per line with its label; `compact` = icons on one line. */
|
||||
mode: OnboardingChecklistMode;
|
||||
/**
|
||||
* False until the Budget entitlement is known. The budget row is then
|
||||
* neutral (neither done nor locked) and the counter is hidden, because
|
||||
|
|
@ -20,102 +17,68 @@ export interface OnboardingChecklistProps {
|
|||
*/
|
||||
budgetReady: boolean;
|
||||
onHide: () => void;
|
||||
/** "Resume" on a skipped row: the provider's `resume`, before its link opens the step's page. */
|
||||
onResume: (key: ChecklistItemKey) => void;
|
||||
}
|
||||
|
||||
type RowState = "done" | "todo" | "locked" | "pending";
|
||||
type RowState = "done" | "skipped" | "todo" | "locked" | "pending";
|
||||
|
||||
/**
|
||||
* A locked row stays locked whatever else it is, and a row the data says is
|
||||
* done shows as done even if it was skipped too: "Resume" would bring back
|
||||
* nothing to do.
|
||||
*/
|
||||
function rowState(item: ChecklistItem, budgetReady: boolean): RowState {
|
||||
if (item.key === "budget" && !budgetReady) return "pending";
|
||||
if (item.locked) return "locked";
|
||||
return item.done ? "done" : "todo";
|
||||
if (item.done) return "done";
|
||||
return item.skipped ? "skipped" : "todo";
|
||||
}
|
||||
|
||||
const ICON_CLASS: Record<RowState, string> = {
|
||||
done: "text-[var(--positive)]",
|
||||
skipped: "text-[var(--muted-foreground)]",
|
||||
todo: "text-[var(--muted-foreground)]",
|
||||
locked: "text-[var(--muted-foreground)]",
|
||||
pending: "text-[var(--muted-foreground)] opacity-50",
|
||||
};
|
||||
|
||||
function RowIcon({ state, size }: { state: RowState; size: number }) {
|
||||
function RowIcon({ state }: { state: RowState }) {
|
||||
const className = `shrink-0 ${ICON_CLASS[state]}`;
|
||||
if (state === "done") return <CheckCircle2 size={size} className={className} aria-hidden="true" />;
|
||||
if (state === "locked") return <Lock size={size} className={className} aria-hidden="true" />;
|
||||
return <Circle size={size} className={className} aria-hidden="true" />;
|
||||
if (state === "done") return <CheckCircle2 size={20} className={className} aria-hidden="true" />;
|
||||
if (state === "skipped") return <SkipForward size={20} className={className} aria-hidden="true" />;
|
||||
if (state === "locked") return <Lock size={20} className={className} aria-hidden="true" />;
|
||||
return <Circle size={20} className={className} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
const BADGE_CLASS =
|
||||
"rounded-full border border-[var(--border)] px-2 py-0.5 text-xs text-[var(--muted-foreground)]";
|
||||
|
||||
/**
|
||||
* Getting-started checklist of the dashboard (#352). Pure: the only hook is
|
||||
* Getting-started checklist (#352, #397): the welcome card of the empty
|
||||
* dashboard and the list that the banner unfolds. Pure: the only hook is
|
||||
* `useTranslation`, so the component is tested by calling it as a function.
|
||||
*
|
||||
* - A row that is not done links to its `to` route; a done row has no link.
|
||||
* - A row still to do links to its `to` route with the step's navigation
|
||||
* state (`stepNavState`); a done row has no link.
|
||||
* - A skipped row shows a skip icon, the "Skipped" badge and "Resume", which
|
||||
* takes the step back, then opens its page like the link of a row to do.
|
||||
* - A locked row (Budget outside the edition) shows a lock, the "Base and up"
|
||||
* label and links to the license card (`/settings/users`).
|
||||
* - Compact mode keeps the title, the icons (one per row, labelled for
|
||||
* assistive technologies), the counter and the hide button, no sentences.
|
||||
* label and links to the license card (`/settings/users`), which carries no
|
||||
* step card: no navigation state.
|
||||
* - The icons are hidden from assistive technologies, so each state but "to
|
||||
* do" is read out by a visually hidden text after the label (#384); the
|
||||
* "Skipped" badge is hidden from them, that text already says it.
|
||||
*/
|
||||
export default function OnboardingChecklist({
|
||||
items,
|
||||
done,
|
||||
total,
|
||||
mode,
|
||||
budgetReady,
|
||||
onHide,
|
||||
onResume,
|
||||
}: OnboardingChecklistProps) {
|
||||
const { t } = useTranslation();
|
||||
const counter = budgetReady ? (
|
||||
<span className="text-sm font-medium text-[var(--muted-foreground)] tabular-nums">
|
||||
{t("onboarding.checklist.counter", { done, total })}
|
||||
</span>
|
||||
) : null;
|
||||
const hideButton = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onHide}
|
||||
className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors"
|
||||
>
|
||||
{t("onboarding.checklist.hide")}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (mode === "compact") {
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={t("onboarding.checklist.title")}
|
||||
className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-2 mb-6"
|
||||
>
|
||||
<span className="text-sm font-semibold">{t("onboarding.checklist.title")}</span>
|
||||
<ul className="flex items-center gap-2">
|
||||
{items.map((item) => {
|
||||
const state = rowState(item, budgetReady);
|
||||
const label = t(`onboarding.checklist.items.${item.key}`);
|
||||
const icon = <RowIcon state={state} size={18} />;
|
||||
return (
|
||||
<li key={item.key} className="flex">
|
||||
{state === "todo" || state === "locked" ? (
|
||||
<Link
|
||||
to={item.to}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className="rounded-md p-0.5 hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
{icon}
|
||||
</Link>
|
||||
) : (
|
||||
<span title={label} aria-label={label} role="img" className="p-0.5">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{counter}
|
||||
<span className="ml-auto">{hideButton}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -125,36 +88,84 @@ export default function OnboardingChecklist({
|
|||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<h3 className="font-semibold">{t("onboarding.checklist.title")}</h3>
|
||||
{counter}
|
||||
<span className="ml-auto">{hideButton}</span>
|
||||
{budgetReady && (
|
||||
<span className="text-sm font-medium text-[var(--muted-foreground)] tabular-nums">
|
||||
{t("onboarding.checklist.counter", { done, total })}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onHide}
|
||||
className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors"
|
||||
>
|
||||
{t("onboarding.checklist.hide")}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const state = rowState(item, budgetReady);
|
||||
const label = t(`onboarding.checklist.items.${item.key}`);
|
||||
const stateText =
|
||||
state === "todo" ? null : (
|
||||
<span className="sr-only"> {t(`onboarding.state.${state}`)}</span>
|
||||
);
|
||||
return (
|
||||
<li key={item.key} className="flex items-center gap-3 text-sm">
|
||||
<RowIcon state={state} size={20} />
|
||||
<li key={item.key} className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<RowIcon state={state} />
|
||||
{state === "todo" && (
|
||||
<Link to={item.to} className="hover:underline text-[var(--foreground)]">
|
||||
<Link
|
||||
to={item.to}
|
||||
state={stepNavState(item.key)}
|
||||
className="hover:underline text-[var(--foreground)]"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)}
|
||||
{state === "skipped" && (
|
||||
<>
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{label}
|
||||
{stateText}
|
||||
</span>
|
||||
<span aria-hidden="true" className={BADGE_CLASS}>
|
||||
{t("onboarding.checklist.skippedBadge")}
|
||||
</span>
|
||||
<Link
|
||||
to={item.to}
|
||||
state={stepNavState(item.key)}
|
||||
onClick={() => onResume(item.key)}
|
||||
className="font-medium text-[var(--primary)] hover:underline"
|
||||
>
|
||||
{t("onboarding.checklist.resume")}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{state === "locked" && (
|
||||
<Link
|
||||
to={item.to}
|
||||
className="flex flex-wrap items-center gap-2 hover:underline text-[var(--foreground)]"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span className="rounded-full border border-[var(--border)] px-2 py-0.5 text-xs text-[var(--muted-foreground)]">
|
||||
{t("onboarding.checklist.lockedBudget")}
|
||||
<span>
|
||||
{label}
|
||||
{stateText}
|
||||
</span>
|
||||
<span className={BADGE_CLASS}>{t("onboarding.checklist.lockedBudget")}</span>
|
||||
</Link>
|
||||
)}
|
||||
{state === "done" && (
|
||||
<span className="text-[var(--muted-foreground)] line-through">{label}</span>
|
||||
<span className="text-[var(--muted-foreground)] line-through">
|
||||
{label}
|
||||
{stateText}
|
||||
</span>
|
||||
)}
|
||||
{state === "pending" && (
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{label}
|
||||
{stateText}
|
||||
</span>
|
||||
)}
|
||||
{state === "pending" && <span className="text-[var(--muted-foreground)]">{label}</span>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
194
src/components/layout/AppShell.render.test.tsx
Normal file
194
src/components/layout/AppShell.render.test.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
// AppShell and the getting-started banner (#397). No jsdom: react-dom/server
|
||||
// renders in vitest's node environment. The path comes from a fake
|
||||
// `useOnboarding()` built like the provider builds it: a loaded state, the
|
||||
// real `computeChecklist`, and the provider's own pure derivation
|
||||
// (`deriveOnboardingView`, with the real `CARD_PAGES`) at the rendered route.
|
||||
// What is pinned is where the banner shows, and where it sits.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
|
||||
// Importing the hook module pulls the onboarding service (→ db → tauri
|
||||
// plugins) and useEntitlement (→ LicenseContext): stub both.
|
||||
vi.mock("../../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
||||
vi.mock("../../contexts/LicenseContext", () => ({ useLicenseContext: vi.fn() }));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (k: string, o?: Record<string, unknown>) =>
|
||||
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
|
||||
i18n: { language: "fr" },
|
||||
}),
|
||||
}));
|
||||
vi.mock("./Sidebar", () => ({ default: () => <nav>sidebar</nav> }));
|
||||
|
||||
let onboarding: OnboardingValue;
|
||||
vi.mock("../../contexts/OnboardingContext", () => ({
|
||||
OnboardingProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
useOnboarding: () => onboarding,
|
||||
}));
|
||||
|
||||
import AppShell from "./AppShell";
|
||||
import {
|
||||
deriveOnboardingView,
|
||||
initialOnboardingChecklistState,
|
||||
onboardingChecklistReducer,
|
||||
type OnboardingChecklistState,
|
||||
type OnboardingValue,
|
||||
} from "../../hooks/useOnboardingChecklist";
|
||||
import { computeChecklist } from "../../utils/onboardingChecklist";
|
||||
import { CARD_PAGES } from "../../utils/onboardingPath";
|
||||
import type { OnboardingSnapshot } from "../../services/onboardingService";
|
||||
|
||||
const NEW_PROFILE: OnboardingSnapshot = {
|
||||
counters: { sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 },
|
||||
importFolderSet: false,
|
||||
hidden: false,
|
||||
skipped: [],
|
||||
};
|
||||
/** Folder, source and import done: the next step is the manual category, on /transactions. */
|
||||
const IMPORTED: OnboardingSnapshot = {
|
||||
...NEW_PROFILE,
|
||||
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 },
|
||||
importFolderSet: true,
|
||||
};
|
||||
/** Everything but the budget: the next step is on /budget. */
|
||||
const BUDGET_LEFT: OnboardingSnapshot = {
|
||||
...IMPORTED,
|
||||
counters: { ...IMPORTED.counters, manualCategorized: 1 },
|
||||
};
|
||||
const ALL_DONE: OnboardingSnapshot = {
|
||||
...IMPORTED,
|
||||
counters: { ...IMPORTED.counters, manualCategorized: 1, budgetEntries: 1 },
|
||||
};
|
||||
|
||||
/** The provider's state after a first successful load of `snapshot`. */
|
||||
const loaded = (snapshot: OnboardingSnapshot): OnboardingChecklistState =>
|
||||
[
|
||||
{ type: "LOAD_START", seq: 1 } as const,
|
||||
{ type: "LOAD_DONE", seq: 1, snapshot } as const,
|
||||
].reduce(onboardingChecklistReducer, initialOnboardingChecklistState);
|
||||
|
||||
/** What `useOnboarding()` gives at `pathname` for `state`, derived as the provider does. */
|
||||
function pathAt(pathname: string, state: OnboardingChecklistState, budgetReady = true): OnboardingValue {
|
||||
const checklist = state.counters
|
||||
? computeChecklist(state.counters, {
|
||||
importFolderSet: state.importFolderSet,
|
||||
budgetAllowed: true,
|
||||
skipped: state.skipped,
|
||||
})
|
||||
: null;
|
||||
const { nextStep, cardPage, mode } = deriveOnboardingView({ state, checklist, pathname, budgetReady });
|
||||
return {
|
||||
status: state.status,
|
||||
checklist,
|
||||
counters: state.counters,
|
||||
hidden: state.hidden,
|
||||
budgetReady,
|
||||
nextStep,
|
||||
mode,
|
||||
cardPage,
|
||||
hide: vi.fn(async () => undefined),
|
||||
show: vi.fn(async () => undefined),
|
||||
skip: vi.fn(async () => undefined),
|
||||
resume: vi.fn(async () => undefined),
|
||||
refresh: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function renderWith(pathname: string, value: OnboardingValue): string {
|
||||
onboarding = value;
|
||||
return renderToStaticMarkup(
|
||||
<MemoryRouter initialEntries={[pathname]}>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="*" element={<p data-page="">page</p>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
const renderAt = (pathname: string, state: OnboardingChecklistState, budgetReady = true) =>
|
||||
renderWith(pathname, pathAt(pathname, state, budgetReady));
|
||||
|
||||
const BANNER = 'aria-label="t(onboarding.banner.label)"';
|
||||
const count = (html: string, needle: string) => html.split(needle).length - 1;
|
||||
|
||||
describe("AppShell — the getting-started banner (#397)", () => {
|
||||
it("puts the banner on top of the routed page, inside main", () => {
|
||||
const html = renderAt("/reports", loaded(IMPORTED));
|
||||
expect(count(html, BANNER)).toBe(1);
|
||||
const main = html.indexOf("<main");
|
||||
const banner = html.indexOf(BANNER);
|
||||
expect(main).toBeGreaterThan(-1);
|
||||
expect(banner).toBeGreaterThan(main);
|
||||
expect(html.indexOf("data-page")).toBeGreaterThan(banner);
|
||||
});
|
||||
|
||||
it("shows nothing before the first load", () => {
|
||||
const html = renderAt("/import", initialOnboardingChecklistState);
|
||||
expect(html).not.toContain(BANNER);
|
||||
expect(html).toContain("data-page");
|
||||
});
|
||||
|
||||
it("shows nothing when the path is hidden", () => {
|
||||
const html = renderAt("/import", loaded({ ...IMPORTED, hidden: true }));
|
||||
expect(html).not.toContain(BANNER);
|
||||
expect(html).toContain("data-page");
|
||||
});
|
||||
|
||||
it("shows nothing on the dashboard of a profile without transactions (D11), but on its other pages", () => {
|
||||
expect(renderAt("/", loaded(NEW_PROFILE))).not.toContain(BANNER);
|
||||
expect(renderAt("/import", loaded(NEW_PROFILE))).toContain(BANNER);
|
||||
expect(renderAt("/settings/users", loaded(NEW_PROFILE))).toContain(BANNER);
|
||||
});
|
||||
|
||||
it("shows the banner on a dashboard with transactions, and on any other page", () => {
|
||||
for (const pathname of ["/", "/reports", "/categories", "/settings/users"]) {
|
||||
expect(renderAt(pathname, loaded(IMPORTED)), pathname).toContain(BANNER);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/import", NEW_PROFILE],
|
||||
["/transactions", IMPORTED],
|
||||
["/budget", BUDGET_LEFT],
|
||||
] as const)(
|
||||
"keeps the banner on %s, the page of the next step, while no step card is delivered",
|
||||
(pathname, snapshot) => {
|
||||
expect(CARD_PAGES.has(pathname)).toBe(false);
|
||||
const value = pathAt(pathname, loaded(snapshot));
|
||||
expect(value.nextStep?.to).toBe(pathname);
|
||||
const html = renderWith(pathname, value);
|
||||
expect(html).toContain(BANNER);
|
||||
expect(html).toContain(`href="${pathname}"`);
|
||||
},
|
||||
);
|
||||
|
||||
it("shows nothing on a page that carries a step card", () => {
|
||||
const value = { ...pathAt("/import", loaded(NEW_PROFILE)), mode: "card" as const, cardPage: "/import" };
|
||||
expect(renderWith("/import", value)).not.toContain(BANNER);
|
||||
});
|
||||
|
||||
it("shows the end message once every step is done (D4)", () => {
|
||||
const html = renderAt("/reports", loaded(ALL_DONE));
|
||||
expect(html).toContain(BANNER);
|
||||
expect(html).toContain("t(onboarding.banner.complete)");
|
||||
});
|
||||
|
||||
it("waits for the Budget entitlement before showing the counter", () => {
|
||||
const waiting = renderAt("/reports", loaded(IMPORTED), false);
|
||||
expect(waiting).toContain(BANNER);
|
||||
expect(waiting).not.toContain("onboarding.banner.progress");
|
||||
expect(renderAt("/reports", loaded(IMPORTED))).toContain(
|
||||
"t(onboarding.banner.progress[done=4,total=6])",
|
||||
);
|
||||
});
|
||||
|
||||
it("starts with the list folded", () => {
|
||||
const html = renderAt("/reports", loaded(IMPORTED));
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
expect(html).not.toContain("onboarding.checklist.title");
|
||||
});
|
||||
});
|
||||
146
src/components/layout/AppShell.slot.test.tsx
Normal file
146
src/components/layout/AppShell.slot.test.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// OnboardingBannerSlot — the list's fold state and the path's wiring (#397).
|
||||
//
|
||||
// renderToStaticMarkup runs no click, so React's `useState` is stubbed (same
|
||||
// technique as useOnboardingChecklist.wiring.test.ts): the slot runs as a
|
||||
// plain function, and its state survives from one call to the next as it
|
||||
// would between two renders of a mounted component.
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
const harness = vi.hoisted(() => ({
|
||||
slots: [] as unknown[],
|
||||
cursor: 0,
|
||||
pathname: "/reports",
|
||||
onboarding: null as unknown,
|
||||
}));
|
||||
|
||||
vi.mock("react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react")>();
|
||||
return {
|
||||
...actual,
|
||||
useState: (init: unknown) => {
|
||||
const index = harness.cursor++;
|
||||
if (!(index in harness.slots)) {
|
||||
harness.slots[index] = typeof init === "function" ? (init as () => unknown)() : init;
|
||||
}
|
||||
const set = (next: unknown) => {
|
||||
harness.slots[index] =
|
||||
typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next;
|
||||
};
|
||||
return [harness.slots[index], set];
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("react-router-dom", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-router-dom")>()),
|
||||
useLocation: () => ({ pathname: harness.pathname }),
|
||||
}));
|
||||
vi.mock("../../contexts/OnboardingContext", () => ({
|
||||
OnboardingProvider: ({ children }: { children: ReactNode }) => children,
|
||||
useOnboarding: () => harness.onboarding,
|
||||
}));
|
||||
vi.mock("./Sidebar", () => ({ default: () => null }));
|
||||
|
||||
import AppShell, { OnboardingBannerSlot } from "./AppShell";
|
||||
import OnboardingBanner, { type OnboardingBannerProps } from "../onboarding/OnboardingBanner";
|
||||
import { computeChecklist } from "../../utils/onboardingChecklist";
|
||||
import { nextStep } from "../../utils/onboardingPath";
|
||||
import type { OnboardingValue } from "../../hooks/useOnboardingChecklist";
|
||||
|
||||
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 onboardingValue(): OnboardingValue {
|
||||
const counters = { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 };
|
||||
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
|
||||
return {
|
||||
status: "ready",
|
||||
checklist,
|
||||
counters,
|
||||
hidden: false,
|
||||
budgetReady: true,
|
||||
nextStep: nextStep(checklist),
|
||||
mode: "banner",
|
||||
cardPage: null,
|
||||
hide: vi.fn(async () => undefined),
|
||||
show: vi.fn(async () => undefined),
|
||||
skip: vi.fn(async () => undefined),
|
||||
resume: vi.fn(async () => undefined),
|
||||
refresh: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
/** One render of the mounted slot: its hooks are read in the same order each time. */
|
||||
function renderSlot(): OnboardingBannerProps {
|
||||
harness.cursor = 0;
|
||||
const element = OnboardingBannerSlot() as ReactElement<OnboardingBannerProps>;
|
||||
expect(element.type).toBe(OnboardingBanner);
|
||||
return element.props;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
harness.slots = [];
|
||||
harness.pathname = "/reports";
|
||||
harness.onboarding = onboardingValue();
|
||||
});
|
||||
|
||||
describe("OnboardingBannerSlot (#397)", () => {
|
||||
it("hands the shared path to the banner, the list folded", () => {
|
||||
const value = harness.onboarding as OnboardingValue;
|
||||
const props = renderSlot();
|
||||
expect(props.mode).toBe("banner");
|
||||
expect(props.checklist).toBe(value.checklist);
|
||||
expect(props.nextStep).toBe(value.nextStep);
|
||||
expect(props.budgetReady).toBe(true);
|
||||
expect(props.listOpen).toBe(false);
|
||||
expect(props.onHide).toBe(value.hide);
|
||||
expect(props.onResume).toBe(value.resume);
|
||||
});
|
||||
|
||||
it("passes the mode and the entitlement as they come: the banner decides what to show", () => {
|
||||
harness.onboarding = { ...onboardingValue(), mode: "none", budgetReady: false };
|
||||
const props = renderSlot();
|
||||
expect(props.mode).toBe("none");
|
||||
expect(props.budgetReady).toBe(false);
|
||||
});
|
||||
|
||||
it("'Show the list' unfolds it, 'Close the list' folds it back", () => {
|
||||
renderSlot().onToggleList();
|
||||
expect(renderSlot().listOpen).toBe(true);
|
||||
// Another render without a click keeps it unfolded.
|
||||
expect(renderSlot().listOpen).toBe(true);
|
||||
renderSlot().onToggleList();
|
||||
expect(renderSlot().listOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("AppShell mounts one slot per route, so each page starts with the list folded", () => {
|
||||
harness.pathname = "/import";
|
||||
const shell = AppShell() as ReactNode;
|
||||
const [slot] = find(shell, (e) => e.type === OnboardingBannerSlot);
|
||||
expect(slot).toBeDefined();
|
||||
expect(slot.key).toBe("/import");
|
||||
|
||||
harness.pathname = "/transactions";
|
||||
const [next] = find(AppShell() as ReactNode, (e) => e.type === OnboardingBannerSlot);
|
||||
expect(next.key).toBe("/transactions");
|
||||
});
|
||||
|
||||
it("AppShell puts the slot before the routed page, inside the provider, inside main", () => {
|
||||
const shell = AppShell() as ReactNode;
|
||||
const [main] = find(shell, (e) => e.type === "main");
|
||||
expect(main).toBeDefined();
|
||||
const [provider] = find(main.props.children, (e) => typeof e.type === "function");
|
||||
const children = provider.props.children as ReactNode[];
|
||||
expect(Array.isArray(children)).toBe(true);
|
||||
const [first, second] = children as AnyElement[];
|
||||
expect(first.type).toBe(OnboardingBannerSlot);
|
||||
expect(second.type).toBe(Outlet);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,14 +1,47 @@
|
|||
import { Outlet } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import Sidebar from "./Sidebar";
|
||||
import { OnboardingProvider } from "../../contexts/OnboardingContext";
|
||||
import OnboardingBanner from "../onboarding/OnboardingBanner";
|
||||
import { OnboardingProvider, useOnboarding } from "../../contexts/OnboardingContext";
|
||||
|
||||
/**
|
||||
* The getting-started banner (#397) on top of the routed page. It reads the
|
||||
* shared path, so it sits inside the provider; the path's `mode` already keeps
|
||||
* it off a hidden path, the empty dashboard (D11), a page that carries a step
|
||||
* card and the first load, and the banner renders nothing then.
|
||||
*
|
||||
* "Show the list" unfolds the list in place. `AppShell` mounts one slot per
|
||||
* route, so each page starts folded and the banner stays one line on top of
|
||||
* it (D7).
|
||||
*/
|
||||
export function OnboardingBannerSlot() {
|
||||
const onboarding = useOnboarding();
|
||||
const [listOpen, setListOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<OnboardingBanner
|
||||
mode={onboarding.mode}
|
||||
checklist={onboarding.checklist}
|
||||
nextStep={onboarding.nextStep}
|
||||
budgetReady={onboarding.budgetReady}
|
||||
listOpen={listOpen}
|
||||
onToggleList={() => setListOpen((open) => !open)}
|
||||
onHide={onboarding.hide}
|
||||
onResume={onboarding.resume}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppShell() {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
{/* The getting-started path, shared by every page (#396). */}
|
||||
{/* The getting-started path, shared by every page (#396), and its banner in the page flow (#397). */}
|
||||
<OnboardingProvider>
|
||||
<OnboardingBannerSlot key={pathname} />
|
||||
<Outlet />
|
||||
</OnboardingProvider>
|
||||
</main>
|
||||
|
|
|
|||
214
src/components/onboarding/OnboardingBanner.render.test.tsx
Normal file
214
src/components/onboarding/OnboardingBanner.render.test.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
// 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 fake `t` marks what it translates, `t(key[options])`: a string that
|
||||
// skipped `t` would show unmarked.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (k: string, o?: Record<string, unknown>) =>
|
||||
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
|
||||
i18n: { language: "fr" },
|
||||
}),
|
||||
}));
|
||||
|
||||
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/6, 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 {
|
||||
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
|
||||
return {
|
||||
mode: checklist.complete ? "complete" : "banner",
|
||||
checklist,
|
||||
nextStep: nextStep(checklist),
|
||||
budgetReady: 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;
|
||||
};
|
||||
|
||||
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=6])");
|
||||
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 html = markup(bannerProps({ budgetReady: false }));
|
||||
expect(html).not.toContain("onboarding.banner.progress");
|
||||
expect(html).toMatch(/href="\/import"/);
|
||||
});
|
||||
|
||||
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", "manualCategory", "budget"]) {
|
||||
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(6);
|
||||
expect(list.props.budgetReady).toBe(true);
|
||||
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("");
|
||||
});
|
||||
});
|
||||
144
src/components/onboarding/OnboardingBanner.tsx
Normal file
144
src/components/onboarding/OnboardingBanner.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowRight, Rocket } from "lucide-react";
|
||||
import OnboardingChecklist from "../dashboard/OnboardingChecklist";
|
||||
import { stepNavState, type BannerMode } from "../../utils/onboardingPath";
|
||||
import type { Checklist } from "../../utils/onboardingChecklist";
|
||||
import type { ChecklistItem, ChecklistItemKey } from "../../shared/types";
|
||||
|
||||
/** Id of the unfolded list, named by the toggle's `aria-controls`: one banner per window. */
|
||||
export const ONBOARDING_BANNER_LIST_ID = "onboarding-banner-list";
|
||||
|
||||
/**
|
||||
* "Open the guide": the getting-started section of the user guide, on the
|
||||
* Users settings page. `DocsContent` gives each section `id={key}` and
|
||||
* scrolls to the section named by the hash.
|
||||
*/
|
||||
export const GETTING_STARTED_GUIDE = "/settings/users#gettingStarted";
|
||||
|
||||
export interface OnboardingBannerProps {
|
||||
/** What the path shows on this page (`useOnboarding().mode`): the banner renders `banner` and `complete` only. */
|
||||
mode: BannerMode;
|
||||
checklist: Checklist | null;
|
||||
/** The next step, linked to its page with the step's navigation state. */
|
||||
nextStep: ChecklistItem | null;
|
||||
/** False until the Budget entitlement is known: the counter waits, its total depends on the lock. */
|
||||
budgetReady: boolean;
|
||||
/** The detailed list is unfolded under the banner. */
|
||||
listOpen: boolean;
|
||||
onToggleList: () => void;
|
||||
/** "Hide", and "Close" at the end: the provider's `hide`. */
|
||||
onHide: () => void;
|
||||
/** "Resume" on a skipped row of the unfolded list. */
|
||||
onResume: (key: ChecklistItemKey) => void;
|
||||
}
|
||||
|
||||
const REGION_CLASS =
|
||||
"rounded-xl border border-[var(--primary)]/30 bg-[var(--primary)]/5 px-4 py-3 mb-6";
|
||||
const ROW_CLASS = "flex flex-wrap items-center gap-x-3 gap-y-2 text-sm";
|
||||
const TEXT_BUTTON_CLASS =
|
||||
"text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors";
|
||||
const LINK_CLASS = "inline-flex items-center gap-1 font-medium text-[var(--primary)] hover:underline";
|
||||
|
||||
function BannerIcon() {
|
||||
return <Rocket size={18} className="shrink-0 text-[var(--primary)]" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Getting-started banner (#397), mounted by `AppShell` on top of every page,
|
||||
* in the page flow (D1: a fixed bar could cover the focused control). Driven
|
||||
* by props, so it is tested without the provider.
|
||||
*
|
||||
* - `banner`: "Getting started n/N · Next step: <step> →", "Show the list",
|
||||
* which unfolds the detailed list in place, and "Hide". The counter waits
|
||||
* for `budgetReady`, like the list's own counter.
|
||||
* - `complete` (D4): the end message, "Open the guide" and "Close", which
|
||||
* hides the path until it is shown again from Settings → Users.
|
||||
* - Anything else (`none`, `card`) renders nothing.
|
||||
*/
|
||||
export default function OnboardingBanner({
|
||||
mode,
|
||||
checklist,
|
||||
nextStep,
|
||||
budgetReady,
|
||||
listOpen,
|
||||
onToggleList,
|
||||
onHide,
|
||||
onResume,
|
||||
}: OnboardingBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
if (checklist === null || (mode !== "banner" && mode !== "complete")) return null;
|
||||
|
||||
if (mode === "complete") {
|
||||
return (
|
||||
<div role="region" aria-label={t("onboarding.banner.label")} className={REGION_CLASS}>
|
||||
<div className={ROW_CLASS}>
|
||||
<BannerIcon />
|
||||
<p className="flex-1 min-w-[12rem] text-[var(--foreground)]">
|
||||
{t("onboarding.banner.complete")}
|
||||
</p>
|
||||
<Link to={GETTING_STARTED_GUIDE} className={LINK_CLASS}>
|
||||
{t("onboarding.banner.openGuide")}
|
||||
</Link>
|
||||
<button type="button" onClick={onHide} className={TEXT_BUTTON_CLASS}>
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="region" aria-label={t("onboarding.banner.label")} className={REGION_CLASS}>
|
||||
<div className={ROW_CLASS}>
|
||||
<BannerIcon />
|
||||
{budgetReady && (
|
||||
<span className="font-semibold text-[var(--foreground)] tabular-nums">
|
||||
{t("onboarding.banner.progress", { done: checklist.done, total: checklist.total })}
|
||||
</span>
|
||||
)}
|
||||
{budgetReady && nextStep && (
|
||||
<span aria-hidden="true" className="h-1 w-1 shrink-0 rounded-full bg-[var(--muted-foreground)]" />
|
||||
)}
|
||||
{nextStep && (
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{t("onboarding.banner.next")}{" "}
|
||||
<Link to={nextStep.to} state={stepNavState(nextStep.key)} className={LINK_CLASS}>
|
||||
{t(`onboarding.checklist.items.${nextStep.key}`)}
|
||||
<ArrowRight size={14} aria-hidden="true" />
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={listOpen}
|
||||
aria-controls={ONBOARDING_BANNER_LIST_ID}
|
||||
onClick={onToggleList}
|
||||
className={TEXT_BUTTON_CLASS}
|
||||
>
|
||||
{t(listOpen ? "onboarding.banner.hideList" : "onboarding.banner.showList")}
|
||||
</button>
|
||||
<button type="button" onClick={onHide} className={TEXT_BUTTON_CLASS}>
|
||||
{t("onboarding.checklist.hide")}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{/* Always in the page, so `aria-controls` names an element even while folded. */}
|
||||
<div id={ONBOARDING_BANNER_LIST_ID}>
|
||||
{listOpen && (
|
||||
<div className="mt-3">
|
||||
<OnboardingChecklist
|
||||
items={checklist.items}
|
||||
done={checklist.done}
|
||||
total={checklist.total}
|
||||
budgetReady={budgetReady}
|
||||
onHide={onHide}
|
||||
onResume={onResume}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -65,11 +65,28 @@
|
|||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"banner": {
|
||||
"label": "Getting-started path",
|
||||
"progress": "Getting started {{done}}/{{total}}",
|
||||
"next": "Next step:",
|
||||
"showList": "Show the list",
|
||||
"hideList": "Close the list",
|
||||
"complete": "Getting started complete. The user guide takes it from here.",
|
||||
"openGuide": "Open the guide"
|
||||
},
|
||||
"state": {
|
||||
"done": "(done)",
|
||||
"skipped": "(skipped)",
|
||||
"locked": "(locked)",
|
||||
"pending": "(pending)"
|
||||
},
|
||||
"checklist": {
|
||||
"title": "Getting started",
|
||||
"counter": "{{done}} / {{total}}",
|
||||
"hide": "Hide",
|
||||
"lockedBudget": "Base and up",
|
||||
"skippedBadge": "Skipped",
|
||||
"resume": "Resume",
|
||||
"items": {
|
||||
"profile": "Create a profile",
|
||||
"importFolder": "Choose an import folder",
|
||||
|
|
@ -771,9 +788,9 @@
|
|||
"userGuide": "User guide"
|
||||
},
|
||||
"onboarding": {
|
||||
"label": "Getting-started checklist on the dashboard",
|
||||
"label": "Getting-started path",
|
||||
"show": "Show the getting-started checklist",
|
||||
"confirmation": "The checklist will reappear on the dashboard"
|
||||
"confirmation": "The getting-started path reappears at the top of the pages"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
|
|
|
|||
|
|
@ -65,11 +65,28 @@
|
|||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"banner": {
|
||||
"label": "Parcours de démarrage",
|
||||
"progress": "Démarrage {{done}}/{{total}}",
|
||||
"next": "Étape suivante :",
|
||||
"showList": "Voir la liste",
|
||||
"hideList": "Fermer la liste",
|
||||
"complete": "Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite.",
|
||||
"openGuide": "Ouvrir le guide"
|
||||
},
|
||||
"state": {
|
||||
"done": "(faite)",
|
||||
"skipped": "(passée)",
|
||||
"locked": "(verrouillée)",
|
||||
"pending": "(en attente)"
|
||||
},
|
||||
"checklist": {
|
||||
"title": "Liste de démarrage",
|
||||
"counter": "{{done}} / {{total}}",
|
||||
"hide": "Masquer",
|
||||
"lockedBudget": "Base et plus",
|
||||
"skippedBadge": "Passée",
|
||||
"resume": "Reprendre",
|
||||
"items": {
|
||||
"profile": "Créer un profil",
|
||||
"importFolder": "Choisir un dossier d'import",
|
||||
|
|
@ -771,9 +788,9 @@
|
|||
"userGuide": "Guide d'utilisation"
|
||||
},
|
||||
"onboarding": {
|
||||
"label": "Liste de démarrage sur le tableau de bord",
|
||||
"label": "Parcours de démarrage",
|
||||
"show": "Afficher la liste de démarrage",
|
||||
"confirmation": "La liste réapparaîtra sur le tableau de bord"
|
||||
"confirmation": "Le parcours de démarrage réapparaît en haut des pages"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// 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, unchanged
|
||||
// since #352 except that its source is now the provider.
|
||||
// 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";
|
||||
|
|
@ -40,14 +41,20 @@ vi.mock("../hooks/useDashboard", () => ({
|
|||
}));
|
||||
|
||||
// Hoisted with the mocks below, which run before the imports.
|
||||
const { marker } = vi.hoisted(() => ({
|
||||
marker: (name: string) => ({
|
||||
default: (props: { mode?: string }) => <i data-marker={name} data-mode={props.mode} />,
|
||||
}),
|
||||
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", () => marker("empty-state"));
|
||||
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"));
|
||||
|
|
@ -102,9 +109,10 @@ const render = () =>
|
|||
|
||||
beforeEach(() => {
|
||||
onboarding = path();
|
||||
emptyStateProps.current = null;
|
||||
});
|
||||
|
||||
describe("DashboardPage on the shared path (#352, #396)", () => {
|
||||
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();
|
||||
|
|
@ -120,16 +128,25 @@ describe("DashboardPage on the shared path (#352, #396)", () => {
|
|||
expect(html).not.toContain('data-marker="filters"');
|
||||
});
|
||||
|
||||
it("shows the compact checklist above the widgets on a profile with transactions", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-marker="checklist" data-mode="compact"');
|
||||
expect(html).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("hides the compact checklist of a hidden path (a profile already active, D9)", () => {
|
||||
onboarding = path({ hidden: true, mode: "none" });
|
||||
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"');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,12 +10,13 @@
|
|||
//
|
||||
// Onboarding (#352): the page branches on the getting-started checklist
|
||||
// `status`, never on a bare counter. While it loads, only the title and the
|
||||
// banner are rendered (no flash of 0 $ widgets nor of the empty state). A
|
||||
// categories banner are rendered (no flash of 0 $ widgets nor of the empty state). A
|
||||
// profile without any transaction gets `DashboardEmptyState` instead of the
|
||||
// widgets, outside the `isLoading` veil so its link stays clickable. With
|
||||
// transactions, the compact checklist sits between the title and the filter
|
||||
// panel until it is complete or dismissed. A first load that fails falls back
|
||||
// to the v0.15.0 page without the list; the provider logs it.
|
||||
// transactions, the page shows no list of its own: the getting-started banner
|
||||
// that `AppShell` puts on top of every page replaced the compact bar (#397,
|
||||
// D7). A first load that fails falls back to the v0.15.0 page without the
|
||||
// list; the provider logs it.
|
||||
//
|
||||
// Since #396 the checklist comes from `OnboardingProvider` (AppShell), shared
|
||||
// by every page and re-read live, instead of a load of its own at mount.
|
||||
|
|
@ -28,7 +29,6 @@ import PeriodSelector from "../components/dashboard/PeriodSelector";
|
|||
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
||||
import CategoriesV1DiscoveryBanner from "../components/dashboard/CategoriesV1DiscoveryBanner";
|
||||
import DashboardEmptyState from "../components/dashboard/DashboardEmptyState";
|
||||
import OnboardingChecklist from "../components/dashboard/OnboardingChecklist";
|
||||
import NetWorthTile from "../components/dashboard/NetWorthTile";
|
||||
import FilterPanel from "../components/reports/FilterPanel";
|
||||
import KpiCard from "../components/reports/cards/KpiCard";
|
||||
|
|
@ -100,32 +100,16 @@ export default function DashboardPage() {
|
|||
budgetReady={onboarding.budgetReady}
|
||||
hidden={onboarding.hidden}
|
||||
onHide={onboarding.hide}
|
||||
onResume={onboarding.resume}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// `complete` is computed with the fail-closed entitlement (Budget locked → 5/5)
|
||||
// until the licence resolves; gate on `budgetReady` so a Base+ profile with the
|
||||
// five other items done does not see the list vanish, then reappear as 5/6.
|
||||
const showCompactChecklist =
|
||||
checklistReady && !onboarding.hidden && !(onboarding.budgetReady && checklist.complete);
|
||||
|
||||
return (
|
||||
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||
{header}
|
||||
|
||||
{showCompactChecklist && (
|
||||
<OnboardingChecklist
|
||||
items={checklist.items}
|
||||
done={checklist.done}
|
||||
total={checklist.total}
|
||||
mode="compact"
|
||||
budgetReady={onboarding.budgetReady}
|
||||
onHide={onboarding.hide}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.error && (
|
||||
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
||||
{state.error}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,16 @@ import {
|
|||
latchCardPage,
|
||||
nextStep,
|
||||
readOnboardingNavState,
|
||||
stepNavState,
|
||||
stepsOfPage,
|
||||
type BannerModeInput,
|
||||
} from "./onboardingPath";
|
||||
import type { ChecklistItem, ChecklistItemKey, OnboardingCounters } from "../shared/types";
|
||||
import {
|
||||
CHECKLIST_ITEM_KEYS,
|
||||
type ChecklistItem,
|
||||
type ChecklistItemKey,
|
||||
type OnboardingCounters,
|
||||
} from "../shared/types";
|
||||
|
||||
const zero: OnboardingCounters = {
|
||||
sources: 0,
|
||||
|
|
@ -308,3 +314,23 @@ describe("readOnboardingNavState (#396)", () => {
|
|||
).toEqual({ focus: true, guided: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepNavState (#397)", () => {
|
||||
it("asks for the focus on every step, and for the guided entry on the manual category only", () => {
|
||||
for (const key of CHECKLIST_ITEM_KEYS) {
|
||||
expect(stepNavState(key), key).toEqual(
|
||||
key === "manualCategory"
|
||||
? { onboarding: { focus: true, guided: true } }
|
||||
: { onboarding: { focus: true } },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads back through readOnboardingNavState", () => {
|
||||
expect(readOnboardingNavState(stepNavState("manualCategory"))).toEqual({
|
||||
focus: true,
|
||||
guided: true,
|
||||
});
|
||||
expect(readOnboardingNavState(stepNavState("import"))).toEqual({ focus: true, guided: false });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -114,6 +114,23 @@ export interface OnboardingNavState {
|
|||
guided: boolean;
|
||||
}
|
||||
|
||||
/** What a link to a step puts in `location.state`, read back by `readOnboardingNavState`. */
|
||||
export interface OnboardingLinkState {
|
||||
onboarding: { focus: true; guided?: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* The navigation state of every link to a step's page (#397): the banner's
|
||||
* next step, the rows of the list and their "Resume". The step card of the
|
||||
* page takes the focus, and the "categorize by hand" step also opens
|
||||
* Transactions in its guided entry, wherever its link was clicked.
|
||||
*/
|
||||
export function stepNavState(key: ChecklistItemKey): OnboardingLinkState {
|
||||
return key === "manualCategory"
|
||||
? { onboarding: { focus: true, guided: true } }
|
||||
: { onboarding: { focus: true } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The only way to read `location.state` (typed `any` by react-router) for the
|
||||
* path (CWE-476): anything that is not `{ onboarding: { focus: true } }` or
|
||||
|
|
|
|||
Loading…
Reference in a new issue