diff --git a/src/components/onboarding/OnboardingStepCard.test.tsx b/src/components/onboarding/OnboardingStepCard.test.tsx index 51596da..294ff49 100644 --- a/src/components/onboarding/OnboardingStepCard.test.tsx +++ b/src/components/onboarding/OnboardingStepCard.test.tsx @@ -21,22 +21,6 @@ vi.mock("react-router-dom", async (importOriginal) => ({ useNavigate: () => ctx.navigate, })); 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(); - return { - ...orig, - cardStep: (...args: Parameters) => { - 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 OnboardingStepCard from "./OnboardingStepCard"; import StepCard, { type StepCardProps } from "./StepCard"; @@ -183,10 +167,10 @@ describe("OnboardingStepCard — what it hands the card (#398)", () => { expect(render()!.props.onAcknowledge).toBeUndefined(); }); - it("'Got it' on a visit step acknowledges the step the card shows (#425)", () => { - ctx.location = { pathname: "/stand-in-categories", state: null, key: "arrival-1" }; + it("'Got it' on the Categories card acknowledges the categories step (#425, #432)", () => { + ctx.location = { pathname: "/categories", state: null, key: "arrival-1" }; ctx.onboarding = path(IMPORTED, { - cardPage: "/stand-in-categories", + cardPage: "/categories", checklist: computeChecklist(IMPORTED, { importFolderSet: true, budgetAllowed: true, @@ -203,6 +187,26 @@ describe("OnboardingStepCard — what it hands the card (#398)", () => { 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", () => { const value = ctx.onboarding as OnboardingValue; const props = render()!.props; diff --git a/src/components/reports/HubReportNavCard.render.test.tsx b/src/components/reports/HubReportNavCard.render.test.tsx new file mode 100644 index 0000000..3b4dd72 --- /dev/null +++ b/src/components/reports/HubReportNavCard.render.test.tsx @@ -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 = {}) => + renderToStaticMarkup( + + + , + ); + +/** The link the card returns, read without rendering it. */ +const link = (props: Partial = {}) => + 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)"); + }); +}); diff --git a/src/components/reports/HubReportNavCard.tsx b/src/components/reports/HubReportNavCard.tsx index b7074d6..3ce57f7 100644 --- a/src/components/reports/HubReportNavCard.tsx +++ b/src/components/reports/HubReportNavCard.tsx @@ -2,6 +2,10 @@ import type { ReactNode } from "react"; import { Link } from "react-router-dom"; import { Lock } from "lucide-react"; 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 { to: string; @@ -13,6 +17,13 @@ export interface HubReportNavCardProps { * entitled). The card stays clickable — the gated route renders the upsell. */ 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({ @@ -21,12 +32,16 @@ export default function HubReportNavCard({ title, description, locked, + onboardingTarget, + onNavigate, }: HubReportNavCardProps) { const { t } = useTranslation(); return ( {locked && ( diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 85c5dd9..dfc76fc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -98,6 +98,11 @@ "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.", "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": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 51dab39..0ec4291 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -98,6 +98,11 @@ "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.", "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": { diff --git a/src/pages/CategoriesPage.render.test.tsx b/src/pages/CategoriesPage.render.test.tsx new file mode 100644 index 0000000..595b876 --- /dev/null +++ b/src/pages/CategoriesPage.render.test.tsx @@ -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 { + 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( + + + , + ); + +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>/)?.[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(" { + 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)"); + const reset = html.indexOf("t(categories.reinitialize)"); + // The ring sits on the button whose label closes right after it, before "Reset". + expect(ring).toBeLessThan(allKeywords); + expect(html.lastIndexOf(" { + 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(); + }); +}); diff --git a/src/pages/CategoriesPage.tsx b/src/pages/CategoriesPage.tsx index 568dc54..54f489e 100644 --- a/src/pages/CategoriesPage.tsx +++ b/src/pages/CategoriesPage.tsx @@ -1,11 +1,22 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { useLocation } from "react-router-dom"; import { Plus, RotateCcw, List, AlertTriangle } from "lucide-react"; import { PageHelp } from "../components/shared/PageHelp"; import { useCategories } from "../hooks/useCategories"; import CategoryTree from "../components/categories/CategoryTree"; import CategoryDetailPanel from "../components/categories/CategoryDetailPanel"; 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() { const { t } = useTranslation(); @@ -26,6 +37,16 @@ export default function CategoriesPage() { moveCategory, } = 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 () => { setShowReinitConfirm(false); await reinitializeCategories(); @@ -38,6 +59,8 @@ export default function CategoriesPage() { return (

+ +

{t("categories.title")}

@@ -46,6 +69,7 @@ export default function CategoriesPage() {