feat(onboarding): step cards of the Categories and Reports pages (#432) #445
16 changed files with 734 additions and 32 deletions
|
|
@ -5,7 +5,7 @@
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
||||||
- Import : un avertissement nomme les fichiers CSV posés directement dans le dossier d'import, qui sont ignorés (#427).
|
- Import : un avertissement nomme les fichiers CSV posés directement dans le dossier d'import, qui sont ignorés (#427).
|
||||||
- Parcours de démarrage : deux étapes de visite, « Parcourir les catégories et les mots-clés » (avant le classement) et « Parcourir les rapports » (en dernier), cochées par « J'ai compris » ; un profil qui avait terminé le parcours les trouve déjà faites (#425).
|
- Parcours de démarrage : deux étapes de visite, « Parcourir les catégories et les mots-clés » (avant le classement) et « Parcourir les rapports » (en dernier), cochées par « J'ai compris » ; un profil qui avait terminé le parcours les trouve déjà faites ; leurs cartes, sur les pages Catégories et Rapports, expliquent les mots-clés et le hub des rapports (#425, #432).
|
||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Import: a warning names the CSV files placed directly in the import folder, which are ignored (#427).
|
- Import: a warning names the CSV files placed directly in the import folder, which are ignored (#427).
|
||||||
- Getting started: two visit steps, “Browse categories and keywords” (before categorizing) and “Browse the reports” (last), checked with “Got it”; a profile that had finished the path finds them already done (#425).
|
- Getting started: two visit steps, “Browse categories and keywords” (before categorizing) and “Browse the reports” (last), checked with “Got it”; a profile that had finished the path finds them already done; their cards, on the Categories and Reports pages, explain keywords and the reports hub (#425, #432).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -371,12 +371,12 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
|
||||||
**Les modèles purs** (`src/utils/onboardingPath.ts`, sans React, routeur ni base) :
|
**Les modèles purs** (`src/utils/onboardingPath.ts`, sans React, routeur ni base) :
|
||||||
|
|
||||||
- `nextStep` : la première ligne déverrouillée ni faite ni passée ;
|
- `nextStep` : la première ligne déverrouillée ni faite ni passée ;
|
||||||
- `CARD_PAGES` (`/import`, `/transactions`, `/budget`) et `stepsOfPage` : les pages qui portent une carte d'étape, et les étapes que chacune guide ;
|
- `CARD_PAGES` (`/import`, `/categories`, `/transactions`, `/budget`, `/reports`, ces deux pages pour les étapes de visite, #432) et `stepsOfPage` : les pages qui portent une carte d'étape, et les étapes que chacune guide ;
|
||||||
- `latchCardPage` : le verrou de carte, décrit ci-dessous ;
|
- `latchCardPage` : le verrou de carte, décrit ci-dessous ;
|
||||||
- `bannerMode` : `none` avant le premier chargement réussi, quand le parcours est masqué et sur le tableau de bord d'un profil sans transaction (sa carte d'accueil porte déjà la liste) ; `card` sur la page verrouillée ; `complete` quand toutes les lignes déverrouillées sont faites ou passées, une fois l'entitlement Budget connu (`budgetReady`) ; `banner` partout ailleurs ;
|
- `bannerMode` : `none` avant le premier chargement réussi, quand le parcours est masqué et sur le tableau de bord d'un profil sans transaction (sa carte d'accueil porte déjà la liste) ; `card` sur la page verrouillée ; `complete` quand toutes les lignes déverrouillées sont faites ou passées, une fois l'entitlement Budget connu (`budgetReady`) ; `banner` partout ailleurs ;
|
||||||
- `stepNavState` et `readOnboardingNavState` : l'état de navigation de tout lien vers une étape (`{ onboarding: { focus: true } }`, plus `guided: true` pour « Classer une transaction à la main »), et la seule lecture admise de `location.state` (CWE-476) ;
|
- `stepNavState` et `readOnboardingNavState` : l'état de navigation de tout lien vers une étape (`{ onboarding: { focus: true } }`, plus `guided: true` pour « Classer une transaction à la main »), et la seule lecture admise de `location.state` (CWE-476) ;
|
||||||
- `showsStepCard`, `cardStep` (l'étape montrée et « Étape n sur N ») et `cardNext` (après « Fait. » : le lien vers l'étape suivante, la fin, ou rien tant que la relecture n'est pas arrivée) ;
|
- `showsStepCard`, `cardStep` (l'étape montrée et « Étape n sur N ») et `cardNext` (après « Fait. » : le lien vers l'étape suivante, la fin, ou rien tant que la relecture n'est pas arrivée) ;
|
||||||
- les modèles de carte des pages, qui rendent la phrase, l'état « fait » et la cible : `importCardModel` (suit l'assistant ; « Fait. » seulement pour un import qui a ajouté des transactions, sur le rapport comme au repos, où le compteur ne retient que les fichiers qui en ont amené), `transactionsCardModel` (phrase selon le filtre « Non catégorisées ») et `budgetCardModel` (« Tout déplier » ou la cellule du mois, selon la cible que la grille remonte, calculée par `budgetOnboardingTarget`, `src/components/budget/`) ;
|
- les modèles de carte des pages, qui rendent la phrase, l'état « fait » et la cible : `importCardModel` (suit l'assistant ; « Fait. » seulement pour un import qui a ajouté des transactions, sur le rapport comme au repos, où le compteur ne retient que les fichiers qui en ont amené), `transactionsCardModel` (phrase selon le filtre « Non catégorisées ») `budgetCardModel` (« Tout déplier » ou la cellule du mois, selon la cible que la grille remonte, calculée par `budgetOnboardingTarget`, `src/components/budget/`), `categoriesCardModel` (les mots-clés, cible « Tous les mots-clés », jamais « Réinitialiser ») et `reportsCardModel(checklist, advancedLocked)` (le hub, cible la carte Tendances ; variante qui mentionne le cadenas quand les rapports avancés sont verrouillés, licence connue) ;
|
||||||
- `onboardingTargetAttributes` : les attributs du contrôle mis en évidence.
|
- `onboardingTargetAttributes` : les attributs du contrôle mis en évidence.
|
||||||
|
|
||||||
`deriveOnboardingView` (`useOnboardingChecklist.ts`, pur) les compose à chaque rendu du fournisseur : étape suivante, verrou de carte et mode.
|
`deriveOnboardingView` (`useOnboardingChecklist.ts`, pur) les compose à chaque rendu du fournisseur : étape suivante, verrou de carte et mode.
|
||||||
|
|
@ -387,7 +387,7 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
|
||||||
|
|
||||||
**Les cartes d'étape.** `StepCard` (props seules) et `OnboardingStepCard` (connectée, seule pièce de la carte qui lit `useOnboarding()`). Chaque page qui porte une carte rend `OnboardingStepCard` en premier enfant, avant son en-tête, et ne lui passe que son modèle. La carte a un titre `h2` « Étape n sur N · <étape> », qui prend le focus après une arrivée par un lien d'étape (une fois par arrivée, garde sur `location.key`, puis l'état de navigation est consommé) ; la phrase du modèle, où les libellés des boutons viennent de leurs propres clés ; une zone `role="status"` toujours rendue, qui reçoit « Fait. » ; « J'ai compris » avant « Passer cette étape » pour une étape de visite (`onAcknowledge`, passé par `OnboardingStepCard` quand `VISIT_STEP_KEYS` contient l'étape de la carte ; `ACKNOWLEDGE` garde le verrou de carte, qui annonce « Fait. » puis l'étape suivante), « Passer cette étape » tant que l'étape n'est pas faite ; le rang « Étape n sur N » et la fin attendent `budgetReady && visitStepsInitialized` ; puis le lien vers l'étape suivante ou le message de fin. Sur la page Transactions, l'entrée guidée (`guided`) passe `GUIDED_ENTRY_FILTERS` à `useTransactions` : non catégorisées, sans borne de date, avec un repli unique sur toutes les transactions quand aucune ne reste à classer.
|
**Les cartes d'étape.** `StepCard` (props seules) et `OnboardingStepCard` (connectée, seule pièce de la carte qui lit `useOnboarding()`). Chaque page qui porte une carte rend `OnboardingStepCard` en premier enfant, avant son en-tête, et ne lui passe que son modèle. La carte a un titre `h2` « Étape n sur N · <étape> », qui prend le focus après une arrivée par un lien d'étape (une fois par arrivée, garde sur `location.key`, puis l'état de navigation est consommé) ; la phrase du modèle, où les libellés des boutons viennent de leurs propres clés ; une zone `role="status"` toujours rendue, qui reçoit « Fait. » ; « J'ai compris » avant « Passer cette étape » pour une étape de visite (`onAcknowledge`, passé par `OnboardingStepCard` quand `VISIT_STEP_KEYS` contient l'étape de la carte ; `ACKNOWLEDGE` garde le verrou de carte, qui annonce « Fait. » puis l'étape suivante), « Passer cette étape » tant que l'étape n'est pas faite ; le rang « Étape n sur N » et la fin attendent `budgetReady && visitStepsInitialized` ; puis le lien vers l'étape suivante ou le message de fin. Sur la page Transactions, l'entrée guidée (`guided`) passe `GUIDED_ENTRY_FILTERS` à `useTransactions` : non catégorisées, sans borne de date, avec un repli unique sur toutes les transactions quand aucune ne reste à classer.
|
||||||
|
|
||||||
**La mise en évidence.** Elle passe par props, jamais par une requête DOM. La page tire la cible de son modèle et ne la transmet que pendant que la carte est affichée (`showsStepCard`) : `onboardingTarget` sur l'action de `EmptyState` (par `ImportEmptyState`), sur la première carte de `SourceList`, sur le bouton suivant de `WizardNavigation`, sur la catégorie de la première ligne de `TransactionTable`, et sur « Tout déplier » ou la cellule du mois courant de `BudgetTable`, une seule cible à la fois. Le contrôle rend `data-onboarding-target="<clé fixe>"` et `data-onboarding-highlight="true"` (`onboardingTargetAttributes`). `styles.css` dessine sur ce second attribut un contour `3px solid var(--accent)` décalé de 2 px et une pulsation `box-shadow` de 1,2 s jouée trois fois, retirée sous `prefers-reduced-motion: reduce`. `--accent` atteint 3:1 contre le fond de la page et contre les cartes, dans les deux thèmes (`styles.contrast.test.ts`). Une cible absente de l'écran laisse la carte sans anneau.
|
**La mise en évidence.** Elle passe par props, jamais par une requête DOM. La page tire la cible de son modèle et ne la transmet que pendant que la carte est affichée (`showsStepCard`) : `onboardingTarget` sur l'action de `EmptyState` (par `ImportEmptyState`), sur la première carte de `SourceList`, sur le bouton suivant de `WizardNavigation`, sur la catégorie de la première ligne de `TransactionTable`, sur « Tout déplier » ou la cellule du mois courant de `BudgetTable`, sur « Tous les mots-clés » de `CategoriesPage` et sur la carte Tendances du hub (`HubReportNavCard`), une seule cible à la fois. Sur le hub, la carte Tendances reçoit aussi `onNavigate`, appelé au clic avant la navigation, tant que la carte d'étape s'affiche et que l'étape n'est pas faite : il reconnaît l'étape des rapports (`acknowledge("reports")`, D17), car suivre l'anneau quitte la page et relâche le verrou de carte. Le contrôle rend `data-onboarding-target="<clé fixe>"` et `data-onboarding-highlight="true"` (`onboardingTargetAttributes`). `styles.css` dessine sur ce second attribut un contour `3px solid var(--accent)` décalé de 2 px et une pulsation `box-shadow` de 1,2 s jouée trois fois, retirée sous `prefers-reduced-motion: reduce`. `--accent` atteint 3:1 contre le fond de la page et contre les cartes, dans les deux thèmes (`styles.contrast.test.ts`). Une cible absente de l'écran laisse la carte sans anneau.
|
||||||
|
|
||||||
**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 même page vient de le masquer. Le clic passe par `show()` du fournisseur, et une confirmation remplace le bouton (#397).
|
**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 même page vient de le masquer. Le clic passe par `show()` du fournisseur, et une confirmation remplace le bouton (#397).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ Les huit étapes ci-dessous sont celles du parcours de démarrage. La carte d'ac
|
||||||
|
|
||||||
1. Le bandeau de démarrage s'affiche en haut de chaque page tant que le parcours n'est pas masqué. Il indique votre progression, par exemple « Démarrage 3/8 », et l'étape suivante, avec un lien vers la page où la faire. 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
|
1. Le bandeau de démarrage s'affiche en haut de chaque page tant que le parcours n'est pas masqué. Il indique votre progression, par exemple « Démarrage 3/8 », et l'étape suivante, avec un lien vers la page où la faire. 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
|
||||||
2. « Voir la liste » déplie les huit étapes sous le bandeau et « Fermer la liste » les replie ; la liste se replie aussi quand vous changez de page. Chaque étape à faire mène à la page où la faire
|
2. « Voir la liste » déplie les huit étapes sous le bandeau et « Fermer la liste » les replie ; la liste se replie aussi quand vous changez de page. Chaque étape à faire mène à la page où la faire
|
||||||
3. Sur les pages Import, Transactions et Budget, quand l'étape suivante s'y fait, une carte de démarrage remplace le bandeau. Elle indique « Étape n sur N », dit quoi faire et entoure d'un contour le contrôle à utiliser. Le contour pulse trois fois, et pas du tout si votre système demande de réduire les animations. Si vous arrivez par un lien « Étape suivante », le focus du clavier se place sur le titre de la carte
|
3. Sur les pages Import, Catégories, Transactions, Budget et Rapports, quand l'étape suivante s'y fait, une carte de démarrage remplace le bandeau. Elle indique « Étape n sur N », dit quoi faire et entoure d'un contour le contrôle à utiliser. Le contour pulse trois fois, et pas du tout si votre système demande de réduire les animations. Si vous arrivez par un lien « Étape suivante », le focus du clavier se place sur le titre de la carte. Sur les pages Catégories et Rapports, la carte se termine par « J'ai compris », qui coche l'étape ; sur la page Rapports, ouvrir la carte Tendances entourée la coche aussi
|
||||||
4. La carte suit l'étape sans que vous changiez de page. Sur la page Import, elle accompagne l'assistant écran par écran. Le lien de l'étape « Classer une transaction à la main » ouvre la page Transactions sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer. Sur la page Budget, la carte désigne d'abord « Tout déplier », puis la cellule du mois courant de la première catégorie à remplir ; elle ne déplie jamais la grille à votre place. Sur la page Transactions, la carte propose le clic droit et « Ajouter comme mot-clé » ; sur la page Budget, elle rappelle que seules les catégories du dernier niveau acceptent un montant et ce que fait la colonne Annuel
|
4. La carte suit l'étape sans que vous changiez de page. Sur la page Import, elle accompagne l'assistant écran par écran. Le lien de l'étape « Classer une transaction à la main » ouvre la page Transactions sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer. Sur la page Budget, la carte désigne d'abord « Tout déplier », puis la cellule du mois courant de la première catégorie à remplir ; elle ne déplie jamais la grille à votre place. Sur la page Transactions, la carte propose le clic droit et « Ajouter comme mot-clé » ; sur la page Budget, elle rappelle que seules les catégories du dernier niveau acceptent un montant et ce que fait la colonne Annuel
|
||||||
5. Dès que l'étape est faite, la carte reste affichée, annonce « Fait. » et propose l'étape suivante, avec un lien vers sa page. Sur la page Import, elle passe d'une étape à la suivante sans quitter la page et annonce « Fait. » une fois le fichier importé ; jamais pour un import qui n'a ajouté aucune transaction : le rapport dit pourquoi, et l'étape reste à faire
|
5. Dès que l'étape est faite, la carte reste affichée, annonce « Fait. » et propose l'étape suivante, avec un lien vers sa page. Sur la page Import, elle passe d'une étape à la suivante sans quitter la page et annonce « Fait. » une fois le fichier importé ; jamais pour un import qui n'a ajouté aucune transaction : le rapport dit pourquoi, et l'étape reste à faire
|
||||||
6. « Passer cette étape », sur la carte, marque l'étape comme passée : elle compte dans la progression, et la liste l'affiche avec le badge « Passée » et un lien « Reprendre ». « Reprendre » remet l'étape à faire et ouvre sa page
|
6. « Passer cette étape », sur la carte, marque l'étape comme passée : elle compte dans la progression, et la liste l'affiche avec le badge « Passée » et un lien « Reprendre ». « Reprendre » remet l'étape à faire et ouvre sa page
|
||||||
|
|
|
||||||
|
|
@ -219,12 +219,16 @@
|
||||||
expect: "Choisis un dossier vide : sans quitter la page, la carte passe à « Étape 3 sur 8 · Configurer une source d'import », nomme le dossier et entoure « Créer un dossier d'exemple ». Après la création, elle demande de cliquer sur la carte d'un compte et entoure la première source." },
|
expect: "Choisis un dossier vide : sans quitter la page, la carte passe à « Étape 3 sur 8 · Configurer une source d'import », nomme le dossier et entoure « Créer un dossier d'exemple ». Après la création, elle demande de cliquer sur la carte d'un compte et entoure la première source." },
|
||||||
{ id: "guided-import-wizard", title: "Étape 4 : l'assistant, écran par écran",
|
{ id: "guided-import-wizard", title: "Étape 4 : l'assistant, écran par écran",
|
||||||
expect: "Clique la source entourée (« Exemple Desjardins »). À chaque écran, la carte dit quoi vérifier et entoure le bouton suivant : « Aperçu », « Vérifier les doublons », « Confirmer », puis « Importer ». Pendant l'import, elle affiche « Import en cours… ». Sur le rapport, elle annonce « Fait. » et propose « Étape suivante : Parcourir les catégories et les mots-clés → » ; après « Terminé », elle garde « Fait. »." },
|
expect: "Clique la source entourée (« Exemple Desjardins »). À chaque écran, la carte dit quoi vérifier et entoure le bouton suivant : « Aperçu », « Vérifier les doublons », « Confirmer », puis « Importer ». Pendant l'import, elle affiche « Import en cours… ». Sur le rapport, elle annonce « Fait. » et propose « Étape suivante : Parcourir les catégories et les mots-clés → » ; après « Terminé », elle garde « Fait. »." },
|
||||||
|
{ id: "guided-categories", title: "Étape 5 : la carte Catégories, « Tous les mots-clés » et « J'ai compris » (#432)",
|
||||||
|
expect: "Clique « Étape suivante » sur la carte Import. La page Catégories s'ouvre sur la carte « Étape 5 sur 8 · Parcourir les catégories et les mots-clés », avant le titre, et le focus est sur le titre de la carte. Elle dit : « Un mot-clé classe d'office, dès l'import, chaque transaction dont la description le contient ; quand deux mots-clés s'appliquent, le plus prioritaire l'emporte. « Tous les mots-clés » les liste tous, avec leur catégorie. À l'étape suivante, vous en ajouterez un depuis une transaction. » Le contour entoure « Tous les mots-clés », jamais « Réinitialiser ». Ouvrir « Tous les mots-clés » ne coche rien. Clique « J'ai compris » : la carte reste, annonce « Fait. » et propose « Étape suivante : Classer une transaction à la main → », et le contour disparaît. En anglais, “All Keywords” est entre “ ”." },
|
||||||
{ id: "guided-transactions", title: "Étape 6 : Transactions, entrée guidée",
|
{ id: "guided-transactions", title: "Étape 6 : Transactions, entrée guidée",
|
||||||
expect: "Clique « Étape suivante » sur la carte Catégories, après « J'ai compris » (étape 5, #432). La page Transactions s'ouvre filtrée sur « Non catégorisées », période « Tout », avec la carte « Étape 6 sur 8 · Classer une transaction à la main » et le focus sur son titre ; le contour entoure la catégorie de la première ligne. Choisis une catégorie pour l'une d'elles (« GARDERIE LES PETITS PAS », par exemple) : sans quitter la page, la carte annonce « Fait. » et propose « Étape suivante : Saisir un premier budget → », et le contour disparaît." },
|
expect: "Clique « Étape suivante » sur la carte Catégories, après « J'ai compris » (étape 5, #432). La page Transactions s'ouvre filtrée sur « Non catégorisées », période « Tout », avec la carte « Étape 6 sur 8 · Classer une transaction à la main » et le focus sur son titre ; le contour entoure la catégorie de la première ligne. Choisis une catégorie pour l'une d'elles (« GARDERIE LES PETITS PAS », par exemple) : sans quitter la page, la carte annonce « Fait. » et propose « Étape suivante : Saisir un premier budget → », et le contour disparaît." },
|
||||||
{ id: "guided-budget", title: "Étape 7 : Budget, « Tout déplier » puis la cellule du mois",
|
{ id: "guided-budget", title: "Étape 7 : Budget, « Tout déplier » puis la cellule du mois",
|
||||||
expect: "Clique « Étape suivante » sur la carte Transactions. La page Budget montre la carte « Étape 7 sur 8 · Saisir un premier budget », la grille repliée et le contour sur « Tout déplier ». Clique-le : la phrase change et le contour passe sur la cellule du mois courant de la première catégorie à remplir. Saisis un montant puis Entrée : la carte annonce « Fait. » et propose « Étape suivante : Parcourir les rapports → »." },
|
expect: "Clique « Étape suivante » sur la carte Transactions. La page Budget montre la carte « Étape 7 sur 8 · Saisir un premier budget », la grille repliée et le contour sur « Tout déplier ». Clique-le : la phrase change et le contour passe sur la cellule du mois courant de la première catégorie à remplir. Saisis un montant puis Entrée : la carte annonce « Fait. » et propose « Étape suivante : Parcourir les rapports → »." },
|
||||||
{ id: "guided-budget-tab", title: "Budget : Tab enregistre et passe au mois suivant",
|
{ id: "guided-budget-tab", title: "Budget : Tab enregistre et passe au mois suivant",
|
||||||
expect: "Dans une cellule, saisis un montant puis Tab : le montant est enregistré et la saisie passe au mois suivant, comme le dit la carte." },
|
expect: "Dans une cellule, saisis un montant puis Tab : le montant est enregistré et la saisie passe au mois suivant, comme le dit la carte." },
|
||||||
|
{ id: "guided-reports", title: "Étape 8 : la carte Rapports, la carte Tendances et « J'ai compris » (#432)",
|
||||||
|
expect: "Clique « Étape suivante » sur la carte Budget. La page Rapports s'ouvre sur la carte « Étape 8 sur 8 · Parcourir les rapports », avant le titre, et le focus est sur le titre de la carte. Elle dit : « Choisissez la période en haut de la page : le panneau des faits saillants résume votre solde et vos plus gros mouvements. Chaque carte du bas ouvre un rapport détaillé ; commencez par « Tendances ». » Le contour entoure la carte Tendances, seule. Changer la période ne coche rien. Clique « J'ai compris » : la carte reste, annonce « Fait. » et le message de fin « Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite. », et le contour disparaît. En anglais, “Trends” est entre “ ”." },
|
||||||
{ id: "guided-live", title: "Les étapes se cochent page ouverte",
|
{ id: "guided-live", title: "Les étapes se cochent page ouverte",
|
||||||
expect: "Pendant les étapes 2 à 8, chaque étape s'est cochée sur la page même, sans recharger ni changer de page, en moins d'une seconde : passage de l'étape 2 à l'étape 3 au choix du dossier, puis « Fait. » après l'import, chaque « J'ai compris », le classement et le budget. Note tout délai visible, ou une étape qui ne se coche qu'au changement de page." },
|
expect: "Pendant les étapes 2 à 8, chaque étape s'est cochée sur la page même, sans recharger ni changer de page, en moins d'une seconde : passage de l'étape 2 à l'étape 3 au choix du dossier, puis « Fait. » après l'import, chaque « J'ai compris », le classement et le budget. Note tout délai visible, ou une étape qui ne se coche qu'au changement de page." },
|
||||||
{ id: "guided-end", title: "Fin du parcours : bandeau de fin, « Ouvrir le guide » et « Fermer »",
|
{ id: "guided-end", title: "Fin du parcours : bandeau de fin, « Ouvrir le guide » et « Fermer »",
|
||||||
|
|
@ -237,7 +241,7 @@
|
||||||
intro: "Sur un profil où le parcours n'est pas terminé, par exemple un second profil neuf (clé Base ou plus).",
|
intro: "Sur un profil où le parcours n'est pas terminé, par exemple un second profil neuf (clé Base ou plus).",
|
||||||
steps: [
|
steps: [
|
||||||
{ id: "guided-list", title: "« Voir la liste » et « Fermer la liste »",
|
{ id: "guided-list", title: "« Voir la liste » et « Fermer la liste »",
|
||||||
expect: "Sur une page sans carte (Catégories, par exemple), « Voir la liste » déplie les huit étapes sous le bandeau et devient « Fermer la liste », qui les replie. Change de page : la liste est de nouveau repliée. Une étape faite est barrée et sans lien ; une étape à faire mène à sa page. À juger : liste dépliée, le compteur et « Masquer » apparaissent deux fois, dans le bandeau et dans l'en-tête de la liste." },
|
expect: "Sur une page sans carte (Paramètres, par exemple), « Voir la liste » déplie les huit étapes sous le bandeau et devient « Fermer la liste », qui les replie. Change de page : la liste est de nouveau repliée. Une étape faite est barrée et sans lien ; une étape à faire mène à sa page. À juger : liste dépliée, le compteur et « Masquer » apparaissent deux fois, dans le bandeau et dans l'en-tête de la liste." },
|
||||||
{ id: "guided-skip-resume", title: "« Passer cette étape », puis « Reprendre »",
|
{ id: "guided-skip-resume", title: "« Passer cette étape », puis « Reprendre »",
|
||||||
expect: "Sur la carte Transactions ou Budget, clique « Passer cette étape » : la carte laisse la place au bandeau (ou au message de fin si c'était la dernière étape) et le compteur avance d'une étape. Sur la page Import, passer une étape fait passer la carte à l'étape suivante de la page. Dans la liste dépliée, l'étape passée montre l'icône « passer », le badge « Passée » et « Reprendre ». Depuis une autre page, « Reprendre » rouvre la page de l'étape avec sa carte, le focus sur son titre, et l'étape redevient à faire." },
|
expect: "Sur la carte Transactions ou Budget, clique « Passer cette étape » : la carte laisse la place au bandeau (ou au message de fin si c'était la dernière étape) et le compteur avance d'une étape. Sur la page Import, passer une étape fait passer la carte à l'étape suivante de la page. Dans la liste dépliée, l'étape passée montre l'icône « passer », le badge « Passée » et « Reprendre ». Depuis une autre page, « Reprendre » rouvre la page de l'étape avec sa carte, le focus sur son titre, et l'étape redevient à faire." },
|
||||||
{ id: "guided-hide-users", title: "« Masquer » depuis Paramètres → Utilisateurs",
|
{ id: "guided-hide-users", title: "« Masquer » depuis Paramètres → Utilisateurs",
|
||||||
|
|
@ -306,6 +310,10 @@
|
||||||
expect: "Sur un profil neuf, la liste montre dans l'ordre : « Créer un profil », « Choisir un dossier d'import », « Configurer une source d'import », « Importer un premier fichier », « Parcourir les catégories et les mots-clés », « Classer une transaction à la main », « Saisir un premier budget », « Parcourir les rapports ». Le compteur dit 1 / 8 (1 / 7 sans clé, la ligne Budget verrouillée « Base et plus »). Après l'import, « Étape suivante » mène à « Parcourir les catégories et les mots-clés ». Ouvrir Catégories ou Rapports sans cliquer ne coche rien. En anglais : “Browse categories and keywords”, “Browse the reports”." },
|
expect: "Sur un profil neuf, la liste montre dans l'ordre : « Créer un profil », « Choisir un dossier d'import », « Configurer une source d'import », « Importer un premier fichier », « Parcourir les catégories et les mots-clés », « Classer une transaction à la main », « Saisir un premier budget », « Parcourir les rapports ». Le compteur dit 1 / 8 (1 / 7 sans clé, la ligne Budget verrouillée « Base et plus »). Après l'import, « Étape suivante » mène à « Parcourir les catégories et les mots-clés ». Ouvrir Catégories ou Rapports sans cliquer ne coche rien. En anglais : “Browse categories and keywords”, “Browse the reports”." },
|
||||||
{ id: "v0171-visit-got-it", title: "« J'ai compris » coche une étape de visite (#425, #432)",
|
{ id: "v0171-visit-got-it", title: "« J'ai compris » coche une étape de visite (#425, #432)",
|
||||||
expect: "Sur la carte d'une étape de visite (pages Catégories et Rapports), « J'ai compris » s'affiche avant « Passer cette étape ». Un clic coche l'étape : la carte reste, annonce « Fait. » et propose l'étape suivante (ou le message de fin après Rapports) ; dans la liste, la ligne est barrée, sans le badge « Passée ». « Passer cette étape » donne, lui, une ligne « Passée » avec « Reprendre ». En anglais : “Got it”." },
|
expect: "Sur la carte d'une étape de visite (pages Catégories et Rapports), « J'ai compris » s'affiche avant « Passer cette étape ». Un clic coche l'étape : la carte reste, annonce « Fait. » et propose l'étape suivante (ou le message de fin après Rapports) ; dans la liste, la ligne est barrée, sans le badge « Passée ». « Passer cette étape » donne, lui, une ligne « Passée » avec « Reprendre ». En anglais : “Got it”." },
|
||||||
|
{ id: "v0171-reports-free-padlock", title: "Rapports : la carte mentionne le cadenas en édition Gratuite (#432)",
|
||||||
|
expect: "Sur une session ou une machine sans clé, arrive sur la carte Rapports par « Étape suivante » (« Étape 7 sur 7 »). La phrase finit par « Les cartes à cadenas demandent l'édition Base. », et les cartes Faits saillants, Comparables, Zoom catégorie et Cartes portent un cadenas ; la carte Tendances, entourée, n'en a pas. Avec une clé Base ou plus, la phrase s'arrête à « commencez par « Tendances ». ». En anglais : “Cards with a padlock require the Base edition.”" },
|
||||||
|
{ id: "v0171-reports-trends-click", title: "Rapports : ouvrir la carte Tendances entourée coche l'étape (#432)",
|
||||||
|
expect: "Sur un profil où l'étape 8 est la dernière à faire, arrive sur la carte Rapports par « Étape suivante », puis clique la carte Tendances entourée (pas « J'ai compris »). Le rapport Tendances s'ouvre, et le bandeau, en haut de cette page, annonce « Démarrage terminé. » ; dans la liste, « Parcourir les rapports » est barrée, sans le badge « Passée ». Sur un profil où une étape antérieure reste à faire, ouvre Rapports par le menu : le bandeau s'affiche, sans carte d'étape ni contour, et cliquer Tendances ne coche pas « Parcourir les rapports »." },
|
||||||
{ id: "v0171-visit-auto-done", title: "Profil au parcours fini : les étapes de visite sont faites d'office (#425)",
|
{ id: "v0171-visit-auto-done", title: "Profil au parcours fini : les étapes de visite sont faites d'office (#425)",
|
||||||
expect: "Ouvre, avec cette version, un profil dont les six anciennes étapes étaient faites ou passées et dont le parcours n'est pas masqué (il affichait « Démarrage terminé »). À aucun moment le bandeau ne passe par « Étape suivante : Parcourir les catégories et les mots-clés » : il garde « Démarrage terminé ». Dans la liste, les deux étapes de visite sont barrées. Le Journal (Paramètres → Système) porte « Onboarding: the path was already complete, visit steps marked done ». Rouvrir le profil ne change plus rien." },
|
expect: "Ouvre, avec cette version, un profil dont les six anciennes étapes étaient faites ou passées et dont le parcours n'est pas masqué (il affichait « Démarrage terminé »). À aucun moment le bandeau ne passe par « Étape suivante : Parcourir les catégories et les mots-clés » : il garde « Démarrage terminé ». Dans la liste, les deux étapes de visite sont barrées. Le Journal (Paramètres → Système) porte « Onboarding: the path was already complete, visit steps marked done ». Rouvrir le profil ne change plus rien." },
|
||||||
{ id: "v0171-visit-incomplete", title: "Profil au parcours incomplet : les étapes de visite restent à faire (#425)",
|
{ id: "v0171-visit-incomplete", title: "Profil au parcours incomplet : les étapes de visite restent à faire (#425)",
|
||||||
|
|
|
||||||
|
|
@ -21,22 +21,6 @@ vi.mock("react-router-dom", async (importOriginal) => ({
|
||||||
useNavigate: () => ctx.navigate,
|
useNavigate: () => ctx.navigate,
|
||||||
}));
|
}));
|
||||||
vi.mock("../../contexts/OnboardingContext", () => ({ useOnboarding: () => ctx.onboarding }));
|
vi.mock("../../contexts/OnboardingContext", () => ({ useOnboarding: () => ctx.onboarding }));
|
||||||
// /categories guides no step before its card ships (#432): a stand-in page
|
|
||||||
// that guides the categories step, so the wiring of "Got it" is exercised now.
|
|
||||||
vi.mock("../../utils/onboardingPath", async (importOriginal) => {
|
|
||||||
const orig = await importOriginal<typeof import("../../utils/onboardingPath")>();
|
|
||||||
return {
|
|
||||||
...orig,
|
|
||||||
cardStep: (...args: Parameters<typeof orig.cardStep>) => {
|
|
||||||
const [checklist, pathname] = args;
|
|
||||||
if (pathname !== "/stand-in-categories") return orig.cardStep(checklist, pathname);
|
|
||||||
const unlocked = checklist.items.filter((item) => !item.locked);
|
|
||||||
const step = unlocked.find((item) => item.key === "categories")!;
|
|
||||||
return { step, n: unlocked.indexOf(step) + 1, total: unlocked.length };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
import { hookHarness } from "../../test/hookHarness";
|
import { hookHarness } from "../../test/hookHarness";
|
||||||
import OnboardingStepCard from "./OnboardingStepCard";
|
import OnboardingStepCard from "./OnboardingStepCard";
|
||||||
import StepCard, { type StepCardProps } from "./StepCard";
|
import StepCard, { type StepCardProps } from "./StepCard";
|
||||||
|
|
@ -183,10 +167,10 @@ describe("OnboardingStepCard — what it hands the card (#398)", () => {
|
||||||
expect(render()!.props.onAcknowledge).toBeUndefined();
|
expect(render()!.props.onAcknowledge).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("'Got it' on a visit step acknowledges the step the card shows (#425)", () => {
|
it("'Got it' on the Categories card acknowledges the categories step (#425, #432)", () => {
|
||||||
ctx.location = { pathname: "/stand-in-categories", state: null, key: "arrival-1" };
|
ctx.location = { pathname: "/categories", state: null, key: "arrival-1" };
|
||||||
ctx.onboarding = path(IMPORTED, {
|
ctx.onboarding = path(IMPORTED, {
|
||||||
cardPage: "/stand-in-categories",
|
cardPage: "/categories",
|
||||||
checklist: computeChecklist(IMPORTED, {
|
checklist: computeChecklist(IMPORTED, {
|
||||||
importFolderSet: true,
|
importFolderSet: true,
|
||||||
budgetAllowed: true,
|
budgetAllowed: true,
|
||||||
|
|
@ -203,6 +187,26 @@ describe("OnboardingStepCard — what it hands the card (#398)", () => {
|
||||||
expect(value.skip).not.toHaveBeenCalled();
|
expect(value.skip).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("'Got it' on the Reports card acknowledges the reports step, last of eight (#432)", () => {
|
||||||
|
const allButReports: OnboardingCounters = { ...IMPORTED, manualCategorized: 1, budgetEntries: 1 };
|
||||||
|
ctx.location = { pathname: "/reports", state: null, key: "arrival-1" };
|
||||||
|
ctx.onboarding = path(allButReports, {
|
||||||
|
cardPage: "/reports",
|
||||||
|
checklist: computeChecklist(allButReports, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
acknowledged: ["categories"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const value = ctx.onboarding as OnboardingValue;
|
||||||
|
const props = render()!.props;
|
||||||
|
expect(props.step.key).toBe("reports");
|
||||||
|
expect(props.position).toEqual({ n: 8, total: 8 });
|
||||||
|
props.onAcknowledge!();
|
||||||
|
expect(value.acknowledge).toHaveBeenCalledWith("reports");
|
||||||
|
});
|
||||||
|
|
||||||
it("'Skip this step' skips the step the card shows; 'Close' hides the path", () => {
|
it("'Skip this step' skips the step the card shows; 'Close' hides the path", () => {
|
||||||
const value = ctx.onboarding as OnboardingValue;
|
const value = ctx.onboarding as OnboardingValue;
|
||||||
const props = render()!.props;
|
const props = render()!.props;
|
||||||
|
|
|
||||||
66
src/components/reports/HubReportNavCard.render.test.tsx
Normal file
66
src/components/reports/HubReportNavCard.render.test.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// HubReportNavCard — the report card of the hub and its getting-started props
|
||||||
|
// (#432). No jsdom: react-dom/server renders the markup, and the component
|
||||||
|
// called as a function exposes the props of the link it returns (the
|
||||||
|
// FilterPanel.test.tsx pattern), for the click handler.
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
|
|
||||||
|
import HubReportNavCard, { REPORTS_TRENDS_TARGET, type HubReportNavCardProps } from "./HubReportNavCard";
|
||||||
|
|
||||||
|
const BASE: HubReportNavCardProps = {
|
||||||
|
to: "/reports/trends",
|
||||||
|
icon: null,
|
||||||
|
title: "Tendances",
|
||||||
|
description: "Évolution",
|
||||||
|
};
|
||||||
|
|
||||||
|
const HIGHLIGHT = 'data-onboarding-highlight="true"';
|
||||||
|
|
||||||
|
const render = (props: Partial<HubReportNavCardProps> = {}) =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<MemoryRouter>
|
||||||
|
<HubReportNavCard {...BASE} {...props} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The link the card returns, read without rendering it. */
|
||||||
|
const link = (props: Partial<HubReportNavCardProps> = {}) =>
|
||||||
|
HubReportNavCard({ ...BASE, ...props }) as ReactElement<{ onClick?: () => void; to: string }>;
|
||||||
|
|
||||||
|
describe("HubReportNavCard — the getting-started target (#432)", () => {
|
||||||
|
it("carries the highlight on its link with onboardingTarget", () => {
|
||||||
|
const html = render({ onboardingTarget: true });
|
||||||
|
expect(html).toContain(`data-onboarding-target="${REPORTS_TRENDS_TARGET}" ${HIGHLIGHT}`);
|
||||||
|
expect(html.indexOf(HIGHLIGHT)).toBeLessThan(html.indexOf('href="/reports/trends"'));
|
||||||
|
expect(html.split(HIGHLIGHT).length - 1).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries no highlight without it", () => {
|
||||||
|
for (const onboardingTarget of [undefined, false]) {
|
||||||
|
const html = render({ onboardingTarget });
|
||||||
|
expect(html).not.toContain("data-onboarding-target");
|
||||||
|
expect(html).not.toContain("data-onboarding-highlight");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a click calls onNavigate when it is given", () => {
|
||||||
|
const onNavigate = vi.fn();
|
||||||
|
const element = link({ onNavigate });
|
||||||
|
expect(element.props.to).toBe("/reports/trends");
|
||||||
|
element.props.onClick!();
|
||||||
|
expect(onNavigate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without onNavigate, the link has no click handler of its own", () => {
|
||||||
|
expect(link().props.onClick).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps its lock badge, translated", () => {
|
||||||
|
expect(render({ locked: true })).toContain('aria-label="t(nav.locked)"');
|
||||||
|
expect(render()).not.toContain("t(nav.locked)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,6 +2,10 @@ import type { ReactNode } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Lock } from "lucide-react";
|
import { Lock } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
|
||||||
|
|
||||||
|
/** The target key of the Trends card while the Reports step card points at it (#432). */
|
||||||
|
export const REPORTS_TRENDS_TARGET = "reports-trends-card";
|
||||||
|
|
||||||
export interface HubReportNavCardProps {
|
export interface HubReportNavCardProps {
|
||||||
to: string;
|
to: string;
|
||||||
|
|
@ -13,6 +17,13 @@ export interface HubReportNavCardProps {
|
||||||
* entitled). The card stays clickable — the gated route renders the upsell.
|
* entitled). The card stays clickable — the gated route renders the upsell.
|
||||||
*/
|
*/
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
|
/**
|
||||||
|
* The getting-started card of the Reports page points at this card (#432):
|
||||||
|
* the link carries the highlight attributes.
|
||||||
|
*/
|
||||||
|
onboardingTarget?: boolean;
|
||||||
|
/** Called on a click, before the navigation (the Trends card acknowledges the step, D17). */
|
||||||
|
onNavigate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HubReportNavCard({
|
export default function HubReportNavCard({
|
||||||
|
|
@ -21,12 +32,16 @@ export default function HubReportNavCard({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
locked,
|
locked,
|
||||||
|
onboardingTarget,
|
||||||
|
onNavigate,
|
||||||
}: HubReportNavCardProps) {
|
}: HubReportNavCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
|
onClick={onNavigate}
|
||||||
|
{...onboardingTargetAttributes(REPORTS_TRENDS_TARGET, onboardingTarget)}
|
||||||
className="relative group bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 flex flex-col gap-2 hover:border-[var(--primary)] hover:shadow-sm transition-all"
|
className="relative group bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 flex flex-col gap-2 hover:border-[var(--primary)] hover:shadow-sm transition-all"
|
||||||
>
|
>
|
||||||
{locked && (
|
{locked && (
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,11 @@
|
||||||
"budget": {
|
"budget": {
|
||||||
"collapsed": "Click “{{expand}}” to show your categories. Only the last-level ones, without a chevron, take an amount: click a month and type it (Enter saves; Tab saves and moves to the next month). An amount typed in the “{{annual}}” column is split evenly over the 12 months and replaces the monthly amounts.",
|
"collapsed": "Click “{{expand}}” to show your categories. Only the last-level ones, without a chevron, take an amount: click a month and type it (Enter saves; Tab saves and moves to the next month). An amount typed in the “{{annual}}” column is split evenly over the 12 months and replaces the monthly amounts.",
|
||||||
"flat": "Only last-level categories, without a chevron, take an amount: click a month and type it (Enter saves; Tab saves and moves to the next month). An amount typed in the “{{annual}}” column is split evenly over the 12 months and replaces the monthly amounts."
|
"flat": "Only last-level categories, without a chevron, take an amount: click a month and type it (Enter saves; Tab saves and moves to the next month). An amount typed in the “{{annual}}” column is split evenly over the 12 months and replaces the monthly amounts."
|
||||||
|
},
|
||||||
|
"categories": "A keyword categorizes, as soon as it is imported, every transaction whose description contains it; when two keywords apply, the one with the higher priority wins. “{{allKeywords}}” lists them all, with their category. In the next step, you will add one from a transaction.",
|
||||||
|
"reports": {
|
||||||
|
"all": "Pick the period at the top of the page: the highlights panel sums up your balance and your biggest moves. Each card below opens a detailed report; start with “{{trends}}”.",
|
||||||
|
"locked": "Pick the period at the top of the page: the highlights panel sums up your balance and your biggest moves. Each card below opens a detailed report; start with “{{trends}}”. Cards with a padlock require the Base edition."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
|
|
@ -920,7 +925,7 @@
|
||||||
"steps": [
|
"steps": [
|
||||||
"The getting-started banner shows at the top of every page while the path is not hidden. It shows your progress, for instance \"Getting started 3/8\", and the next step, with a link to the page where it is done. It does not show on the dashboard of a profile without transactions, whose welcome card already holds the list",
|
"The getting-started banner shows at the top of every page while the path is not hidden. It shows your progress, for instance \"Getting started 3/8\", and the next step, with a link to the page where it is done. It does not show on the dashboard of a profile without transactions, whose welcome card already holds the list",
|
||||||
"\"Show the list\" unfolds the eight steps under the banner and \"Close the list\" folds them; the list also folds when you change pages. Each step still to do leads to the page where it is done",
|
"\"Show the list\" unfolds the eight steps under the banner and \"Close the list\" folds them; the list also folds when you change pages. Each step still to do leads to the page where it is done",
|
||||||
"On the Import, Transactions and Budget pages, while the next step is done there, a getting-started card takes the place of the banner. It shows \"Step n of N\", says what to do and puts an outline around the control to use. The outline pulses three times, and not at all if your system asks for reduced motion. When you arrive by a \"Next step\" link, the keyboard focus goes to the card's title",
|
"On the Import, Categories, Transactions, Budget and Reports pages, while the next step is done there, a getting-started card takes the place of the banner. It shows \"Step n of N\", says what to do and puts an outline around the control to use. The outline pulses three times, and not at all if your system asks for reduced motion. When you arrive by a \"Next step\" link, the keyboard focus goes to the card's title. On the Categories and Reports pages, the card ends with \"Got it\", which checks the step; on the Reports page, opening the outlined Trends card checks it too",
|
||||||
"The card follows the step without you changing pages. On the Import page, it goes along with the wizard screen by screen. The link of the \"Categorize a transaction by hand\" step opens the Transactions page on the uncategorized transactions whatever their date, or on every transaction when none is left uncategorized. On the Budget page, the card first points at \"Expand all\", then at the current month's cell of the first category you can fill in; it never unfolds the grid for you. On the Transactions page, the card suggests right-clicking and “Add as keyword”; on the Budget page, it recalls that only last-level categories take an amount and what the Annual column does",
|
"The card follows the step without you changing pages. On the Import page, it goes along with the wizard screen by screen. The link of the \"Categorize a transaction by hand\" step opens the Transactions page on the uncategorized transactions whatever their date, or on every transaction when none is left uncategorized. On the Budget page, the card first points at \"Expand all\", then at the current month's cell of the first category you can fill in; it never unfolds the grid for you. On the Transactions page, the card suggests right-clicking and “Add as keyword”; on the Budget page, it recalls that only last-level categories take an amount and what the Annual column does",
|
||||||
"As soon as the step is done, the card stays, says \"Done.\" and offers the next step, with a link to its page. On the Import page, it moves from one step to the next without leaving the page and says \"Done.\" once the file is imported; never for an import that added no transaction: the report says why, and the step stays to do",
|
"As soon as the step is done, the card stays, says \"Done.\" and offers the next step, with a link to its page. On the Import page, it moves from one step to the next without leaving the page and says \"Done.\" once the file is imported; never for an import that added no transaction: the report says why, and the step stays to do",
|
||||||
"\"Skip this step\", on the card, marks the step as skipped: it counts towards your progress, and the list shows it with the \"Skipped\" badge and a \"Resume\" link. \"Resume\" puts the step back to do and opens its page",
|
"\"Skip this step\", on the card, marks the step as skipped: it counts towards your progress, and the list shows it with the \"Skipped\" badge and a \"Resume\" link. \"Resume\" puts the step back to do and opens its page",
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,11 @@
|
||||||
"budget": {
|
"budget": {
|
||||||
"collapsed": "Cliquez « {{expand}} » pour afficher vos catégories. Seules celles du dernier niveau, sans chevron, acceptent un montant : cliquez sur un mois et saisissez-le (Entrée enregistre ; Tab enregistre et passe au mois suivant). Un montant saisi dans la colonne « {{annual}} » est réparti également sur les 12 mois et remplace les montants mensuels.",
|
"collapsed": "Cliquez « {{expand}} » pour afficher vos catégories. Seules celles du dernier niveau, sans chevron, acceptent un montant : cliquez sur un mois et saisissez-le (Entrée enregistre ; Tab enregistre et passe au mois suivant). Un montant saisi dans la colonne « {{annual}} » est réparti également sur les 12 mois et remplace les montants mensuels.",
|
||||||
"flat": "Seules les catégories du dernier niveau, sans chevron, acceptent un montant : cliquez sur un mois et saisissez-le (Entrée enregistre ; Tab enregistre et passe au mois suivant). Un montant saisi dans la colonne « {{annual}} » est réparti également sur les 12 mois et remplace les montants mensuels."
|
"flat": "Seules les catégories du dernier niveau, sans chevron, acceptent un montant : cliquez sur un mois et saisissez-le (Entrée enregistre ; Tab enregistre et passe au mois suivant). Un montant saisi dans la colonne « {{annual}} » est réparti également sur les 12 mois et remplace les montants mensuels."
|
||||||
|
},
|
||||||
|
"categories": "Un mot-clé classe d'office, dès l'import, chaque transaction dont la description le contient ; quand deux mots-clés s'appliquent, le plus prioritaire l'emporte. « {{allKeywords}} » les liste tous, avec leur catégorie. À l'étape suivante, vous en ajouterez un depuis une transaction.",
|
||||||
|
"reports": {
|
||||||
|
"all": "Choisissez la période en haut de la page : le panneau des faits saillants résume votre solde et vos plus gros mouvements. Chaque carte du bas ouvre un rapport détaillé ; commencez par « {{trends}} ».",
|
||||||
|
"locked": "Choisissez la période en haut de la page : le panneau des faits saillants résume votre solde et vos plus gros mouvements. Chaque carte du bas ouvre un rapport détaillé ; commencez par « {{trends}} ». Les cartes à cadenas demandent l'édition Base."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
|
|
@ -920,7 +925,7 @@
|
||||||
"steps": [
|
"steps": [
|
||||||
"Le bandeau de démarrage s'affiche en haut de chaque page tant que le parcours n'est pas masqué. Il indique votre progression, par exemple « Démarrage 3/8 », et l'étape suivante, avec un lien vers la page où la faire. 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",
|
"Le bandeau de démarrage s'affiche en haut de chaque page tant que le parcours n'est pas masqué. Il indique votre progression, par exemple « Démarrage 3/8 », et l'étape suivante, avec un lien vers la page où la faire. 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",
|
||||||
"« Voir la liste » déplie les huit étapes sous le bandeau et « Fermer la liste » les replie ; la liste se replie aussi quand vous changez de page. Chaque étape à faire mène à la page où la faire",
|
"« Voir la liste » déplie les huit étapes sous le bandeau et « Fermer la liste » les replie ; la liste se replie aussi quand vous changez de page. Chaque étape à faire mène à la page où la faire",
|
||||||
"Sur les pages Import, Transactions et Budget, quand l'étape suivante s'y fait, une carte de démarrage remplace le bandeau. Elle indique « Étape n sur N », dit quoi faire et entoure d'un contour le contrôle à utiliser. Le contour pulse trois fois, et pas du tout si votre système demande de réduire les animations. Si vous arrivez par un lien « Étape suivante », le focus du clavier se place sur le titre de la carte",
|
"Sur les pages Import, Catégories, Transactions, Budget et Rapports, quand l'étape suivante s'y fait, une carte de démarrage remplace le bandeau. Elle indique « Étape n sur N », dit quoi faire et entoure d'un contour le contrôle à utiliser. Le contour pulse trois fois, et pas du tout si votre système demande de réduire les animations. Si vous arrivez par un lien « Étape suivante », le focus du clavier se place sur le titre de la carte. Sur les pages Catégories et Rapports, la carte se termine par « J'ai compris », qui coche l'étape ; sur la page Rapports, ouvrir la carte Tendances entourée la coche aussi",
|
||||||
"La carte suit l'étape sans que vous changiez de page. Sur la page Import, elle accompagne l'assistant écran par écran. Le lien de l'étape « Classer une transaction à la main » ouvre la page Transactions sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer. Sur la page Budget, la carte désigne d'abord « Tout déplier », puis la cellule du mois courant de la première catégorie à remplir ; elle ne déplie jamais la grille à votre place. Sur la page Transactions, la carte propose le clic droit et « Ajouter comme mot-clé » ; sur la page Budget, elle rappelle que seules les catégories du dernier niveau acceptent un montant et ce que fait la colonne Annuel",
|
"La carte suit l'étape sans que vous changiez de page. Sur la page Import, elle accompagne l'assistant écran par écran. Le lien de l'étape « Classer une transaction à la main » ouvre la page Transactions sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer. Sur la page Budget, la carte désigne d'abord « Tout déplier », puis la cellule du mois courant de la première catégorie à remplir ; elle ne déplie jamais la grille à votre place. Sur la page Transactions, la carte propose le clic droit et « Ajouter comme mot-clé » ; sur la page Budget, elle rappelle que seules les catégories du dernier niveau acceptent un montant et ce que fait la colonne Annuel",
|
||||||
"Dès que l'étape est faite, la carte reste affichée, annonce « Fait. » et propose l'étape suivante, avec un lien vers sa page. Sur la page Import, elle passe d'une étape à la suivante sans quitter la page et annonce « Fait. » une fois le fichier importé ; jamais pour un import qui n'a ajouté aucune transaction : le rapport dit pourquoi, et l'étape reste à faire",
|
"Dès que l'étape est faite, la carte reste affichée, annonce « Fait. » et propose l'étape suivante, avec un lien vers sa page. Sur la page Import, elle passe d'une étape à la suivante sans quitter la page et annonce « Fait. » une fois le fichier importé ; jamais pour un import qui n'a ajouté aucune transaction : le rapport dit pourquoi, et l'étape reste à faire",
|
||||||
"« Passer cette étape », sur la carte, marque l'étape comme passée : elle compte dans la progression, et la liste l'affiche avec le badge « Passée » et un lien « Reprendre ». « Reprendre » remet l'étape à faire et ouvre sa page",
|
"« Passer cette étape », sur la carte, marque l'étape comme passée : elle compte dans la progression, et la liste l'affiche avec le badge « Passée » et un lien « Reprendre ». « Reprendre » remet l'étape à faire et ouvre sa page",
|
||||||
|
|
|
||||||
152
src/pages/CategoriesPage.render.test.tsx
Normal file
152
src/pages/CategoriesPage.render.test.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
// CategoriesPage and its getting-started card (#432). No jsdom:
|
||||||
|
// react-dom/server renders in vitest's node environment. The categories hook
|
||||||
|
// is stubbed (the page is loading, its header shows), and the path is a fake
|
||||||
|
// `useOnboarding()` built from the real checklist. What is pinned is the
|
||||||
|
// page's wiring: where the card sits, what it says, and which control carries
|
||||||
|
// the one highlight ("All keywords", never "Reset").
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
||||||
|
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
|
||||||
|
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
|
||||||
|
|
||||||
|
const harness = vi.hoisted(() => ({ onboarding: null as unknown }));
|
||||||
|
|
||||||
|
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
|
||||||
|
vi.mock("../hooks/useCategories", () => ({
|
||||||
|
useCategories: () => ({
|
||||||
|
state: {
|
||||||
|
categories: [],
|
||||||
|
tree: [],
|
||||||
|
keywords: [],
|
||||||
|
selectedCategoryId: null,
|
||||||
|
editingCategory: null,
|
||||||
|
isCreating: false,
|
||||||
|
isLoading: true,
|
||||||
|
isSaving: false,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import CategoriesPage, { CATEGORIES_ALL_KEYWORDS_TARGET } from "./CategoriesPage";
|
||||||
|
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
|
||||||
|
import { computeChecklist } from "../utils/onboardingChecklist";
|
||||||
|
import { nextStep } from "../utils/onboardingPath";
|
||||||
|
import type { ChecklistItemKey, OnboardingCounters } from "../shared/types";
|
||||||
|
|
||||||
|
/** Folder, source and import done: the next step is the categories step. */
|
||||||
|
const IMPORTED: OnboardingCounters = {
|
||||||
|
sources: 1,
|
||||||
|
imports: 1,
|
||||||
|
manualCategorized: 0,
|
||||||
|
budgetEntries: 0,
|
||||||
|
transactions: 41,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The path latched on /categories (card mode), as the provider shares it. */
|
||||||
|
function path(acknowledged: ChecklistItemKey[], overrides: Partial<OnboardingValue> = {}): OnboardingValue {
|
||||||
|
const checklist = computeChecklist(IMPORTED, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
acknowledged,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: "ready",
|
||||||
|
checklist,
|
||||||
|
counters: IMPORTED,
|
||||||
|
hidden: false,
|
||||||
|
budgetReady: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
nextStep: nextStep(checklist),
|
||||||
|
mode: "card",
|
||||||
|
cardPage: "/categories",
|
||||||
|
hide: vi.fn(async () => undefined),
|
||||||
|
show: vi.fn(async () => undefined),
|
||||||
|
skip: vi.fn(async () => undefined),
|
||||||
|
resume: vi.fn(async () => undefined),
|
||||||
|
acknowledge: vi.fn(async () => undefined),
|
||||||
|
refresh: vi.fn(async () => undefined),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const render = () =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<MemoryRouter initialEntries={["/categories"]}>
|
||||||
|
<CategoriesPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const CARD = 'aria-label="t(onboarding.banner.label)"';
|
||||||
|
const HIGHLIGHT = 'data-onboarding-highlight="true"';
|
||||||
|
const count = (html: string, needle: string) => html.split(needle).length - 1;
|
||||||
|
const sentence = (html: string) => html.match(/<\/h2><p [^>]*>(.*?)<\/p>/)?.[1] ?? null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
harness.onboarding = path([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CategoriesPage — the step card (#432)", () => {
|
||||||
|
it("renders the card first, before the page's heading, fifth of eight", () => {
|
||||||
|
const html = render();
|
||||||
|
expect(count(html, CARD)).toBe(1);
|
||||||
|
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
|
||||||
|
expect(html).toContain("t(onboarding.card.stepOf[n=5,total=8])");
|
||||||
|
expect(html).toContain("t(onboarding.checklist.items.categories)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains keywords, 'All keywords' named by its own label", () => {
|
||||||
|
expect(sentence(render())).toBe(
|
||||||
|
"t(onboarding.card.categories[allKeywords=t(categories.allKeywords)])",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers 'Got it' and 'Skip this step'", () => {
|
||||||
|
const html = render();
|
||||||
|
expect(html).toContain("t(onboarding.card.acknowledge)");
|
||||||
|
expect(html).toContain("t(onboarding.card.skip)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'All keywords' is the one target, never 'Reset'", () => {
|
||||||
|
const html = render();
|
||||||
|
expect(count(html, HIGHLIGHT)).toBe(1);
|
||||||
|
expect(html).toContain(`data-onboarding-target="${CATEGORIES_ALL_KEYWORDS_TARGET}" ${HIGHLIGHT}`);
|
||||||
|
const ring = html.indexOf(HIGHLIGHT);
|
||||||
|
const allKeywords = html.indexOf("t(categories.allKeywords)</button>");
|
||||||
|
const reset = html.indexOf("t(categories.reinitialize)</button>");
|
||||||
|
// The ring sits on the button whose label closes right after it, before "Reset".
|
||||||
|
expect(ring).toBeLessThan(allKeywords);
|
||||||
|
expect(html.lastIndexOf("<button", allKeywords)).toBeLessThan(ring);
|
||||||
|
expect(ring).toBeLessThan(reset);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the step acknowledged: 'Done.', the next step is the manual category, no ring", () => {
|
||||||
|
harness.onboarding = path(["categories"]);
|
||||||
|
const html = render();
|
||||||
|
expect(count(html, CARD)).toBe(1);
|
||||||
|
expect(html).toContain("t(onboarding.card.done)");
|
||||||
|
expect(html).toContain("t(onboarding.checklist.items.manualCategory)");
|
||||||
|
expect(html).not.toContain("t(onboarding.card.acknowledge)");
|
||||||
|
expect(html).not.toContain(HIGHLIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without the card on screen, no card and no ring", () => {
|
||||||
|
for (const overrides of [{ mode: "banner" as const }, { cardPage: null }, { cardPage: "/import" }]) {
|
||||||
|
harness.onboarding = path([], overrides);
|
||||||
|
const html = render();
|
||||||
|
expect(html, JSON.stringify(overrides)).not.toContain(CARD);
|
||||||
|
expect(html, JSON.stringify(overrides)).not.toContain(HIGHLIGHT);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opening the page acknowledges nothing", () => {
|
||||||
|
const value = harness.onboarding as OnboardingValue;
|
||||||
|
render();
|
||||||
|
expect(value.acknowledge).not.toHaveBeenCalled();
|
||||||
|
expect(value.skip).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,11 +1,22 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
import { Plus, RotateCcw, List, AlertTriangle } from "lucide-react";
|
import { Plus, RotateCcw, List, AlertTriangle } from "lucide-react";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import { useCategories } from "../hooks/useCategories";
|
import { useCategories } from "../hooks/useCategories";
|
||||||
import CategoryTree from "../components/categories/CategoryTree";
|
import CategoryTree from "../components/categories/CategoryTree";
|
||||||
import CategoryDetailPanel from "../components/categories/CategoryDetailPanel";
|
import CategoryDetailPanel from "../components/categories/CategoryDetailPanel";
|
||||||
import AllKeywordsPanel from "../components/categories/AllKeywordsPanel";
|
import AllKeywordsPanel from "../components/categories/AllKeywordsPanel";
|
||||||
|
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
|
||||||
|
import { useOnboarding } from "../contexts/OnboardingContext";
|
||||||
|
import {
|
||||||
|
categoriesCardModel,
|
||||||
|
onboardingTargetAttributes,
|
||||||
|
showsStepCard,
|
||||||
|
} from "../utils/onboardingPath";
|
||||||
|
|
||||||
|
/** The target key of "All keywords" while the Categories step card points at it (#432). */
|
||||||
|
export const CATEGORIES_ALL_KEYWORDS_TARGET = "categories-all-keywords";
|
||||||
|
|
||||||
export default function CategoriesPage() {
|
export default function CategoriesPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -26,6 +37,16 @@ export default function CategoriesPage() {
|
||||||
moveCategory,
|
moveCategory,
|
||||||
} = useCategories();
|
} = useCategories();
|
||||||
|
|
||||||
|
// The getting-started card (#432) points at "All keywords" while it is on
|
||||||
|
// screen, never at "Reset" (it deletes the keywords and uncategorizes every
|
||||||
|
// transaction).
|
||||||
|
const location = useLocation();
|
||||||
|
const onboarding = useOnboarding();
|
||||||
|
const cardModel = categoriesCardModel(onboarding.checklist);
|
||||||
|
const target = showsStepCard(onboarding.mode, onboarding.cardPage, location.pathname)
|
||||||
|
? cardModel.target
|
||||||
|
: null;
|
||||||
|
|
||||||
const handleReinitialize = async () => {
|
const handleReinitialize = async () => {
|
||||||
setShowReinitConfirm(false);
|
setShowReinitConfirm(false);
|
||||||
await reinitializeCategories();
|
await reinitializeCategories();
|
||||||
|
|
@ -38,6 +59,8 @@ export default function CategoriesPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<OnboardingStepCard model={cardModel} />
|
||||||
|
|
||||||
<div className="relative flex items-center justify-between mb-6">
|
<div className="relative flex items-center justify-between mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold">{t("categories.title")}</h1>
|
<h1 className="text-2xl font-bold">{t("categories.title")}</h1>
|
||||||
|
|
@ -46,6 +69,7 @@ export default function CategoriesPage() {
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAllKeywords((v) => !v)}
|
onClick={() => setShowAllKeywords((v) => !v)}
|
||||||
|
{...onboardingTargetAttributes(CATEGORIES_ALL_KEYWORDS_TARGET, target === "allKeywords")}
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||||
showAllKeywords
|
showAllKeywords
|
||||||
? "border-[var(--primary)] bg-[var(--primary)]/10 text-[var(--primary)]"
|
? "border-[var(--primary)] bg-[var(--primary)]/10 text-[var(--primary)]"
|
||||||
|
|
|
||||||
178
src/pages/ReportsPage.render.test.tsx
Normal file
178
src/pages/ReportsPage.render.test.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
// ReportsPage and its getting-started card (#432). No jsdom:
|
||||||
|
// react-dom/server renders in vitest's node environment. The highlights and
|
||||||
|
// the entitlement are stubbed, the path is a fake `useOnboarding()` built from
|
||||||
|
// the real checklist, and the report cards are the real ones, their props
|
||||||
|
// recorded on the way. What is pinned is the page's wiring: where the card
|
||||||
|
// sits, the padlock variant, the Trends card as the one target, and its click
|
||||||
|
// acknowledging the step (D17).
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import type { HubReportNavCardProps } from "../components/reports/HubReportNavCard";
|
||||||
|
|
||||||
|
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
||||||
|
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
|
||||||
|
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
|
||||||
|
vi.mock("../components/reports/HubHighlightsPanel", () => ({ default: () => null }));
|
||||||
|
|
||||||
|
const harness = vi.hoisted(() => ({
|
||||||
|
onboarding: null as unknown,
|
||||||
|
entitlement: { allowed: true, ready: true },
|
||||||
|
cards: [] as HubReportNavCardProps[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
|
||||||
|
vi.mock("../hooks/useEntitlement", () => ({ useEntitlement: () => harness.entitlement }));
|
||||||
|
vi.mock("../hooks/useHighlights", () => ({
|
||||||
|
useHighlights: () => ({ data: null, isLoading: false, error: null }),
|
||||||
|
}));
|
||||||
|
vi.mock("../components/reports/HubReportNavCard", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../components/reports/HubReportNavCard")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
default: (props: HubReportNavCardProps) => {
|
||||||
|
harness.cards.push(props);
|
||||||
|
return actual.default(props);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import ReportsPage from "./ReportsPage";
|
||||||
|
import { REPORTS_TRENDS_TARGET } from "../components/reports/HubReportNavCard";
|
||||||
|
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
|
||||||
|
import { computeChecklist } from "../utils/onboardingChecklist";
|
||||||
|
import { nextStep } from "../utils/onboardingPath";
|
||||||
|
import type { ChecklistItemKey, OnboardingCounters } from "../shared/types";
|
||||||
|
|
||||||
|
/** Every data step done: only the reports step is left. */
|
||||||
|
const ALL_DATA: OnboardingCounters = {
|
||||||
|
sources: 1,
|
||||||
|
imports: 1,
|
||||||
|
manualCategorized: 1,
|
||||||
|
budgetEntries: 1,
|
||||||
|
transactions: 41,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The path latched on /reports (card mode), as the provider shares it. */
|
||||||
|
function path(
|
||||||
|
acknowledged: ChecklistItemKey[],
|
||||||
|
overrides: Partial<OnboardingValue> = {},
|
||||||
|
budgetAllowed = true,
|
||||||
|
): OnboardingValue {
|
||||||
|
const checklist = computeChecklist(ALL_DATA, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
acknowledged,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: "ready",
|
||||||
|
checklist,
|
||||||
|
counters: ALL_DATA,
|
||||||
|
hidden: false,
|
||||||
|
budgetReady: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
nextStep: nextStep(checklist),
|
||||||
|
mode: "card",
|
||||||
|
cardPage: "/reports",
|
||||||
|
hide: vi.fn(async () => undefined),
|
||||||
|
show: vi.fn(async () => undefined),
|
||||||
|
skip: vi.fn(async () => undefined),
|
||||||
|
resume: vi.fn(async () => undefined),
|
||||||
|
acknowledge: vi.fn(async () => undefined),
|
||||||
|
refresh: vi.fn(async () => undefined),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const render = () =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<MemoryRouter initialEntries={["/reports"]}>
|
||||||
|
<ReportsPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const CARD = 'aria-label="t(onboarding.banner.label)"';
|
||||||
|
const HIGHLIGHT = 'data-onboarding-highlight="true"';
|
||||||
|
const count = (html: string, needle: string) => html.split(needle).length - 1;
|
||||||
|
const sentence = (html: string) => html.match(/<\/h2><p [^>]*>(.*?)<\/p>/)?.[1] ?? null;
|
||||||
|
const trends = () => harness.cards.find((card) => card.to.startsWith("/reports/trends"))!;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
harness.onboarding = path(["categories"]);
|
||||||
|
harness.entitlement = { allowed: true, ready: true };
|
||||||
|
harness.cards = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ReportsPage — the step card (#432)", () => {
|
||||||
|
it("renders the card first, before the page's heading, last of eight", () => {
|
||||||
|
const html = render();
|
||||||
|
expect(count(html, CARD)).toBe(1);
|
||||||
|
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
|
||||||
|
expect(html).toContain("t(onboarding.card.stepOf[n=8,total=8])");
|
||||||
|
expect(html).toContain("t(onboarding.checklist.items.reports)");
|
||||||
|
expect(html).toContain("t(onboarding.card.acknowledge)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every report open: the sentence without the padlock, Trends named by its own label", () => {
|
||||||
|
expect(sentence(render())).toBe("t(onboarding.card.reports.all[trends=t(reports.hub.trends)])");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the Free edition: the sentence that mentions the padlock, last of seven", () => {
|
||||||
|
harness.entitlement = { allowed: false, ready: true };
|
||||||
|
harness.onboarding = path(["categories"], {}, false);
|
||||||
|
const html = render();
|
||||||
|
expect(sentence(html)).toBe("t(onboarding.card.reports.locked[trends=t(reports.hub.trends)])");
|
||||||
|
expect(html).toContain("t(onboarding.card.stepOf[n=7,total=7])");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the license not read yet: no padlock in the sentence, as on the cards", () => {
|
||||||
|
harness.entitlement = { allowed: false, ready: false };
|
||||||
|
expect(sentence(render())).toBe("t(onboarding.card.reports.all[trends=t(reports.hub.trends)])");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the Trends card is the one target", () => {
|
||||||
|
const html = render();
|
||||||
|
expect(count(html, HIGHLIGHT)).toBe(1);
|
||||||
|
expect(html).toContain(`data-onboarding-target="${REPORTS_TRENDS_TARGET}" ${HIGHLIGHT}`);
|
||||||
|
const ring = html.indexOf(HIGHLIGHT);
|
||||||
|
expect(html.indexOf('href="/reports/trends"', ring)).toBeGreaterThan(ring);
|
||||||
|
expect(html.indexOf('href="/reports/compare"')).toBeGreaterThan(ring);
|
||||||
|
expect(html.indexOf('href="/reports/highlights"')).toBeLessThan(ring);
|
||||||
|
for (const card of harness.cards) {
|
||||||
|
if (card === trends()) continue;
|
||||||
|
expect(card.onboardingTarget, card.to).toBeUndefined();
|
||||||
|
expect(card.onNavigate, card.to).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opening the Trends card acknowledges the reports step (D17)", () => {
|
||||||
|
const value = harness.onboarding as OnboardingValue;
|
||||||
|
render();
|
||||||
|
expect(value.acknowledge).not.toHaveBeenCalled();
|
||||||
|
trends().onNavigate!();
|
||||||
|
expect(value.acknowledge).toHaveBeenCalledWith("reports");
|
||||||
|
expect(value.skip).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the step acknowledged: 'Done.', the end message, no ring, the Trends card back to a plain link", () => {
|
||||||
|
harness.onboarding = path(["categories", "reports"]);
|
||||||
|
const html = render();
|
||||||
|
expect(count(html, CARD)).toBe(1);
|
||||||
|
expect(html).toContain("t(onboarding.card.done)");
|
||||||
|
expect(html).toContain("t(onboarding.banner.complete)");
|
||||||
|
expect(html).not.toContain(HIGHLIGHT);
|
||||||
|
expect(trends().onNavigate).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without the card on screen, no card, no ring and no acknowledgement on the Trends card", () => {
|
||||||
|
for (const overrides of [{ mode: "banner" as const }, { cardPage: null }, { cardPage: "/budget" }]) {
|
||||||
|
harness.cards = [];
|
||||||
|
harness.onboarding = path(["categories"], overrides);
|
||||||
|
const html = render();
|
||||||
|
expect(html, JSON.stringify(overrides)).not.toContain(CARD);
|
||||||
|
expect(html, JSON.stringify(overrides)).not.toContain(HIGHLIGHT);
|
||||||
|
expect(trends().onNavigate, JSON.stringify(overrides)).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
import { Sparkles, TrendingUp, Scale, Search, LayoutDashboard } from "lucide-react";
|
import { Sparkles, TrendingUp, Scale, Search, LayoutDashboard } from "lucide-react";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
import HubHighlightsPanel from "../components/reports/HubHighlightsPanel";
|
import HubHighlightsPanel from "../components/reports/HubHighlightsPanel";
|
||||||
import HubReportNavCard from "../components/reports/HubReportNavCard";
|
import HubReportNavCard from "../components/reports/HubReportNavCard";
|
||||||
|
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
|
||||||
|
import { useOnboarding } from "../contexts/OnboardingContext";
|
||||||
|
import { reportsCardModel, showsStepCard } from "../utils/onboardingPath";
|
||||||
import { useEntitlement } from "../hooks/useEntitlement";
|
import { useEntitlement } from "../hooks/useEntitlement";
|
||||||
import { useHighlights } from "../hooks/useHighlights";
|
import { useHighlights } from "../hooks/useHighlights";
|
||||||
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
||||||
|
|
@ -17,6 +21,17 @@ export default function ReportsPage() {
|
||||||
const { allowed: advancedAllowed, ready: licenseReady } = useEntitlement("reports-advanced");
|
const { allowed: advancedAllowed, ready: licenseReady } = useEntitlement("reports-advanced");
|
||||||
const advancedLocked = licenseReady && !advancedAllowed;
|
const advancedLocked = licenseReady && !advancedAllowed;
|
||||||
|
|
||||||
|
// The getting-started card (#432) points at the Trends card while it is on
|
||||||
|
// screen and the step is not done; opening that card acknowledges the step
|
||||||
|
// (D17): following the ring leaves the page, which releases the card.
|
||||||
|
const location = useLocation();
|
||||||
|
const onboarding = useOnboarding();
|
||||||
|
const cardModel = reportsCardModel(onboarding.checklist, advancedLocked);
|
||||||
|
const trendsTargeted =
|
||||||
|
showsStepCard(onboarding.mode, onboarding.cardPage, location.pathname) &&
|
||||||
|
cardModel.target === "trendsCard";
|
||||||
|
const { acknowledge } = onboarding;
|
||||||
|
|
||||||
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
||||||
const navCards = [
|
const navCards = [
|
||||||
{
|
{
|
||||||
|
|
@ -31,6 +46,9 @@ export default function ReportsPage() {
|
||||||
icon: <TrendingUp size={24} />,
|
icon: <TrendingUp size={24} />,
|
||||||
title: t("reports.hub.trends"),
|
title: t("reports.hub.trends"),
|
||||||
description: t("reports.hub.trendsDescription"),
|
description: t("reports.hub.trendsDescription"),
|
||||||
|
...(trendsTargeted
|
||||||
|
? { onboardingTarget: true, onNavigate: () => void acknowledge("reports") }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: `/reports/compare${preserveSearch}`,
|
to: `/reports/compare${preserveSearch}`,
|
||||||
|
|
@ -57,6 +75,8 @@ export default function ReportsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<OnboardingStepCard model={cardModel} />
|
||||||
|
|
||||||
<div className="relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
<div className="relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold">{t("reports.hub.title")}</h1>
|
<h1 className="text-2xl font-bold">{t("reports.hub.title")}</h1>
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,23 @@ import {
|
||||||
bannerMode,
|
bannerMode,
|
||||||
budgetCardModel,
|
budgetCardModel,
|
||||||
cardNext,
|
cardNext,
|
||||||
|
categoriesCardModel,
|
||||||
cardStep,
|
cardStep,
|
||||||
importCardModel,
|
importCardModel,
|
||||||
latchCardPage,
|
latchCardPage,
|
||||||
nextStep,
|
nextStep,
|
||||||
onboardingTargetAttributes,
|
onboardingTargetAttributes,
|
||||||
readOnboardingNavState,
|
readOnboardingNavState,
|
||||||
|
reportsCardModel,
|
||||||
showsStepCard,
|
showsStepCard,
|
||||||
stepNavState,
|
stepNavState,
|
||||||
stepsOfPage,
|
stepsOfPage,
|
||||||
transactionsCardModel,
|
transactionsCardModel,
|
||||||
type BannerModeInput,
|
type BannerModeInput,
|
||||||
type BudgetCardModel,
|
type BudgetCardModel,
|
||||||
|
type CategoriesCardModel,
|
||||||
type ImportCardModel,
|
type ImportCardModel,
|
||||||
|
type ReportsCardModel,
|
||||||
type StepCardText,
|
type StepCardText,
|
||||||
type TransactionsCardModel,
|
type TransactionsCardModel,
|
||||||
} from "./onboardingPath";
|
} from "./onboardingPath";
|
||||||
|
|
@ -146,12 +150,14 @@ describe("nextStep (#396)", () => {
|
||||||
describe("stepsOfPage (#396)", () => {
|
describe("stepsOfPage (#396)", () => {
|
||||||
it("maps each guided page to its steps, in checklist order", () => {
|
it("maps each guided page to its steps, in checklist order", () => {
|
||||||
expect(stepsOfPage("/import")).toEqual(["importFolder", "source", "import"]);
|
expect(stepsOfPage("/import")).toEqual(["importFolder", "source", "import"]);
|
||||||
|
expect(stepsOfPage("/categories")).toEqual(["categories"]);
|
||||||
expect(stepsOfPage("/transactions")).toEqual(["manualCategory"]);
|
expect(stepsOfPage("/transactions")).toEqual(["manualCategory"]);
|
||||||
expect(stepsOfPage("/budget")).toEqual(["budget"]);
|
expect(stepsOfPage("/budget")).toEqual(["budget"]);
|
||||||
|
expect(stepsOfPage("/reports")).toEqual(["reports"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives no step to any other page, inherited object keys included", () => {
|
it("gives no step to any other page, inherited object keys included", () => {
|
||||||
for (const page of ["/", "/settings/users", "/reports", "constructor", "__proto__", ""]) {
|
for (const page of ["/", "/settings/users", "/reports/trends", "constructor", "__proto__", ""]) {
|
||||||
expect(stepsOfPage(page)).toEqual([]);
|
expect(stepsOfPage(page)).toEqual([]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -411,6 +417,12 @@ describe("CARD_PAGES (#398, #399, #400)", () => {
|
||||||
it("carries the Budget page", () => {
|
it("carries the Budget page", () => {
|
||||||
expect(CARD_PAGES.has("/budget")).toBe(true);
|
expect(CARD_PAGES.has("/budget")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("carries the Categories and Reports pages (#432), not a report's own page", () => {
|
||||||
|
expect(CARD_PAGES.has("/categories")).toBe(true);
|
||||||
|
expect(CARD_PAGES.has("/reports")).toBe(true);
|
||||||
|
expect(CARD_PAGES.has("/reports/trends")).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("showsStepCard (#398)", () => {
|
describe("showsStepCard (#398)", () => {
|
||||||
|
|
@ -477,8 +489,44 @@ describe("cardStep (#398)", () => {
|
||||||
expect(at(beforeInit, "/transactions")).toEqual({ key: "manualCategory", n: 5, total: 6 });
|
expect(at(beforeInit, "/transactions")).toEqual({ key: "manualCategory", n: 5, total: 6 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("on /categories, the categories step, fifth of eight (#432)", () => {
|
||||||
|
const imported = { ...zero, sources: 1, imports: 1, transactions: 41 };
|
||||||
|
expect(at(checklistOf(imported, { importFolderSet: true, acknowledged: [] }), "/categories")).toEqual({
|
||||||
|
key: "categories",
|
||||||
|
n: 5,
|
||||||
|
total: 8,
|
||||||
|
});
|
||||||
|
// Acknowledged: the card keeps showing it (the page's last step), "Done." then says so.
|
||||||
|
expect(at(checklistOf(imported, { importFolderSet: true }), "/categories")).toEqual({
|
||||||
|
key: "categories",
|
||||||
|
n: 5,
|
||||||
|
total: 8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on /reports, the reports step, last of eight, last of seven in the Free edition (#432)", () => {
|
||||||
|
const allButReports = checklistOf(
|
||||||
|
{ ...upToManual, budgetEntries: 1 },
|
||||||
|
{ importFolderSet: true, acknowledged: ["categories"] },
|
||||||
|
);
|
||||||
|
expect(at(allButReports, "/reports")).toEqual({ key: "reports", n: 8, total: 8 });
|
||||||
|
const free = checklistOf(upToManual, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed: false,
|
||||||
|
acknowledged: ["categories"],
|
||||||
|
});
|
||||||
|
expect(at(free, "/reports")).toEqual({ key: "reports", n: 7, total: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is null on a visit step's page until the visit steps are initialized (#425, #432)", () => {
|
||||||
|
const beforeInit = checklistOf(zero, { acknowledged: [], visitStepsInitialized: false });
|
||||||
|
expect(cardStep(beforeInit, "/categories")).toBeNull();
|
||||||
|
expect(cardStep(beforeInit, "/reports")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("is null on a page that guides no unlocked step", () => {
|
it("is null on a page that guides no unlocked step", () => {
|
||||||
expect(cardStep(checklistOf(zero), "/reports")).toBeNull();
|
expect(cardStep(checklistOf(zero), "/settings")).toBeNull();
|
||||||
|
expect(cardStep(checklistOf(zero), "/reports/trends")).toBeNull();
|
||||||
expect(cardStep(checklistOf(zero, { budgetAllowed: false }), "/budget")).toBeNull();
|
expect(cardStep(checklistOf(zero, { budgetAllowed: false }), "/budget")).toBeNull();
|
||||||
expect(cardStep(checklistOf(zero), "/budget")?.step.key).toBe("budget");
|
expect(cardStep(checklistOf(zero), "/budget")?.step.key).toBe("budget");
|
||||||
});
|
});
|
||||||
|
|
@ -505,6 +553,25 @@ describe("cardNext (#398)", () => {
|
||||||
}
|
}
|
||||||
expect(cardNext(step("budget"), "/budget", true)).toEqual({ kind: "wait" });
|
expect(cardNext(step("budget"), "/budget", true)).toEqual({ kind: "wait" });
|
||||||
expect(cardNext(step("budget"), "/import", true).kind).toBe("step");
|
expect(cardNext(step("budget"), "/import", true).kind).toBe("step");
|
||||||
|
expect(cardNext(step("categories"), "/categories", true)).toEqual({ kind: "wait" });
|
||||||
|
expect(cardNext(step("reports"), "/reports", true)).toEqual({ kind: "wait" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after the categories step, the next step is the manual category (#432)", () => {
|
||||||
|
const acknowledged = checklistOf(
|
||||||
|
{ ...zero, sources: 1, imports: 1, transactions: 41 },
|
||||||
|
{ importFolderSet: true, acknowledged: ["categories"] },
|
||||||
|
);
|
||||||
|
expect(cardNext(nextStep(acknowledged), "/categories", true)).toEqual({
|
||||||
|
kind: "step",
|
||||||
|
step: step("manualCategory"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after the reports step, the last one, the end (#432)", () => {
|
||||||
|
const finished = checklistOf({ ...upToManual, budgetEntries: 1 }, { importFolderSet: true });
|
||||||
|
expect(nextStep(finished)).toBeNull();
|
||||||
|
expect(cardNext(nextStep(finished), "/reports", true)).toEqual({ kind: "end" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -793,6 +860,103 @@ describe("budgetCardModel (#400)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("categoriesCardModel (#432)", () => {
|
||||||
|
/** Imported, the categories step left: the card's sentence. */
|
||||||
|
const left = checklistOf({ ...zero, sources: 1, imports: 1, transactions: 41 }, { importFolderSet: true, acknowledged: [] });
|
||||||
|
const SENTENCE: CategoriesCardModel = {
|
||||||
|
text: { key: "onboarding.card.categories", labels: { allKeywords: "categories.allKeywords" } },
|
||||||
|
done: false,
|
||||||
|
target: "allKeywords",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("the step left: what a keyword does, 'All keywords' named by its own label and pointed at", () => {
|
||||||
|
expect(categoriesCardModel(left)).toEqual(SENTENCE);
|
||||||
|
expect(categoriesCardModel(null)).toEqual(SENTENCE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the step acknowledged: 'Done.', nothing to point at", () => {
|
||||||
|
const acknowledged = checklistOf(
|
||||||
|
{ ...zero, sources: 1, imports: 1, transactions: 41 },
|
||||||
|
{ importFolderSet: true, acknowledged: ["categories"] },
|
||||||
|
);
|
||||||
|
expect(categoriesCardModel(acknowledged)).toEqual({ text: null, done: true, target: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a skipped step is not a checked one", () => {
|
||||||
|
const skipped = checklistOf(
|
||||||
|
{ ...zero, sources: 1, imports: 1, transactions: 41 },
|
||||||
|
{ importFolderSet: true, acknowledged: [], skipped: ["categories"] },
|
||||||
|
);
|
||||||
|
expect(categoriesCardModel(skipped)).toEqual(SENTENCE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the sentence in French and in English", () => {
|
||||||
|
expect(render(SENTENCE.text, "fr")).toBe(
|
||||||
|
"Un mot-clé classe d'office, dès l'import, chaque transaction dont la description le contient ; quand deux mots-clés s'appliquent, le plus prioritaire l'emporte. « Tous les mots-clés » les liste tous, avec leur catégorie. À l'étape suivante, vous en ajouterez un depuis une transaction.",
|
||||||
|
);
|
||||||
|
expect(render(SENTENCE.text, "en")).toBe(
|
||||||
|
"A keyword categorizes, as soon as it is imported, every transaction whose description contains it; when two keywords apply, the one with the higher priority wins. “All Keywords” lists them all, with their category. In the next step, you will add one from a transaction.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reportsCardModel (#432)", () => {
|
||||||
|
/** Every step done but the reports one. */
|
||||||
|
const left = checklistOf({ ...upToManual, budgetEntries: 1 }, { importFolderSet: true, acknowledged: ["categories"] });
|
||||||
|
const ALL: ReportsCardModel = {
|
||||||
|
text: { key: "onboarding.card.reports.all", labels: { trends: "reports.hub.trends" } },
|
||||||
|
done: false,
|
||||||
|
target: "trendsCard",
|
||||||
|
};
|
||||||
|
const LOCKED: ReportsCardModel = {
|
||||||
|
text: { key: "onboarding.card.reports.locked", labels: { trends: "reports.hub.trends" } },
|
||||||
|
done: false,
|
||||||
|
target: "trendsCard",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("every report open: the sentence without the padlock, Trends named and pointed at", () => {
|
||||||
|
expect(reportsCardModel(left, false)).toEqual(ALL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the advanced reports locked (Free edition): the sentence that mentions the padlock", () => {
|
||||||
|
expect(reportsCardModel(left, true)).toEqual(LOCKED);
|
||||||
|
expect(reportsCardModel(null, true)).toEqual(LOCKED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the step acknowledged: 'Done.', nothing to point at, locked or not", () => {
|
||||||
|
const finished = checklistOf({ ...upToManual, budgetEntries: 1 }, { importFolderSet: true });
|
||||||
|
for (const advancedLocked of [true, false]) {
|
||||||
|
expect(reportsCardModel(finished, advancedLocked)).toEqual({ text: null, done: true, target: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a skipped step is not a checked one", () => {
|
||||||
|
const skipped = checklistOf(
|
||||||
|
{ ...upToManual, budgetEntries: 1 },
|
||||||
|
{ importFolderSet: true, acknowledged: ["categories"], skipped: ["reports"] },
|
||||||
|
);
|
||||||
|
expect(reportsCardModel(skipped, false)).toEqual(ALL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the two sentences in French", () => {
|
||||||
|
expect(render(ALL.text, "fr")).toBe(
|
||||||
|
"Choisissez la période en haut de la page : le panneau des faits saillants résume votre solde et vos plus gros mouvements. Chaque carte du bas ouvre un rapport détaillé ; commencez par « Tendances ».",
|
||||||
|
);
|
||||||
|
expect(render(LOCKED.text, "fr")).toBe(
|
||||||
|
"Choisissez la période en haut de la page : le panneau des faits saillants résume votre solde et vos plus gros mouvements. Chaque carte du bas ouvre un rapport détaillé ; commencez par « Tendances ». Les cartes à cadenas demandent l'édition Base.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the two sentences in English", () => {
|
||||||
|
expect(render(ALL.text, "en")).toBe(
|
||||||
|
"Pick the period at the top of the page: the highlights panel sums up your balance and your biggest moves. Each card below opens a detailed report; start with “Trends”.",
|
||||||
|
);
|
||||||
|
expect(render(LOCKED.text, "en")).toBe(
|
||||||
|
"Pick the period at the top of the page: the highlights panel sums up your balance and your biggest moves. Each card below opens a detailed report; start with “Trends”. Cards with a padlock require the Base edition.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("onboardingTargetAttributes (#398)", () => {
|
describe("onboardingTargetAttributes (#398)", () => {
|
||||||
it("marks the target and its highlight when on, nothing otherwise", () => {
|
it("marks the target and its highlight when on, nothing otherwise", () => {
|
||||||
expect(onboardingTargetAttributes("wizard-next", true)).toEqual({
|
expect(onboardingTargetAttributes("wizard-next", true)).toEqual({
|
||||||
|
|
|
||||||
|
|
@ -27,19 +27,24 @@ export function nextStep(checklist: Checklist): ChecklistItem | null {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routes whose page carries a step card. Each card issue adds its page (#398:
|
* Routes whose page carries a step card. Each card issue adds its page (#398:
|
||||||
* Import, #399: Transactions, #400: Budget), and a page without a delivered
|
* Import, #399: Transactions, #400: Budget, #432: Categories and Reports), and
|
||||||
* card keeps the banner, so a partly merged stack never removes any guidance.
|
* a page without a delivered card keeps the banner, so a partly merged stack
|
||||||
|
* never removes any guidance.
|
||||||
*/
|
*/
|
||||||
export const CARD_PAGES: ReadonlySet<string> = new Set<string>([
|
export const CARD_PAGES: ReadonlySet<string> = new Set<string>([
|
||||||
"/import",
|
"/import",
|
||||||
|
"/categories",
|
||||||
"/transactions",
|
"/transactions",
|
||||||
"/budget",
|
"/budget",
|
||||||
|
"/reports",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = {
|
const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = {
|
||||||
"/import": ["importFolder", "source", "import"],
|
"/import": ["importFolder", "source", "import"],
|
||||||
|
"/categories": ["categories"],
|
||||||
"/transactions": ["manualCategory"],
|
"/transactions": ["manualCategory"],
|
||||||
"/budget": ["budget"],
|
"/budget": ["budget"],
|
||||||
|
"/reports": ["reports"],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The steps a page guides, in checklist order (none for any other page). */
|
/** The steps a page guides, in checklist order (none for any other page). */
|
||||||
|
|
@ -439,6 +444,62 @@ export function budgetCardModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The control of the Categories page that the card points at. */
|
||||||
|
export type CategoriesCardTarget = "allKeywords";
|
||||||
|
|
||||||
|
export interface CategoriesCardModel extends StepCardModel {
|
||||||
|
/** The control to highlight, null for none. */
|
||||||
|
target: CategoriesCardTarget | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Categories page's card (#432), a visit step done by "Got it" (#425):
|
||||||
|
* - the step checked: "Done.", nothing to point at;
|
||||||
|
* - otherwise, what a keyword does, "All keywords" named by its own label and
|
||||||
|
* pointed at, and the keyword the next step adds from a transaction. Never
|
||||||
|
* "Reset": it deletes the keywords and uncategorizes every transaction.
|
||||||
|
*/
|
||||||
|
export function categoriesCardModel(checklist: Checklist | null): CategoriesCardModel {
|
||||||
|
if (checked(checklist, "categories")) return { text: null, done: true, target: null };
|
||||||
|
return {
|
||||||
|
text: { key: "onboarding.card.categories", labels: { allKeywords: "categories.allKeywords" } },
|
||||||
|
done: false,
|
||||||
|
target: "allKeywords",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The control of the Reports page that the card points at. */
|
||||||
|
export type ReportsCardTarget = "trendsCard";
|
||||||
|
|
||||||
|
export interface ReportsCardModel extends StepCardModel {
|
||||||
|
/** The control to highlight, null for none. */
|
||||||
|
target: ReportsCardTarget | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Reports page's card (#432), a visit step done by "Got it" or by opening
|
||||||
|
* the highlighted Trends card (D17):
|
||||||
|
* - the step checked: "Done.", nothing to point at;
|
||||||
|
* - otherwise, the period, the highlights panel and the report cards, Trends
|
||||||
|
* named by its own label and pointed at (it is never locked); with
|
||||||
|
* `advancedLocked` (the page's: license known and not entitled), the
|
||||||
|
* sentence also says the padlocked cards require the Base edition.
|
||||||
|
*/
|
||||||
|
export function reportsCardModel(
|
||||||
|
checklist: Checklist | null,
|
||||||
|
advancedLocked: boolean,
|
||||||
|
): ReportsCardModel {
|
||||||
|
if (checked(checklist, "reports")) return { text: null, done: true, target: null };
|
||||||
|
return {
|
||||||
|
text: {
|
||||||
|
key: advancedLocked ? "onboarding.card.reports.locked" : "onboarding.card.reports.all",
|
||||||
|
labels: { trends: "reports.hub.trends" },
|
||||||
|
},
|
||||||
|
done: false,
|
||||||
|
target: "trendsCard",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** The attributes of a highlighted control; `styles.css` draws the ring on the second one. */
|
/** The attributes of a highlighted control; `styles.css` draws the ring on the second one. */
|
||||||
export interface OnboardingTargetAttributes {
|
export interface OnboardingTargetAttributes {
|
||||||
"data-onboarding-target"?: string;
|
"data-onboarding-target"?: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue