diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 0cdd659..b5eef23 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -2,9 +2,15 @@ ## [Non publié] +### Ajouté + +- Un bandeau de démarrage en haut de chaque page tant que le parcours de démarrage est affiché : « Démarrage 3/6 », l'étape suivante avec un lien vers la page où la faire, « Voir la liste », qui déplie les six étapes en place, et « Masquer », qui masque le bandeau et la liste pour le profil courant. Il n'apparaît pas sur le tableau de bord d'un profil sans transaction, dont la carte d'accueil contient déjà la liste. Une fois toutes les étapes faites, il l'annonce et propose « Ouvrir le guide », qui mène à la section « Premiers pas » du guide d'utilisation, et « Fermer », qui le retire jusqu'à un réaffichage depuis Paramètres → Utilisateurs (#397). + ### Modifié - La liste de démarrage ne s'affiche plus dans un profil qui avait déjà des transactions avant cette version : à la première ouverture d'un tel profil, la liste est masquée, comme après un clic sur « Masquer », et « Afficher la liste de démarrage », dans Paramètres → Utilisateurs, la fait revenir. Un profil sans transaction la garde (#396). +- Le tableau de bord n'affiche plus la barre compacte de démarrage une fois que le profil a des transactions : le bandeau la remplace sur chaque page. Dans la liste détaillée, les lecteurs d'écran annoncent désormais si chaque étape est faite, verrouillée ou en attente (#397). +- Paramètres → Utilisateurs propose « Afficher la liste de démarrage » dès que le parcours est masqué, y compris depuis le bandeau de cette même page, et confirme désormais que le parcours réapparaît en haut des pages (#397). ### Corrigé diff --git a/CHANGELOG.md b/CHANGELOG.md index 5940561..e4b9dec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,15 @@ ## [Unreleased] +### Added + +- A getting-started banner at the top of every page while the getting-started path is shown: "Getting started 3/6", the next step with a link to the page where it is done, "Show the list", which unfolds the six steps in place, and "Hide", which hides the banner and the list for the current profile. It does not show on the dashboard of a profile without transactions, whose welcome card already holds the list. Once every step is done, it says so and offers "Open the guide", which leads to the "Getting Started" section of the user guide, and "Close", which removes it until it is shown again from Settings → Users (#397). + ### Changed - The getting-started checklist no longer shows in a profile that already had transactions before this version: the first time such a profile is opened, the checklist is hidden, as if you had clicked "Hide", and "Show the getting-started checklist" in Settings → Users brings it back. A profile without transactions keeps it (#396). +- The dashboard no longer shows the compact getting-started bar once the profile has transactions: the banner replaces it on every page. In the detailed list, screen readers now hear whether each step is done, locked or pending (#397). +- Settings → Users offers "Show the getting-started checklist" as soon as the path is hidden, also when it was hidden from the banner of that same page, and now confirms that the path reappears at the top of the pages (#397). ### Fixed diff --git a/docs/architecture.md b/docs/architecture.md index cfe9afc..0c91ef2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,7 +26,7 @@ ``` simpl-resultat/ ├── src/ # Frontend React/TypeScript -│ ├── components/ # 58 composants organisés par domaine +│ ├── components/ # 59 composants organisés par domaine │ │ ├── adjustments/ # 3 composants │ │ ├── balance/ # 8 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, BalanceOnboardingCard, BalanceOverviewCard, LinkTransfersModal, SnapshotEditor, SnapshotLineRow) │ │ ├── budget/ # 5 composants @@ -34,6 +34,7 @@ simpl-resultat/ │ │ ├── dashboard/ # 7 composants (dont DashboardEmptyState et OnboardingChecklist) │ │ ├── import/ # 15 composants (wizard d'import, dont FormatDriftPanel, RepairPathNotice et ImportEmptyState) │ │ ├── layout/ # AppShell, Sidebar +│ │ ├── onboarding/ # OnboardingBanner (bandeau de démarrage, #397) │ │ ├── profile/ # 3 composants (PIN, formulaire, switcher) │ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie) │ │ ├── settings/ # 17 composants (dont LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard, DocsContent et DocsSubsections) @@ -313,12 +314,12 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d | Page | Signal | Composant | Action proposée | |---|---|---|---| -| Tableau de bord `/` | `counters.transactions === 0`, compteur lu par `useOnboardingChecklist` | `DashboardEmptyState` (carte d'accueil + liste en mode `expanded`) | « Choisir un dossier d'import » → `/import` | +| Tableau de bord `/` | `counters.transactions === 0`, compteur lu par `useOnboardingChecklist` | `DashboardEmptyState` (carte d'accueil + liste détaillée) | « Choisir un dossier d'import » → `/import` | | Import `/import` | aucun dossier d'import, ou dossier sans aucun sous-dossier | `ImportEmptyState` (deux variantes) | « Choisir un dossier » ; ou « Créer un dossier d'exemple » + « Actualiser » ; lien vers le guide `/settings/users#import-export` dans les deux cas | | Transactions `/transactions` | `showsEmptyState(state)` = `totalInDb === 0 && !isLoading`, `totalInDb` via `getTransactionCount` | `EmptyState` dans la page | « Importer des transactions » → `/import` | | Budget `/budget` | `showsEmptyState(state)` = `totalInDb === 0`, même compteur | `EmptyState` dans la page | « Importer des transactions » → `/import` | -**La liste de démarrage** (`OnboardingChecklist`, mode `expanded` sur le tableau de bord vide, mode `compact` entre le titre et les filtres dès qu'il y a des transactions) a six lignes dans un ordre fixe. Chaque case est cochée par une **donnée**, jamais par un clic ; la source de vérité est `computeChecklist(counters, { importFolderSet, budgetAllowed })` : +**La liste de démarrage** (`OnboardingChecklist`, dans la carte d'accueil du tableau de bord vide, et dépliée en place par « Voir la liste » du bandeau de démarrage `OnboardingBanner`, que `AppShell` pose en haut de chaque page, #397) a six lignes dans un ordre fixe. Chaque case est cochée par une **donnée**, jamais par un clic ; la source de vérité est `computeChecklist(counters, { importFolderSet, budgetAllowed })` : | Case | Source de vérité | |---|---| @@ -329,7 +330,7 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d | Classer une transaction à la main | `COUNT(*) FROM transactions WHERE is_manually_categorized = 1` ≥ 1 — ni « règle créée » ni « transactions catégorisées » : le seed IPC pose 180 mots-clés dans tout profil neuf et le wizard catégorise pendant l'import, ces deux cases seraient donc cochées d'avance | | Saisir un premier budget | `COUNT(*) FROM budget_entries` ≥ 1 ; verrouillée hors Base+ (`useEntitlement("budget")`), alors exclue du total (5 au lieu de 6) et renvoyée vers `/settings/users` | -`computeChecklist` rend `{ items, done, total, complete }` ; la liste compacte disparaît quand `complete`. « Masquer » écrit `onboarding_checklist_hidden = "1"` dans `user_preferences` — **par profil**, donc détruite avec lui ([ADR 0016](adr/0016-persistance-etat-ui-par-profil.md)) et sans effet sur les autres profils. Paramètres → Utilisateurs montre « Afficher la liste de démarrage » tant que la clé existe et la supprime au clic (`showChecklist`), avec une confirmation en place du bouton. +`computeChecklist` rend `{ items, done, total, complete }` ; quand `complete`, le bandeau passe au message de fin (« Ouvrir le guide », « Fermer »). « Masquer » écrit `onboarding_checklist_hidden = "1"` dans `user_preferences` — **par profil**, donc détruite avec lui ([ADR 0016](adr/0016-persistance-etat-ui-par-profil.md)) et sans effet sur les autres profils. Paramètres → Utilisateurs lit `hidden` dans `useOnboarding()` et montre « Afficher la liste de démarrage » tant que le parcours est masqué, y compris quand le bandeau de cette page vient de le masquer ; le clic passe par `show()` du fournisseur, seul écrivain de la préférence, et une confirmation remplace le bouton (#397). **Le dossier d'exemple** : sur un dossier d'import sans sous-dossier, `ImportEmptyState` propose « Créer un dossier d'exemple ». `useImportWizard.createSampleFolders` invoque `create_sample_import_folders` avec le dossier et la date du jour, rescanne, puis affiche `import.empty.sampleCreated` avec les noms rendus par la commande ; le bandeau tombe au changement de dossier. La commande refuse d'écraser (voir son contrat dans « Commandes Tauri ») ; les gabarits et leur test d'intégration sont décrits dans « Gabarits du dossier d'exemple ». diff --git a/src/components/dashboard/DashboardEmptyState.render.test.tsx b/src/components/dashboard/DashboardEmptyState.render.test.tsx new file mode 100644 index 0000000..30967e3 --- /dev/null +++ b/src/components/dashboard/DashboardEmptyState.render.test.tsx @@ -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) => + 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( + + + , + ); + +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(/]*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>; + 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); + }); +}); diff --git a/src/components/dashboard/DashboardEmptyState.tsx b/src/components/dashboard/DashboardEmptyState.tsx index 44846db..bbef967 100644 --- a/src/components/dashboard/DashboardEmptyState.tsx +++ b/src/components/dashboard/DashboardEmptyState.tsx @@ -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} /> )} diff --git a/src/components/dashboard/OnboardingChecklist.test.tsx b/src/components/dashboard/OnboardingChecklist.test.tsx index a6c053d..0dd6503 100644 --- a/src/components/dashboard/OnboardingChecklist.test.tsx +++ b/src/components/dashboard/OnboardingChecklist.test.tsx @@ -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 & { 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([Link, CheckCircle2, Circle, Lock]); +const LEAVES = new Set([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 = {}, counters = ZERO, budgetAllowed = true) { - const checklist = computeChecklist(counters, { importFolderSet: false, budgetAllowed }); +function render( + overrides: Partial = {}, + 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"); }); }); diff --git a/src/components/dashboard/OnboardingChecklist.tsx b/src/components/dashboard/OnboardingChecklist.tsx index 3b84560..f4fac18 100644 --- a/src/components/dashboard/OnboardingChecklist.tsx +++ b/src/components/dashboard/OnboardingChecklist.tsx @@ -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 = { 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

{t("onboarding.checklist.title")}

- {counter} - {hideButton} + {budgetReady && ( + + {t("onboarding.checklist.counter", { done, total })} + + )} + + +
+ ); + } + + return ( +
+
+ + {budgetReady && ( + + {t("onboarding.banner.progress", { done: checklist.done, total: checklist.total })} + + )} + {budgetReady && nextStep && ( +
+ {/* Always in the page, so `aria-controls` names an element even while folded. */} +
+ {listOpen && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 259d87e..45b5849 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index b7ac9da..15908d0 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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": { diff --git a/src/pages/DashboardPage.render.test.tsx b/src/pages/DashboardPage.render.test.tsx index 9416fd0..833dcd6 100644 --- a/src/pages/DashboardPage.render.test.tsx +++ b/src/pages/DashboardPage.render.test.tsx @@ -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 }) => , - }), +const { marker, emptyStateProps } = vi.hoisted(() => ({ + marker: (name: string) => ({ default: () => }), + emptyStateProps: { current: null as Record | 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) => { + emptyStateProps.current = props; + return ; + }, +})); +// 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"'); }); diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index d8dda50..c7536f9 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -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} /> ); } - // `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 (
{header} - {showCompactChecklist && ( - - )} - {state.error && (
{state.error} diff --git a/src/pages/settings/UsersSettingsPage.test.tsx b/src/pages/settings/UsersSettingsPage.test.tsx new file mode 100644 index 0000000..947cb7d --- /dev/null +++ b/src/pages/settings/UsersSettingsPage.test.tsx @@ -0,0 +1,153 @@ +// UsersSettingsPage — the re-show button reads the shared path (#397). +// +// React's `useState` is stubbed (same technique as +// useOnboardingChecklist.wiring.test.ts): the page runs as a plain function +// and its state survives from one call to the next, as between two renders of +// the mounted page. So "the path got hidden by the banner of this very page" +// is a new `useOnboarding()` value on the next render, without remounting. +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { isValidElement, type ReactElement, type ReactNode } from "react"; + +const harness = vi.hoisted(() => ({ + slots: [] as unknown[], + cursor: 0, + onboarding: null as unknown, +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + 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-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }), +})); +vi.mock("../../contexts/OnboardingContext", () => ({ + useOnboarding: () => harness.onboarding, +})); +// The page's cards are not under test, and they reach tauri. +vi.mock("../../components/settings/AccountCard", () => ({ default: () => null })); +vi.mock("../../components/settings/LicenseCard", () => ({ default: () => null })); +vi.mock("../../components/settings/DocsContent", () => ({ default: () => null })); +// The provider is the only writer of the path's preferences: the page must +// neither read the preference nor write it itself. +vi.mock("../../services/userPreferenceService", () => ({ getPreference: vi.fn() })); +vi.mock("../../services/onboardingService", () => ({ showChecklist: vi.fn() })); + +import UsersSettingsPage from "./UsersSettingsPage"; +import { getPreference } from "../../services/userPreferenceService"; +import { showChecklist } from "../../services/onboardingService"; + +type AnyElement = ReactElement & { 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); +} + +/** What the page reads from the provider: only `hidden` and `show` matter here. */ +function path(hidden: boolean) { + return { hidden, show: vi.fn(async () => undefined) }; +} + +/** One render of the mounted page. */ +function renderPage(): ReactNode { + harness.cursor = 0; + return UsersSettingsPage() as ReactNode; +} + +const showButton = (page: ReactNode) => + find(page, (e) => e.type === "button" && texts(e.props.children).includes("t(settings.users.onboarding.show)")); +const confirmation = (page: ReactNode) => + find(page, (e) => e.props.role === "status" && texts(e.props.children).includes("t(settings.users.onboarding.confirmation)")); + +beforeEach(() => { + harness.slots = []; + harness.onboarding = path(false); + vi.mocked(getPreference).mockReset(); + vi.mocked(showChecklist).mockReset(); +}); + +describe("UsersSettingsPage — showing the path again (#397)", () => { + it("offers nothing while the path is shown", () => { + const page = renderPage(); + expect(showButton(page)).toHaveLength(0); + expect(confirmation(page)).toHaveLength(0); + expect(texts(page)).not.toContain("t(settings.users.onboarding.label)"); + }); + + it("offers the button as soon as the path is hidden from the banner of this page, without remounting", () => { + expect(showButton(renderPage())).toHaveLength(0); + harness.onboarding = path(true); + const page = renderPage(); + expect(texts(page)).toContain("t(settings.users.onboarding.label)"); + expect(showButton(page)).toHaveLength(1); + expect(confirmation(page)).toHaveLength(0); + }); + + it("the button shows the path through the provider, then confirms in place", async () => { + const hiddenPath = path(true); + harness.onboarding = hiddenPath; + const [button] = showButton(renderPage()); + await (button.props.onClick as () => Promise)(); + expect(hiddenPath.show).toHaveBeenCalledTimes(1); + expect(showChecklist).not.toHaveBeenCalled(); + + // The provider applied SHOW: the next render reads the path shown. + harness.onboarding = path(false); + const page = renderPage(); + expect(showButton(page)).toHaveLength(0); + expect(confirmation(page)).toHaveLength(1); + expect(texts(page)).toContain("t(settings.users.onboarding.label)"); + }); + + it("hidden again after a restore, it offers the button again", async () => { + harness.onboarding = path(true); + const [button] = showButton(renderPage()); + await (button.props.onClick as () => Promise)(); + harness.onboarding = path(false); + expect(confirmation(renderPage())).toHaveLength(1); + + harness.onboarding = path(true); + const page = renderPage(); + expect(showButton(page)).toHaveLength(1); + expect(confirmation(page)).toHaveLength(0); + }); + + it("keeps the button when showing failed: the provider left the path hidden", async () => { + const hiddenPath = path(true); + harness.onboarding = hiddenPath; + const [button] = showButton(renderPage()); + await (button.props.onClick as () => Promise)(); + const page = renderPage(); + expect(showButton(page)).toHaveLength(1); + expect(confirmation(page)).toHaveLength(0); + }); + + it("never reads the preference itself", () => { + harness.onboarding = path(true); + renderPage(); + expect(getPreference).not.toHaveBeenCalled(); + }); +}); diff --git a/src/pages/settings/UsersSettingsPage.tsx b/src/pages/settings/UsersSettingsPage.tsx index de75c2e..0942fcf 100644 --- a/src/pages/settings/UsersSettingsPage.tsx +++ b/src/pages/settings/UsersSettingsPage.tsx @@ -1,51 +1,26 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { ArrowLeft, ListChecks } from "lucide-react"; import AccountCard from "../../components/settings/AccountCard"; import LicenseCard from "../../components/settings/LicenseCard"; import DocsContent from "../../components/settings/DocsContent"; -import { getPreference } from "../../services/userPreferenceService"; -import { - ONBOARDING_CHECKLIST_HIDDEN_KEY, - showChecklist, -} from "../../services/onboardingService"; -import { logError } from "../../services/logService"; - -// Getting-started checklist preference, for its re-show button (#352). -// `dismissed` = the preference was "1" at mount, the button is offered; -// `restored` = the button was clicked and the preference removed, so a -// confirmation line replaces it; `shown` = nothing to offer. -type ChecklistPreference = "unknown" | "shown" | "dismissed" | "restored"; +import { useOnboarding } from "../../contexts/OnboardingContext"; export default function UsersSettingsPage() { const { t } = useTranslation(); - const [checklistPref, setChecklistPref] = useState("unknown"); + // The getting-started path comes from its provider, the only writer of its + // preferences (#397): hiding it from the banner of this very page offers + // the re-show button at once, without reading the preference here. + const { hidden, show } = useOnboarding(); + // Set once the path was shown again from here: the confirmation then + // replaces the button, until the path is hidden again. + const [restored, setRestored] = useState(false); - useEffect(() => { - let cancelled = false; - (async () => { - try { - const value = await getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY); - if (!cancelled) setChecklistPref(value === "1" ? "dismissed" : "shown"); - } catch (e) { - // Informational control: without the preference, keep it out of the way. - if (!cancelled) setChecklistPref("shown"); - logError("Settings: failed to read the checklist preference", e); - } - })(); - return () => { - cancelled = true; - }; - }, []); - - const handleShowChecklist = async () => { - try { - await showChecklist(); - setChecklistPref("restored"); - } catch (e) { - logError("Settings: failed to show the checklist", e); - } + const handleShowPath = async () => { + // `show` logs its own failure and leaves the path hidden, which keeps the button. + await show(); + setRestored(true); }; return ( @@ -78,14 +53,14 @@ export default function UsersSettingsPage() {

{t("settings.users.sections.userGuide")}

- {(checklistPref === "dismissed" || checklistPref === "restored") && ( + {(hidden || restored) && (