feat(onboarding): getting-started banner on top of every page #403
20 changed files with 1313 additions and 209 deletions
|
|
@ -2,9 +2,15 @@
|
||||||
|
|
||||||
## [Non publié]
|
## [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é
|
### 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).
|
- 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é
|
### Corrigé
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,15 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### 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 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
|
### Fixed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
```
|
```
|
||||||
simpl-resultat/
|
simpl-resultat/
|
||||||
├── src/ # Frontend React/TypeScript
|
├── src/ # Frontend React/TypeScript
|
||||||
│ ├── components/ # 58 composants organisés par domaine
|
│ ├── components/ # 59 composants organisés par domaine
|
||||||
│ │ ├── adjustments/ # 3 composants
|
│ │ ├── adjustments/ # 3 composants
|
||||||
│ │ ├── balance/ # 8 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, BalanceOnboardingCard, BalanceOverviewCard, LinkTransfersModal, SnapshotEditor, SnapshotLineRow)
|
│ │ ├── balance/ # 8 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, BalanceOnboardingCard, BalanceOverviewCard, LinkTransfersModal, SnapshotEditor, SnapshotLineRow)
|
||||||
│ │ ├── budget/ # 5 composants
|
│ │ ├── budget/ # 5 composants
|
||||||
|
|
@ -34,6 +34,7 @@ simpl-resultat/
|
||||||
│ │ ├── dashboard/ # 7 composants (dont DashboardEmptyState et OnboardingChecklist)
|
│ │ ├── dashboard/ # 7 composants (dont DashboardEmptyState et OnboardingChecklist)
|
||||||
│ │ ├── import/ # 15 composants (wizard d'import, dont FormatDriftPanel, RepairPathNotice et ImportEmptyState)
|
│ │ ├── import/ # 15 composants (wizard d'import, dont FormatDriftPanel, RepairPathNotice et ImportEmptyState)
|
||||||
│ │ ├── layout/ # AppShell, Sidebar
|
│ │ ├── layout/ # AppShell, Sidebar
|
||||||
|
│ │ ├── onboarding/ # OnboardingBanner (bandeau de démarrage, #397)
|
||||||
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
||||||
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie)
|
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie)
|
||||||
│ │ ├── settings/ # 17 composants (dont LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard, DocsContent et DocsSubsections)
|
│ │ ├── 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 |
|
| 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 |
|
| 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` |
|
| 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` |
|
| 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é |
|
| 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 |
|
| 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` |
|
| 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 ».
|
**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 ».
|
||||||
|
|
||||||
|
|
|
||||||
64
src/components/dashboard/DashboardEmptyState.render.test.tsx
Normal file
64
src/components/dashboard/DashboardEmptyState.render.test.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
// DashboardEmptyState — the welcome card of a profile without transactions
|
||||||
|
// (#352). #397 leaves it as it was: the card keeps the detailed list, which
|
||||||
|
// now also takes the "Resume" handler. No jsdom: react-dom/server for the
|
||||||
|
// markup, a function call for the wiring.
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import type { ReactElement, ReactNode } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (k: string, o?: Record<string, unknown>) =>
|
||||||
|
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
|
||||||
|
i18n: { language: "fr" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import DashboardEmptyState from "./DashboardEmptyState";
|
||||||
|
import OnboardingChecklist from "./OnboardingChecklist";
|
||||||
|
import { computeChecklist } from "../../utils/onboardingChecklist";
|
||||||
|
|
||||||
|
const checklist = computeChecklist(
|
||||||
|
{ sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 },
|
||||||
|
{ importFolderSet: false, budgetAllowed: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function cardProps(hidden = false) {
|
||||||
|
return { checklist, budgetReady: true, hidden, onHide: vi.fn(), onResume: vi.fn() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const markup = (hidden = false) =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<MemoryRouter>
|
||||||
|
<DashboardEmptyState {...cardProps(hidden)} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("DashboardEmptyState (#352, #397)", () => {
|
||||||
|
it("shows the welcome card, its action towards Import and the detailed list", () => {
|
||||||
|
const html = markup();
|
||||||
|
expect(html).toContain("t(dashboard.empty.title)");
|
||||||
|
expect(html).toContain("t(dashboard.empty.description)");
|
||||||
|
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(dashboard\.empty\.cta\)/);
|
||||||
|
expect(html).toContain('aria-label="t(onboarding.checklist.title)"');
|
||||||
|
expect(html).toContain("t(onboarding.checklist.counter[done=1,total=6])");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the card and drops the list once the path is hidden", () => {
|
||||||
|
const html = markup(true);
|
||||||
|
expect(html).toContain("t(dashboard.empty.title)");
|
||||||
|
expect(html).not.toContain("onboarding.checklist.title");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands its list the path's handlers", () => {
|
||||||
|
const props = cardProps();
|
||||||
|
const card = DashboardEmptyState(props) as ReactElement<{ children?: ReactNode }>;
|
||||||
|
const list = card.props.children as ReactElement<Record<string, unknown>>;
|
||||||
|
expect(list.type).toBe(OnboardingChecklist);
|
||||||
|
expect(list.props.items).toBe(checklist.items);
|
||||||
|
expect(list.props.budgetReady).toBe(true);
|
||||||
|
expect(list.props.onHide).toBe(props.onHide);
|
||||||
|
expect(list.props.onResume).toBe(props.onResume);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,6 +3,7 @@ import { LayoutDashboard } from "lucide-react";
|
||||||
import EmptyState from "../shared/EmptyState";
|
import EmptyState from "../shared/EmptyState";
|
||||||
import OnboardingChecklist from "./OnboardingChecklist";
|
import OnboardingChecklist from "./OnboardingChecklist";
|
||||||
import type { Checklist } from "../../utils/onboardingChecklist";
|
import type { Checklist } from "../../utils/onboardingChecklist";
|
||||||
|
import type { ChecklistItemKey } from "../../shared/types";
|
||||||
|
|
||||||
interface DashboardEmptyStateProps {
|
interface DashboardEmptyStateProps {
|
||||||
checklist: Checklist;
|
checklist: Checklist;
|
||||||
|
|
@ -10,18 +11,22 @@ interface DashboardEmptyStateProps {
|
||||||
/** The user dismissed the checklist: the welcome card stays, the list goes. */
|
/** The user dismissed the checklist: the welcome card stays, the list goes. */
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
onHide: () => void;
|
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
|
* Dashboard of a profile without any transaction (#352): a welcome card with
|
||||||
* the expanded getting-started checklist and one primary action towards the
|
* the detailed getting-started checklist and one primary action towards the
|
||||||
* import page, in place of widgets showing 0 $.
|
* 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({
|
export default function DashboardEmptyState({
|
||||||
checklist,
|
checklist,
|
||||||
budgetReady,
|
budgetReady,
|
||||||
hidden,
|
hidden,
|
||||||
onHide,
|
onHide,
|
||||||
|
onResume,
|
||||||
}: DashboardEmptyStateProps) {
|
}: DashboardEmptyStateProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
|
@ -37,9 +42,9 @@ export default function DashboardEmptyState({
|
||||||
items={checklist.items}
|
items={checklist.items}
|
||||||
done={checklist.done}
|
done={checklist.done}
|
||||||
total={checklist.total}
|
total={checklist.total}
|
||||||
mode="expanded"
|
|
||||||
budgetReady={budgetReady}
|
budgetReady={budgetReady}
|
||||||
onHide={onHide}
|
onHide={onHide}
|
||||||
|
onResume={onResume}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// OnboardingChecklist — unit tests (#352).
|
// OnboardingChecklist — unit tests (#352, #397).
|
||||||
//
|
//
|
||||||
// FilterPanel.test.tsx pattern: no jsdom, `react-i18next` and `lucide-react`
|
// FilterPanel.test.tsx pattern: no jsdom, `react-i18next` and `lucide-react`
|
||||||
// mocked, the component called as a function and its element tree walked.
|
// mocked, the component called as a function and its element tree walked.
|
||||||
|
|
@ -20,12 +20,13 @@ vi.mock("lucide-react", () => ({
|
||||||
CheckCircle2: () => null,
|
CheckCircle2: () => null,
|
||||||
Circle: () => null,
|
Circle: () => null,
|
||||||
Lock: () => 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 OnboardingChecklist, { type OnboardingChecklistProps } from "./OnboardingChecklist";
|
||||||
import { computeChecklist } from "../../utils/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 }>;
|
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.
|
// 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. */
|
/** Resolves the inner `RowIcon` component so the icon elements are visible. */
|
||||||
function expand(node: ReactNode): ReactNode {
|
function expand(node: ReactNode): ReactNode {
|
||||||
|
|
@ -67,19 +68,25 @@ const ZERO: OnboardingCounters = {
|
||||||
transactions: 0,
|
transactions: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
function render(overrides: Partial<OnboardingChecklistProps> = {}, counters = ZERO, budgetAllowed = true) {
|
function render(
|
||||||
const checklist = computeChecklist(counters, { importFolderSet: false, budgetAllowed });
|
overrides: Partial<OnboardingChecklistProps> = {},
|
||||||
|
counters = ZERO,
|
||||||
|
budgetAllowed = true,
|
||||||
|
skipped: ChecklistItemKey[] = [],
|
||||||
|
) {
|
||||||
|
const checklist = computeChecklist(counters, { importFolderSet: false, budgetAllowed, skipped });
|
||||||
const onHide = vi.fn();
|
const onHide = vi.fn();
|
||||||
|
const onResume = vi.fn();
|
||||||
const props: OnboardingChecklistProps = {
|
const props: OnboardingChecklistProps = {
|
||||||
items: checklist.items,
|
items: checklist.items,
|
||||||
done: checklist.done,
|
done: checklist.done,
|
||||||
total: checklist.total,
|
total: checklist.total,
|
||||||
mode: "expanded",
|
|
||||||
budgetReady: true,
|
budgetReady: true,
|
||||||
onHide,
|
onHide,
|
||||||
|
onResume,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
return { element: expand(OnboardingChecklist(props)), onHide, checklist };
|
return { element: expand(OnboardingChecklist(props)), onHide, onResume, checklist };
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowOf(element: ReactNode, key: string): AnyElement {
|
function rowOf(element: ReactNode, key: string): AnyElement {
|
||||||
|
|
@ -88,7 +95,14 @@ function rowOf(element: ReactNode, key: string): AnyElement {
|
||||||
return row;
|
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", () => {
|
it("renders the title, the counter 1 / 6 and the hide button", () => {
|
||||||
const { element, onHide } = render();
|
const { element, onHide } = render();
|
||||||
const text = texts(element);
|
const text = texts(element);
|
||||||
|
|
@ -120,6 +134,15 @@ describe("OnboardingChecklist — expanded", () => {
|
||||||
expect(find(profile, (e) => e.type === CheckCircle2)).toHaveLength(1);
|
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", () => {
|
it("marks a row done when its counter is reached", () => {
|
||||||
const { element } = render({}, { ...ZERO, sources: 1 });
|
const { element } = render({}, { ...ZERO, sources: 1 });
|
||||||
const source = rowOf(element, "source");
|
const source = rowOf(element, "source");
|
||||||
|
|
@ -134,6 +157,8 @@ describe("OnboardingChecklist — expanded", () => {
|
||||||
expect(texts(budget)).toContain("onboarding.checklist.lockedBudget");
|
expect(texts(budget)).toContain("onboarding.checklist.lockedBudget");
|
||||||
const [link] = find(budget, (e) => e.type === Link);
|
const [link] = find(budget, (e) => e.type === Link);
|
||||||
expect(link.props.to).toBe("/settings/users");
|
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]");
|
expect(texts(element)).toContain("onboarding.checklist.counter[done=1,total=5]");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -149,35 +174,84 @@ describe("OnboardingChecklist — expanded", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("OnboardingChecklist — compact", () => {
|
describe("OnboardingChecklist — states read out (#384, #397)", () => {
|
||||||
it("renders the icons on one line with the counter, without the row sentences", () => {
|
it("reads done, skipped and locked after the label, and nothing for a row still to do", () => {
|
||||||
const { element } = render({ mode: "compact" }, { ...ZERO, sources: 1, imports: 1 });
|
const { element } = render({}, ZERO, false, ["source"]);
|
||||||
const text = texts(element);
|
expect(stateTextsOf(rowOf(element, "profile"))).toEqual(["onboarding.state.done"]);
|
||||||
expect(text).toContain("onboarding.checklist.title");
|
expect(stateTextsOf(rowOf(element, "source"))).toEqual(["onboarding.state.skipped"]);
|
||||||
expect(text).toContain("onboarding.checklist.counter[done=3,total=6]");
|
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["onboarding.state.locked"]);
|
||||||
expect(text.some((s) => s.startsWith("onboarding.checklist.items."))).toBe(false);
|
expect(stateTextsOf(rowOf(element, "importFolder"))).toEqual([]);
|
||||||
expect(find(element, (e) => e.type === "li")).toHaveLength(6);
|
expect(stateTextsOf(rowOf(element, "import"))).toEqual([]);
|
||||||
expect(find(element, (e) => e.type === CheckCircle2)).toHaveLength(3);
|
|
||||||
expect(find(element, (e) => e.type === Circle)).toHaveLength(3);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("labels each icon for assistive technologies and links the undone rows", () => {
|
it("reads the budget row as pending while the entitlement is unknown", () => {
|
||||||
const { element, checklist } = render({ mode: "compact" });
|
const { element } = render({ budgetReady: false }, ZERO, false);
|
||||||
const links = find(element, (e) => e.type === Link);
|
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["onboarding.state.pending"]);
|
||||||
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("keeps the hide button and hides the counter until the entitlement is known", () => {
|
it("puts the state text right after the label, inside the same element", () => {
|
||||||
const { element, onHide } = render({ mode: "compact", budgetReady: false });
|
const { element } = render({}, { ...ZERO, sources: 1 });
|
||||||
expect(texts(element).some((s) => s.startsWith("onboarding.checklist.counter"))).toBe(false);
|
const [labelled] = find(
|
||||||
const [hide] = find(element, (e) => e.type === "button");
|
rowOf(element, "source"),
|
||||||
(hide.props.onClick as () => void)();
|
(e) => e.type === "span" && texts(e.props.children).includes("onboarding.checklist.items.source"),
|
||||||
expect(onHide).toHaveBeenCalledTimes(1);
|
);
|
||||||
|
expect(texts(labelled.props.children).map((s) => s.trim()).filter(Boolean)).toEqual([
|
||||||
|
"onboarding.checklist.items.source",
|
||||||
|
"onboarding.state.done",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OnboardingChecklist — skipped rows (#397)", () => {
|
||||||
|
it("shows the skip icon, the 'Skipped' badge and 'Resume', and counts the row", () => {
|
||||||
|
const { element } = render({}, ZERO, true, ["source"]);
|
||||||
|
const source = rowOf(element, "source");
|
||||||
|
expect(find(source, (e) => e.type === SkipForward)).toHaveLength(1);
|
||||||
|
expect(find(source, (e) => e.type === Circle)).toHaveLength(0);
|
||||||
|
const [badge] = find(
|
||||||
|
source,
|
||||||
|
(e) => e.type === "span" && texts(e.props.children).includes("onboarding.checklist.skippedBadge"),
|
||||||
|
);
|
||||||
|
// The hidden "(skipped)" text already says it: the badge is not read twice.
|
||||||
|
expect(badge.props["aria-hidden"]).toBe("true");
|
||||||
|
expect(texts(element)).toContain("onboarding.checklist.counter[done=2,total=6]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'Resume' takes the step back, then opens its page with the step's navigation state", () => {
|
||||||
|
const { element, onResume } = render({}, ZERO, true, ["source", "manualCategory"]);
|
||||||
|
const sourceLinks = find(rowOf(element, "source"), (e) => e.type === Link);
|
||||||
|
// The label is no link: only "Resume" leads to the page, after taking the step back.
|
||||||
|
expect(sourceLinks).toHaveLength(1);
|
||||||
|
const [resume] = sourceLinks;
|
||||||
|
expect(texts(resume.props.children)).toEqual(["onboarding.checklist.resume"]);
|
||||||
|
expect(resume.props.to).toBe("/import");
|
||||||
|
expect(resume.props.state).toEqual({ onboarding: { focus: true } });
|
||||||
|
(resume.props.onClick as () => void)();
|
||||||
|
expect(onResume).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onResume).toHaveBeenCalledWith("source");
|
||||||
|
|
||||||
|
const [manual] = find(rowOf(element, "manualCategory"), (e) => e.type === Link);
|
||||||
|
expect(manual.props.state).toEqual({ onboarding: { focus: true, guided: true } });
|
||||||
|
(manual.props.onClick as () => void)();
|
||||||
|
expect(onResume).toHaveBeenLastCalledWith("manualCategory");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a row done and skipped shows as done, without 'Resume'", () => {
|
||||||
|
const { element } = render({}, { ...ZERO, sources: 1 }, true, ["source"]);
|
||||||
|
const source = rowOf(element, "source");
|
||||||
|
expect(find(source, (e) => e.type === CheckCircle2)).toHaveLength(1);
|
||||||
|
expect(find(source, (e) => e.type === SkipForward)).toHaveLength(0);
|
||||||
|
expect(find(source, (e) => e.type === Link)).toHaveLength(0);
|
||||||
|
expect(texts(source)).not.toContain("onboarding.checklist.resume");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a locked row stays locked even when skipped", () => {
|
||||||
|
const { element } = render({}, ZERO, false, ["budget"]);
|
||||||
|
const budget = rowOf(element, "budget");
|
||||||
|
expect(find(budget, (e) => e.type === Lock)).toHaveLength(1);
|
||||||
|
expect(find(budget, (e) => e.type === SkipForward)).toHaveLength(0);
|
||||||
|
expect(texts(budget)).not.toContain("onboarding.checklist.resume");
|
||||||
|
const [link] = find(budget, (e) => e.type === Link);
|
||||||
|
expect(link.props.to).toBe("/settings/users");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,15 @@
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { CheckCircle2, Circle, Lock } from "lucide-react";
|
import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react";
|
||||||
import type { ChecklistItem } from "../../shared/types";
|
import { stepNavState } from "../../utils/onboardingPath";
|
||||||
|
import type { ChecklistItem, ChecklistItemKey } from "../../shared/types";
|
||||||
export type OnboardingChecklistMode = "expanded" | "compact";
|
|
||||||
|
|
||||||
export interface OnboardingChecklistProps {
|
export interface OnboardingChecklistProps {
|
||||||
items: ChecklistItem[];
|
items: ChecklistItem[];
|
||||||
/** Number of unlocked rows that are done. */
|
/** Number of unlocked rows that are done or skipped. */
|
||||||
done: number;
|
done: number;
|
||||||
/** Number of unlocked rows (6, or 5 when Budget is locked). */
|
/** Number of unlocked rows (6, or 5 when Budget is locked). */
|
||||||
total: number;
|
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
|
* False until the Budget entitlement is known. The budget row is then
|
||||||
* neutral (neither done nor locked) and the counter is hidden, because
|
* neutral (neither done nor locked) and the counter is hidden, because
|
||||||
|
|
@ -20,102 +17,68 @@ export interface OnboardingChecklistProps {
|
||||||
*/
|
*/
|
||||||
budgetReady: boolean;
|
budgetReady: boolean;
|
||||||
onHide: () => void;
|
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 {
|
function rowState(item: ChecklistItem, budgetReady: boolean): RowState {
|
||||||
if (item.key === "budget" && !budgetReady) return "pending";
|
if (item.key === "budget" && !budgetReady) return "pending";
|
||||||
if (item.locked) return "locked";
|
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> = {
|
const ICON_CLASS: Record<RowState, string> = {
|
||||||
done: "text-[var(--positive)]",
|
done: "text-[var(--positive)]",
|
||||||
|
skipped: "text-[var(--muted-foreground)]",
|
||||||
todo: "text-[var(--muted-foreground)]",
|
todo: "text-[var(--muted-foreground)]",
|
||||||
locked: "text-[var(--muted-foreground)]",
|
locked: "text-[var(--muted-foreground)]",
|
||||||
pending: "text-[var(--muted-foreground)] opacity-50",
|
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]}`;
|
const className = `shrink-0 ${ICON_CLASS[state]}`;
|
||||||
if (state === "done") return <CheckCircle2 size={size} className={className} aria-hidden="true" />;
|
if (state === "done") return <CheckCircle2 size={20} className={className} aria-hidden="true" />;
|
||||||
if (state === "locked") return <Lock size={size} className={className} aria-hidden="true" />;
|
if (state === "skipped") return <SkipForward size={20} className={className} aria-hidden="true" />;
|
||||||
return <Circle size={size} 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.
|
* `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"
|
* - A locked row (Budget outside the edition) shows a lock, the "Base and up"
|
||||||
* label and links to the license card (`/settings/users`).
|
* label and links to the license card (`/settings/users`), which carries no
|
||||||
* - Compact mode keeps the title, the icons (one per row, labelled for
|
* step card: no navigation state.
|
||||||
* assistive technologies), the counter and the hide button, no sentences.
|
* - 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({
|
export default function OnboardingChecklist({
|
||||||
items,
|
items,
|
||||||
done,
|
done,
|
||||||
total,
|
total,
|
||||||
mode,
|
|
||||||
budgetReady,
|
budgetReady,
|
||||||
onHide,
|
onHide,
|
||||||
|
onResume,
|
||||||
}: OnboardingChecklistProps) {
|
}: OnboardingChecklistProps) {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -125,36 +88,84 @@ export default function OnboardingChecklist({
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<h3 className="font-semibold">{t("onboarding.checklist.title")}</h3>
|
<h3 className="font-semibold">{t("onboarding.checklist.title")}</h3>
|
||||||
{counter}
|
{budgetReady && (
|
||||||
<span className="ml-auto">{hideButton}</span>
|
<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>
|
</div>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const state = rowState(item, budgetReady);
|
const state = rowState(item, budgetReady);
|
||||||
const label = t(`onboarding.checklist.items.${item.key}`);
|
const label = t(`onboarding.checklist.items.${item.key}`);
|
||||||
|
const stateText =
|
||||||
|
state === "todo" ? null : (
|
||||||
|
<span className="sr-only"> {t(`onboarding.state.${state}`)}</span>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<li key={item.key} className="flex items-center gap-3 text-sm">
|
<li key={item.key} className="flex flex-wrap items-center gap-3 text-sm">
|
||||||
<RowIcon state={state} size={20} />
|
<RowIcon state={state} />
|
||||||
{state === "todo" && (
|
{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}
|
{label}
|
||||||
</Link>
|
</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" && (
|
{state === "locked" && (
|
||||||
<Link
|
<Link
|
||||||
to={item.to}
|
to={item.to}
|
||||||
className="flex flex-wrap items-center gap-2 hover:underline text-[var(--foreground)]"
|
className="flex flex-wrap items-center gap-2 hover:underline text-[var(--foreground)]"
|
||||||
>
|
>
|
||||||
<span>{label}</span>
|
<span>
|
||||||
<span className="rounded-full border border-[var(--border)] px-2 py-0.5 text-xs text-[var(--muted-foreground)]">
|
{label}
|
||||||
{t("onboarding.checklist.lockedBudget")}
|
{stateText}
|
||||||
</span>
|
</span>
|
||||||
|
<span className={BADGE_CLASS}>{t("onboarding.checklist.lockedBudget")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{state === "done" && (
|
{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>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
194
src/components/layout/AppShell.render.test.tsx
Normal file
194
src/components/layout/AppShell.render.test.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
// AppShell and the getting-started banner (#397). No jsdom: react-dom/server
|
||||||
|
// renders in vitest's node environment. The path comes from a fake
|
||||||
|
// `useOnboarding()` built like the provider builds it: a loaded state, the
|
||||||
|
// real `computeChecklist`, and the provider's own pure derivation
|
||||||
|
// (`deriveOnboardingView`, with the real `CARD_PAGES`) at the rendered route.
|
||||||
|
// What is pinned is where the banner shows, and where it sits.
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
|
// Importing the hook module pulls the onboarding service (→ db → tauri
|
||||||
|
// plugins) and useEntitlement (→ LicenseContext): stub both.
|
||||||
|
vi.mock("../../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
||||||
|
vi.mock("../../contexts/LicenseContext", () => ({ useLicenseContext: vi.fn() }));
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (k: string, o?: Record<string, unknown>) =>
|
||||||
|
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
|
||||||
|
i18n: { language: "fr" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("./Sidebar", () => ({ default: () => <nav>sidebar</nav> }));
|
||||||
|
|
||||||
|
let onboarding: OnboardingValue;
|
||||||
|
vi.mock("../../contexts/OnboardingContext", () => ({
|
||||||
|
OnboardingProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
useOnboarding: () => onboarding,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AppShell from "./AppShell";
|
||||||
|
import {
|
||||||
|
deriveOnboardingView,
|
||||||
|
initialOnboardingChecklistState,
|
||||||
|
onboardingChecklistReducer,
|
||||||
|
type OnboardingChecklistState,
|
||||||
|
type OnboardingValue,
|
||||||
|
} from "../../hooks/useOnboardingChecklist";
|
||||||
|
import { computeChecklist } from "../../utils/onboardingChecklist";
|
||||||
|
import { CARD_PAGES } from "../../utils/onboardingPath";
|
||||||
|
import type { OnboardingSnapshot } from "../../services/onboardingService";
|
||||||
|
|
||||||
|
const NEW_PROFILE: OnboardingSnapshot = {
|
||||||
|
counters: { sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 },
|
||||||
|
importFolderSet: false,
|
||||||
|
hidden: false,
|
||||||
|
skipped: [],
|
||||||
|
};
|
||||||
|
/** Folder, source and import done: the next step is the manual category, on /transactions. */
|
||||||
|
const IMPORTED: OnboardingSnapshot = {
|
||||||
|
...NEW_PROFILE,
|
||||||
|
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 },
|
||||||
|
importFolderSet: true,
|
||||||
|
};
|
||||||
|
/** Everything but the budget: the next step is on /budget. */
|
||||||
|
const BUDGET_LEFT: OnboardingSnapshot = {
|
||||||
|
...IMPORTED,
|
||||||
|
counters: { ...IMPORTED.counters, manualCategorized: 1 },
|
||||||
|
};
|
||||||
|
const ALL_DONE: OnboardingSnapshot = {
|
||||||
|
...IMPORTED,
|
||||||
|
counters: { ...IMPORTED.counters, manualCategorized: 1, budgetEntries: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The provider's state after a first successful load of `snapshot`. */
|
||||||
|
const loaded = (snapshot: OnboardingSnapshot): OnboardingChecklistState =>
|
||||||
|
[
|
||||||
|
{ type: "LOAD_START", seq: 1 } as const,
|
||||||
|
{ type: "LOAD_DONE", seq: 1, snapshot } as const,
|
||||||
|
].reduce(onboardingChecklistReducer, initialOnboardingChecklistState);
|
||||||
|
|
||||||
|
/** What `useOnboarding()` gives at `pathname` for `state`, derived as the provider does. */
|
||||||
|
function pathAt(pathname: string, state: OnboardingChecklistState, budgetReady = true): OnboardingValue {
|
||||||
|
const checklist = state.counters
|
||||||
|
? computeChecklist(state.counters, {
|
||||||
|
importFolderSet: state.importFolderSet,
|
||||||
|
budgetAllowed: true,
|
||||||
|
skipped: state.skipped,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const { nextStep, cardPage, mode } = deriveOnboardingView({ state, checklist, pathname, budgetReady });
|
||||||
|
return {
|
||||||
|
status: state.status,
|
||||||
|
checklist,
|
||||||
|
counters: state.counters,
|
||||||
|
hidden: state.hidden,
|
||||||
|
budgetReady,
|
||||||
|
nextStep,
|
||||||
|
mode,
|
||||||
|
cardPage,
|
||||||
|
hide: vi.fn(async () => undefined),
|
||||||
|
show: vi.fn(async () => undefined),
|
||||||
|
skip: vi.fn(async () => undefined),
|
||||||
|
resume: vi.fn(async () => undefined),
|
||||||
|
refresh: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWith(pathname: string, value: OnboardingValue): string {
|
||||||
|
onboarding = value;
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
<MemoryRouter initialEntries={[pathname]}>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<AppShell />}>
|
||||||
|
<Route path="*" element={<p data-page="">page</p>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderAt = (pathname: string, state: OnboardingChecklistState, budgetReady = true) =>
|
||||||
|
renderWith(pathname, pathAt(pathname, state, budgetReady));
|
||||||
|
|
||||||
|
const BANNER = 'aria-label="t(onboarding.banner.label)"';
|
||||||
|
const count = (html: string, needle: string) => html.split(needle).length - 1;
|
||||||
|
|
||||||
|
describe("AppShell — the getting-started banner (#397)", () => {
|
||||||
|
it("puts the banner on top of the routed page, inside main", () => {
|
||||||
|
const html = renderAt("/reports", loaded(IMPORTED));
|
||||||
|
expect(count(html, BANNER)).toBe(1);
|
||||||
|
const main = html.indexOf("<main");
|
||||||
|
const banner = html.indexOf(BANNER);
|
||||||
|
expect(main).toBeGreaterThan(-1);
|
||||||
|
expect(banner).toBeGreaterThan(main);
|
||||||
|
expect(html.indexOf("data-page")).toBeGreaterThan(banner);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows nothing before the first load", () => {
|
||||||
|
const html = renderAt("/import", initialOnboardingChecklistState);
|
||||||
|
expect(html).not.toContain(BANNER);
|
||||||
|
expect(html).toContain("data-page");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows nothing when the path is hidden", () => {
|
||||||
|
const html = renderAt("/import", loaded({ ...IMPORTED, hidden: true }));
|
||||||
|
expect(html).not.toContain(BANNER);
|
||||||
|
expect(html).toContain("data-page");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows nothing on the dashboard of a profile without transactions (D11), but on its other pages", () => {
|
||||||
|
expect(renderAt("/", loaded(NEW_PROFILE))).not.toContain(BANNER);
|
||||||
|
expect(renderAt("/import", loaded(NEW_PROFILE))).toContain(BANNER);
|
||||||
|
expect(renderAt("/settings/users", loaded(NEW_PROFILE))).toContain(BANNER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the banner on a dashboard with transactions, and on any other page", () => {
|
||||||
|
for (const pathname of ["/", "/reports", "/categories", "/settings/users"]) {
|
||||||
|
expect(renderAt(pathname, loaded(IMPORTED)), pathname).toContain(BANNER);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["/import", NEW_PROFILE],
|
||||||
|
["/transactions", IMPORTED],
|
||||||
|
["/budget", BUDGET_LEFT],
|
||||||
|
] as const)(
|
||||||
|
"keeps the banner on %s, the page of the next step, while no step card is delivered",
|
||||||
|
(pathname, snapshot) => {
|
||||||
|
expect(CARD_PAGES.has(pathname)).toBe(false);
|
||||||
|
const value = pathAt(pathname, loaded(snapshot));
|
||||||
|
expect(value.nextStep?.to).toBe(pathname);
|
||||||
|
const html = renderWith(pathname, value);
|
||||||
|
expect(html).toContain(BANNER);
|
||||||
|
expect(html).toContain(`href="${pathname}"`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("shows nothing on a page that carries a step card", () => {
|
||||||
|
const value = { ...pathAt("/import", loaded(NEW_PROFILE)), mode: "card" as const, cardPage: "/import" };
|
||||||
|
expect(renderWith("/import", value)).not.toContain(BANNER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the end message once every step is done (D4)", () => {
|
||||||
|
const html = renderAt("/reports", loaded(ALL_DONE));
|
||||||
|
expect(html).toContain(BANNER);
|
||||||
|
expect(html).toContain("t(onboarding.banner.complete)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for the Budget entitlement before showing the counter", () => {
|
||||||
|
const waiting = renderAt("/reports", loaded(IMPORTED), false);
|
||||||
|
expect(waiting).toContain(BANNER);
|
||||||
|
expect(waiting).not.toContain("onboarding.banner.progress");
|
||||||
|
expect(renderAt("/reports", loaded(IMPORTED))).toContain(
|
||||||
|
"t(onboarding.banner.progress[done=4,total=6])",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts with the list folded", () => {
|
||||||
|
const html = renderAt("/reports", loaded(IMPORTED));
|
||||||
|
expect(html).toContain('aria-expanded="false"');
|
||||||
|
expect(html).not.toContain("onboarding.checklist.title");
|
||||||
|
});
|
||||||
|
});
|
||||||
146
src/components/layout/AppShell.slot.test.tsx
Normal file
146
src/components/layout/AppShell.slot.test.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
// OnboardingBannerSlot — the list's fold state and the path's wiring (#397).
|
||||||
|
//
|
||||||
|
// renderToStaticMarkup runs no click, so React's `useState` is stubbed (same
|
||||||
|
// technique as useOnboardingChecklist.wiring.test.ts): the slot runs as a
|
||||||
|
// plain function, and its state survives from one call to the next as it
|
||||||
|
// would between two renders of a mounted component.
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
const harness = vi.hoisted(() => ({
|
||||||
|
slots: [] as unknown[],
|
||||||
|
cursor: 0,
|
||||||
|
pathname: "/reports",
|
||||||
|
onboarding: null as unknown,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useState: (init: unknown) => {
|
||||||
|
const index = harness.cursor++;
|
||||||
|
if (!(index in harness.slots)) {
|
||||||
|
harness.slots[index] = typeof init === "function" ? (init as () => unknown)() : init;
|
||||||
|
}
|
||||||
|
const set = (next: unknown) => {
|
||||||
|
harness.slots[index] =
|
||||||
|
typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next;
|
||||||
|
};
|
||||||
|
return [harness.slots[index], set];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("react-router-dom", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-router-dom")>()),
|
||||||
|
useLocation: () => ({ pathname: harness.pathname }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../contexts/OnboardingContext", () => ({
|
||||||
|
OnboardingProvider: ({ children }: { children: ReactNode }) => children,
|
||||||
|
useOnboarding: () => harness.onboarding,
|
||||||
|
}));
|
||||||
|
vi.mock("./Sidebar", () => ({ default: () => null }));
|
||||||
|
|
||||||
|
import AppShell, { OnboardingBannerSlot } from "./AppShell";
|
||||||
|
import OnboardingBanner, { type OnboardingBannerProps } from "../onboarding/OnboardingBanner";
|
||||||
|
import { computeChecklist } from "../../utils/onboardingChecklist";
|
||||||
|
import { nextStep } from "../../utils/onboardingPath";
|
||||||
|
import type { OnboardingValue } from "../../hooks/useOnboardingChecklist";
|
||||||
|
|
||||||
|
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
|
||||||
|
|
||||||
|
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
|
||||||
|
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
|
||||||
|
if (!isValidElement(node)) return [];
|
||||||
|
const el = node as AnyElement;
|
||||||
|
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function onboardingValue(): OnboardingValue {
|
||||||
|
const counters = { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 };
|
||||||
|
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
|
||||||
|
return {
|
||||||
|
status: "ready",
|
||||||
|
checklist,
|
||||||
|
counters,
|
||||||
|
hidden: false,
|
||||||
|
budgetReady: true,
|
||||||
|
nextStep: nextStep(checklist),
|
||||||
|
mode: "banner",
|
||||||
|
cardPage: null,
|
||||||
|
hide: vi.fn(async () => undefined),
|
||||||
|
show: vi.fn(async () => undefined),
|
||||||
|
skip: vi.fn(async () => undefined),
|
||||||
|
resume: vi.fn(async () => undefined),
|
||||||
|
refresh: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One render of the mounted slot: its hooks are read in the same order each time. */
|
||||||
|
function renderSlot(): OnboardingBannerProps {
|
||||||
|
harness.cursor = 0;
|
||||||
|
const element = OnboardingBannerSlot() as ReactElement<OnboardingBannerProps>;
|
||||||
|
expect(element.type).toBe(OnboardingBanner);
|
||||||
|
return element.props;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
harness.slots = [];
|
||||||
|
harness.pathname = "/reports";
|
||||||
|
harness.onboarding = onboardingValue();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OnboardingBannerSlot (#397)", () => {
|
||||||
|
it("hands the shared path to the banner, the list folded", () => {
|
||||||
|
const value = harness.onboarding as OnboardingValue;
|
||||||
|
const props = renderSlot();
|
||||||
|
expect(props.mode).toBe("banner");
|
||||||
|
expect(props.checklist).toBe(value.checklist);
|
||||||
|
expect(props.nextStep).toBe(value.nextStep);
|
||||||
|
expect(props.budgetReady).toBe(true);
|
||||||
|
expect(props.listOpen).toBe(false);
|
||||||
|
expect(props.onHide).toBe(value.hide);
|
||||||
|
expect(props.onResume).toBe(value.resume);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the mode and the entitlement as they come: the banner decides what to show", () => {
|
||||||
|
harness.onboarding = { ...onboardingValue(), mode: "none", budgetReady: false };
|
||||||
|
const props = renderSlot();
|
||||||
|
expect(props.mode).toBe("none");
|
||||||
|
expect(props.budgetReady).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'Show the list' unfolds it, 'Close the list' folds it back", () => {
|
||||||
|
renderSlot().onToggleList();
|
||||||
|
expect(renderSlot().listOpen).toBe(true);
|
||||||
|
// Another render without a click keeps it unfolded.
|
||||||
|
expect(renderSlot().listOpen).toBe(true);
|
||||||
|
renderSlot().onToggleList();
|
||||||
|
expect(renderSlot().listOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AppShell mounts one slot per route, so each page starts with the list folded", () => {
|
||||||
|
harness.pathname = "/import";
|
||||||
|
const shell = AppShell() as ReactNode;
|
||||||
|
const [slot] = find(shell, (e) => e.type === OnboardingBannerSlot);
|
||||||
|
expect(slot).toBeDefined();
|
||||||
|
expect(slot.key).toBe("/import");
|
||||||
|
|
||||||
|
harness.pathname = "/transactions";
|
||||||
|
const [next] = find(AppShell() as ReactNode, (e) => e.type === OnboardingBannerSlot);
|
||||||
|
expect(next.key).toBe("/transactions");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AppShell puts the slot before the routed page, inside the provider, inside main", () => {
|
||||||
|
const shell = AppShell() as ReactNode;
|
||||||
|
const [main] = find(shell, (e) => e.type === "main");
|
||||||
|
expect(main).toBeDefined();
|
||||||
|
const [provider] = find(main.props.children, (e) => typeof e.type === "function");
|
||||||
|
const children = provider.props.children as ReactNode[];
|
||||||
|
expect(Array.isArray(children)).toBe(true);
|
||||||
|
const [first, second] = children as AnyElement[];
|
||||||
|
expect(first.type).toBe(OnboardingBannerSlot);
|
||||||
|
expect(second.type).toBe(Outlet);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,14 +1,47 @@
|
||||||
import { Outlet } from "react-router-dom";
|
import { useState } from "react";
|
||||||
|
import { Outlet, useLocation } from "react-router-dom";
|
||||||
import Sidebar from "./Sidebar";
|
import 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() {
|
export default function AppShell() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden">
|
<div className="flex h-screen overflow-hidden">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
<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>
|
<OnboardingProvider>
|
||||||
|
<OnboardingBannerSlot key={pathname} />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</OnboardingProvider>
|
</OnboardingProvider>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
214
src/components/onboarding/OnboardingBanner.render.test.tsx
Normal file
214
src/components/onboarding/OnboardingBanner.render.test.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
// OnboardingBanner — render tests (#397). No jsdom: react-dom/server renders
|
||||||
|
// in vitest's node environment for the markup (region, aria-expanded, hrefs),
|
||||||
|
// and the component is called as a function for what the markup does not
|
||||||
|
// carry (a link's navigation state, the handlers).
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { Link, MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
// The fake `t` marks what it translates, `t(key[options])`: a string that
|
||||||
|
// skipped `t` would show unmarked.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (k: string, o?: Record<string, unknown>) =>
|
||||||
|
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
|
||||||
|
i18n: { language: "fr" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import OnboardingBanner, {
|
||||||
|
GETTING_STARTED_GUIDE,
|
||||||
|
ONBOARDING_BANNER_LIST_ID,
|
||||||
|
type OnboardingBannerProps,
|
||||||
|
} from "./OnboardingBanner";
|
||||||
|
import OnboardingChecklist from "../dashboard/OnboardingChecklist";
|
||||||
|
import { computeChecklist } from "../../utils/onboardingChecklist";
|
||||||
|
import { nextStep } from "../../utils/onboardingPath";
|
||||||
|
import type { OnboardingCounters } from "../../shared/types";
|
||||||
|
|
||||||
|
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
|
||||||
|
|
||||||
|
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
|
||||||
|
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
|
||||||
|
if (!isValidElement(node)) return [];
|
||||||
|
const el = node as AnyElement;
|
||||||
|
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function texts(node: ReactNode): string[] {
|
||||||
|
if (Array.isArray(node)) return node.flatMap(texts);
|
||||||
|
if (typeof node === "string") return [node];
|
||||||
|
if (!isValidElement(node)) return [];
|
||||||
|
return texts((node as AnyElement).props.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZERO: OnboardingCounters = {
|
||||||
|
sources: 0,
|
||||||
|
imports: 0,
|
||||||
|
manualCategorized: 0,
|
||||||
|
budgetEntries: 0,
|
||||||
|
transactions: 0,
|
||||||
|
};
|
||||||
|
/** Profile, folder and source done: 3/6, the next step is "import" on /import. */
|
||||||
|
const THREE_DONE: OnboardingCounters = { ...ZERO, sources: 1 };
|
||||||
|
/** Up to the import: the next step is "manualCategory" on /transactions. */
|
||||||
|
const FOUR_DONE: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 40 };
|
||||||
|
const ALL_DONE: OnboardingCounters = {
|
||||||
|
sources: 1,
|
||||||
|
imports: 1,
|
||||||
|
manualCategorized: 1,
|
||||||
|
budgetEntries: 1,
|
||||||
|
transactions: 40,
|
||||||
|
};
|
||||||
|
|
||||||
|
function bannerProps(
|
||||||
|
overrides: Partial<OnboardingBannerProps> = {},
|
||||||
|
counters: OnboardingCounters = THREE_DONE,
|
||||||
|
): OnboardingBannerProps {
|
||||||
|
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
|
||||||
|
return {
|
||||||
|
mode: checklist.complete ? "complete" : "banner",
|
||||||
|
checklist,
|
||||||
|
nextStep: nextStep(checklist),
|
||||||
|
budgetReady: true,
|
||||||
|
listOpen: false,
|
||||||
|
onToggleList: vi.fn(),
|
||||||
|
onHide: vi.fn(),
|
||||||
|
onResume: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const markup = (props: OnboardingBannerProps) =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<MemoryRouter>
|
||||||
|
<OnboardingBanner {...props} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tree = (props: OnboardingBannerProps) => OnboardingBanner(props) as ReactNode;
|
||||||
|
|
||||||
|
const buttonNamed = (node: ReactNode, key: string) => {
|
||||||
|
const [button] = find(node, (e) => e.type === "button" && texts(e.props.children).includes(`t(${key})`));
|
||||||
|
expect(button, `button ${key}`).toBeDefined();
|
||||||
|
return button;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("OnboardingBanner — banner mode (#397)", () => {
|
||||||
|
it("is a region named for the path, with the counter, the next step and its link", () => {
|
||||||
|
const html = markup(bannerProps());
|
||||||
|
expect(html).toContain('role="region"');
|
||||||
|
expect(html).toContain('aria-label="t(onboarding.banner.label)"');
|
||||||
|
expect(html).toContain("t(onboarding.banner.progress[done=3,total=6])");
|
||||||
|
expect(html).toContain("t(onboarding.banner.next)");
|
||||||
|
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(onboarding\.checklist\.items\.import\)/);
|
||||||
|
expect(html).toContain("t(onboarding.checklist.hide)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links the next step to its page with the step's navigation state", () => {
|
||||||
|
const [toImport] = find(tree(bannerProps()), (e) => e.type === Link);
|
||||||
|
expect(toImport.props.to).toBe("/import");
|
||||||
|
expect(toImport.props.state).toEqual({ onboarding: { focus: true } });
|
||||||
|
|
||||||
|
// The manual category opens Transactions in its guided entry too.
|
||||||
|
const [toTransactions] = find(tree(bannerProps({}, FOUR_DONE)), (e) => e.type === Link);
|
||||||
|
expect(toTransactions.props.to).toBe("/transactions");
|
||||||
|
expect(toTransactions.props.state).toEqual({ onboarding: { focus: true, guided: true } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the counter waits for the Budget entitlement, the next step does not", () => {
|
||||||
|
const html = markup(bannerProps({ budgetReady: false }));
|
||||||
|
expect(html).not.toContain("onboarding.banner.progress");
|
||||||
|
expect(html).toMatch(/href="\/import"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folded: 'Show the list' with aria-expanded false, and no list", () => {
|
||||||
|
const html = markup(bannerProps());
|
||||||
|
expect(html).toMatch(
|
||||||
|
new RegExp(`<button [^>]*aria-expanded="false"[^>]*aria-controls="${ONBOARDING_BANNER_LIST_ID}"[^>]*>t\\(onboarding\\.banner\\.showList\\)`),
|
||||||
|
);
|
||||||
|
expect(html).toContain(`id="${ONBOARDING_BANNER_LIST_ID}"`);
|
||||||
|
expect(html).not.toContain("onboarding.checklist.title");
|
||||||
|
expect(html).not.toContain("onboarding.banner.hideList");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unfolded: 'Close the list' with aria-expanded true, and the detailed list in place", () => {
|
||||||
|
const html = markup(bannerProps({ listOpen: true }));
|
||||||
|
expect(html).toMatch(/<button [^>]*aria-expanded="true"[^>]*>t\(onboarding\.banner\.hideList\)/);
|
||||||
|
expect(html).not.toContain("onboarding.banner.showList");
|
||||||
|
const container = html.indexOf(`id="${ONBOARDING_BANNER_LIST_ID}"`);
|
||||||
|
expect(container).toBeGreaterThan(-1);
|
||||||
|
expect(html.indexOf("t(onboarding.checklist.title)")).toBeGreaterThan(container);
|
||||||
|
for (const key of ["profile", "importFolder", "source", "import", "manualCategory", "budget"]) {
|
||||||
|
expect(html).toContain(`t(onboarding.checklist.items.${key})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the unfolded list is the detailed list, wired to the path", () => {
|
||||||
|
const props = bannerProps({ listOpen: true });
|
||||||
|
const [list] = find(tree(props), (e) => e.type === OnboardingChecklist);
|
||||||
|
expect(list.props.items).toBe(props.checklist!.items);
|
||||||
|
expect(list.props.done).toBe(3);
|
||||||
|
expect(list.props.total).toBe(6);
|
||||||
|
expect(list.props.budgetReady).toBe(true);
|
||||||
|
expect(list.props.onHide).toBe(props.onHide);
|
||||||
|
expect(list.props.onResume).toBe(props.onResume);
|
||||||
|
expect(find(tree(bannerProps()), (e) => e.type === OnboardingChecklist)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'Show the list' toggles the list and 'Hide' hides the path", () => {
|
||||||
|
const props = bannerProps();
|
||||||
|
const node = tree(props);
|
||||||
|
(buttonNamed(node, "onboarding.banner.showList").props.onClick as () => void)();
|
||||||
|
expect(props.onToggleList).toHaveBeenCalledTimes(1);
|
||||||
|
expect(props.onHide).not.toHaveBeenCalled();
|
||||||
|
(buttonNamed(node, "onboarding.checklist.hide").props.onClick as () => void)();
|
||||||
|
expect(props.onHide).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sits in the page flow, never fixed over the page (D1)", () => {
|
||||||
|
const root = tree(bannerProps()) as AnyElement;
|
||||||
|
expect(root.props.role).toBe("region");
|
||||||
|
expect(String(root.props.className)).not.toMatch(/\b(fixed|sticky|absolute)\b/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OnboardingBanner — end of the path (D4)", () => {
|
||||||
|
it("says the path is complete, opens the guide's getting-started section and offers 'Close'", () => {
|
||||||
|
const props = bannerProps({}, ALL_DONE);
|
||||||
|
expect(props.mode).toBe("complete");
|
||||||
|
const html = markup(props);
|
||||||
|
expect(html).toContain('aria-label="t(onboarding.banner.label)"');
|
||||||
|
expect(html).toContain("t(onboarding.banner.complete)");
|
||||||
|
expect(GETTING_STARTED_GUIDE).toBe("/settings/users#gettingStarted");
|
||||||
|
expect(html).toMatch(/<a [^>]*href="\/settings\/users#gettingStarted"[^>]*>t\(onboarding\.banner\.openGuide\)/);
|
||||||
|
expect(html).toContain("t(common.close)");
|
||||||
|
expect(html).not.toContain("onboarding.banner.progress");
|
||||||
|
expect(html).not.toContain("onboarding.banner.next");
|
||||||
|
expect(html).not.toContain("aria-expanded");
|
||||||
|
expect(html).not.toContain("onboarding.checklist.hide");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'Close' hides the path", () => {
|
||||||
|
const props = bannerProps({}, ALL_DONE);
|
||||||
|
(buttonNamed(tree(props), "common.close").props.onClick as () => void)();
|
||||||
|
expect(props.onHide).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no list at the end, even if it was unfolded", () => {
|
||||||
|
const html = markup(bannerProps({ listOpen: true }, ALL_DONE));
|
||||||
|
expect(html).not.toContain("onboarding.checklist.title");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OnboardingBanner — nothing to show", () => {
|
||||||
|
it("renders nothing in mode none, nor on a page that carries a step card", () => {
|
||||||
|
expect(markup(bannerProps({ mode: "none" }))).toBe("");
|
||||||
|
expect(markup(bannerProps({ mode: "card" }))).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing without a checklist", () => {
|
||||||
|
expect(markup(bannerProps({ checklist: null, nextStep: null }))).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
144
src/components/onboarding/OnboardingBanner.tsx
Normal file
144
src/components/onboarding/OnboardingBanner.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { ArrowRight, Rocket } from "lucide-react";
|
||||||
|
import OnboardingChecklist from "../dashboard/OnboardingChecklist";
|
||||||
|
import { stepNavState, type BannerMode } from "../../utils/onboardingPath";
|
||||||
|
import type { Checklist } from "../../utils/onboardingChecklist";
|
||||||
|
import type { ChecklistItem, ChecklistItemKey } from "../../shared/types";
|
||||||
|
|
||||||
|
/** Id of the unfolded list, named by the toggle's `aria-controls`: one banner per window. */
|
||||||
|
export const ONBOARDING_BANNER_LIST_ID = "onboarding-banner-list";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Open the guide": the getting-started section of the user guide, on the
|
||||||
|
* Users settings page. `DocsContent` gives each section `id={key}` and
|
||||||
|
* scrolls to the section named by the hash.
|
||||||
|
*/
|
||||||
|
export const GETTING_STARTED_GUIDE = "/settings/users#gettingStarted";
|
||||||
|
|
||||||
|
export interface OnboardingBannerProps {
|
||||||
|
/** What the path shows on this page (`useOnboarding().mode`): the banner renders `banner` and `complete` only. */
|
||||||
|
mode: BannerMode;
|
||||||
|
checklist: Checklist | null;
|
||||||
|
/** The next step, linked to its page with the step's navigation state. */
|
||||||
|
nextStep: ChecklistItem | null;
|
||||||
|
/** False until the Budget entitlement is known: the counter waits, its total depends on the lock. */
|
||||||
|
budgetReady: boolean;
|
||||||
|
/** The detailed list is unfolded under the banner. */
|
||||||
|
listOpen: boolean;
|
||||||
|
onToggleList: () => void;
|
||||||
|
/** "Hide", and "Close" at the end: the provider's `hide`. */
|
||||||
|
onHide: () => void;
|
||||||
|
/** "Resume" on a skipped row of the unfolded list. */
|
||||||
|
onResume: (key: ChecklistItemKey) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REGION_CLASS =
|
||||||
|
"rounded-xl border border-[var(--primary)]/30 bg-[var(--primary)]/5 px-4 py-3 mb-6";
|
||||||
|
const ROW_CLASS = "flex flex-wrap items-center gap-x-3 gap-y-2 text-sm";
|
||||||
|
const TEXT_BUTTON_CLASS =
|
||||||
|
"text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors";
|
||||||
|
const LINK_CLASS = "inline-flex items-center gap-1 font-medium text-[var(--primary)] hover:underline";
|
||||||
|
|
||||||
|
function BannerIcon() {
|
||||||
|
return <Rocket size={18} className="shrink-0 text-[var(--primary)]" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getting-started banner (#397), mounted by `AppShell` on top of every page,
|
||||||
|
* in the page flow (D1: a fixed bar could cover the focused control). Driven
|
||||||
|
* by props, so it is tested without the provider.
|
||||||
|
*
|
||||||
|
* - `banner`: "Getting started n/N · Next step: <step> →", "Show the list",
|
||||||
|
* which unfolds the detailed list in place, and "Hide". The counter waits
|
||||||
|
* for `budgetReady`, like the list's own counter.
|
||||||
|
* - `complete` (D4): the end message, "Open the guide" and "Close", which
|
||||||
|
* hides the path until it is shown again from Settings → Users.
|
||||||
|
* - Anything else (`none`, `card`) renders nothing.
|
||||||
|
*/
|
||||||
|
export default function OnboardingBanner({
|
||||||
|
mode,
|
||||||
|
checklist,
|
||||||
|
nextStep,
|
||||||
|
budgetReady,
|
||||||
|
listOpen,
|
||||||
|
onToggleList,
|
||||||
|
onHide,
|
||||||
|
onResume,
|
||||||
|
}: OnboardingBannerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (checklist === null || (mode !== "banner" && mode !== "complete")) return null;
|
||||||
|
|
||||||
|
if (mode === "complete") {
|
||||||
|
return (
|
||||||
|
<div role="region" aria-label={t("onboarding.banner.label")} className={REGION_CLASS}>
|
||||||
|
<div className={ROW_CLASS}>
|
||||||
|
<BannerIcon />
|
||||||
|
<p className="flex-1 min-w-[12rem] text-[var(--foreground)]">
|
||||||
|
{t("onboarding.banner.complete")}
|
||||||
|
</p>
|
||||||
|
<Link to={GETTING_STARTED_GUIDE} className={LINK_CLASS}>
|
||||||
|
{t("onboarding.banner.openGuide")}
|
||||||
|
</Link>
|
||||||
|
<button type="button" onClick={onHide} className={TEXT_BUTTON_CLASS}>
|
||||||
|
{t("common.close")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="region" aria-label={t("onboarding.banner.label")} className={REGION_CLASS}>
|
||||||
|
<div className={ROW_CLASS}>
|
||||||
|
<BannerIcon />
|
||||||
|
{budgetReady && (
|
||||||
|
<span className="font-semibold text-[var(--foreground)] tabular-nums">
|
||||||
|
{t("onboarding.banner.progress", { done: checklist.done, total: checklist.total })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{budgetReady && nextStep && (
|
||||||
|
<span aria-hidden="true" className="h-1 w-1 shrink-0 rounded-full bg-[var(--muted-foreground)]" />
|
||||||
|
)}
|
||||||
|
{nextStep && (
|
||||||
|
<span className="text-[var(--muted-foreground)]">
|
||||||
|
{t("onboarding.banner.next")}{" "}
|
||||||
|
<Link to={nextStep.to} state={stepNavState(nextStep.key)} className={LINK_CLASS}>
|
||||||
|
{t(`onboarding.checklist.items.${nextStep.key}`)}
|
||||||
|
<ArrowRight size={14} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={listOpen}
|
||||||
|
aria-controls={ONBOARDING_BANNER_LIST_ID}
|
||||||
|
onClick={onToggleList}
|
||||||
|
className={TEXT_BUTTON_CLASS}
|
||||||
|
>
|
||||||
|
{t(listOpen ? "onboarding.banner.hideList" : "onboarding.banner.showList")}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onHide} className={TEXT_BUTTON_CLASS}>
|
||||||
|
{t("onboarding.checklist.hide")}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Always in the page, so `aria-controls` names an element even while folded. */}
|
||||||
|
<div id={ONBOARDING_BANNER_LIST_ID}>
|
||||||
|
{listOpen && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<OnboardingChecklist
|
||||||
|
items={checklist.items}
|
||||||
|
done={checklist.done}
|
||||||
|
total={checklist.total}
|
||||||
|
budgetReady={budgetReady}
|
||||||
|
onHide={onHide}
|
||||||
|
onResume={onResume}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -65,11 +65,28 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"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": {
|
"checklist": {
|
||||||
"title": "Getting started",
|
"title": "Getting started",
|
||||||
"counter": "{{done}} / {{total}}",
|
"counter": "{{done}} / {{total}}",
|
||||||
"hide": "Hide",
|
"hide": "Hide",
|
||||||
"lockedBudget": "Base and up",
|
"lockedBudget": "Base and up",
|
||||||
|
"skippedBadge": "Skipped",
|
||||||
|
"resume": "Resume",
|
||||||
"items": {
|
"items": {
|
||||||
"profile": "Create a profile",
|
"profile": "Create a profile",
|
||||||
"importFolder": "Choose an import folder",
|
"importFolder": "Choose an import folder",
|
||||||
|
|
@ -771,9 +788,9 @@
|
||||||
"userGuide": "User guide"
|
"userGuide": "User guide"
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"label": "Getting-started checklist on the dashboard",
|
"label": "Getting-started path",
|
||||||
"show": "Show the getting-started checklist",
|
"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": {
|
"data": {
|
||||||
|
|
|
||||||
|
|
@ -65,11 +65,28 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"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": {
|
"checklist": {
|
||||||
"title": "Liste de démarrage",
|
"title": "Liste de démarrage",
|
||||||
"counter": "{{done}} / {{total}}",
|
"counter": "{{done}} / {{total}}",
|
||||||
"hide": "Masquer",
|
"hide": "Masquer",
|
||||||
"lockedBudget": "Base et plus",
|
"lockedBudget": "Base et plus",
|
||||||
|
"skippedBadge": "Passée",
|
||||||
|
"resume": "Reprendre",
|
||||||
"items": {
|
"items": {
|
||||||
"profile": "Créer un profil",
|
"profile": "Créer un profil",
|
||||||
"importFolder": "Choisir un dossier d'import",
|
"importFolder": "Choisir un dossier d'import",
|
||||||
|
|
@ -771,9 +788,9 @@
|
||||||
"userGuide": "Guide d'utilisation"
|
"userGuide": "Guide d'utilisation"
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"label": "Liste de démarrage sur le tableau de bord",
|
"label": "Parcours de démarrage",
|
||||||
"show": "Afficher la liste 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": {
|
"data": {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
// DashboardPage reads the shared getting-started path (#396). No jsdom:
|
// DashboardPage reads the shared getting-started path (#396). No jsdom:
|
||||||
// react-dom/server renders in vitest's node environment. The path comes from a
|
// 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
|
// 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
|
// to markers: what is pinned is the page's branching on the path. Since #397
|
||||||
// since #352 except that its source is now the provider.
|
// 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 { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
@ -40,14 +41,20 @@ vi.mock("../hooks/useDashboard", () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Hoisted with the mocks below, which run before the imports.
|
// Hoisted with the mocks below, which run before the imports.
|
||||||
const { marker } = vi.hoisted(() => ({
|
const { marker, emptyStateProps } = vi.hoisted(() => ({
|
||||||
marker: (name: string) => ({
|
marker: (name: string) => ({ default: () => <i data-marker={name} /> }),
|
||||||
default: (props: { mode?: string }) => <i data-marker={name} data-mode={props.mode} />,
|
emptyStateProps: { current: null as Record<string, unknown> | null },
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
|
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
|
||||||
vi.mock("../components/dashboard/CategoriesV1DiscoveryBanner", () => marker("discovery"));
|
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/OnboardingChecklist", () => marker("checklist"));
|
||||||
vi.mock("../components/dashboard/PeriodSelector", () => marker("period"));
|
vi.mock("../components/dashboard/PeriodSelector", () => marker("period"));
|
||||||
vi.mock("../components/dashboard/NetWorthTile", () => marker("net-worth"));
|
vi.mock("../components/dashboard/NetWorthTile", () => marker("net-worth"));
|
||||||
|
|
@ -102,9 +109,10 @@ const render = () =>
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
onboarding = path();
|
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", () => {
|
it("renders only its header while the path first loads", () => {
|
||||||
onboarding = path({ status: "loading", checklist: null, counters: null });
|
onboarding = path({ status: "loading", checklist: null, counters: null });
|
||||||
const html = render();
|
const html = render();
|
||||||
|
|
@ -120,16 +128,25 @@ describe("DashboardPage on the shared path (#352, #396)", () => {
|
||||||
expect(html).not.toContain('data-marker="filters"');
|
expect(html).not.toContain('data-marker="filters"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the compact checklist above the widgets on a profile with transactions", () => {
|
it("wires the welcome card's list to the path: hidden state, 'Hide' and 'Resume'", () => {
|
||||||
const html = render();
|
onboarding = path({ transactions: 0, hidden: true });
|
||||||
expect(html).toContain('data-marker="checklist" data-mode="compact"');
|
render();
|
||||||
expect(html).toContain('data-marker="filters"');
|
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)", () => {
|
it.each([
|
||||||
onboarding = path({ hidden: true, mode: "none" });
|
["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();
|
const html = render();
|
||||||
expect(html).not.toContain('data-marker="checklist"');
|
expect(html).not.toContain('data-marker="checklist"');
|
||||||
|
expect(html).not.toContain('data-marker="empty-state"');
|
||||||
expect(html).toContain('data-marker="filters"');
|
expect(html).toContain('data-marker="filters"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,13 @@
|
||||||
//
|
//
|
||||||
// Onboarding (#352): the page branches on the getting-started checklist
|
// Onboarding (#352): the page branches on the getting-started checklist
|
||||||
// `status`, never on a bare counter. While it loads, only the title and the
|
// `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
|
// profile without any transaction gets `DashboardEmptyState` instead of the
|
||||||
// widgets, outside the `isLoading` veil so its link stays clickable. With
|
// widgets, outside the `isLoading` veil so its link stays clickable. With
|
||||||
// transactions, the compact checklist sits between the title and the filter
|
// transactions, the page shows no list of its own: the getting-started banner
|
||||||
// panel until it is complete or dismissed. A first load that fails falls back
|
// that `AppShell` puts on top of every page replaced the compact bar (#397,
|
||||||
// to the v0.15.0 page without the list; the provider logs it.
|
// 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
|
// 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.
|
// 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 CategoryBarChart from "../components/reports/CategoryBarChart";
|
||||||
import CategoriesV1DiscoveryBanner from "../components/dashboard/CategoriesV1DiscoveryBanner";
|
import CategoriesV1DiscoveryBanner from "../components/dashboard/CategoriesV1DiscoveryBanner";
|
||||||
import DashboardEmptyState from "../components/dashboard/DashboardEmptyState";
|
import DashboardEmptyState from "../components/dashboard/DashboardEmptyState";
|
||||||
import OnboardingChecklist from "../components/dashboard/OnboardingChecklist";
|
|
||||||
import NetWorthTile from "../components/dashboard/NetWorthTile";
|
import NetWorthTile from "../components/dashboard/NetWorthTile";
|
||||||
import FilterPanel from "../components/reports/FilterPanel";
|
import FilterPanel from "../components/reports/FilterPanel";
|
||||||
import KpiCard from "../components/reports/cards/KpiCard";
|
import KpiCard from "../components/reports/cards/KpiCard";
|
||||||
|
|
@ -100,32 +100,16 @@ export default function DashboardPage() {
|
||||||
budgetReady={onboarding.budgetReady}
|
budgetReady={onboarding.budgetReady}
|
||||||
hidden={onboarding.hidden}
|
hidden={onboarding.hidden}
|
||||||
onHide={onboarding.hide}
|
onHide={onboarding.hide}
|
||||||
|
onResume={onboarding.resume}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||||
{header}
|
{header}
|
||||||
|
|
||||||
{showCompactChecklist && (
|
|
||||||
<OnboardingChecklist
|
|
||||||
items={checklist.items}
|
|
||||||
done={checklist.done}
|
|
||||||
total={checklist.total}
|
|
||||||
mode="compact"
|
|
||||||
budgetReady={onboarding.budgetReady}
|
|
||||||
onHide={onboarding.hide}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{state.error && (
|
{state.error && (
|
||||||
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
||||||
{state.error}
|
{state.error}
|
||||||
|
|
|
||||||
153
src/pages/settings/UsersSettingsPage.test.tsx
Normal file
153
src/pages/settings/UsersSettingsPage.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,51 +1,26 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { ArrowLeft, ListChecks } from "lucide-react";
|
import { ArrowLeft, ListChecks } from "lucide-react";
|
||||||
import AccountCard from "../../components/settings/AccountCard";
|
import AccountCard from "../../components/settings/AccountCard";
|
||||||
import LicenseCard from "../../components/settings/LicenseCard";
|
import LicenseCard from "../../components/settings/LicenseCard";
|
||||||
import DocsContent from "../../components/settings/DocsContent";
|
import DocsContent from "../../components/settings/DocsContent";
|
||||||
import { getPreference } from "../../services/userPreferenceService";
|
import { useOnboarding } from "../../contexts/OnboardingContext";
|
||||||
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";
|
|
||||||
|
|
||||||
export default function UsersSettingsPage() {
|
export default function UsersSettingsPage() {
|
||||||
const { t } = useTranslation();
|
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(() => {
|
const handleShowPath = async () => {
|
||||||
let cancelled = false;
|
// `show` logs its own failure and leaves the path hidden, which keeps the button.
|
||||||
(async () => {
|
await show();
|
||||||
try {
|
setRestored(true);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -78,14 +53,14 @@ export default function UsersSettingsPage() {
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||||
{t("settings.users.sections.userGuide")}
|
{t("settings.users.sections.userGuide")}
|
||||||
</h2>
|
</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">
|
<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" />
|
<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>
|
<span className="flex-1 min-w-[12rem]">{t("settings.users.onboarding.label")}</span>
|
||||||
{checklistPref === "dismissed" ? (
|
{hidden ? (
|
||||||
<button
|
<button
|
||||||
type="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"
|
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")}
|
{t("settings.users.onboarding.show")}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,16 @@ import {
|
||||||
latchCardPage,
|
latchCardPage,
|
||||||
nextStep,
|
nextStep,
|
||||||
readOnboardingNavState,
|
readOnboardingNavState,
|
||||||
|
stepNavState,
|
||||||
stepsOfPage,
|
stepsOfPage,
|
||||||
type BannerModeInput,
|
type BannerModeInput,
|
||||||
} from "./onboardingPath";
|
} 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 = {
|
const zero: OnboardingCounters = {
|
||||||
sources: 0,
|
sources: 0,
|
||||||
|
|
@ -308,3 +314,23 @@ describe("readOnboardingNavState (#396)", () => {
|
||||||
).toEqual({ focus: true, guided: false });
|
).toEqual({ focus: true, guided: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("stepNavState (#397)", () => {
|
||||||
|
it("asks for the focus on every step, and for the guided entry on the manual category only", () => {
|
||||||
|
for (const key of CHECKLIST_ITEM_KEYS) {
|
||||||
|
expect(stepNavState(key), key).toEqual(
|
||||||
|
key === "manualCategory"
|
||||||
|
? { onboarding: { focus: true, guided: true } }
|
||||||
|
: { onboarding: { focus: true } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads back through readOnboardingNavState", () => {
|
||||||
|
expect(readOnboardingNavState(stepNavState("manualCategory"))).toEqual({
|
||||||
|
focus: true,
|
||||||
|
guided: true,
|
||||||
|
});
|
||||||
|
expect(readOnboardingNavState(stepNavState("import"))).toEqual({ focus: true, guided: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,23 @@ export interface OnboardingNavState {
|
||||||
guided: boolean;
|
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
|
* 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
|
* path (CWE-476): anything that is not `{ onboarding: { focus: true } }` or
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue