feat(onboarding): step cards of the Categories and Reports pages
The two visit steps get their card: /categories explains keywords and points at "All keywords" (never "Reset"), /reports explains the hub and points at the Trends card, with a padlock variant in the Free edition. "Got it" acknowledges the step; opening the highlighted Trends card does too (D17), since following the ring leaves the page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a064043c20
commit
00be2877f3
11 changed files with 717 additions and 23 deletions
|
|
@ -21,22 +21,6 @@ vi.mock("react-router-dom", async (importOriginal) => ({
|
||||||
useNavigate: () => ctx.navigate,
|
useNavigate: () => ctx.navigate,
|
||||||
}));
|
}));
|
||||||
vi.mock("../../contexts/OnboardingContext", () => ({ useOnboarding: () => ctx.onboarding }));
|
vi.mock("../../contexts/OnboardingContext", () => ({ useOnboarding: () => ctx.onboarding }));
|
||||||
// /categories guides no step before its card ships (#432): a stand-in page
|
|
||||||
// that guides the categories step, so the wiring of "Got it" is exercised now.
|
|
||||||
vi.mock("../../utils/onboardingPath", async (importOriginal) => {
|
|
||||||
const orig = await importOriginal<typeof import("../../utils/onboardingPath")>();
|
|
||||||
return {
|
|
||||||
...orig,
|
|
||||||
cardStep: (...args: Parameters<typeof orig.cardStep>) => {
|
|
||||||
const [checklist, pathname] = args;
|
|
||||||
if (pathname !== "/stand-in-categories") return orig.cardStep(checklist, pathname);
|
|
||||||
const unlocked = checklist.items.filter((item) => !item.locked);
|
|
||||||
const step = unlocked.find((item) => item.key === "categories")!;
|
|
||||||
return { step, n: unlocked.indexOf(step) + 1, total: unlocked.length };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
import { hookHarness } from "../../test/hookHarness";
|
import { hookHarness } from "../../test/hookHarness";
|
||||||
import OnboardingStepCard from "./OnboardingStepCard";
|
import OnboardingStepCard from "./OnboardingStepCard";
|
||||||
import StepCard, { type StepCardProps } from "./StepCard";
|
import StepCard, { type StepCardProps } from "./StepCard";
|
||||||
|
|
@ -183,10 +167,10 @@ describe("OnboardingStepCard — what it hands the card (#398)", () => {
|
||||||
expect(render()!.props.onAcknowledge).toBeUndefined();
|
expect(render()!.props.onAcknowledge).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("'Got it' on a visit step acknowledges the step the card shows (#425)", () => {
|
it("'Got it' on the Categories card acknowledges the categories step (#425, #432)", () => {
|
||||||
ctx.location = { pathname: "/stand-in-categories", state: null, key: "arrival-1" };
|
ctx.location = { pathname: "/categories", state: null, key: "arrival-1" };
|
||||||
ctx.onboarding = path(IMPORTED, {
|
ctx.onboarding = path(IMPORTED, {
|
||||||
cardPage: "/stand-in-categories",
|
cardPage: "/categories",
|
||||||
checklist: computeChecklist(IMPORTED, {
|
checklist: computeChecklist(IMPORTED, {
|
||||||
importFolderSet: true,
|
importFolderSet: true,
|
||||||
budgetAllowed: true,
|
budgetAllowed: true,
|
||||||
|
|
@ -203,6 +187,26 @@ describe("OnboardingStepCard — what it hands the card (#398)", () => {
|
||||||
expect(value.skip).not.toHaveBeenCalled();
|
expect(value.skip).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("'Got it' on the Reports card acknowledges the reports step, last of eight (#432)", () => {
|
||||||
|
const allButReports: OnboardingCounters = { ...IMPORTED, manualCategorized: 1, budgetEntries: 1 };
|
||||||
|
ctx.location = { pathname: "/reports", state: null, key: "arrival-1" };
|
||||||
|
ctx.onboarding = path(allButReports, {
|
||||||
|
cardPage: "/reports",
|
||||||
|
checklist: computeChecklist(allButReports, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
acknowledged: ["categories"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const value = ctx.onboarding as OnboardingValue;
|
||||||
|
const props = render()!.props;
|
||||||
|
expect(props.step.key).toBe("reports");
|
||||||
|
expect(props.position).toEqual({ n: 8, total: 8 });
|
||||||
|
props.onAcknowledge!();
|
||||||
|
expect(value.acknowledge).toHaveBeenCalledWith("reports");
|
||||||
|
});
|
||||||
|
|
||||||
it("'Skip this step' skips the step the card shows; 'Close' hides the path", () => {
|
it("'Skip this step' skips the step the card shows; 'Close' hides the path", () => {
|
||||||
const value = ctx.onboarding as OnboardingValue;
|
const value = ctx.onboarding as OnboardingValue;
|
||||||
const props = render()!.props;
|
const props = render()!.props;
|
||||||
|
|
|
||||||
66
src/components/reports/HubReportNavCard.render.test.tsx
Normal file
66
src/components/reports/HubReportNavCard.render.test.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// HubReportNavCard — the report card of the hub and its getting-started props
|
||||||
|
// (#432). No jsdom: react-dom/server renders the markup, and the component
|
||||||
|
// called as a function exposes the props of the link it returns (the
|
||||||
|
// FilterPanel.test.tsx pattern), for the click handler.
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
|
|
||||||
|
import HubReportNavCard, { REPORTS_TRENDS_TARGET, type HubReportNavCardProps } from "./HubReportNavCard";
|
||||||
|
|
||||||
|
const BASE: HubReportNavCardProps = {
|
||||||
|
to: "/reports/trends",
|
||||||
|
icon: null,
|
||||||
|
title: "Tendances",
|
||||||
|
description: "Évolution",
|
||||||
|
};
|
||||||
|
|
||||||
|
const HIGHLIGHT = 'data-onboarding-highlight="true"';
|
||||||
|
|
||||||
|
const render = (props: Partial<HubReportNavCardProps> = {}) =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<MemoryRouter>
|
||||||
|
<HubReportNavCard {...BASE} {...props} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The link the card returns, read without rendering it. */
|
||||||
|
const link = (props: Partial<HubReportNavCardProps> = {}) =>
|
||||||
|
HubReportNavCard({ ...BASE, ...props }) as ReactElement<{ onClick?: () => void; to: string }>;
|
||||||
|
|
||||||
|
describe("HubReportNavCard — the getting-started target (#432)", () => {
|
||||||
|
it("carries the highlight on its link with onboardingTarget", () => {
|
||||||
|
const html = render({ onboardingTarget: true });
|
||||||
|
expect(html).toContain(`data-onboarding-target="${REPORTS_TRENDS_TARGET}" ${HIGHLIGHT}`);
|
||||||
|
expect(html.indexOf(HIGHLIGHT)).toBeLessThan(html.indexOf('href="/reports/trends"'));
|
||||||
|
expect(html.split(HIGHLIGHT).length - 1).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries no highlight without it", () => {
|
||||||
|
for (const onboardingTarget of [undefined, false]) {
|
||||||
|
const html = render({ onboardingTarget });
|
||||||
|
expect(html).not.toContain("data-onboarding-target");
|
||||||
|
expect(html).not.toContain("data-onboarding-highlight");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a click calls onNavigate when it is given", () => {
|
||||||
|
const onNavigate = vi.fn();
|
||||||
|
const element = link({ onNavigate });
|
||||||
|
expect(element.props.to).toBe("/reports/trends");
|
||||||
|
element.props.onClick!();
|
||||||
|
expect(onNavigate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without onNavigate, the link has no click handler of its own", () => {
|
||||||
|
expect(link().props.onClick).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps its lock badge, translated", () => {
|
||||||
|
expect(render({ locked: true })).toContain('aria-label="t(nav.locked)"');
|
||||||
|
expect(render()).not.toContain("t(nav.locked)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,6 +2,10 @@ import type { ReactNode } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Lock } from "lucide-react";
|
import { Lock } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
|
||||||
|
|
||||||
|
/** The target key of the Trends card while the Reports step card points at it (#432). */
|
||||||
|
export const REPORTS_TRENDS_TARGET = "reports-trends-card";
|
||||||
|
|
||||||
export interface HubReportNavCardProps {
|
export interface HubReportNavCardProps {
|
||||||
to: string;
|
to: string;
|
||||||
|
|
@ -13,6 +17,13 @@ export interface HubReportNavCardProps {
|
||||||
* entitled). The card stays clickable — the gated route renders the upsell.
|
* entitled). The card stays clickable — the gated route renders the upsell.
|
||||||
*/
|
*/
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
|
/**
|
||||||
|
* The getting-started card of the Reports page points at this card (#432):
|
||||||
|
* the link carries the highlight attributes.
|
||||||
|
*/
|
||||||
|
onboardingTarget?: boolean;
|
||||||
|
/** Called on a click, before the navigation (the Trends card acknowledges the step, D17). */
|
||||||
|
onNavigate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HubReportNavCard({
|
export default function HubReportNavCard({
|
||||||
|
|
@ -21,12 +32,16 @@ export default function HubReportNavCard({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
locked,
|
locked,
|
||||||
|
onboardingTarget,
|
||||||
|
onNavigate,
|
||||||
}: HubReportNavCardProps) {
|
}: HubReportNavCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
|
onClick={onNavigate}
|
||||||
|
{...onboardingTargetAttributes(REPORTS_TRENDS_TARGET, onboardingTarget)}
|
||||||
className="relative group bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 flex flex-col gap-2 hover:border-[var(--primary)] hover:shadow-sm transition-all"
|
className="relative group bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 flex flex-col gap-2 hover:border-[var(--primary)] hover:shadow-sm transition-all"
|
||||||
>
|
>
|
||||||
{locked && (
|
{locked && (
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,11 @@
|
||||||
"budget": {
|
"budget": {
|
||||||
"collapsed": "Click “{{expand}}” to show your categories. Only the last-level ones, without a chevron, take an amount: click a month and type it (Enter saves; Tab saves and moves to the next month). An amount typed in the “{{annual}}” column is split evenly over the 12 months and replaces the monthly amounts.",
|
"collapsed": "Click “{{expand}}” to show your categories. Only the last-level ones, without a chevron, take an amount: click a month and type it (Enter saves; Tab saves and moves to the next month). An amount typed in the “{{annual}}” column is split evenly over the 12 months and replaces the monthly amounts.",
|
||||||
"flat": "Only last-level categories, without a chevron, take an amount: click a month and type it (Enter saves; Tab saves and moves to the next month). An amount typed in the “{{annual}}” column is split evenly over the 12 months and replaces the monthly amounts."
|
"flat": "Only last-level categories, without a chevron, take an amount: click a month and type it (Enter saves; Tab saves and moves to the next month). An amount typed in the “{{annual}}” column is split evenly over the 12 months and replaces the monthly amounts."
|
||||||
|
},
|
||||||
|
"categories": "A keyword categorizes, as soon as it is imported, every transaction whose description contains it; when two keywords apply, the one with the higher priority wins. “{{allKeywords}}” lists them all, with their category. In the next step, you will add one from a transaction.",
|
||||||
|
"reports": {
|
||||||
|
"all": "Pick the period at the top of the page: the highlights panel sums up your balance and your biggest moves. Each card below opens a detailed report; start with “{{trends}}”.",
|
||||||
|
"locked": "Pick the period at the top of the page: the highlights panel sums up your balance and your biggest moves. Each card below opens a detailed report; start with “{{trends}}”. Cards with a padlock require the Base edition."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,11 @@
|
||||||
"budget": {
|
"budget": {
|
||||||
"collapsed": "Cliquez « {{expand}} » pour afficher vos catégories. Seules celles du dernier niveau, sans chevron, acceptent un montant : cliquez sur un mois et saisissez-le (Entrée enregistre ; Tab enregistre et passe au mois suivant). Un montant saisi dans la colonne « {{annual}} » est réparti également sur les 12 mois et remplace les montants mensuels.",
|
"collapsed": "Cliquez « {{expand}} » pour afficher vos catégories. Seules celles du dernier niveau, sans chevron, acceptent un montant : cliquez sur un mois et saisissez-le (Entrée enregistre ; Tab enregistre et passe au mois suivant). Un montant saisi dans la colonne « {{annual}} » est réparti également sur les 12 mois et remplace les montants mensuels.",
|
||||||
"flat": "Seules les catégories du dernier niveau, sans chevron, acceptent un montant : cliquez sur un mois et saisissez-le (Entrée enregistre ; Tab enregistre et passe au mois suivant). Un montant saisi dans la colonne « {{annual}} » est réparti également sur les 12 mois et remplace les montants mensuels."
|
"flat": "Seules les catégories du dernier niveau, sans chevron, acceptent un montant : cliquez sur un mois et saisissez-le (Entrée enregistre ; Tab enregistre et passe au mois suivant). Un montant saisi dans la colonne « {{annual}} » est réparti également sur les 12 mois et remplace les montants mensuels."
|
||||||
|
},
|
||||||
|
"categories": "Un mot-clé classe d'office, dès l'import, chaque transaction dont la description le contient ; quand deux mots-clés s'appliquent, le plus prioritaire l'emporte. « {{allKeywords}} » les liste tous, avec leur catégorie. À l'étape suivante, vous en ajouterez un depuis une transaction.",
|
||||||
|
"reports": {
|
||||||
|
"all": "Choisissez la période en haut de la page : le panneau des faits saillants résume votre solde et vos plus gros mouvements. Chaque carte du bas ouvre un rapport détaillé ; commencez par « {{trends}} ».",
|
||||||
|
"locked": "Choisissez la période en haut de la page : le panneau des faits saillants résume votre solde et vos plus gros mouvements. Chaque carte du bas ouvre un rapport détaillé ; commencez par « {{trends}} ». Les cartes à cadenas demandent l'édition Base."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
|
|
|
||||||
152
src/pages/CategoriesPage.render.test.tsx
Normal file
152
src/pages/CategoriesPage.render.test.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
// CategoriesPage and its getting-started card (#432). No jsdom:
|
||||||
|
// react-dom/server renders in vitest's node environment. The categories hook
|
||||||
|
// is stubbed (the page is loading, its header shows), and the path is a fake
|
||||||
|
// `useOnboarding()` built from the real checklist. What is pinned is the
|
||||||
|
// page's wiring: where the card sits, what it says, and which control carries
|
||||||
|
// the one highlight ("All keywords", never "Reset").
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
||||||
|
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
|
||||||
|
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
|
||||||
|
|
||||||
|
const harness = vi.hoisted(() => ({ onboarding: null as unknown }));
|
||||||
|
|
||||||
|
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
|
||||||
|
vi.mock("../hooks/useCategories", () => ({
|
||||||
|
useCategories: () => ({
|
||||||
|
state: {
|
||||||
|
categories: [],
|
||||||
|
tree: [],
|
||||||
|
keywords: [],
|
||||||
|
selectedCategoryId: null,
|
||||||
|
editingCategory: null,
|
||||||
|
isCreating: false,
|
||||||
|
isLoading: true,
|
||||||
|
isSaving: false,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import CategoriesPage, { CATEGORIES_ALL_KEYWORDS_TARGET } from "./CategoriesPage";
|
||||||
|
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
|
||||||
|
import { computeChecklist } from "../utils/onboardingChecklist";
|
||||||
|
import { nextStep } from "../utils/onboardingPath";
|
||||||
|
import type { ChecklistItemKey, OnboardingCounters } from "../shared/types";
|
||||||
|
|
||||||
|
/** Folder, source and import done: the next step is the categories step. */
|
||||||
|
const IMPORTED: OnboardingCounters = {
|
||||||
|
sources: 1,
|
||||||
|
imports: 1,
|
||||||
|
manualCategorized: 0,
|
||||||
|
budgetEntries: 0,
|
||||||
|
transactions: 41,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The path latched on /categories (card mode), as the provider shares it. */
|
||||||
|
function path(acknowledged: ChecklistItemKey[], overrides: Partial<OnboardingValue> = {}): OnboardingValue {
|
||||||
|
const checklist = computeChecklist(IMPORTED, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
acknowledged,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: "ready",
|
||||||
|
checklist,
|
||||||
|
counters: IMPORTED,
|
||||||
|
hidden: false,
|
||||||
|
budgetReady: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
nextStep: nextStep(checklist),
|
||||||
|
mode: "card",
|
||||||
|
cardPage: "/categories",
|
||||||
|
hide: vi.fn(async () => undefined),
|
||||||
|
show: vi.fn(async () => undefined),
|
||||||
|
skip: vi.fn(async () => undefined),
|
||||||
|
resume: vi.fn(async () => undefined),
|
||||||
|
acknowledge: vi.fn(async () => undefined),
|
||||||
|
refresh: vi.fn(async () => undefined),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const render = () =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<MemoryRouter initialEntries={["/categories"]}>
|
||||||
|
<CategoriesPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const CARD = 'aria-label="t(onboarding.banner.label)"';
|
||||||
|
const HIGHLIGHT = 'data-onboarding-highlight="true"';
|
||||||
|
const count = (html: string, needle: string) => html.split(needle).length - 1;
|
||||||
|
const sentence = (html: string) => html.match(/<\/h2><p [^>]*>(.*?)<\/p>/)?.[1] ?? null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
harness.onboarding = path([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CategoriesPage — the step card (#432)", () => {
|
||||||
|
it("renders the card first, before the page's heading, fifth of eight", () => {
|
||||||
|
const html = render();
|
||||||
|
expect(count(html, CARD)).toBe(1);
|
||||||
|
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
|
||||||
|
expect(html).toContain("t(onboarding.card.stepOf[n=5,total=8])");
|
||||||
|
expect(html).toContain("t(onboarding.checklist.items.categories)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains keywords, 'All keywords' named by its own label", () => {
|
||||||
|
expect(sentence(render())).toBe(
|
||||||
|
"t(onboarding.card.categories[allKeywords=t(categories.allKeywords)])",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers 'Got it' and 'Skip this step'", () => {
|
||||||
|
const html = render();
|
||||||
|
expect(html).toContain("t(onboarding.card.acknowledge)");
|
||||||
|
expect(html).toContain("t(onboarding.card.skip)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'All keywords' is the one target, never 'Reset'", () => {
|
||||||
|
const html = render();
|
||||||
|
expect(count(html, HIGHLIGHT)).toBe(1);
|
||||||
|
expect(html).toContain(`data-onboarding-target="${CATEGORIES_ALL_KEYWORDS_TARGET}" ${HIGHLIGHT}`);
|
||||||
|
const ring = html.indexOf(HIGHLIGHT);
|
||||||
|
const allKeywords = html.indexOf("t(categories.allKeywords)</button>");
|
||||||
|
const reset = html.indexOf("t(categories.reinitialize)</button>");
|
||||||
|
// The ring sits on the button whose label closes right after it, before "Reset".
|
||||||
|
expect(ring).toBeLessThan(allKeywords);
|
||||||
|
expect(html.lastIndexOf("<button", allKeywords)).toBeLessThan(ring);
|
||||||
|
expect(ring).toBeLessThan(reset);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the step acknowledged: 'Done.', the next step is the manual category, no ring", () => {
|
||||||
|
harness.onboarding = path(["categories"]);
|
||||||
|
const html = render();
|
||||||
|
expect(count(html, CARD)).toBe(1);
|
||||||
|
expect(html).toContain("t(onboarding.card.done)");
|
||||||
|
expect(html).toContain("t(onboarding.checklist.items.manualCategory)");
|
||||||
|
expect(html).not.toContain("t(onboarding.card.acknowledge)");
|
||||||
|
expect(html).not.toContain(HIGHLIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without the card on screen, no card and no ring", () => {
|
||||||
|
for (const overrides of [{ mode: "banner" as const }, { cardPage: null }, { cardPage: "/import" }]) {
|
||||||
|
harness.onboarding = path([], overrides);
|
||||||
|
const html = render();
|
||||||
|
expect(html, JSON.stringify(overrides)).not.toContain(CARD);
|
||||||
|
expect(html, JSON.stringify(overrides)).not.toContain(HIGHLIGHT);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opening the page acknowledges nothing", () => {
|
||||||
|
const value = harness.onboarding as OnboardingValue;
|
||||||
|
render();
|
||||||
|
expect(value.acknowledge).not.toHaveBeenCalled();
|
||||||
|
expect(value.skip).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,11 +1,22 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
import { Plus, RotateCcw, List, AlertTriangle } from "lucide-react";
|
import { Plus, RotateCcw, List, AlertTriangle } from "lucide-react";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import { useCategories } from "../hooks/useCategories";
|
import { useCategories } from "../hooks/useCategories";
|
||||||
import CategoryTree from "../components/categories/CategoryTree";
|
import CategoryTree from "../components/categories/CategoryTree";
|
||||||
import CategoryDetailPanel from "../components/categories/CategoryDetailPanel";
|
import CategoryDetailPanel from "../components/categories/CategoryDetailPanel";
|
||||||
import AllKeywordsPanel from "../components/categories/AllKeywordsPanel";
|
import AllKeywordsPanel from "../components/categories/AllKeywordsPanel";
|
||||||
|
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
|
||||||
|
import { useOnboarding } from "../contexts/OnboardingContext";
|
||||||
|
import {
|
||||||
|
categoriesCardModel,
|
||||||
|
onboardingTargetAttributes,
|
||||||
|
showsStepCard,
|
||||||
|
} from "../utils/onboardingPath";
|
||||||
|
|
||||||
|
/** The target key of "All keywords" while the Categories step card points at it (#432). */
|
||||||
|
export const CATEGORIES_ALL_KEYWORDS_TARGET = "categories-all-keywords";
|
||||||
|
|
||||||
export default function CategoriesPage() {
|
export default function CategoriesPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -26,6 +37,16 @@ export default function CategoriesPage() {
|
||||||
moveCategory,
|
moveCategory,
|
||||||
} = useCategories();
|
} = useCategories();
|
||||||
|
|
||||||
|
// The getting-started card (#432) points at "All keywords" while it is on
|
||||||
|
// screen, never at "Reset" (it deletes the keywords and uncategorizes every
|
||||||
|
// transaction).
|
||||||
|
const location = useLocation();
|
||||||
|
const onboarding = useOnboarding();
|
||||||
|
const cardModel = categoriesCardModel(onboarding.checklist);
|
||||||
|
const target = showsStepCard(onboarding.mode, onboarding.cardPage, location.pathname)
|
||||||
|
? cardModel.target
|
||||||
|
: null;
|
||||||
|
|
||||||
const handleReinitialize = async () => {
|
const handleReinitialize = async () => {
|
||||||
setShowReinitConfirm(false);
|
setShowReinitConfirm(false);
|
||||||
await reinitializeCategories();
|
await reinitializeCategories();
|
||||||
|
|
@ -38,6 +59,8 @@ export default function CategoriesPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<OnboardingStepCard model={cardModel} />
|
||||||
|
|
||||||
<div className="relative flex items-center justify-between mb-6">
|
<div className="relative flex items-center justify-between mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold">{t("categories.title")}</h1>
|
<h1 className="text-2xl font-bold">{t("categories.title")}</h1>
|
||||||
|
|
@ -46,6 +69,7 @@ export default function CategoriesPage() {
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAllKeywords((v) => !v)}
|
onClick={() => setShowAllKeywords((v) => !v)}
|
||||||
|
{...onboardingTargetAttributes(CATEGORIES_ALL_KEYWORDS_TARGET, target === "allKeywords")}
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||||
showAllKeywords
|
showAllKeywords
|
||||||
? "border-[var(--primary)] bg-[var(--primary)]/10 text-[var(--primary)]"
|
? "border-[var(--primary)] bg-[var(--primary)]/10 text-[var(--primary)]"
|
||||||
|
|
|
||||||
178
src/pages/ReportsPage.render.test.tsx
Normal file
178
src/pages/ReportsPage.render.test.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
// ReportsPage and its getting-started card (#432). No jsdom:
|
||||||
|
// react-dom/server renders in vitest's node environment. The highlights and
|
||||||
|
// the entitlement are stubbed, the path is a fake `useOnboarding()` built from
|
||||||
|
// the real checklist, and the report cards are the real ones, their props
|
||||||
|
// recorded on the way. What is pinned is the page's wiring: where the card
|
||||||
|
// sits, the padlock variant, the Trends card as the one target, and its click
|
||||||
|
// acknowledging the step (D17).
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import type { HubReportNavCardProps } from "../components/reports/HubReportNavCard";
|
||||||
|
|
||||||
|
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
||||||
|
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
|
||||||
|
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
|
||||||
|
vi.mock("../components/reports/HubHighlightsPanel", () => ({ default: () => null }));
|
||||||
|
|
||||||
|
const harness = vi.hoisted(() => ({
|
||||||
|
onboarding: null as unknown,
|
||||||
|
entitlement: { allowed: true, ready: true },
|
||||||
|
cards: [] as HubReportNavCardProps[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
|
||||||
|
vi.mock("../hooks/useEntitlement", () => ({ useEntitlement: () => harness.entitlement }));
|
||||||
|
vi.mock("../hooks/useHighlights", () => ({
|
||||||
|
useHighlights: () => ({ data: null, isLoading: false, error: null }),
|
||||||
|
}));
|
||||||
|
vi.mock("../components/reports/HubReportNavCard", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../components/reports/HubReportNavCard")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
default: (props: HubReportNavCardProps) => {
|
||||||
|
harness.cards.push(props);
|
||||||
|
return actual.default(props);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import ReportsPage from "./ReportsPage";
|
||||||
|
import { REPORTS_TRENDS_TARGET } from "../components/reports/HubReportNavCard";
|
||||||
|
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
|
||||||
|
import { computeChecklist } from "../utils/onboardingChecklist";
|
||||||
|
import { nextStep } from "../utils/onboardingPath";
|
||||||
|
import type { ChecklistItemKey, OnboardingCounters } from "../shared/types";
|
||||||
|
|
||||||
|
/** Every data step done: only the reports step is left. */
|
||||||
|
const ALL_DATA: OnboardingCounters = {
|
||||||
|
sources: 1,
|
||||||
|
imports: 1,
|
||||||
|
manualCategorized: 1,
|
||||||
|
budgetEntries: 1,
|
||||||
|
transactions: 41,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The path latched on /reports (card mode), as the provider shares it. */
|
||||||
|
function path(
|
||||||
|
acknowledged: ChecklistItemKey[],
|
||||||
|
overrides: Partial<OnboardingValue> = {},
|
||||||
|
budgetAllowed = true,
|
||||||
|
): OnboardingValue {
|
||||||
|
const checklist = computeChecklist(ALL_DATA, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
acknowledged,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: "ready",
|
||||||
|
checklist,
|
||||||
|
counters: ALL_DATA,
|
||||||
|
hidden: false,
|
||||||
|
budgetReady: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
nextStep: nextStep(checklist),
|
||||||
|
mode: "card",
|
||||||
|
cardPage: "/reports",
|
||||||
|
hide: vi.fn(async () => undefined),
|
||||||
|
show: vi.fn(async () => undefined),
|
||||||
|
skip: vi.fn(async () => undefined),
|
||||||
|
resume: vi.fn(async () => undefined),
|
||||||
|
acknowledge: vi.fn(async () => undefined),
|
||||||
|
refresh: vi.fn(async () => undefined),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const render = () =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<MemoryRouter initialEntries={["/reports"]}>
|
||||||
|
<ReportsPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const CARD = 'aria-label="t(onboarding.banner.label)"';
|
||||||
|
const HIGHLIGHT = 'data-onboarding-highlight="true"';
|
||||||
|
const count = (html: string, needle: string) => html.split(needle).length - 1;
|
||||||
|
const sentence = (html: string) => html.match(/<\/h2><p [^>]*>(.*?)<\/p>/)?.[1] ?? null;
|
||||||
|
const trends = () => harness.cards.find((card) => card.to.startsWith("/reports/trends"))!;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
harness.onboarding = path(["categories"]);
|
||||||
|
harness.entitlement = { allowed: true, ready: true };
|
||||||
|
harness.cards = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ReportsPage — the step card (#432)", () => {
|
||||||
|
it("renders the card first, before the page's heading, last of eight", () => {
|
||||||
|
const html = render();
|
||||||
|
expect(count(html, CARD)).toBe(1);
|
||||||
|
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
|
||||||
|
expect(html).toContain("t(onboarding.card.stepOf[n=8,total=8])");
|
||||||
|
expect(html).toContain("t(onboarding.checklist.items.reports)");
|
||||||
|
expect(html).toContain("t(onboarding.card.acknowledge)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every report open: the sentence without the padlock, Trends named by its own label", () => {
|
||||||
|
expect(sentence(render())).toBe("t(onboarding.card.reports.all[trends=t(reports.hub.trends)])");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the Free edition: the sentence that mentions the padlock, last of seven", () => {
|
||||||
|
harness.entitlement = { allowed: false, ready: true };
|
||||||
|
harness.onboarding = path(["categories"], {}, false);
|
||||||
|
const html = render();
|
||||||
|
expect(sentence(html)).toBe("t(onboarding.card.reports.locked[trends=t(reports.hub.trends)])");
|
||||||
|
expect(html).toContain("t(onboarding.card.stepOf[n=7,total=7])");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the license not read yet: no padlock in the sentence, as on the cards", () => {
|
||||||
|
harness.entitlement = { allowed: false, ready: false };
|
||||||
|
expect(sentence(render())).toBe("t(onboarding.card.reports.all[trends=t(reports.hub.trends)])");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the Trends card is the one target", () => {
|
||||||
|
const html = render();
|
||||||
|
expect(count(html, HIGHLIGHT)).toBe(1);
|
||||||
|
expect(html).toContain(`data-onboarding-target="${REPORTS_TRENDS_TARGET}" ${HIGHLIGHT}`);
|
||||||
|
const ring = html.indexOf(HIGHLIGHT);
|
||||||
|
expect(html.indexOf('href="/reports/trends"', ring)).toBeGreaterThan(ring);
|
||||||
|
expect(html.indexOf('href="/reports/compare"')).toBeGreaterThan(ring);
|
||||||
|
expect(html.indexOf('href="/reports/highlights"')).toBeLessThan(ring);
|
||||||
|
for (const card of harness.cards) {
|
||||||
|
if (card === trends()) continue;
|
||||||
|
expect(card.onboardingTarget, card.to).toBeUndefined();
|
||||||
|
expect(card.onNavigate, card.to).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opening the Trends card acknowledges the reports step (D17)", () => {
|
||||||
|
const value = harness.onboarding as OnboardingValue;
|
||||||
|
render();
|
||||||
|
expect(value.acknowledge).not.toHaveBeenCalled();
|
||||||
|
trends().onNavigate!();
|
||||||
|
expect(value.acknowledge).toHaveBeenCalledWith("reports");
|
||||||
|
expect(value.skip).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the step acknowledged: 'Done.', the end message, no ring, the Trends card back to a plain link", () => {
|
||||||
|
harness.onboarding = path(["categories", "reports"]);
|
||||||
|
const html = render();
|
||||||
|
expect(count(html, CARD)).toBe(1);
|
||||||
|
expect(html).toContain("t(onboarding.card.done)");
|
||||||
|
expect(html).toContain("t(onboarding.banner.complete)");
|
||||||
|
expect(html).not.toContain(HIGHLIGHT);
|
||||||
|
expect(trends().onNavigate).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without the card on screen, no card, no ring and no acknowledgement on the Trends card", () => {
|
||||||
|
for (const overrides of [{ mode: "banner" as const }, { cardPage: null }, { cardPage: "/budget" }]) {
|
||||||
|
harness.cards = [];
|
||||||
|
harness.onboarding = path(["categories"], overrides);
|
||||||
|
const html = render();
|
||||||
|
expect(html, JSON.stringify(overrides)).not.toContain(CARD);
|
||||||
|
expect(html, JSON.stringify(overrides)).not.toContain(HIGHLIGHT);
|
||||||
|
expect(trends().onNavigate, JSON.stringify(overrides)).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
import { Sparkles, TrendingUp, Scale, Search, LayoutDashboard } from "lucide-react";
|
import { Sparkles, TrendingUp, Scale, Search, LayoutDashboard } from "lucide-react";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
import HubHighlightsPanel from "../components/reports/HubHighlightsPanel";
|
import HubHighlightsPanel from "../components/reports/HubHighlightsPanel";
|
||||||
import HubReportNavCard from "../components/reports/HubReportNavCard";
|
import HubReportNavCard from "../components/reports/HubReportNavCard";
|
||||||
|
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
|
||||||
|
import { useOnboarding } from "../contexts/OnboardingContext";
|
||||||
|
import { reportsCardModel, showsStepCard } from "../utils/onboardingPath";
|
||||||
import { useEntitlement } from "../hooks/useEntitlement";
|
import { useEntitlement } from "../hooks/useEntitlement";
|
||||||
import { useHighlights } from "../hooks/useHighlights";
|
import { useHighlights } from "../hooks/useHighlights";
|
||||||
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
||||||
|
|
@ -17,6 +21,17 @@ export default function ReportsPage() {
|
||||||
const { allowed: advancedAllowed, ready: licenseReady } = useEntitlement("reports-advanced");
|
const { allowed: advancedAllowed, ready: licenseReady } = useEntitlement("reports-advanced");
|
||||||
const advancedLocked = licenseReady && !advancedAllowed;
|
const advancedLocked = licenseReady && !advancedAllowed;
|
||||||
|
|
||||||
|
// The getting-started card (#432) points at the Trends card while it is on
|
||||||
|
// screen and the step is not done; opening that card acknowledges the step
|
||||||
|
// (D17): following the ring leaves the page, which releases the card.
|
||||||
|
const location = useLocation();
|
||||||
|
const onboarding = useOnboarding();
|
||||||
|
const cardModel = reportsCardModel(onboarding.checklist, advancedLocked);
|
||||||
|
const trendsTargeted =
|
||||||
|
showsStepCard(onboarding.mode, onboarding.cardPage, location.pathname) &&
|
||||||
|
cardModel.target === "trendsCard";
|
||||||
|
const { acknowledge } = onboarding;
|
||||||
|
|
||||||
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
||||||
const navCards = [
|
const navCards = [
|
||||||
{
|
{
|
||||||
|
|
@ -31,6 +46,9 @@ export default function ReportsPage() {
|
||||||
icon: <TrendingUp size={24} />,
|
icon: <TrendingUp size={24} />,
|
||||||
title: t("reports.hub.trends"),
|
title: t("reports.hub.trends"),
|
||||||
description: t("reports.hub.trendsDescription"),
|
description: t("reports.hub.trendsDescription"),
|
||||||
|
...(trendsTargeted
|
||||||
|
? { onboardingTarget: true, onNavigate: () => void acknowledge("reports") }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: `/reports/compare${preserveSearch}`,
|
to: `/reports/compare${preserveSearch}`,
|
||||||
|
|
@ -57,6 +75,8 @@ export default function ReportsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<OnboardingStepCard model={cardModel} />
|
||||||
|
|
||||||
<div className="relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
<div className="relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold">{t("reports.hub.title")}</h1>
|
<h1 className="text-2xl font-bold">{t("reports.hub.title")}</h1>
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,23 @@ import {
|
||||||
bannerMode,
|
bannerMode,
|
||||||
budgetCardModel,
|
budgetCardModel,
|
||||||
cardNext,
|
cardNext,
|
||||||
|
categoriesCardModel,
|
||||||
cardStep,
|
cardStep,
|
||||||
importCardModel,
|
importCardModel,
|
||||||
latchCardPage,
|
latchCardPage,
|
||||||
nextStep,
|
nextStep,
|
||||||
onboardingTargetAttributes,
|
onboardingTargetAttributes,
|
||||||
readOnboardingNavState,
|
readOnboardingNavState,
|
||||||
|
reportsCardModel,
|
||||||
showsStepCard,
|
showsStepCard,
|
||||||
stepNavState,
|
stepNavState,
|
||||||
stepsOfPage,
|
stepsOfPage,
|
||||||
transactionsCardModel,
|
transactionsCardModel,
|
||||||
type BannerModeInput,
|
type BannerModeInput,
|
||||||
type BudgetCardModel,
|
type BudgetCardModel,
|
||||||
|
type CategoriesCardModel,
|
||||||
type ImportCardModel,
|
type ImportCardModel,
|
||||||
|
type ReportsCardModel,
|
||||||
type StepCardText,
|
type StepCardText,
|
||||||
type TransactionsCardModel,
|
type TransactionsCardModel,
|
||||||
} from "./onboardingPath";
|
} from "./onboardingPath";
|
||||||
|
|
@ -146,12 +150,14 @@ describe("nextStep (#396)", () => {
|
||||||
describe("stepsOfPage (#396)", () => {
|
describe("stepsOfPage (#396)", () => {
|
||||||
it("maps each guided page to its steps, in checklist order", () => {
|
it("maps each guided page to its steps, in checklist order", () => {
|
||||||
expect(stepsOfPage("/import")).toEqual(["importFolder", "source", "import"]);
|
expect(stepsOfPage("/import")).toEqual(["importFolder", "source", "import"]);
|
||||||
|
expect(stepsOfPage("/categories")).toEqual(["categories"]);
|
||||||
expect(stepsOfPage("/transactions")).toEqual(["manualCategory"]);
|
expect(stepsOfPage("/transactions")).toEqual(["manualCategory"]);
|
||||||
expect(stepsOfPage("/budget")).toEqual(["budget"]);
|
expect(stepsOfPage("/budget")).toEqual(["budget"]);
|
||||||
|
expect(stepsOfPage("/reports")).toEqual(["reports"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives no step to any other page, inherited object keys included", () => {
|
it("gives no step to any other page, inherited object keys included", () => {
|
||||||
for (const page of ["/", "/settings/users", "/reports", "constructor", "__proto__", ""]) {
|
for (const page of ["/", "/settings/users", "/reports/trends", "constructor", "__proto__", ""]) {
|
||||||
expect(stepsOfPage(page)).toEqual([]);
|
expect(stepsOfPage(page)).toEqual([]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -411,6 +417,12 @@ describe("CARD_PAGES (#398, #399, #400)", () => {
|
||||||
it("carries the Budget page", () => {
|
it("carries the Budget page", () => {
|
||||||
expect(CARD_PAGES.has("/budget")).toBe(true);
|
expect(CARD_PAGES.has("/budget")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("carries the Categories and Reports pages (#432), not a report's own page", () => {
|
||||||
|
expect(CARD_PAGES.has("/categories")).toBe(true);
|
||||||
|
expect(CARD_PAGES.has("/reports")).toBe(true);
|
||||||
|
expect(CARD_PAGES.has("/reports/trends")).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("showsStepCard (#398)", () => {
|
describe("showsStepCard (#398)", () => {
|
||||||
|
|
@ -477,8 +489,44 @@ describe("cardStep (#398)", () => {
|
||||||
expect(at(beforeInit, "/transactions")).toEqual({ key: "manualCategory", n: 5, total: 6 });
|
expect(at(beforeInit, "/transactions")).toEqual({ key: "manualCategory", n: 5, total: 6 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("on /categories, the categories step, fifth of eight (#432)", () => {
|
||||||
|
const imported = { ...zero, sources: 1, imports: 1, transactions: 41 };
|
||||||
|
expect(at(checklistOf(imported, { importFolderSet: true, acknowledged: [] }), "/categories")).toEqual({
|
||||||
|
key: "categories",
|
||||||
|
n: 5,
|
||||||
|
total: 8,
|
||||||
|
});
|
||||||
|
// Acknowledged: the card keeps showing it (the page's last step), "Done." then says so.
|
||||||
|
expect(at(checklistOf(imported, { importFolderSet: true }), "/categories")).toEqual({
|
||||||
|
key: "categories",
|
||||||
|
n: 5,
|
||||||
|
total: 8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on /reports, the reports step, last of eight, last of seven in the Free edition (#432)", () => {
|
||||||
|
const allButReports = checklistOf(
|
||||||
|
{ ...upToManual, budgetEntries: 1 },
|
||||||
|
{ importFolderSet: true, acknowledged: ["categories"] },
|
||||||
|
);
|
||||||
|
expect(at(allButReports, "/reports")).toEqual({ key: "reports", n: 8, total: 8 });
|
||||||
|
const free = checklistOf(upToManual, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed: false,
|
||||||
|
acknowledged: ["categories"],
|
||||||
|
});
|
||||||
|
expect(at(free, "/reports")).toEqual({ key: "reports", n: 7, total: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is null on a visit step's page until the visit steps are initialized (#425, #432)", () => {
|
||||||
|
const beforeInit = checklistOf(zero, { acknowledged: [], visitStepsInitialized: false });
|
||||||
|
expect(cardStep(beforeInit, "/categories")).toBeNull();
|
||||||
|
expect(cardStep(beforeInit, "/reports")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("is null on a page that guides no unlocked step", () => {
|
it("is null on a page that guides no unlocked step", () => {
|
||||||
expect(cardStep(checklistOf(zero), "/reports")).toBeNull();
|
expect(cardStep(checklistOf(zero), "/settings")).toBeNull();
|
||||||
|
expect(cardStep(checklistOf(zero), "/reports/trends")).toBeNull();
|
||||||
expect(cardStep(checklistOf(zero, { budgetAllowed: false }), "/budget")).toBeNull();
|
expect(cardStep(checklistOf(zero, { budgetAllowed: false }), "/budget")).toBeNull();
|
||||||
expect(cardStep(checklistOf(zero), "/budget")?.step.key).toBe("budget");
|
expect(cardStep(checklistOf(zero), "/budget")?.step.key).toBe("budget");
|
||||||
});
|
});
|
||||||
|
|
@ -505,6 +553,25 @@ describe("cardNext (#398)", () => {
|
||||||
}
|
}
|
||||||
expect(cardNext(step("budget"), "/budget", true)).toEqual({ kind: "wait" });
|
expect(cardNext(step("budget"), "/budget", true)).toEqual({ kind: "wait" });
|
||||||
expect(cardNext(step("budget"), "/import", true).kind).toBe("step");
|
expect(cardNext(step("budget"), "/import", true).kind).toBe("step");
|
||||||
|
expect(cardNext(step("categories"), "/categories", true)).toEqual({ kind: "wait" });
|
||||||
|
expect(cardNext(step("reports"), "/reports", true)).toEqual({ kind: "wait" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after the categories step, the next step is the manual category (#432)", () => {
|
||||||
|
const acknowledged = checklistOf(
|
||||||
|
{ ...zero, sources: 1, imports: 1, transactions: 41 },
|
||||||
|
{ importFolderSet: true, acknowledged: ["categories"] },
|
||||||
|
);
|
||||||
|
expect(cardNext(nextStep(acknowledged), "/categories", true)).toEqual({
|
||||||
|
kind: "step",
|
||||||
|
step: step("manualCategory"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after the reports step, the last one, the end (#432)", () => {
|
||||||
|
const finished = checklistOf({ ...upToManual, budgetEntries: 1 }, { importFolderSet: true });
|
||||||
|
expect(nextStep(finished)).toBeNull();
|
||||||
|
expect(cardNext(nextStep(finished), "/reports", true)).toEqual({ kind: "end" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -793,6 +860,103 @@ describe("budgetCardModel (#400)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("categoriesCardModel (#432)", () => {
|
||||||
|
/** Imported, the categories step left: the card's sentence. */
|
||||||
|
const left = checklistOf({ ...zero, sources: 1, imports: 1, transactions: 41 }, { importFolderSet: true, acknowledged: [] });
|
||||||
|
const SENTENCE: CategoriesCardModel = {
|
||||||
|
text: { key: "onboarding.card.categories", labels: { allKeywords: "categories.allKeywords" } },
|
||||||
|
done: false,
|
||||||
|
target: "allKeywords",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("the step left: what a keyword does, 'All keywords' named by its own label and pointed at", () => {
|
||||||
|
expect(categoriesCardModel(left)).toEqual(SENTENCE);
|
||||||
|
expect(categoriesCardModel(null)).toEqual(SENTENCE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the step acknowledged: 'Done.', nothing to point at", () => {
|
||||||
|
const acknowledged = checklistOf(
|
||||||
|
{ ...zero, sources: 1, imports: 1, transactions: 41 },
|
||||||
|
{ importFolderSet: true, acknowledged: ["categories"] },
|
||||||
|
);
|
||||||
|
expect(categoriesCardModel(acknowledged)).toEqual({ text: null, done: true, target: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a skipped step is not a checked one", () => {
|
||||||
|
const skipped = checklistOf(
|
||||||
|
{ ...zero, sources: 1, imports: 1, transactions: 41 },
|
||||||
|
{ importFolderSet: true, acknowledged: [], skipped: ["categories"] },
|
||||||
|
);
|
||||||
|
expect(categoriesCardModel(skipped)).toEqual(SENTENCE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the sentence in French and in English", () => {
|
||||||
|
expect(render(SENTENCE.text, "fr")).toBe(
|
||||||
|
"Un mot-clé classe d'office, dès l'import, chaque transaction dont la description le contient ; quand deux mots-clés s'appliquent, le plus prioritaire l'emporte. « Tous les mots-clés » les liste tous, avec leur catégorie. À l'étape suivante, vous en ajouterez un depuis une transaction.",
|
||||||
|
);
|
||||||
|
expect(render(SENTENCE.text, "en")).toBe(
|
||||||
|
"A keyword categorizes, as soon as it is imported, every transaction whose description contains it; when two keywords apply, the one with the higher priority wins. “All Keywords” lists them all, with their category. In the next step, you will add one from a transaction.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reportsCardModel (#432)", () => {
|
||||||
|
/** Every step done but the reports one. */
|
||||||
|
const left = checklistOf({ ...upToManual, budgetEntries: 1 }, { importFolderSet: true, acknowledged: ["categories"] });
|
||||||
|
const ALL: ReportsCardModel = {
|
||||||
|
text: { key: "onboarding.card.reports.all", labels: { trends: "reports.hub.trends" } },
|
||||||
|
done: false,
|
||||||
|
target: "trendsCard",
|
||||||
|
};
|
||||||
|
const LOCKED: ReportsCardModel = {
|
||||||
|
text: { key: "onboarding.card.reports.locked", labels: { trends: "reports.hub.trends" } },
|
||||||
|
done: false,
|
||||||
|
target: "trendsCard",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("every report open: the sentence without the padlock, Trends named and pointed at", () => {
|
||||||
|
expect(reportsCardModel(left, false)).toEqual(ALL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the advanced reports locked (Free edition): the sentence that mentions the padlock", () => {
|
||||||
|
expect(reportsCardModel(left, true)).toEqual(LOCKED);
|
||||||
|
expect(reportsCardModel(null, true)).toEqual(LOCKED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the step acknowledged: 'Done.', nothing to point at, locked or not", () => {
|
||||||
|
const finished = checklistOf({ ...upToManual, budgetEntries: 1 }, { importFolderSet: true });
|
||||||
|
for (const advancedLocked of [true, false]) {
|
||||||
|
expect(reportsCardModel(finished, advancedLocked)).toEqual({ text: null, done: true, target: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a skipped step is not a checked one", () => {
|
||||||
|
const skipped = checklistOf(
|
||||||
|
{ ...upToManual, budgetEntries: 1 },
|
||||||
|
{ importFolderSet: true, acknowledged: ["categories"], skipped: ["reports"] },
|
||||||
|
);
|
||||||
|
expect(reportsCardModel(skipped, false)).toEqual(ALL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the two sentences in French", () => {
|
||||||
|
expect(render(ALL.text, "fr")).toBe(
|
||||||
|
"Choisissez la période en haut de la page : le panneau des faits saillants résume votre solde et vos plus gros mouvements. Chaque carte du bas ouvre un rapport détaillé ; commencez par « Tendances ».",
|
||||||
|
);
|
||||||
|
expect(render(LOCKED.text, "fr")).toBe(
|
||||||
|
"Choisissez la période en haut de la page : le panneau des faits saillants résume votre solde et vos plus gros mouvements. Chaque carte du bas ouvre un rapport détaillé ; commencez par « Tendances ». Les cartes à cadenas demandent l'édition Base.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the two sentences in English", () => {
|
||||||
|
expect(render(ALL.text, "en")).toBe(
|
||||||
|
"Pick the period at the top of the page: the highlights panel sums up your balance and your biggest moves. Each card below opens a detailed report; start with “Trends”.",
|
||||||
|
);
|
||||||
|
expect(render(LOCKED.text, "en")).toBe(
|
||||||
|
"Pick the period at the top of the page: the highlights panel sums up your balance and your biggest moves. Each card below opens a detailed report; start with “Trends”. Cards with a padlock require the Base edition.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("onboardingTargetAttributes (#398)", () => {
|
describe("onboardingTargetAttributes (#398)", () => {
|
||||||
it("marks the target and its highlight when on, nothing otherwise", () => {
|
it("marks the target and its highlight when on, nothing otherwise", () => {
|
||||||
expect(onboardingTargetAttributes("wizard-next", true)).toEqual({
|
expect(onboardingTargetAttributes("wizard-next", true)).toEqual({
|
||||||
|
|
|
||||||
|
|
@ -27,19 +27,24 @@ export function nextStep(checklist: Checklist): ChecklistItem | null {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routes whose page carries a step card. Each card issue adds its page (#398:
|
* Routes whose page carries a step card. Each card issue adds its page (#398:
|
||||||
* Import, #399: Transactions, #400: Budget), and a page without a delivered
|
* Import, #399: Transactions, #400: Budget, #432: Categories and Reports), and
|
||||||
* card keeps the banner, so a partly merged stack never removes any guidance.
|
* a page without a delivered card keeps the banner, so a partly merged stack
|
||||||
|
* never removes any guidance.
|
||||||
*/
|
*/
|
||||||
export const CARD_PAGES: ReadonlySet<string> = new Set<string>([
|
export const CARD_PAGES: ReadonlySet<string> = new Set<string>([
|
||||||
"/import",
|
"/import",
|
||||||
|
"/categories",
|
||||||
"/transactions",
|
"/transactions",
|
||||||
"/budget",
|
"/budget",
|
||||||
|
"/reports",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = {
|
const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = {
|
||||||
"/import": ["importFolder", "source", "import"],
|
"/import": ["importFolder", "source", "import"],
|
||||||
|
"/categories": ["categories"],
|
||||||
"/transactions": ["manualCategory"],
|
"/transactions": ["manualCategory"],
|
||||||
"/budget": ["budget"],
|
"/budget": ["budget"],
|
||||||
|
"/reports": ["reports"],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The steps a page guides, in checklist order (none for any other page). */
|
/** The steps a page guides, in checklist order (none for any other page). */
|
||||||
|
|
@ -439,6 +444,62 @@ export function budgetCardModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The control of the Categories page that the card points at. */
|
||||||
|
export type CategoriesCardTarget = "allKeywords";
|
||||||
|
|
||||||
|
export interface CategoriesCardModel extends StepCardModel {
|
||||||
|
/** The control to highlight, null for none. */
|
||||||
|
target: CategoriesCardTarget | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Categories page's card (#432), a visit step done by "Got it" (#425):
|
||||||
|
* - the step checked: "Done.", nothing to point at;
|
||||||
|
* - otherwise, what a keyword does, "All keywords" named by its own label and
|
||||||
|
* pointed at, and the keyword the next step adds from a transaction. Never
|
||||||
|
* "Reset": it deletes the keywords and uncategorizes every transaction.
|
||||||
|
*/
|
||||||
|
export function categoriesCardModel(checklist: Checklist | null): CategoriesCardModel {
|
||||||
|
if (checked(checklist, "categories")) return { text: null, done: true, target: null };
|
||||||
|
return {
|
||||||
|
text: { key: "onboarding.card.categories", labels: { allKeywords: "categories.allKeywords" } },
|
||||||
|
done: false,
|
||||||
|
target: "allKeywords",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The control of the Reports page that the card points at. */
|
||||||
|
export type ReportsCardTarget = "trendsCard";
|
||||||
|
|
||||||
|
export interface ReportsCardModel extends StepCardModel {
|
||||||
|
/** The control to highlight, null for none. */
|
||||||
|
target: ReportsCardTarget | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Reports page's card (#432), a visit step done by "Got it" or by opening
|
||||||
|
* the highlighted Trends card (D17):
|
||||||
|
* - the step checked: "Done.", nothing to point at;
|
||||||
|
* - otherwise, the period, the highlights panel and the report cards, Trends
|
||||||
|
* named by its own label and pointed at (it is never locked); with
|
||||||
|
* `advancedLocked` (the page's: license known and not entitled), the
|
||||||
|
* sentence also says the padlocked cards require the Base edition.
|
||||||
|
*/
|
||||||
|
export function reportsCardModel(
|
||||||
|
checklist: Checklist | null,
|
||||||
|
advancedLocked: boolean,
|
||||||
|
): ReportsCardModel {
|
||||||
|
if (checked(checklist, "reports")) return { text: null, done: true, target: null };
|
||||||
|
return {
|
||||||
|
text: {
|
||||||
|
key: advancedLocked ? "onboarding.card.reports.locked" : "onboarding.card.reports.all",
|
||||||
|
labels: { trends: "reports.hub.trends" },
|
||||||
|
},
|
||||||
|
done: false,
|
||||||
|
target: "trendsCard",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** The attributes of a highlighted control; `styles.css` draws the ring on the second one. */
|
/** The attributes of a highlighted control; `styles.css` draws the ring on the second one. */
|
||||||
export interface OnboardingTargetAttributes {
|
export interface OnboardingTargetAttributes {
|
||||||
"data-onboarding-target"?: string;
|
"data-onboarding-target"?: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue