feat(onboarding): getting-started banner on top of every page #403

Merged
maximus merged 3 commits from issue-397-onboarding-banner into main 2026-10-09 01:10:32 +00:00
20 changed files with 1313 additions and 209 deletions

View file

@ -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é

View file

@ -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

View file

@ -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 ».

View file

@ -0,0 +1,64 @@
// DashboardEmptyState — the welcome card of a profile without transactions
// (#352). #397 leaves it as it was: the card keeps the detailed list, which
// now also takes the "Resume" handler. No jsdom: react-dom/server for the
// markup, a function call for the wiring.
import { describe, it, expect, vi } from "vitest";
import type { ReactElement, ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
i18n: { language: "fr" },
}),
}));
import DashboardEmptyState from "./DashboardEmptyState";
import OnboardingChecklist from "./OnboardingChecklist";
import { computeChecklist } from "../../utils/onboardingChecklist";
const checklist = computeChecklist(
{ sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 },
{ importFolderSet: false, budgetAllowed: true },
);
function cardProps(hidden = false) {
return { checklist, budgetReady: true, hidden, onHide: vi.fn(), onResume: vi.fn() };
}
const markup = (hidden = false) =>
renderToStaticMarkup(
<MemoryRouter>
<DashboardEmptyState {...cardProps(hidden)} />
</MemoryRouter>,
);
describe("DashboardEmptyState (#352, #397)", () => {
it("shows the welcome card, its action towards Import and the detailed list", () => {
const html = markup();
expect(html).toContain("t(dashboard.empty.title)");
expect(html).toContain("t(dashboard.empty.description)");
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(dashboard\.empty\.cta\)/);
expect(html).toContain('aria-label="t(onboarding.checklist.title)"');
expect(html).toContain("t(onboarding.checklist.counter[done=1,total=6])");
});
it("keeps the card and drops the list once the path is hidden", () => {
const html = markup(true);
expect(html).toContain("t(dashboard.empty.title)");
expect(html).not.toContain("onboarding.checklist.title");
});
it("hands its list the path's handlers", () => {
const props = cardProps();
const card = DashboardEmptyState(props) as ReactElement<{ children?: ReactNode }>;
const list = card.props.children as ReactElement<Record<string, unknown>>;
expect(list.type).toBe(OnboardingChecklist);
expect(list.props.items).toBe(checklist.items);
expect(list.props.budgetReady).toBe(true);
expect(list.props.onHide).toBe(props.onHide);
expect(list.props.onResume).toBe(props.onResume);
});
});

View file

@ -3,6 +3,7 @@ import { LayoutDashboard } from "lucide-react";
import EmptyState from "../shared/EmptyState";
import OnboardingChecklist from "./OnboardingChecklist";
import type { Checklist } from "../../utils/onboardingChecklist";
import type { ChecklistItemKey } from "../../shared/types";
interface DashboardEmptyStateProps {
checklist: Checklist;
@ -10,18 +11,22 @@ interface DashboardEmptyStateProps {
/** The user dismissed the checklist: the welcome card stays, the list goes. */
hidden: boolean;
onHide: () => void;
/** "Resume" on a skipped row of the list (#397). */
onResume: (key: ChecklistItemKey) => void;
}
/**
* Dashboard of a profile without any transaction (#352): a welcome card with
* the expanded getting-started checklist and one primary action towards the
* import page, in place of widgets showing 0 $.
* the detailed getting-started checklist and one primary action towards the
* import page, in place of widgets showing 0 $. The getting-started banner
* stays off this page (D11): the card already holds the list.
*/
export default function DashboardEmptyState({
checklist,
budgetReady,
hidden,
onHide,
onResume,
}: DashboardEmptyStateProps) {
const { t } = useTranslation();
@ -37,9 +42,9 @@ export default function DashboardEmptyState({
items={checklist.items}
done={checklist.done}
total={checklist.total}
mode="expanded"
budgetReady={budgetReady}
onHide={onHide}
onResume={onResume}
/>
)}
</EmptyState>

View file

@ -1,4 +1,4 @@
// OnboardingChecklist — unit tests (#352).
// OnboardingChecklist — unit tests (#352, #397).
//
// FilterPanel.test.tsx pattern: no jsdom, `react-i18next` and `lucide-react`
// mocked, the component called as a function and its element tree walked.
@ -20,12 +20,13 @@ vi.mock("lucide-react", () => ({
CheckCircle2: () => null,
Circle: () => null,
Lock: () => null,
SkipForward: () => null,
}));
import { CheckCircle2, Circle, Lock } from "lucide-react";
import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react";
import OnboardingChecklist, { type OnboardingChecklistProps } from "./OnboardingChecklist";
import { computeChecklist } from "../../utils/onboardingChecklist";
import type { OnboardingCounters } from "../../shared/types";
import type { ChecklistItemKey, OnboardingCounters } from "../../shared/types";
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
@ -44,7 +45,7 @@ function texts(node: ReactNode): string[] {
}
// Leaves of the walk: react-router's Link and the mocked icons keep their type.
const LEAVES = new Set<unknown>([Link, CheckCircle2, Circle, Lock]);
const LEAVES = new Set<unknown>([Link, CheckCircle2, Circle, Lock, SkipForward]);
/** Resolves the inner `RowIcon` component so the icon elements are visible. */
function expand(node: ReactNode): ReactNode {
@ -67,19 +68,25 @@ const ZERO: OnboardingCounters = {
transactions: 0,
};
function render(overrides: Partial<OnboardingChecklistProps> = {}, counters = ZERO, budgetAllowed = true) {
const checklist = computeChecklist(counters, { importFolderSet: false, budgetAllowed });
function render(
overrides: Partial<OnboardingChecklistProps> = {},
counters = ZERO,
budgetAllowed = true,
skipped: ChecklistItemKey[] = [],
) {
const checklist = computeChecklist(counters, { importFolderSet: false, budgetAllowed, skipped });
const onHide = vi.fn();
const onResume = vi.fn();
const props: OnboardingChecklistProps = {
items: checklist.items,
done: checklist.done,
total: checklist.total,
mode: "expanded",
budgetReady: true,
onHide,
onResume,
...overrides,
};
return { element: expand(OnboardingChecklist(props)), onHide, checklist };
return { element: expand(OnboardingChecklist(props)), onHide, onResume, checklist };
}
function rowOf(element: ReactNode, key: string): AnyElement {
@ -88,7 +95,14 @@ function rowOf(element: ReactNode, key: string): AnyElement {
return row;
}
describe("OnboardingChecklist — expanded", () => {
/** The visually hidden texts of a row: what assistive technologies read after the label. */
function stateTextsOf(row: AnyElement): string[] {
return find(row, (e) => e.type === "span" && e.props.className === "sr-only").flatMap((e) =>
texts(e.props.children).map((s) => s.trim()).filter(Boolean),
);
}
describe("OnboardingChecklist", () => {
it("renders the title, the counter 1 / 6 and the hide button", () => {
const { element, onHide } = render();
const text = texts(element);
@ -120,6 +134,15 @@ describe("OnboardingChecklist — expanded", () => {
expect(find(profile, (e) => e.type === CheckCircle2)).toHaveLength(1);
});
it("gives the link of a row to do the step's navigation state, guided on the manual category", () => {
const { element } = render();
const [folder] = find(rowOf(element, "importFolder"), (e) => e.type === Link);
expect(folder.props.state).toEqual({ onboarding: { focus: true } });
const [manual] = find(rowOf(element, "manualCategory"), (e) => e.type === Link);
expect(manual.props.to).toBe("/transactions");
expect(manual.props.state).toEqual({ onboarding: { focus: true, guided: true } });
});
it("marks a row done when its counter is reached", () => {
const { element } = render({}, { ...ZERO, sources: 1 });
const source = rowOf(element, "source");
@ -134,6 +157,8 @@ describe("OnboardingChecklist — expanded", () => {
expect(texts(budget)).toContain("onboarding.checklist.lockedBudget");
const [link] = find(budget, (e) => e.type === Link);
expect(link.props.to).toBe("/settings/users");
// The license card carries no step card: nothing to focus there.
expect(link.props.state).toBeUndefined();
expect(texts(element)).toContain("onboarding.checklist.counter[done=1,total=5]");
});
@ -149,35 +174,84 @@ describe("OnboardingChecklist — expanded", () => {
});
});
describe("OnboardingChecklist — compact", () => {
it("renders the icons on one line with the counter, without the row sentences", () => {
const { element } = render({ mode: "compact" }, { ...ZERO, sources: 1, imports: 1 });
const text = texts(element);
expect(text).toContain("onboarding.checklist.title");
expect(text).toContain("onboarding.checklist.counter[done=3,total=6]");
expect(text.some((s) => s.startsWith("onboarding.checklist.items."))).toBe(false);
expect(find(element, (e) => e.type === "li")).toHaveLength(6);
expect(find(element, (e) => e.type === CheckCircle2)).toHaveLength(3);
expect(find(element, (e) => e.type === Circle)).toHaveLength(3);
describe("OnboardingChecklist — states read out (#384, #397)", () => {
it("reads done, skipped and locked after the label, and nothing for a row still to do", () => {
const { element } = render({}, ZERO, false, ["source"]);
expect(stateTextsOf(rowOf(element, "profile"))).toEqual(["onboarding.state.done"]);
expect(stateTextsOf(rowOf(element, "source"))).toEqual(["onboarding.state.skipped"]);
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["onboarding.state.locked"]);
expect(stateTextsOf(rowOf(element, "importFolder"))).toEqual([]);
expect(stateTextsOf(rowOf(element, "import"))).toEqual([]);
});
it("labels each icon for assistive technologies and links the undone rows", () => {
const { element, checklist } = render({ mode: "compact" });
const links = find(element, (e) => e.type === Link);
const undone = checklist.items.filter((i) => !i.done);
expect(links.map((l) => l.props.to)).toEqual(undone.map((i) => i.to));
for (const link of links) {
expect(String(link.props["aria-label"])).toMatch(/^onboarding\.checklist\.items\./);
}
const [profileIcon] = find(element, (e) => e.type === "span" && e.props.role === "img");
expect(profileIcon.props["aria-label"]).toBe("onboarding.checklist.items.profile");
it("reads the budget row as pending while the entitlement is unknown", () => {
const { element } = render({ budgetReady: false }, ZERO, false);
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["onboarding.state.pending"]);
});
it("keeps the hide button and hides the counter until the entitlement is known", () => {
const { element, onHide } = render({ mode: "compact", budgetReady: false });
expect(texts(element).some((s) => s.startsWith("onboarding.checklist.counter"))).toBe(false);
const [hide] = find(element, (e) => e.type === "button");
(hide.props.onClick as () => void)();
expect(onHide).toHaveBeenCalledTimes(1);
it("puts the state text right after the label, inside the same element", () => {
const { element } = render({}, { ...ZERO, sources: 1 });
const [labelled] = find(
rowOf(element, "source"),
(e) => e.type === "span" && texts(e.props.children).includes("onboarding.checklist.items.source"),
);
expect(texts(labelled.props.children).map((s) => s.trim()).filter(Boolean)).toEqual([
"onboarding.checklist.items.source",
"onboarding.state.done",
]);
});
});
describe("OnboardingChecklist — skipped rows (#397)", () => {
it("shows the skip icon, the 'Skipped' badge and 'Resume', and counts the row", () => {
const { element } = render({}, ZERO, true, ["source"]);
const source = rowOf(element, "source");
expect(find(source, (e) => e.type === SkipForward)).toHaveLength(1);
expect(find(source, (e) => e.type === Circle)).toHaveLength(0);
const [badge] = find(
source,
(e) => e.type === "span" && texts(e.props.children).includes("onboarding.checklist.skippedBadge"),
);
// The hidden "(skipped)" text already says it: the badge is not read twice.
expect(badge.props["aria-hidden"]).toBe("true");
expect(texts(element)).toContain("onboarding.checklist.counter[done=2,total=6]");
});
it("'Resume' takes the step back, then opens its page with the step's navigation state", () => {
const { element, onResume } = render({}, ZERO, true, ["source", "manualCategory"]);
const sourceLinks = find(rowOf(element, "source"), (e) => e.type === Link);
// The label is no link: only "Resume" leads to the page, after taking the step back.
expect(sourceLinks).toHaveLength(1);
const [resume] = sourceLinks;
expect(texts(resume.props.children)).toEqual(["onboarding.checklist.resume"]);
expect(resume.props.to).toBe("/import");
expect(resume.props.state).toEqual({ onboarding: { focus: true } });
(resume.props.onClick as () => void)();
expect(onResume).toHaveBeenCalledTimes(1);
expect(onResume).toHaveBeenCalledWith("source");
const [manual] = find(rowOf(element, "manualCategory"), (e) => e.type === Link);
expect(manual.props.state).toEqual({ onboarding: { focus: true, guided: true } });
(manual.props.onClick as () => void)();
expect(onResume).toHaveBeenLastCalledWith("manualCategory");
});
it("a row done and skipped shows as done, without 'Resume'", () => {
const { element } = render({}, { ...ZERO, sources: 1 }, true, ["source"]);
const source = rowOf(element, "source");
expect(find(source, (e) => e.type === CheckCircle2)).toHaveLength(1);
expect(find(source, (e) => e.type === SkipForward)).toHaveLength(0);
expect(find(source, (e) => e.type === Link)).toHaveLength(0);
expect(texts(source)).not.toContain("onboarding.checklist.resume");
});
it("a locked row stays locked even when skipped", () => {
const { element } = render({}, ZERO, false, ["budget"]);
const budget = rowOf(element, "budget");
expect(find(budget, (e) => e.type === Lock)).toHaveLength(1);
expect(find(budget, (e) => e.type === SkipForward)).toHaveLength(0);
expect(texts(budget)).not.toContain("onboarding.checklist.resume");
const [link] = find(budget, (e) => e.type === Link);
expect(link.props.to).toBe("/settings/users");
});
});

View file

@ -1,18 +1,15 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { CheckCircle2, Circle, Lock } from "lucide-react";
import type { ChecklistItem } from "../../shared/types";
export type OnboardingChecklistMode = "expanded" | "compact";
import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react";
import { stepNavState } from "../../utils/onboardingPath";
import type { ChecklistItem, ChecklistItemKey } from "../../shared/types";
export interface OnboardingChecklistProps {
items: ChecklistItem[];
/** Number of unlocked rows that are done. */
/** Number of unlocked rows that are done or skipped. */
done: number;
/** Number of unlocked rows (6, or 5 when Budget is locked). */
total: number;
/** `expanded` = one row per line with its label; `compact` = icons on one line. */
mode: OnboardingChecklistMode;
/**
* False until the Budget entitlement is known. The budget row is then
* neutral (neither done nor locked) and the counter is hidden, because
@ -20,102 +17,68 @@ export interface OnboardingChecklistProps {
*/
budgetReady: boolean;
onHide: () => void;
/** "Resume" on a skipped row: the provider's `resume`, before its link opens the step's page. */
onResume: (key: ChecklistItemKey) => void;
}
type RowState = "done" | "todo" | "locked" | "pending";
type RowState = "done" | "skipped" | "todo" | "locked" | "pending";
/**
* A locked row stays locked whatever else it is, and a row the data says is
* done shows as done even if it was skipped too: "Resume" would bring back
* nothing to do.
*/
function rowState(item: ChecklistItem, budgetReady: boolean): RowState {
if (item.key === "budget" && !budgetReady) return "pending";
if (item.locked) return "locked";
return item.done ? "done" : "todo";
if (item.done) return "done";
return item.skipped ? "skipped" : "todo";
}
const ICON_CLASS: Record<RowState, string> = {
done: "text-[var(--positive)]",
skipped: "text-[var(--muted-foreground)]",
todo: "text-[var(--muted-foreground)]",
locked: "text-[var(--muted-foreground)]",
pending: "text-[var(--muted-foreground)] opacity-50",
};
function RowIcon({ state, size }: { state: RowState; size: number }) {
function RowIcon({ state }: { state: RowState }) {
const className = `shrink-0 ${ICON_CLASS[state]}`;
if (state === "done") return <CheckCircle2 size={size} className={className} aria-hidden="true" />;
if (state === "locked") return <Lock size={size} className={className} aria-hidden="true" />;
return <Circle size={size} className={className} aria-hidden="true" />;
if (state === "done") return <CheckCircle2 size={20} className={className} aria-hidden="true" />;
if (state === "skipped") return <SkipForward size={20} className={className} aria-hidden="true" />;
if (state === "locked") return <Lock size={20} className={className} aria-hidden="true" />;
return <Circle size={20} className={className} aria-hidden="true" />;
}
const BADGE_CLASS =
"rounded-full border border-[var(--border)] px-2 py-0.5 text-xs text-[var(--muted-foreground)]";
/**
* Getting-started checklist of the dashboard (#352). Pure: the only hook is
* Getting-started checklist (#352, #397): the welcome card of the empty
* dashboard and the list that the banner unfolds. Pure: the only hook is
* `useTranslation`, so the component is tested by calling it as a function.
*
* - A row that is not done links to its `to` route; a done row has no link.
* - A row still to do links to its `to` route with the step's navigation
* state (`stepNavState`); a done row has no link.
* - A skipped row shows a skip icon, the "Skipped" badge and "Resume", which
* takes the step back, then opens its page like the link of a row to do.
* - A locked row (Budget outside the edition) shows a lock, the "Base and up"
* label and links to the license card (`/settings/users`).
* - Compact mode keeps the title, the icons (one per row, labelled for
* assistive technologies), the counter and the hide button, no sentences.
* label and links to the license card (`/settings/users`), which carries no
* step card: no navigation state.
* - The icons are hidden from assistive technologies, so each state but "to
* do" is read out by a visually hidden text after the label (#384); the
* "Skipped" badge is hidden from them, that text already says it.
*/
export default function OnboardingChecklist({
items,
done,
total,
mode,
budgetReady,
onHide,
onResume,
}: OnboardingChecklistProps) {
const { t } = useTranslation();
const counter = budgetReady ? (
<span className="text-sm font-medium text-[var(--muted-foreground)] tabular-nums">
{t("onboarding.checklist.counter", { done, total })}
</span>
) : null;
const hideButton = (
<button
type="button"
onClick={onHide}
className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors"
>
{t("onboarding.checklist.hide")}
</button>
);
if (mode === "compact") {
return (
<div
role="region"
aria-label={t("onboarding.checklist.title")}
className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-2 mb-6"
>
<span className="text-sm font-semibold">{t("onboarding.checklist.title")}</span>
<ul className="flex items-center gap-2">
{items.map((item) => {
const state = rowState(item, budgetReady);
const label = t(`onboarding.checklist.items.${item.key}`);
const icon = <RowIcon state={state} size={18} />;
return (
<li key={item.key} className="flex">
{state === "todo" || state === "locked" ? (
<Link
to={item.to}
title={label}
aria-label={label}
className="rounded-md p-0.5 hover:bg-[var(--muted)] transition-colors"
>
{icon}
</Link>
) : (
<span title={label} aria-label={label} role="img" className="p-0.5">
{icon}
</span>
)}
</li>
);
})}
</ul>
{counter}
<span className="ml-auto">{hideButton}</span>
</div>
);
}
return (
<div
@ -125,36 +88,84 @@ export default function OnboardingChecklist({
>
<div className="flex items-center gap-3 mb-3">
<h3 className="font-semibold">{t("onboarding.checklist.title")}</h3>
{counter}
<span className="ml-auto">{hideButton}</span>
{budgetReady && (
<span className="text-sm font-medium text-[var(--muted-foreground)] tabular-nums">
{t("onboarding.checklist.counter", { done, total })}
</span>
)}
<span className="ml-auto">
<button
type="button"
onClick={onHide}
className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors"
>
{t("onboarding.checklist.hide")}
</button>
</span>
</div>
<ul className="space-y-2">
{items.map((item) => {
const state = rowState(item, budgetReady);
const label = t(`onboarding.checklist.items.${item.key}`);
const stateText =
state === "todo" ? null : (
<span className="sr-only"> {t(`onboarding.state.${state}`)}</span>
);
return (
<li key={item.key} className="flex items-center gap-3 text-sm">
<RowIcon state={state} size={20} />
<li key={item.key} className="flex flex-wrap items-center gap-3 text-sm">
<RowIcon state={state} />
{state === "todo" && (
<Link to={item.to} className="hover:underline text-[var(--foreground)]">
<Link
to={item.to}
state={stepNavState(item.key)}
className="hover:underline text-[var(--foreground)]"
>
{label}
</Link>
)}
{state === "skipped" && (
<>
<span className="text-[var(--muted-foreground)]">
{label}
{stateText}
</span>
<span aria-hidden="true" className={BADGE_CLASS}>
{t("onboarding.checklist.skippedBadge")}
</span>
<Link
to={item.to}
state={stepNavState(item.key)}
onClick={() => onResume(item.key)}
className="font-medium text-[var(--primary)] hover:underline"
>
{t("onboarding.checklist.resume")}
</Link>
</>
)}
{state === "locked" && (
<Link
to={item.to}
className="flex flex-wrap items-center gap-2 hover:underline text-[var(--foreground)]"
>
<span>{label}</span>
<span className="rounded-full border border-[var(--border)] px-2 py-0.5 text-xs text-[var(--muted-foreground)]">
{t("onboarding.checklist.lockedBudget")}
<span>
{label}
{stateText}
</span>
<span className={BADGE_CLASS}>{t("onboarding.checklist.lockedBudget")}</span>
</Link>
)}
{state === "done" && (
<span className="text-[var(--muted-foreground)] line-through">{label}</span>
<span className="text-[var(--muted-foreground)] line-through">
{label}
{stateText}
</span>
)}
{state === "pending" && (
<span className="text-[var(--muted-foreground)]">
{label}
{stateText}
</span>
)}
{state === "pending" && <span className="text-[var(--muted-foreground)]">{label}</span>}
</li>
);
})}

View file

@ -0,0 +1,194 @@
// AppShell and the getting-started banner (#397). No jsdom: react-dom/server
// renders in vitest's node environment. The path comes from a fake
// `useOnboarding()` built like the provider builds it: a loaded state, the
// real `computeChecklist`, and the provider's own pure derivation
// (`deriveOnboardingView`, with the real `CARD_PAGES`) at the rendered route.
// What is pinned is where the banner shows, and where it sits.
import { describe, it, expect, vi } from "vitest";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter, Route, Routes } from "react-router-dom";
// Importing the hook module pulls the onboarding service (→ db → tauri
// plugins) and useEntitlement (→ LicenseContext): stub both.
vi.mock("../../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
vi.mock("../../contexts/LicenseContext", () => ({ useLicenseContext: vi.fn() }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
i18n: { language: "fr" },
}),
}));
vi.mock("./Sidebar", () => ({ default: () => <nav>sidebar</nav> }));
let onboarding: OnboardingValue;
vi.mock("../../contexts/OnboardingContext", () => ({
OnboardingProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
useOnboarding: () => onboarding,
}));
import AppShell from "./AppShell";
import {
deriveOnboardingView,
initialOnboardingChecklistState,
onboardingChecklistReducer,
type OnboardingChecklistState,
type OnboardingValue,
} from "../../hooks/useOnboardingChecklist";
import { computeChecklist } from "../../utils/onboardingChecklist";
import { CARD_PAGES } from "../../utils/onboardingPath";
import type { OnboardingSnapshot } from "../../services/onboardingService";
const NEW_PROFILE: OnboardingSnapshot = {
counters: { sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 },
importFolderSet: false,
hidden: false,
skipped: [],
};
/** Folder, source and import done: the next step is the manual category, on /transactions. */
const IMPORTED: OnboardingSnapshot = {
...NEW_PROFILE,
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 },
importFolderSet: true,
};
/** Everything but the budget: the next step is on /budget. */
const BUDGET_LEFT: OnboardingSnapshot = {
...IMPORTED,
counters: { ...IMPORTED.counters, manualCategorized: 1 },
};
const ALL_DONE: OnboardingSnapshot = {
...IMPORTED,
counters: { ...IMPORTED.counters, manualCategorized: 1, budgetEntries: 1 },
};
/** The provider's state after a first successful load of `snapshot`. */
const loaded = (snapshot: OnboardingSnapshot): OnboardingChecklistState =>
[
{ type: "LOAD_START", seq: 1 } as const,
{ type: "LOAD_DONE", seq: 1, snapshot } as const,
].reduce(onboardingChecklistReducer, initialOnboardingChecklistState);
/** What `useOnboarding()` gives at `pathname` for `state`, derived as the provider does. */
function pathAt(pathname: string, state: OnboardingChecklistState, budgetReady = true): OnboardingValue {
const checklist = state.counters
? computeChecklist(state.counters, {
importFolderSet: state.importFolderSet,
budgetAllowed: true,
skipped: state.skipped,
})
: null;
const { nextStep, cardPage, mode } = deriveOnboardingView({ state, checklist, pathname, budgetReady });
return {
status: state.status,
checklist,
counters: state.counters,
hidden: state.hidden,
budgetReady,
nextStep,
mode,
cardPage,
hide: vi.fn(async () => undefined),
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
};
}
function renderWith(pathname: string, value: OnboardingValue): string {
onboarding = value;
return renderToStaticMarkup(
<MemoryRouter initialEntries={[pathname]}>
<Routes>
<Route element={<AppShell />}>
<Route path="*" element={<p data-page="">page</p>} />
</Route>
</Routes>
</MemoryRouter>,
);
}
const renderAt = (pathname: string, state: OnboardingChecklistState, budgetReady = true) =>
renderWith(pathname, pathAt(pathname, state, budgetReady));
const BANNER = 'aria-label="t(onboarding.banner.label)"';
const count = (html: string, needle: string) => html.split(needle).length - 1;
describe("AppShell — the getting-started banner (#397)", () => {
it("puts the banner on top of the routed page, inside main", () => {
const html = renderAt("/reports", loaded(IMPORTED));
expect(count(html, BANNER)).toBe(1);
const main = html.indexOf("<main");
const banner = html.indexOf(BANNER);
expect(main).toBeGreaterThan(-1);
expect(banner).toBeGreaterThan(main);
expect(html.indexOf("data-page")).toBeGreaterThan(banner);
});
it("shows nothing before the first load", () => {
const html = renderAt("/import", initialOnboardingChecklistState);
expect(html).not.toContain(BANNER);
expect(html).toContain("data-page");
});
it("shows nothing when the path is hidden", () => {
const html = renderAt("/import", loaded({ ...IMPORTED, hidden: true }));
expect(html).not.toContain(BANNER);
expect(html).toContain("data-page");
});
it("shows nothing on the dashboard of a profile without transactions (D11), but on its other pages", () => {
expect(renderAt("/", loaded(NEW_PROFILE))).not.toContain(BANNER);
expect(renderAt("/import", loaded(NEW_PROFILE))).toContain(BANNER);
expect(renderAt("/settings/users", loaded(NEW_PROFILE))).toContain(BANNER);
});
it("shows the banner on a dashboard with transactions, and on any other page", () => {
for (const pathname of ["/", "/reports", "/categories", "/settings/users"]) {
expect(renderAt(pathname, loaded(IMPORTED)), pathname).toContain(BANNER);
}
});
it.each([
["/import", NEW_PROFILE],
["/transactions", IMPORTED],
["/budget", BUDGET_LEFT],
] as const)(
"keeps the banner on %s, the page of the next step, while no step card is delivered",
(pathname, snapshot) => {
expect(CARD_PAGES.has(pathname)).toBe(false);
const value = pathAt(pathname, loaded(snapshot));
expect(value.nextStep?.to).toBe(pathname);
const html = renderWith(pathname, value);
expect(html).toContain(BANNER);
expect(html).toContain(`href="${pathname}"`);
},
);
it("shows nothing on a page that carries a step card", () => {
const value = { ...pathAt("/import", loaded(NEW_PROFILE)), mode: "card" as const, cardPage: "/import" };
expect(renderWith("/import", value)).not.toContain(BANNER);
});
it("shows the end message once every step is done (D4)", () => {
const html = renderAt("/reports", loaded(ALL_DONE));
expect(html).toContain(BANNER);
expect(html).toContain("t(onboarding.banner.complete)");
});
it("waits for the Budget entitlement before showing the counter", () => {
const waiting = renderAt("/reports", loaded(IMPORTED), false);
expect(waiting).toContain(BANNER);
expect(waiting).not.toContain("onboarding.banner.progress");
expect(renderAt("/reports", loaded(IMPORTED))).toContain(
"t(onboarding.banner.progress[done=4,total=6])",
);
});
it("starts with the list folded", () => {
const html = renderAt("/reports", loaded(IMPORTED));
expect(html).toContain('aria-expanded="false"');
expect(html).not.toContain("onboarding.checklist.title");
});
});

View file

@ -0,0 +1,146 @@
// OnboardingBannerSlot — the list's fold state and the path's wiring (#397).
//
// renderToStaticMarkup runs no click, so React's `useState` is stubbed (same
// technique as useOnboardingChecklist.wiring.test.ts): the slot runs as a
// plain function, and its state survives from one call to the next as it
// would between two renders of a mounted component.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { Outlet } from "react-router-dom";
const harness = vi.hoisted(() => ({
slots: [] as unknown[],
cursor: 0,
pathname: "/reports",
onboarding: null as unknown,
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useState: (init: unknown) => {
const index = harness.cursor++;
if (!(index in harness.slots)) {
harness.slots[index] = typeof init === "function" ? (init as () => unknown)() : init;
}
const set = (next: unknown) => {
harness.slots[index] =
typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next;
};
return [harness.slots[index], set];
},
};
});
vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()),
useLocation: () => ({ pathname: harness.pathname }),
}));
vi.mock("../../contexts/OnboardingContext", () => ({
OnboardingProvider: ({ children }: { children: ReactNode }) => children,
useOnboarding: () => harness.onboarding,
}));
vi.mock("./Sidebar", () => ({ default: () => null }));
import AppShell, { OnboardingBannerSlot } from "./AppShell";
import OnboardingBanner, { type OnboardingBannerProps } from "../onboarding/OnboardingBanner";
import { computeChecklist } from "../../utils/onboardingChecklist";
import { nextStep } from "../../utils/onboardingPath";
import type { OnboardingValue } from "../../hooks/useOnboardingChecklist";
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
if (!isValidElement(node)) return [];
const el = node as AnyElement;
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
}
function onboardingValue(): OnboardingValue {
const counters = { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 };
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
nextStep: nextStep(checklist),
mode: "banner",
cardPage: null,
hide: vi.fn(async () => undefined),
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
};
}
/** One render of the mounted slot: its hooks are read in the same order each time. */
function renderSlot(): OnboardingBannerProps {
harness.cursor = 0;
const element = OnboardingBannerSlot() as ReactElement<OnboardingBannerProps>;
expect(element.type).toBe(OnboardingBanner);
return element.props;
}
beforeEach(() => {
harness.slots = [];
harness.pathname = "/reports";
harness.onboarding = onboardingValue();
});
describe("OnboardingBannerSlot (#397)", () => {
it("hands the shared path to the banner, the list folded", () => {
const value = harness.onboarding as OnboardingValue;
const props = renderSlot();
expect(props.mode).toBe("banner");
expect(props.checklist).toBe(value.checklist);
expect(props.nextStep).toBe(value.nextStep);
expect(props.budgetReady).toBe(true);
expect(props.listOpen).toBe(false);
expect(props.onHide).toBe(value.hide);
expect(props.onResume).toBe(value.resume);
});
it("passes the mode and the entitlement as they come: the banner decides what to show", () => {
harness.onboarding = { ...onboardingValue(), mode: "none", budgetReady: false };
const props = renderSlot();
expect(props.mode).toBe("none");
expect(props.budgetReady).toBe(false);
});
it("'Show the list' unfolds it, 'Close the list' folds it back", () => {
renderSlot().onToggleList();
expect(renderSlot().listOpen).toBe(true);
// Another render without a click keeps it unfolded.
expect(renderSlot().listOpen).toBe(true);
renderSlot().onToggleList();
expect(renderSlot().listOpen).toBe(false);
});
it("AppShell mounts one slot per route, so each page starts with the list folded", () => {
harness.pathname = "/import";
const shell = AppShell() as ReactNode;
const [slot] = find(shell, (e) => e.type === OnboardingBannerSlot);
expect(slot).toBeDefined();
expect(slot.key).toBe("/import");
harness.pathname = "/transactions";
const [next] = find(AppShell() as ReactNode, (e) => e.type === OnboardingBannerSlot);
expect(next.key).toBe("/transactions");
});
it("AppShell puts the slot before the routed page, inside the provider, inside main", () => {
const shell = AppShell() as ReactNode;
const [main] = find(shell, (e) => e.type === "main");
expect(main).toBeDefined();
const [provider] = find(main.props.children, (e) => typeof e.type === "function");
const children = provider.props.children as ReactNode[];
expect(Array.isArray(children)).toBe(true);
const [first, second] = children as AnyElement[];
expect(first.type).toBe(OnboardingBannerSlot);
expect(second.type).toBe(Outlet);
});
});

View file

@ -1,14 +1,47 @@
import { Outlet } from "react-router-dom";
import { useState } from "react";
import { Outlet, useLocation } from "react-router-dom";
import Sidebar from "./Sidebar";
import { OnboardingProvider } from "../../contexts/OnboardingContext";
import OnboardingBanner from "../onboarding/OnboardingBanner";
import { OnboardingProvider, useOnboarding } from "../../contexts/OnboardingContext";
/**
* The getting-started banner (#397) on top of the routed page. It reads the
* shared path, so it sits inside the provider; the path's `mode` already keeps
* it off a hidden path, the empty dashboard (D11), a page that carries a step
* card and the first load, and the banner renders nothing then.
*
* "Show the list" unfolds the list in place. `AppShell` mounts one slot per
* route, so each page starts folded and the banner stays one line on top of
* it (D7).
*/
export function OnboardingBannerSlot() {
const onboarding = useOnboarding();
const [listOpen, setListOpen] = useState(false);
return (
<OnboardingBanner
mode={onboarding.mode}
checklist={onboarding.checklist}
nextStep={onboarding.nextStep}
budgetReady={onboarding.budgetReady}
listOpen={listOpen}
onToggleList={() => setListOpen((open) => !open)}
onHide={onboarding.hide}
onResume={onboarding.resume}
/>
);
}
export default function AppShell() {
const { pathname } = useLocation();
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto p-6">
{/* The getting-started path, shared by every page (#396). */}
{/* The getting-started path, shared by every page (#396), and its banner in the page flow (#397). */}
<OnboardingProvider>
<OnboardingBannerSlot key={pathname} />
<Outlet />
</OnboardingProvider>
</main>

View file

@ -0,0 +1,214 @@
// OnboardingBanner — render tests (#397). No jsdom: react-dom/server renders
// in vitest's node environment for the markup (region, aria-expanded, hrefs),
// and the component is called as a function for what the markup does not
// carry (a link's navigation state, the handlers).
import { describe, it, expect, vi } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { Link, MemoryRouter } from "react-router-dom";
// The fake `t` marks what it translates, `t(key[options])`: a string that
// skipped `t` would show unmarked.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
i18n: { language: "fr" },
}),
}));
import OnboardingBanner, {
GETTING_STARTED_GUIDE,
ONBOARDING_BANNER_LIST_ID,
type OnboardingBannerProps,
} from "./OnboardingBanner";
import OnboardingChecklist from "../dashboard/OnboardingChecklist";
import { computeChecklist } from "../../utils/onboardingChecklist";
import { nextStep } from "../../utils/onboardingPath";
import type { OnboardingCounters } from "../../shared/types";
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
if (!isValidElement(node)) return [];
const el = node as AnyElement;
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
}
function texts(node: ReactNode): string[] {
if (Array.isArray(node)) return node.flatMap(texts);
if (typeof node === "string") return [node];
if (!isValidElement(node)) return [];
return texts((node as AnyElement).props.children);
}
const ZERO: OnboardingCounters = {
sources: 0,
imports: 0,
manualCategorized: 0,
budgetEntries: 0,
transactions: 0,
};
/** Profile, folder and source done: 3/6, the next step is "import" on /import. */
const THREE_DONE: OnboardingCounters = { ...ZERO, sources: 1 };
/** Up to the import: the next step is "manualCategory" on /transactions. */
const FOUR_DONE: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 40 };
const ALL_DONE: OnboardingCounters = {
sources: 1,
imports: 1,
manualCategorized: 1,
budgetEntries: 1,
transactions: 40,
};
function bannerProps(
overrides: Partial<OnboardingBannerProps> = {},
counters: OnboardingCounters = THREE_DONE,
): OnboardingBannerProps {
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
return {
mode: checklist.complete ? "complete" : "banner",
checklist,
nextStep: nextStep(checklist),
budgetReady: true,
listOpen: false,
onToggleList: vi.fn(),
onHide: vi.fn(),
onResume: vi.fn(),
...overrides,
};
}
const markup = (props: OnboardingBannerProps) =>
renderToStaticMarkup(
<MemoryRouter>
<OnboardingBanner {...props} />
</MemoryRouter>,
);
const tree = (props: OnboardingBannerProps) => OnboardingBanner(props) as ReactNode;
const buttonNamed = (node: ReactNode, key: string) => {
const [button] = find(node, (e) => e.type === "button" && texts(e.props.children).includes(`t(${key})`));
expect(button, `button ${key}`).toBeDefined();
return button;
};
describe("OnboardingBanner — banner mode (#397)", () => {
it("is a region named for the path, with the counter, the next step and its link", () => {
const html = markup(bannerProps());
expect(html).toContain('role="region"');
expect(html).toContain('aria-label="t(onboarding.banner.label)"');
expect(html).toContain("t(onboarding.banner.progress[done=3,total=6])");
expect(html).toContain("t(onboarding.banner.next)");
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(onboarding\.checklist\.items\.import\)/);
expect(html).toContain("t(onboarding.checklist.hide)");
});
it("links the next step to its page with the step's navigation state", () => {
const [toImport] = find(tree(bannerProps()), (e) => e.type === Link);
expect(toImport.props.to).toBe("/import");
expect(toImport.props.state).toEqual({ onboarding: { focus: true } });
// The manual category opens Transactions in its guided entry too.
const [toTransactions] = find(tree(bannerProps({}, FOUR_DONE)), (e) => e.type === Link);
expect(toTransactions.props.to).toBe("/transactions");
expect(toTransactions.props.state).toEqual({ onboarding: { focus: true, guided: true } });
});
it("the counter waits for the Budget entitlement, the next step does not", () => {
const html = markup(bannerProps({ budgetReady: false }));
expect(html).not.toContain("onboarding.banner.progress");
expect(html).toMatch(/href="\/import"/);
});
it("folded: 'Show the list' with aria-expanded false, and no list", () => {
const html = markup(bannerProps());
expect(html).toMatch(
new RegExp(`<button [^>]*aria-expanded="false"[^>]*aria-controls="${ONBOARDING_BANNER_LIST_ID}"[^>]*>t\\(onboarding\\.banner\\.showList\\)`),
);
expect(html).toContain(`id="${ONBOARDING_BANNER_LIST_ID}"`);
expect(html).not.toContain("onboarding.checklist.title");
expect(html).not.toContain("onboarding.banner.hideList");
});
it("unfolded: 'Close the list' with aria-expanded true, and the detailed list in place", () => {
const html = markup(bannerProps({ listOpen: true }));
expect(html).toMatch(/<button [^>]*aria-expanded="true"[^>]*>t\(onboarding\.banner\.hideList\)/);
expect(html).not.toContain("onboarding.banner.showList");
const container = html.indexOf(`id="${ONBOARDING_BANNER_LIST_ID}"`);
expect(container).toBeGreaterThan(-1);
expect(html.indexOf("t(onboarding.checklist.title)")).toBeGreaterThan(container);
for (const key of ["profile", "importFolder", "source", "import", "manualCategory", "budget"]) {
expect(html).toContain(`t(onboarding.checklist.items.${key})`);
}
});
it("the unfolded list is the detailed list, wired to the path", () => {
const props = bannerProps({ listOpen: true });
const [list] = find(tree(props), (e) => e.type === OnboardingChecklist);
expect(list.props.items).toBe(props.checklist!.items);
expect(list.props.done).toBe(3);
expect(list.props.total).toBe(6);
expect(list.props.budgetReady).toBe(true);
expect(list.props.onHide).toBe(props.onHide);
expect(list.props.onResume).toBe(props.onResume);
expect(find(tree(bannerProps()), (e) => e.type === OnboardingChecklist)).toHaveLength(0);
});
it("'Show the list' toggles the list and 'Hide' hides the path", () => {
const props = bannerProps();
const node = tree(props);
(buttonNamed(node, "onboarding.banner.showList").props.onClick as () => void)();
expect(props.onToggleList).toHaveBeenCalledTimes(1);
expect(props.onHide).not.toHaveBeenCalled();
(buttonNamed(node, "onboarding.checklist.hide").props.onClick as () => void)();
expect(props.onHide).toHaveBeenCalledTimes(1);
});
it("sits in the page flow, never fixed over the page (D1)", () => {
const root = tree(bannerProps()) as AnyElement;
expect(root.props.role).toBe("region");
expect(String(root.props.className)).not.toMatch(/\b(fixed|sticky|absolute)\b/);
});
});
describe("OnboardingBanner — end of the path (D4)", () => {
it("says the path is complete, opens the guide's getting-started section and offers 'Close'", () => {
const props = bannerProps({}, ALL_DONE);
expect(props.mode).toBe("complete");
const html = markup(props);
expect(html).toContain('aria-label="t(onboarding.banner.label)"');
expect(html).toContain("t(onboarding.banner.complete)");
expect(GETTING_STARTED_GUIDE).toBe("/settings/users#gettingStarted");
expect(html).toMatch(/<a [^>]*href="\/settings\/users#gettingStarted"[^>]*>t\(onboarding\.banner\.openGuide\)/);
expect(html).toContain("t(common.close)");
expect(html).not.toContain("onboarding.banner.progress");
expect(html).not.toContain("onboarding.banner.next");
expect(html).not.toContain("aria-expanded");
expect(html).not.toContain("onboarding.checklist.hide");
});
it("'Close' hides the path", () => {
const props = bannerProps({}, ALL_DONE);
(buttonNamed(tree(props), "common.close").props.onClick as () => void)();
expect(props.onHide).toHaveBeenCalledTimes(1);
});
it("shows no list at the end, even if it was unfolded", () => {
const html = markup(bannerProps({ listOpen: true }, ALL_DONE));
expect(html).not.toContain("onboarding.checklist.title");
});
});
describe("OnboardingBanner — nothing to show", () => {
it("renders nothing in mode none, nor on a page that carries a step card", () => {
expect(markup(bannerProps({ mode: "none" }))).toBe("");
expect(markup(bannerProps({ mode: "card" }))).toBe("");
});
it("renders nothing without a checklist", () => {
expect(markup(bannerProps({ checklist: null, nextStep: null }))).toBe("");
});
});

View file

@ -0,0 +1,144 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowRight, Rocket } from "lucide-react";
import OnboardingChecklist from "../dashboard/OnboardingChecklist";
import { stepNavState, type BannerMode } from "../../utils/onboardingPath";
import type { Checklist } from "../../utils/onboardingChecklist";
import type { ChecklistItem, ChecklistItemKey } from "../../shared/types";
/** Id of the unfolded list, named by the toggle's `aria-controls`: one banner per window. */
export const ONBOARDING_BANNER_LIST_ID = "onboarding-banner-list";
/**
* "Open the guide": the getting-started section of the user guide, on the
* Users settings page. `DocsContent` gives each section `id={key}` and
* scrolls to the section named by the hash.
*/
export const GETTING_STARTED_GUIDE = "/settings/users#gettingStarted";
export interface OnboardingBannerProps {
/** What the path shows on this page (`useOnboarding().mode`): the banner renders `banner` and `complete` only. */
mode: BannerMode;
checklist: Checklist | null;
/** The next step, linked to its page with the step's navigation state. */
nextStep: ChecklistItem | null;
/** False until the Budget entitlement is known: the counter waits, its total depends on the lock. */
budgetReady: boolean;
/** The detailed list is unfolded under the banner. */
listOpen: boolean;
onToggleList: () => void;
/** "Hide", and "Close" at the end: the provider's `hide`. */
onHide: () => void;
/** "Resume" on a skipped row of the unfolded list. */
onResume: (key: ChecklistItemKey) => void;
}
const REGION_CLASS =
"rounded-xl border border-[var(--primary)]/30 bg-[var(--primary)]/5 px-4 py-3 mb-6";
const ROW_CLASS = "flex flex-wrap items-center gap-x-3 gap-y-2 text-sm";
const TEXT_BUTTON_CLASS =
"text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors";
const LINK_CLASS = "inline-flex items-center gap-1 font-medium text-[var(--primary)] hover:underline";
function BannerIcon() {
return <Rocket size={18} className="shrink-0 text-[var(--primary)]" aria-hidden="true" />;
}
/**
* Getting-started banner (#397), mounted by `AppShell` on top of every page,
* in the page flow (D1: a fixed bar could cover the focused control). Driven
* by props, so it is tested without the provider.
*
* - `banner`: "Getting started n/N · Next step: <step> →", "Show the list",
* which unfolds the detailed list in place, and "Hide". The counter waits
* for `budgetReady`, like the list's own counter.
* - `complete` (D4): the end message, "Open the guide" and "Close", which
* hides the path until it is shown again from Settings → Users.
* - Anything else (`none`, `card`) renders nothing.
*/
export default function OnboardingBanner({
mode,
checklist,
nextStep,
budgetReady,
listOpen,
onToggleList,
onHide,
onResume,
}: OnboardingBannerProps) {
const { t } = useTranslation();
if (checklist === null || (mode !== "banner" && mode !== "complete")) return null;
if (mode === "complete") {
return (
<div role="region" aria-label={t("onboarding.banner.label")} className={REGION_CLASS}>
<div className={ROW_CLASS}>
<BannerIcon />
<p className="flex-1 min-w-[12rem] text-[var(--foreground)]">
{t("onboarding.banner.complete")}
</p>
<Link to={GETTING_STARTED_GUIDE} className={LINK_CLASS}>
{t("onboarding.banner.openGuide")}
</Link>
<button type="button" onClick={onHide} className={TEXT_BUTTON_CLASS}>
{t("common.close")}
</button>
</div>
</div>
);
}
return (
<div role="region" aria-label={t("onboarding.banner.label")} className={REGION_CLASS}>
<div className={ROW_CLASS}>
<BannerIcon />
{budgetReady && (
<span className="font-semibold text-[var(--foreground)] tabular-nums">
{t("onboarding.banner.progress", { done: checklist.done, total: checklist.total })}
</span>
)}
{budgetReady && nextStep && (
<span aria-hidden="true" className="h-1 w-1 shrink-0 rounded-full bg-[var(--muted-foreground)]" />
)}
{nextStep && (
<span className="text-[var(--muted-foreground)]">
{t("onboarding.banner.next")}{" "}
<Link to={nextStep.to} state={stepNavState(nextStep.key)} className={LINK_CLASS}>
{t(`onboarding.checklist.items.${nextStep.key}`)}
<ArrowRight size={14} aria-hidden="true" />
</Link>
</span>
)}
<span className="ml-auto flex items-center gap-4">
<button
type="button"
aria-expanded={listOpen}
aria-controls={ONBOARDING_BANNER_LIST_ID}
onClick={onToggleList}
className={TEXT_BUTTON_CLASS}
>
{t(listOpen ? "onboarding.banner.hideList" : "onboarding.banner.showList")}
</button>
<button type="button" onClick={onHide} className={TEXT_BUTTON_CLASS}>
{t("onboarding.checklist.hide")}
</button>
</span>
</div>
{/* Always in the page, so `aria-controls` names an element even while folded. */}
<div id={ONBOARDING_BANNER_LIST_ID}>
{listOpen && (
<div className="mt-3">
<OnboardingChecklist
items={checklist.items}
done={checklist.done}
total={checklist.total}
budgetReady={budgetReady}
onHide={onHide}
onResume={onResume}
/>
</div>
)}
</div>
</div>
);
}

View file

@ -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": {

View file

@ -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": {

View file

@ -1,8 +1,9 @@
// DashboardPage reads the shared getting-started path (#396). No jsdom:
// react-dom/server renders in vitest's node environment. The path comes from a
// fake `useOnboarding()`, and the dashboard's data hook and widgets are stubbed
// to markers: what is pinned is the page's branching on the path, unchanged
// since #352 except that its source is now the provider.
// to markers: what is pinned is the page's branching on the path. Since #397
// the page has no list of its own once transactions exist: the banner of
// AppShell replaced the compact bar (D7).
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
@ -40,14 +41,20 @@ vi.mock("../hooks/useDashboard", () => ({
}));
// Hoisted with the mocks below, which run before the imports.
const { marker } = vi.hoisted(() => ({
marker: (name: string) => ({
default: (props: { mode?: string }) => <i data-marker={name} data-mode={props.mode} />,
}),
const { marker, emptyStateProps } = vi.hoisted(() => ({
marker: (name: string) => ({ default: () => <i data-marker={name} /> }),
emptyStateProps: { current: null as Record<string, unknown> | null },
}));
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
vi.mock("../components/dashboard/CategoriesV1DiscoveryBanner", () => marker("discovery"));
vi.mock("../components/dashboard/DashboardEmptyState", () => marker("empty-state"));
vi.mock("../components/dashboard/DashboardEmptyState", () => ({
default: (props: Record<string, unknown>) => {
emptyStateProps.current = props;
return <i data-marker="empty-state" />;
},
}));
// The page no longer imports the list; the marker would show a compact bar
// that came back.
vi.mock("../components/dashboard/OnboardingChecklist", () => marker("checklist"));
vi.mock("../components/dashboard/PeriodSelector", () => marker("period"));
vi.mock("../components/dashboard/NetWorthTile", () => marker("net-worth"));
@ -102,9 +109,10 @@ const render = () =>
beforeEach(() => {
onboarding = path();
emptyStateProps.current = null;
});
describe("DashboardPage on the shared path (#352, #396)", () => {
describe("DashboardPage on the shared path (#352, #396, #397)", () => {
it("renders only its header while the path first loads", () => {
onboarding = path({ status: "loading", checklist: null, counters: null });
const html = render();
@ -120,16 +128,25 @@ describe("DashboardPage on the shared path (#352, #396)", () => {
expect(html).not.toContain('data-marker="filters"');
});
it("shows the compact checklist above the widgets on a profile with transactions", () => {
const html = render();
expect(html).toContain('data-marker="checklist" data-mode="compact"');
expect(html).toContain('data-marker="filters"');
it("wires the welcome card's list to the path: hidden state, 'Hide' and 'Resume'", () => {
onboarding = path({ transactions: 0, hidden: true });
render();
const props = emptyStateProps.current!;
expect(props.checklist).toBe(onboarding.checklist);
expect(props.hidden).toBe(true);
expect(props.budgetReady).toBe(true);
expect(props.onHide).toBe(onboarding.hide);
expect(props.onResume).toBe(onboarding.resume);
});
it("hides the compact checklist of a hidden path (a profile already active, D9)", () => {
onboarding = path({ hidden: true, mode: "none" });
it.each([
["shown", false],
["hidden", true],
])("has no list of its own on a profile with transactions, path %s: the banner replaced the compact bar (D7)", (_, hidden) => {
onboarding = path({ hidden, mode: hidden ? "none" : "banner" });
const html = render();
expect(html).not.toContain('data-marker="checklist"');
expect(html).not.toContain('data-marker="empty-state"');
expect(html).toContain('data-marker="filters"');
});

View file

@ -10,12 +10,13 @@
//
// Onboarding (#352): the page branches on the getting-started checklist
// `status`, never on a bare counter. While it loads, only the title and the
// banner are rendered (no flash of 0 $ widgets nor of the empty state). A
// categories banner are rendered (no flash of 0 $ widgets nor of the empty state). A
// profile without any transaction gets `DashboardEmptyState` instead of the
// widgets, outside the `isLoading` veil so its link stays clickable. With
// transactions, the compact checklist sits between the title and the filter
// panel until it is complete or dismissed. A first load that fails falls back
// to the v0.15.0 page without the list; the provider logs it.
// transactions, the page shows no list of its own: the getting-started banner
// that `AppShell` puts on top of every page replaced the compact bar (#397,
// D7). A first load that fails falls back to the v0.15.0 page without the
// list; the provider logs it.
//
// Since #396 the checklist comes from `OnboardingProvider` (AppShell), shared
// by every page and re-read live, instead of a load of its own at mount.
@ -28,7 +29,6 @@ import PeriodSelector from "../components/dashboard/PeriodSelector";
import CategoryBarChart from "../components/reports/CategoryBarChart";
import CategoriesV1DiscoveryBanner from "../components/dashboard/CategoriesV1DiscoveryBanner";
import DashboardEmptyState from "../components/dashboard/DashboardEmptyState";
import OnboardingChecklist from "../components/dashboard/OnboardingChecklist";
import NetWorthTile from "../components/dashboard/NetWorthTile";
import FilterPanel from "../components/reports/FilterPanel";
import KpiCard from "../components/reports/cards/KpiCard";
@ -100,32 +100,16 @@ export default function DashboardPage() {
budgetReady={onboarding.budgetReady}
hidden={onboarding.hidden}
onHide={onboarding.hide}
onResume={onboarding.resume}
/>
</div>
);
}
// `complete` is computed with the fail-closed entitlement (Budget locked → 5/5)
// until the licence resolves; gate on `budgetReady` so a Base+ profile with the
// five other items done does not see the list vanish, then reappear as 5/6.
const showCompactChecklist =
checklistReady && !onboarding.hidden && !(onboarding.budgetReady && checklist.complete);
return (
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
{header}
{showCompactChecklist && (
<OnboardingChecklist
items={checklist.items}
done={checklist.done}
total={checklist.total}
mode="compact"
budgetReady={onboarding.budgetReady}
onHide={onboarding.hide}
/>
)}
{state.error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{state.error}

View file

@ -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<typeof import("react")>();
return {
...actual,
useState: (init: unknown) => {
const index = harness.cursor++;
if (!(index in harness.slots)) {
harness.slots[index] = typeof init === "function" ? (init as () => unknown)() : init;
}
const set = (next: unknown) => {
harness.slots[index] =
typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next;
};
return [harness.slots[index], set];
},
};
});
vi.mock("react-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<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
if (!isValidElement(node)) return [];
const el = node as AnyElement;
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
}
function texts(node: ReactNode): string[] {
if (Array.isArray(node)) return node.flatMap(texts);
if (typeof node === "string") return [node];
if (!isValidElement(node)) return [];
return texts((node as AnyElement).props.children);
}
/** 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<void>)();
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<void>)();
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<void>)();
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();
});
});

View file

@ -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<ChecklistPreference>("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() {
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.users.sections.userGuide")}
</h2>
{(checklistPref === "dismissed" || checklistPref === "restored") && (
{(hidden || restored) && (
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 text-sm">
<ListChecks size={18} className="shrink-0 text-[var(--muted-foreground)]" aria-hidden="true" />
<span className="flex-1 min-w-[12rem]">{t("settings.users.onboarding.label")}</span>
{checklistPref === "dismissed" ? (
{hidden ? (
<button
type="button"
onClick={handleShowChecklist}
onClick={handleShowPath}
className="inline-flex items-center justify-center px-3 py-1.5 rounded-md border border-[var(--border)] font-medium hover:bg-[var(--muted)] transition-colors"
>
{t("settings.users.onboarding.show")}

View file

@ -6,10 +6,16 @@ import {
latchCardPage,
nextStep,
readOnboardingNavState,
stepNavState,
stepsOfPage,
type BannerModeInput,
} from "./onboardingPath";
import type { ChecklistItem, ChecklistItemKey, OnboardingCounters } from "../shared/types";
import {
CHECKLIST_ITEM_KEYS,
type ChecklistItem,
type ChecklistItemKey,
type OnboardingCounters,
} from "../shared/types";
const zero: OnboardingCounters = {
sources: 0,
@ -308,3 +314,23 @@ describe("readOnboardingNavState (#396)", () => {
).toEqual({ focus: true, guided: false });
});
});
describe("stepNavState (#397)", () => {
it("asks for the focus on every step, and for the guided entry on the manual category only", () => {
for (const key of CHECKLIST_ITEM_KEYS) {
expect(stepNavState(key), key).toEqual(
key === "manualCategory"
? { onboarding: { focus: true, guided: true } }
: { onboarding: { focus: true } },
);
}
});
it("reads back through readOnboardingNavState", () => {
expect(readOnboardingNavState(stepNavState("manualCategory"))).toEqual({
focus: true,
guided: true,
});
expect(readOnboardingNavState(stepNavState("import"))).toEqual({ focus: true, guided: false });
});
});

View file

@ -114,6 +114,23 @@ export interface OnboardingNavState {
guided: boolean;
}
/** What a link to a step puts in `location.state`, read back by `readOnboardingNavState`. */
export interface OnboardingLinkState {
onboarding: { focus: true; guided?: true };
}
/**
* The navigation state of every link to a step's page (#397): the banner's
* next step, the rows of the list and their "Resume". The step card of the
* page takes the focus, and the "categorize by hand" step also opens
* Transactions in its guided entry, wherever its link was clicked.
*/
export function stepNavState(key: ChecklistItemKey): OnboardingLinkState {
return key === "manualCategory"
? { onboarding: { focus: true, guided: true } }
: { onboarding: { focus: true } };
}
/**
* The only way to read `location.state` (typed `any` by react-router) for the
* path (CWE-476): anything that is not `{ onboarding: { focus: true } }` or