feat(onboarding): step card with highlight, and the Import page's card #404
29 changed files with 2135 additions and 40 deletions
|
|
@ -5,6 +5,7 @@
|
|||
### Ajouté
|
||||
|
||||
- Un bandeau de démarrage en haut de chaque page tant que le parcours de démarrage est affiché : « Démarrage 3/6 », l'étape suivante avec un lien vers la page où la faire, « Voir la liste », qui déplie les six étapes en place, et « Masquer », qui masque le bandeau et la liste pour le profil courant. Il n'apparaît pas sur le tableau de bord d'un profil sans transaction, dont la carte d'accueil contient déjà la liste. Une fois toutes les étapes faites, il l'annonce et propose « Ouvrir le guide », qui mène à la section « Premiers pas » du guide d'utilisation, et « Fermer », qui le retire jusqu'à un réaffichage depuis Paramètres → Utilisateurs (#397).
|
||||
- Une carte de démarrage sur la page Import, à la place du bandeau, tant que l'étape suivante du parcours s'y fait (choisir un dossier d'import, configurer une source d'import, importer un premier fichier). Elle indique « Étape n sur N », dit quoi faire à chaque écran de l'assistant d'import et désigne le contrôle à utiliser par un contour qui pulse trois fois, et pas du tout quand le système demande de réduire les animations. Elle suit l'assistant pas à pas, annonce « Fait. » une fois le fichier importé et propose « Étape suivante », qui mène à la page de l'étape d'après ; elle n'annonce pas « Fait. » pour un import qui n'a ajouté aucune transaction. Arriver sur la page Import par un lien « Étape suivante » place le focus du clavier sur le titre de la carte. « Passer cette étape » marque l'étape comme passée dans la liste de démarrage, où « Reprendre » l'annule (#398).
|
||||
|
||||
### Modifié
|
||||
|
||||
|
|
@ -14,6 +15,7 @@
|
|||
|
||||
### Corrigé
|
||||
|
||||
- La page Import n'affiche plus un bref instant l'état vide « Aucun dossier d'import » à son ouverture, pendant la lecture du dossier d'import enregistré (#398).
|
||||
- Classer une transaction à la main sur la page Transactions coche désormais l'étape « Classer une transaction à la main » de la liste de démarrage. L'application enregistrait cette marque sous une forme que la liste ne savait pas compter. Les transactions classées à la main avant cette version sont comptées dès l'ouverture du profil, et une sauvegarde exportée entre-temps les restaure correctement (#390).
|
||||
- Les profils autres que le profil par défaut reçoivent désormais les mises à jour de leur base de données. Jusqu'ici, chacun gardait la structure de base de la version qui l'avait créé, et des fonctions ajoutées depuis pouvaient y échouer : dans un profil créé avant la 0.15.0, par exemple, le format d'import enregistré sur une source. À l'ouverture d'un tel profil, l'application garde d'abord une copie de sa base dans le dossier `migration-backups`, à côté des bases, puis applique les mises à jour manquantes. La copie est supprimée avec le profil, et chaque mise à jour est inscrite dans les journaux (Paramètres → Système) (#390).
|
||||
- Sous Linux, supprimer un profil supprime désormais sa base de données, qui restait sur le disque dans `~/.config/com.simpl.resultat/` ; le fichier d'un profil supprimé avec la version 0.17.0 ou antérieure peut être supprimé à la main. Les colonnes de rendement des comptes du Bilan affichent désormais les rendements sous Linux, où chaque cellule affichait un tiret (#390).
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
### Added
|
||||
|
||||
- A getting-started banner at the top of every page while the getting-started path is shown: "Getting started 3/6", the next step with a link to the page where it is done, "Show the list", which unfolds the six steps in place, and "Hide", which hides the banner and the list for the current profile. It does not show on the dashboard of a profile without transactions, whose welcome card already holds the list. Once every step is done, it says so and offers "Open the guide", which leads to the "Getting Started" section of the user guide, and "Close", which removes it until it is shown again from Settings → Users (#397).
|
||||
- A getting-started card on the Import page, in place of the banner, while the next step of the path is done there (choose an import folder, set up an import source, import a first file). It shows "Step n of N", says what to do on each screen of the import wizard and points at the control to use with an outline that pulses three times, not at all when the system asks for reduced motion. It follows the wizard as you go, says "Done." once the file is imported and offers "Next step", which leads to the page of the following step; it does not say "Done." for an import that added no transaction. Arriving on the Import page by a "Next step" link puts the keyboard focus on the card's title. "Skip this step" marks the step as skipped in the getting-started list, where "Resume" takes it back (#398).
|
||||
|
||||
### Changed
|
||||
|
||||
|
|
@ -14,6 +15,7 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- Opening the Import page no longer flashes the "No import folder" empty state while the saved import folder is being read (#398).
|
||||
- Categorizing a transaction by hand on the Transactions page now ticks the "Categorize a transaction by hand" step of the getting-started checklist. The app stored that mark in a form the checklist could not count. Transactions you categorized by hand before this version are counted as soon as the profile is opened, and a backup exported in the meantime restores them correctly (#390).
|
||||
- Profiles other than the default one now receive database updates. Until now each one kept the database layout of the version that created it, so features added since could fail in it: in a profile created before 0.15.0, for instance, the import format saved on a source. When such a profile is opened, the app first keeps a copy of its database in the `migration-backups` folder next to the databases, then applies the missing updates. The copy is deleted with the profile, and each update is written to the logs (Settings → System) (#390).
|
||||
- On Linux, deleting a profile now deletes its database, which used to stay on disk in `~/.config/com.simpl.resultat/`; the file of a profile deleted with version 0.17.0 or earlier can be deleted by hand. The return columns of the Balance sheet accounts now show the returns on Linux, where every cell showed a dash (#390).
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
```
|
||||
simpl-resultat/
|
||||
├── src/ # Frontend React/TypeScript
|
||||
│ ├── components/ # 59 composants organisés par domaine
|
||||
│ ├── components/ # 61 composants organisés par domaine
|
||||
│ │ ├── adjustments/ # 3 composants
|
||||
│ │ ├── balance/ # 8 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, BalanceOnboardingCard, BalanceOverviewCard, LinkTransfersModal, SnapshotEditor, SnapshotLineRow)
|
||||
│ │ ├── budget/ # 5 composants
|
||||
|
|
@ -34,7 +34,7 @@ simpl-resultat/
|
|||
│ │ ├── dashboard/ # 7 composants (dont DashboardEmptyState et OnboardingChecklist)
|
||||
│ │ ├── import/ # 15 composants (wizard d'import, dont FormatDriftPanel, RepairPathNotice et ImportEmptyState)
|
||||
│ │ ├── layout/ # AppShell, Sidebar
|
||||
│ │ ├── onboarding/ # OnboardingBanner (bandeau de démarrage, #397)
|
||||
│ │ ├── onboarding/ # OnboardingBanner (bandeau de démarrage, #397), StepCard et OnboardingStepCard (carte d'étape, #398)
|
||||
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
||||
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie)
|
||||
│ │ ├── settings/ # 17 composants (dont LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard, DocsContent et DocsSubsections)
|
||||
|
|
@ -223,7 +223,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
|||
| `useCategories` | Catégories avec hiérarchie |
|
||||
| `useTransactions` | Transactions et filtrage |
|
||||
| `useDataImport` | Import de données |
|
||||
| `useImportWizard` | Assistant d'import multi-étapes. Restaure le format enregistré **tel quel** via `formatFromRow` (aucune ré-inférence), déclenche la détection seule sur une source sans format (garde `!existing`), traverse l'étape `file-preview` à **chaque** import, et n'écrit la configuration qu'à `executeImport` (point d'écriture unique) — voir la section « Import CSV » et l'[ADR 0019](adr/0019-format-import-persiste.md) |
|
||||
| `useImportWizard` | Assistant d'import multi-étapes. Restaure le format enregistré **tel quel** via `formatFromRow` (aucune ré-inférence), déclenche la détection seule sur une source sans format (garde `!existing`), traverse l'étape `file-preview` à **chaque** import, et n'écrit la configuration qu'à `executeImport` (point d'écriture unique) — voir la section « Import CSV » et l'[ADR 0019](adr/0019-format-import-persiste.md). Expose `importFolderLoaded`, posé dans le `finally` de la lecture du dossier au montage (après son premier scan) et gardé par `RESET` : l'état vide de la page Import et sa carte de démarrage l'attendent (#384, #398) |
|
||||
| `useImportHistory` | Historique des imports |
|
||||
| `useAdjustments` | Ajustements |
|
||||
| `useBudget` | Budget |
|
||||
|
|
@ -315,7 +315,7 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
|
|||
| Page | Signal | Composant | Action proposée |
|
||||
|---|---|---|---|
|
||||
| Tableau de bord `/` | `counters.transactions === 0`, compteur lu par `useOnboardingChecklist` | `DashboardEmptyState` (carte d'accueil + liste détaillée) | « Choisir un dossier d'import » → `/import` |
|
||||
| Import `/import` | aucun dossier d'import, ou dossier sans aucun sous-dossier | `ImportEmptyState` (deux variantes) | « Choisir un dossier » ; ou « Créer un dossier d'exemple » + « Actualiser » ; lien vers le guide `/settings/users#import-export` dans les deux cas |
|
||||
| Import `/import` | aucun dossier d'import, ou dossier sans aucun sous-dossier, une fois le dossier lu (`importFolderLoaded`, #384) | `ImportEmptyState` (deux variantes) | « Choisir un dossier » ; ou « Créer un dossier d'exemple » + « Actualiser » ; lien vers le guide `/settings/users#import-export` dans les deux cas |
|
||||
| Transactions `/transactions` | `showsEmptyState(state)` = `totalInDb === 0 && !isLoading`, `totalInDb` via `getTransactionCount` | `EmptyState` dans la page | « Importer des transactions » → `/import` |
|
||||
| Budget `/budget` | `showsEmptyState(state)` = `totalInDb === 0`, même compteur | `EmptyState` dans la page | « Importer des transactions » → `/import` |
|
||||
|
||||
|
|
|
|||
|
|
@ -145,6 +145,32 @@ describe("ImportEmptyState — folder without sources (#356)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("ImportEmptyState — getting-started target (#398)", () => {
|
||||
const highlighted = (tree: ReactNode) =>
|
||||
find(tree, (e) => e.props["data-onboarding-highlight"] === "true");
|
||||
|
||||
it("highlights 'Choose a folder' without a folder, and only it", () => {
|
||||
const { tree } = render({ hasFolder: false, onboardingTarget: true });
|
||||
const targets = highlighted(tree);
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0].type).toBe("button");
|
||||
expect(texts(targets[0].props.children)).toContain("import.empty.noFolder.cta");
|
||||
});
|
||||
|
||||
it("highlights 'Create a sample folder' in a folder without sources, not 'Refresh'", () => {
|
||||
const { tree } = render({ hasFolder: true, folderName: "Relevés", onboardingTarget: true });
|
||||
const targets = highlighted(tree);
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(texts(targets[0].props.children)).toContain("import.empty.createSample");
|
||||
});
|
||||
|
||||
it("highlights nothing without the property", () => {
|
||||
expect(highlighted(render({ hasFolder: false }).tree)).toHaveLength(0);
|
||||
expect(highlighted(render({ hasFolder: true, folderName: "Relevés" }).tree)).toHaveLength(0);
|
||||
expect(highlighted(render({ hasFolder: true, onboardingTarget: false }).tree)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("folderNameFromPath (#356)", () => {
|
||||
it("returns the last segment for POSIX and Windows paths", () => {
|
||||
expect(folderNameFromPath("/home/max/releves")).toBe("releves");
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ export interface ImportEmptyStateProps {
|
|||
onRefresh: () => void;
|
||||
/** The sample folders are being written: the primary button is disabled. */
|
||||
isCreatingSample: boolean;
|
||||
/**
|
||||
* Highlight the primary action for the getting-started card (#398): "Choose
|
||||
* a folder" without a folder, "Create a sample folder" with one.
|
||||
*/
|
||||
onboardingTarget?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -42,6 +47,7 @@ export default function ImportEmptyState({
|
|||
onCreateSample,
|
||||
onRefresh,
|
||||
isCreatingSample,
|
||||
onboardingTarget,
|
||||
}: ImportEmptyStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -62,7 +68,7 @@ export default function ImportEmptyState({
|
|||
icon={<FolderOpen size={40} />}
|
||||
title={t("import.empty.noFolder.title")}
|
||||
description={t("import.empty.noFolder.description")}
|
||||
primaryAction={{ label: t("import.empty.noFolder.cta"), onClick: onBrowse }}
|
||||
primaryAction={{ label: t("import.empty.noFolder.cta"), onClick: onBrowse, onboardingTarget }}
|
||||
>
|
||||
{guideLink}
|
||||
</EmptyState>
|
||||
|
|
@ -78,6 +84,7 @@ export default function ImportEmptyState({
|
|||
label: t("import.empty.createSample"),
|
||||
onClick: onCreateSample,
|
||||
disabled: isCreatingSample,
|
||||
onboardingTarget,
|
||||
}}
|
||||
secondaryAction={{ label: t("import.empty.refresh"), onClick: onRefresh }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,26 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen, FileText, CheckCircle } from "lucide-react";
|
||||
import type { ScannedSource } from "../../shared/types";
|
||||
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
|
||||
|
||||
interface SourceCardProps {
|
||||
source: ScannedSource;
|
||||
isConfigured: boolean;
|
||||
newFileCount: number;
|
||||
onClick: () => void;
|
||||
/** Highlight this card for the getting-started card (#398). */
|
||||
onboardingTarget?: boolean;
|
||||
}
|
||||
|
||||
/** `data-onboarding-target` of a highlighted source card. */
|
||||
export const SOURCE_CARD_TARGET = "source-card";
|
||||
|
||||
export default function SourceCard({
|
||||
source,
|
||||
isConfigured,
|
||||
newFileCount,
|
||||
onClick,
|
||||
onboardingTarget,
|
||||
}: SourceCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -21,6 +28,7 @@ export default function SourceCard({
|
|||
<button
|
||||
onClick={onClick}
|
||||
className="w-full text-left bg-[var(--card)] rounded-xl p-4 border border-[var(--border)] hover:border-[var(--primary)] hover:shadow-sm transition-all"
|
||||
{...onboardingTargetAttributes(SOURCE_CARD_TARGET, onboardingTarget)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
63
src/components/import/SourceList.test.tsx
Normal file
63
src/components/import/SourceList.test.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// SourceList and SourceCard — the getting-started target (#398). No jsdom:
|
||||
// react-dom/server renders in vitest's node environment, and the markup says
|
||||
// which card carries the highlight.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (k: string, o?: Record<string, unknown>) =>
|
||||
o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k,
|
||||
i18n: { language: "fr" },
|
||||
}),
|
||||
}));
|
||||
|
||||
import SourceList from "./SourceList";
|
||||
import { SOURCE_CARD_TARGET } from "./SourceCard";
|
||||
import type { ScannedSource } from "../../shared/types";
|
||||
|
||||
const HIGHLIGHT = 'data-onboarding-highlight="true"';
|
||||
|
||||
const source = (name: string): ScannedSource => ({
|
||||
folder_name: name,
|
||||
folder_path: `/releves/${name}`,
|
||||
files: [{ filename: "releve.csv", file_path: `/releves/${name}/releve.csv`, size_bytes: 10, modified_at: "" }],
|
||||
});
|
||||
|
||||
const SOURCES = [source("Desjardins"), source("Tangerine"), source("RBC")];
|
||||
|
||||
const markup = (onboardingTarget?: boolean, sources: ScannedSource[] = SOURCES) =>
|
||||
renderToStaticMarkup(
|
||||
<SourceList
|
||||
sources={sources}
|
||||
configuredSourceNames={new Set()}
|
||||
importedFileNames={new Map()}
|
||||
onSelectSource={vi.fn()}
|
||||
onboardingTarget={onboardingTarget}
|
||||
/>,
|
||||
);
|
||||
|
||||
/** The source cards, in order: each is a button. */
|
||||
const cards = (html: string) => html.split("<button").slice(1);
|
||||
|
||||
describe("SourceList — getting-started target (#398)", () => {
|
||||
it("highlights the first card of the list, and only it", () => {
|
||||
const html = markup(true);
|
||||
expect(html.split(HIGHLIGHT).length - 1).toBe(1);
|
||||
const [first, ...others] = cards(html);
|
||||
expect(first).toContain(HIGHLIGHT);
|
||||
expect(first).toContain(`data-onboarding-target="${SOURCE_CARD_TARGET}"`);
|
||||
expect(first).toContain("Desjardins");
|
||||
expect(others).toHaveLength(2);
|
||||
for (const card of others) expect(card).not.toContain("data-onboarding");
|
||||
});
|
||||
|
||||
it("highlights the only card of a one-source list", () => {
|
||||
expect(markup(true, [source("Desjardins")]).split(HIGHLIGHT).length - 1).toBe(1);
|
||||
});
|
||||
|
||||
it("highlights nothing without the property", () => {
|
||||
expect(markup()).not.toContain("data-onboarding");
|
||||
expect(markup(false)).not.toContain("data-onboarding");
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,8 @@ interface SourceListProps {
|
|||
configuredSourceNames: Set<string>;
|
||||
importedFileNames: Map<string, Set<string>>;
|
||||
onSelectSource: (source: ScannedSource) => void;
|
||||
/** Highlight the first card for the getting-started card (#398): one target at a time. */
|
||||
onboardingTarget?: boolean;
|
||||
}
|
||||
|
||||
export default function SourceList({
|
||||
|
|
@ -15,6 +17,7 @@ export default function SourceList({
|
|||
configuredSourceNames,
|
||||
importedFileNames,
|
||||
onSelectSource,
|
||||
onboardingTarget,
|
||||
}: SourceListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -38,7 +41,7 @@ export default function SourceList({
|
|||
{t("import.sources.title")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sources.map((source) => {
|
||||
{sources.map((source, index) => {
|
||||
const isConfigured = configuredSourceNames.has(source.folder_name);
|
||||
// Count files not yet imported for this source
|
||||
const sourceHashes = importedFileNames.get(source.folder_name);
|
||||
|
|
@ -55,6 +58,7 @@ export default function SourceList({
|
|||
isConfigured={isConfigured}
|
||||
newFileCount={newFileCount}
|
||||
onClick={() => onSelectSource(source)}
|
||||
onboardingTarget={onboardingTarget && index === 0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
55
src/components/import/WizardNavigation.test.tsx
Normal file
55
src/components/import/WizardNavigation.test.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// WizardNavigation — the getting-started target (#398). No jsdom:
|
||||
// react-dom/server renders in vitest's node environment, and the markup says
|
||||
// which button carries the highlight.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "fr" } }),
|
||||
}));
|
||||
|
||||
import WizardNavigation, { WIZARD_NEXT_TARGET } from "./WizardNavigation";
|
||||
|
||||
const HIGHLIGHT = 'data-onboarding-highlight="true"';
|
||||
|
||||
const markup = (props: Parameters<typeof WizardNavigation>[0]) =>
|
||||
renderToStaticMarkup(
|
||||
<WizardNavigation
|
||||
onBack={vi.fn()}
|
||||
onNext={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
nextLabel="NEXT_LABEL"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
|
||||
/** The buttons, in order: cancel, back, next. */
|
||||
const buttons = (html: string) => html.split("<button").slice(1);
|
||||
|
||||
describe("WizardNavigation — getting-started target (#398)", () => {
|
||||
it("highlights the next button, and neither back nor cancel", () => {
|
||||
const html = markup({ onboardingTarget: true });
|
||||
expect(html.split(HIGHLIGHT).length - 1).toBe(1);
|
||||
const all = buttons(html);
|
||||
expect(all).toHaveLength(3);
|
||||
const [cancel, back, next] = all;
|
||||
expect(next).toContain("NEXT_LABEL");
|
||||
expect(next).toContain(HIGHLIGHT);
|
||||
expect(next).toContain(`data-onboarding-target="${WIZARD_NEXT_TARGET}"`);
|
||||
expect(cancel).toContain("common.cancel");
|
||||
expect(cancel).not.toContain("data-onboarding");
|
||||
expect(back).toContain("import.wizard.back");
|
||||
expect(back).not.toContain("data-onboarding");
|
||||
});
|
||||
|
||||
it("keeps the highlight on a disabled next button", () => {
|
||||
const html = markup({ onboardingTarget: true, nextDisabled: true });
|
||||
expect(html.split(HIGHLIGHT).length - 1).toBe(1);
|
||||
});
|
||||
|
||||
it("highlights nothing without the property, or without a next button", () => {
|
||||
expect(markup({})).not.toContain("data-onboarding");
|
||||
expect(markup({ onboardingTarget: false })).not.toContain("data-onboarding");
|
||||
expect(markup({ onboardingTarget: true, showNext: false })).not.toContain("data-onboarding");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
|
||||
|
||||
interface WizardNavigationProps {
|
||||
onBack?: () => void;
|
||||
|
|
@ -11,8 +12,13 @@ interface WizardNavigationProps {
|
|||
showBack?: boolean;
|
||||
showNext?: boolean;
|
||||
showCancel?: boolean;
|
||||
/** Highlight the next button for the getting-started card (#398). */
|
||||
onboardingTarget?: boolean;
|
||||
}
|
||||
|
||||
/** `data-onboarding-target` of a highlighted next button. */
|
||||
export const WIZARD_NEXT_TARGET = "wizard-next";
|
||||
|
||||
export default function WizardNavigation({
|
||||
onBack,
|
||||
onNext,
|
||||
|
|
@ -23,6 +29,7 @@ export default function WizardNavigation({
|
|||
showBack = true,
|
||||
showNext = true,
|
||||
showCancel = true,
|
||||
onboardingTarget,
|
||||
}: WizardNavigationProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -54,6 +61,7 @@ export default function WizardNavigation({
|
|||
onClick={onNext}
|
||||
disabled={nextDisabled}
|
||||
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
{...onboardingTargetAttributes(WIZARD_NEXT_TARGET, onboardingTarget)}
|
||||
>
|
||||
{nextLabel || t("import.wizard.next")}
|
||||
<ChevronRight size={16} />
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ describe("AppShell — the getting-started banner (#397)", () => {
|
|||
|
||||
it("shows nothing on the dashboard of a profile without transactions (D11), but on its other pages", () => {
|
||||
expect(renderAt("/", loaded(NEW_PROFILE))).not.toContain(BANNER);
|
||||
expect(renderAt("/import", loaded(NEW_PROFILE))).toContain(BANNER);
|
||||
expect(renderAt("/reports", loaded(NEW_PROFILE))).toContain(BANNER);
|
||||
expect(renderAt("/settings/users", loaded(NEW_PROFILE))).toContain(BANNER);
|
||||
});
|
||||
|
||||
|
|
@ -151,7 +151,6 @@ describe("AppShell — the getting-started banner (#397)", () => {
|
|||
});
|
||||
|
||||
it.each([
|
||||
["/import", NEW_PROFILE],
|
||||
["/transactions", IMPORTED],
|
||||
["/budget", BUDGET_LEFT],
|
||||
] as const)(
|
||||
|
|
@ -166,6 +165,17 @@ describe("AppShell — the getting-started banner (#397)", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("gives /import, the page of the next step, its step card instead of the banner (#398)", () => {
|
||||
expect(CARD_PAGES.has("/import")).toBe(true);
|
||||
const value = pathAt("/import", loaded(NEW_PROFILE));
|
||||
expect(value.nextStep?.to).toBe("/import");
|
||||
expect(value.mode).toBe("card");
|
||||
expect(value.cardPage).toBe("/import");
|
||||
expect(renderWith("/import", value)).not.toContain(BANNER);
|
||||
// Anywhere else, the same profile keeps the banner and its link to /import.
|
||||
expect(renderAt("/reports", loaded(NEW_PROFILE))).toContain('href="/import"');
|
||||
});
|
||||
|
||||
it("shows nothing on a page that carries a step card", () => {
|
||||
const value = { ...pathAt("/import", loaded(NEW_PROFILE)), mode: "card" as const, cardPage: "/import" };
|
||||
expect(renderWith("/import", value)).not.toContain(BANNER);
|
||||
|
|
|
|||
236
src/components/onboarding/OnboardingStepCard.test.tsx
Normal file
236
src/components/onboarding/OnboardingStepCard.test.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
// OnboardingStepCard — the connected card's wiring (#398).
|
||||
//
|
||||
// renderToStaticMarkup runs no effect, so the focus move would stay unguarded.
|
||||
// React's `useRef` and `useEffect` are stubbed (technique of
|
||||
// useOnboardingChecklist.wiring.test.ts and AppShell.slot.test.tsx): the card
|
||||
// runs as a plain function, its refs survive from one call to the next as they
|
||||
// would between two renders of a mounted component, and its effects are
|
||||
// captured and run by hand. The router and the path are faked.
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
type Effect = { fn: () => void | (() => void); deps: readonly unknown[] | undefined };
|
||||
|
||||
const harness = vi.hoisted(() => ({
|
||||
refs: [] as Array<{ current: unknown }>,
|
||||
cursor: 0,
|
||||
effects: [] as Effect[],
|
||||
location: { pathname: "/import", state: null as unknown, key: "arrival-1" },
|
||||
navigate: vi.fn(),
|
||||
onboarding: null as unknown,
|
||||
}));
|
||||
|
||||
vi.mock("react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react")>();
|
||||
return {
|
||||
...actual,
|
||||
useRef: (init: unknown) => {
|
||||
const index = harness.cursor++;
|
||||
if (!(index in harness.refs)) harness.refs[index] = { current: init };
|
||||
return harness.refs[index];
|
||||
},
|
||||
useEffect: (fn: Effect["fn"], deps?: readonly unknown[]) => {
|
||||
harness.effects.push({ fn, deps });
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("react-router-dom", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-router-dom")>()),
|
||||
useLocation: () => harness.location,
|
||||
useNavigate: () => harness.navigate,
|
||||
}));
|
||||
vi.mock("../../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
|
||||
|
||||
import OnboardingStepCard from "./OnboardingStepCard";
|
||||
import StepCard, { type StepCardProps } from "./StepCard";
|
||||
import { computeChecklist } from "../../utils/onboardingChecklist";
|
||||
import { nextStep, type StepCardModel } from "../../utils/onboardingPath";
|
||||
import type { OnboardingValue } from "../../hooks/useOnboardingChecklist";
|
||||
import type { OnboardingCounters } from "../../shared/types";
|
||||
|
||||
const ZERO: OnboardingCounters = {
|
||||
sources: 0,
|
||||
imports: 0,
|
||||
manualCategorized: 0,
|
||||
budgetEntries: 0,
|
||||
transactions: 0,
|
||||
};
|
||||
/** Folder chosen, no source yet: the next step is the source step, on /import. */
|
||||
const FOLDER_SET = ZERO;
|
||||
/** Source and import done: the next step is the manual category, on /transactions. */
|
||||
const IMPORTED: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 41 };
|
||||
|
||||
/** The path latched on /import, as the provider shares it. */
|
||||
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue {
|
||||
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
|
||||
return {
|
||||
status: "ready",
|
||||
checklist,
|
||||
counters,
|
||||
hidden: false,
|
||||
budgetReady: true,
|
||||
nextStep: nextStep(checklist),
|
||||
mode: "card",
|
||||
cardPage: "/import",
|
||||
hide: vi.fn(async () => undefined),
|
||||
show: vi.fn(async () => undefined),
|
||||
skip: vi.fn(async () => undefined),
|
||||
resume: vi.fn(async () => undefined),
|
||||
refresh: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const SOURCE_LIST: StepCardModel = { text: { key: "onboarding.card.import.sourceList" }, done: false };
|
||||
const DONE: StepCardModel = { text: null, done: true };
|
||||
|
||||
/** One render of the mounted card: its hooks are called in the same order each time. */
|
||||
function render(model: StepCardModel | null = SOURCE_LIST): ReactElement<StepCardProps> | null {
|
||||
harness.cursor = 0;
|
||||
harness.effects = [];
|
||||
return OnboardingStepCard({ model }) as ReactElement<StepCardProps> | null;
|
||||
}
|
||||
|
||||
/** The title the card's ref points at, once React would have attached it. */
|
||||
function attachTitle(): { focus: ReturnType<typeof vi.fn> } {
|
||||
const title = { focus: vi.fn() };
|
||||
harness.refs[0].current = title;
|
||||
return title;
|
||||
}
|
||||
|
||||
const runEffects = () => {
|
||||
for (const effect of harness.effects) effect.fn();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
harness.refs = [];
|
||||
harness.effects = [];
|
||||
harness.location = { pathname: "/import", state: null, key: "arrival-1" };
|
||||
harness.navigate.mockReset();
|
||||
harness.onboarding = path(FOLDER_SET);
|
||||
});
|
||||
|
||||
describe("OnboardingStepCard — when it shows (#398)", () => {
|
||||
it("renders the card in card mode, latched on the current route", () => {
|
||||
const card = render();
|
||||
expect(card).not.toBeNull();
|
||||
expect(card!.type).toBe(StepCard);
|
||||
});
|
||||
|
||||
it("renders nothing in another mode, on a route that is not the latched one, or without a model", () => {
|
||||
for (const mode of ["none", "banner", "complete"] as const) {
|
||||
harness.onboarding = path(FOLDER_SET, { mode });
|
||||
expect(render(), mode).toBeNull();
|
||||
}
|
||||
harness.onboarding = path(FOLDER_SET, { cardPage: "/transactions" });
|
||||
expect(render()).toBeNull();
|
||||
harness.onboarding = path(FOLDER_SET);
|
||||
expect(render(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("stays once the page's steps are done: the latch keeps it, not the next step", () => {
|
||||
harness.onboarding = path(IMPORTED);
|
||||
const value = harness.onboarding as OnboardingValue;
|
||||
expect(value.nextStep?.to).toBe("/transactions");
|
||||
const card = render(DONE);
|
||||
expect(card).not.toBeNull();
|
||||
expect(card!.props.done).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OnboardingStepCard — what it hands the card (#398)", () => {
|
||||
it("the step it shows, its place among the unlocked rows, the model's sentence and verdict", () => {
|
||||
const props = render()!.props;
|
||||
expect(props.step.key).toBe("source");
|
||||
expect(props.position).toEqual({ n: 3, total: 6 });
|
||||
expect(props.text).toBe(SOURCE_LIST.text);
|
||||
expect(props.done).toBe(false);
|
||||
expect(props.titleRef).toBe(harness.refs[0]);
|
||||
});
|
||||
|
||||
it("the next step once done, and a wait while the next step is still on this page", () => {
|
||||
harness.onboarding = path(IMPORTED);
|
||||
expect(render(DONE)!.props.next).toEqual({
|
||||
kind: "step",
|
||||
step: (harness.onboarding as OnboardingValue).nextStep,
|
||||
});
|
||||
// Before the re-read, the next step is still the source step, on /import.
|
||||
harness.onboarding = path(FOLDER_SET);
|
||||
expect(render(DONE)!.props.next).toEqual({ kind: "wait" });
|
||||
});
|
||||
|
||||
it("no 'Step n of N' until the Budget entitlement is known", () => {
|
||||
harness.onboarding = path(FOLDER_SET, { budgetReady: false });
|
||||
expect(render()!.props.position).toBeNull();
|
||||
});
|
||||
|
||||
it("'Skip this step' skips the step the card shows; 'Close' hides the path", () => {
|
||||
const value = harness.onboarding as OnboardingValue;
|
||||
const props = render()!.props;
|
||||
props.onSkip();
|
||||
expect(value.skip).toHaveBeenCalledWith("source");
|
||||
expect(value.hide).not.toHaveBeenCalled();
|
||||
props.onHide();
|
||||
expect(value.hide).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OnboardingStepCard — the focus after 'Next step' (#398)", () => {
|
||||
const ARRIVAL = { onboarding: { focus: true } };
|
||||
|
||||
it("moves the focus to the title, then consumes the navigation state", () => {
|
||||
harness.location = { ...harness.location, state: ARRIVAL };
|
||||
render();
|
||||
const title = attachTitle();
|
||||
runEffects();
|
||||
expect(title.focus).toHaveBeenCalledTimes(1);
|
||||
expect(harness.navigate).toHaveBeenCalledTimes(1);
|
||||
expect(harness.navigate).toHaveBeenCalledWith("/import", { replace: true, state: null });
|
||||
expect(vi.mocked(harness.navigate).mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
title.focus.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("does it once per arrival: StrictMode's second run and later renders do nothing", () => {
|
||||
harness.location = { ...harness.location, state: ARRIVAL };
|
||||
render();
|
||||
const title = attachTitle();
|
||||
runEffects();
|
||||
runEffects();
|
||||
render();
|
||||
runEffects();
|
||||
expect(title.focus).toHaveBeenCalledTimes(1);
|
||||
expect(harness.navigate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A new arrival on the same mounted page asks again.
|
||||
harness.location = { ...harness.location, key: "arrival-2" };
|
||||
render();
|
||||
runEffects();
|
||||
expect(title.focus).toHaveBeenCalledTimes(2);
|
||||
expect(harness.navigate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("waits for the card to show before focusing and consuming", () => {
|
||||
harness.location = { ...harness.location, state: ARRIVAL };
|
||||
expect(render(null)).toBeNull();
|
||||
runEffects();
|
||||
expect(harness.navigate).not.toHaveBeenCalled();
|
||||
|
||||
render();
|
||||
const title = attachTitle();
|
||||
runEffects();
|
||||
expect(title.focus).toHaveBeenCalledTimes(1);
|
||||
expect(harness.navigate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("an ordinary visit, without navigation state or with a malformed one, does nothing and does not fail", () => {
|
||||
for (const state of [null, undefined, "focus", { onboarding: "focus" }, { onboarding: { focus: "yes" } }]) {
|
||||
harness.location = { ...harness.location, state };
|
||||
render();
|
||||
const title = attachTitle();
|
||||
expect(() => runEffects()).not.toThrow();
|
||||
expect(title.focus).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(harness.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
71
src/components/onboarding/OnboardingStepCard.tsx
Normal file
71
src/components/onboarding/OnboardingStepCard.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import StepCard from "./StepCard";
|
||||
import { useOnboarding } from "../../contexts/OnboardingContext";
|
||||
import {
|
||||
cardNext,
|
||||
cardStep,
|
||||
readOnboardingNavState,
|
||||
showsStepCard,
|
||||
type StepCardModel,
|
||||
} from "../../utils/onboardingPath";
|
||||
|
||||
export interface OnboardingStepCardProps {
|
||||
/** The page's card model; null for no card yet (the page's own data is not read). */
|
||||
model: StepCardModel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step card of a page (#398), connected to the getting-started path. Each
|
||||
* page that carries a card renders it as its first child, before its header,
|
||||
* and passes only its model; this component reads `useOnboarding()`, the only
|
||||
* one of the card's pieces to do so.
|
||||
*
|
||||
* - It renders only in card mode, latched on the current route: the latch,
|
||||
* not the next step, keeps it on screen once the page's steps are done.
|
||||
* - After an arrival by "Next step" (`readOnboardingNavState(location.state)
|
||||
* .focus`), it moves the focus to its title once the card is on screen,
|
||||
* then consumes the navigation state, once per arrival: StrictMode runs the
|
||||
* effect twice on mount, a guard on the location key keeps it to one focus
|
||||
* and one replace. An ordinary visit carries no state and does nothing.
|
||||
* - It wires "Skip this step" to the step it shows, "Close" to `hide`, and
|
||||
* the next step's link to the path's next step.
|
||||
*/
|
||||
export default function OnboardingStepCard({ model }: OnboardingStepCardProps) {
|
||||
const onboarding = useOnboarding();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const focusedFor = useRef<string | null>(null);
|
||||
|
||||
const { pathname } = location;
|
||||
const current = onboarding.checklist ? cardStep(onboarding.checklist, pathname) : null;
|
||||
const shown =
|
||||
model !== null &&
|
||||
current !== null &&
|
||||
showsStepCard(onboarding.mode, onboarding.cardPage, pathname);
|
||||
const focusRequested = readOnboardingNavState(location.state).focus;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shown || !focusRequested || focusedFor.current === location.key) return;
|
||||
focusedFor.current = location.key;
|
||||
titleRef.current?.focus();
|
||||
navigate(pathname, { replace: true, state: null });
|
||||
}, [shown, focusRequested, location.key, pathname, navigate]);
|
||||
|
||||
if (!shown) return null;
|
||||
|
||||
const { skip, hide } = onboarding;
|
||||
return (
|
||||
<StepCard
|
||||
step={current.step}
|
||||
position={onboarding.budgetReady ? { n: current.n, total: current.total } : null}
|
||||
text={model.text}
|
||||
done={model.done}
|
||||
next={cardNext(onboarding.nextStep, pathname, onboarding.budgetReady)}
|
||||
onSkip={() => void skip(current.step.key)}
|
||||
onHide={() => void hide()}
|
||||
titleRef={titleRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
212
src/components/onboarding/StepCard.render.test.tsx
Normal file
212
src/components/onboarding/StepCard.render.test.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
// StepCard — render tests (#398). No jsdom: react-dom/server renders in
|
||||
// vitest's node environment for the markup (status region, heading, links),
|
||||
// and the component is called as a function for what the markup does not
|
||||
// carry (a link's navigation state, the handlers, the title's ref).
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createRef, isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Link, MemoryRouter } from "react-router-dom";
|
||||
|
||||
// The fake `t` marks what it translates, `t(key[options])`: a string that
|
||||
// skipped `t` would show unmarked.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (k: string, o?: Record<string, unknown>) =>
|
||||
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
|
||||
i18n: { language: "fr" },
|
||||
}),
|
||||
}));
|
||||
|
||||
import StepCard, { type StepCardProps } from "./StepCard";
|
||||
import { GETTING_STARTED_GUIDE } from "./OnboardingBanner";
|
||||
import { computeChecklist } from "../../utils/onboardingChecklist";
|
||||
import type { ChecklistItem, ChecklistItemKey, OnboardingCounters } from "../../shared/types";
|
||||
|
||||
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
|
||||
|
||||
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
|
||||
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
|
||||
if (!isValidElement(node)) return [];
|
||||
const el = node as AnyElement;
|
||||
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
|
||||
}
|
||||
|
||||
function texts(node: ReactNode): string[] {
|
||||
if (Array.isArray(node)) return node.flatMap(texts);
|
||||
if (typeof node === "string") return [node];
|
||||
if (!isValidElement(node)) return [];
|
||||
return texts((node as AnyElement).props.children);
|
||||
}
|
||||
|
||||
const ZERO: OnboardingCounters = {
|
||||
sources: 0,
|
||||
imports: 0,
|
||||
manualCategorized: 0,
|
||||
budgetEntries: 0,
|
||||
transactions: 0,
|
||||
};
|
||||
const rows = (counters: OnboardingCounters = ZERO) =>
|
||||
computeChecklist(counters, { importFolderSet: true, budgetAllowed: true }).items;
|
||||
const row = (key: ChecklistItemKey, counters?: OnboardingCounters): ChecklistItem =>
|
||||
rows(counters).find((item) => item.key === key)!;
|
||||
|
||||
/** The source step on /import, step 3 of 6, not done yet. */
|
||||
function cardProps(overrides: Partial<StepCardProps> = {}): StepCardProps {
|
||||
return {
|
||||
step: row("source"),
|
||||
position: { n: 3, total: 6 },
|
||||
text: { key: "onboarding.card.import.sourceList" },
|
||||
done: false,
|
||||
next: { kind: "wait" },
|
||||
onSkip: vi.fn(),
|
||||
onHide: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** After a successful import: the page's steps are done, the next step is elsewhere. */
|
||||
const doneProps = (overrides: Partial<StepCardProps> = {}) =>
|
||||
cardProps({
|
||||
step: row("import", { ...ZERO, sources: 1, imports: 1 }),
|
||||
position: { n: 4, total: 6 },
|
||||
text: null,
|
||||
done: true,
|
||||
next: { kind: "step", step: row("manualCategory") },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const markup = (props: StepCardProps) =>
|
||||
renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<StepCard {...props} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const tree = (props: StepCardProps) => StepCard(props) as ReactNode;
|
||||
|
||||
const STATUS = /<p role="status"[^>]*>(.*?)<\/p>/;
|
||||
|
||||
describe("StepCard — the card (#398)", () => {
|
||||
it("is a region of the getting-started path", () => {
|
||||
const html = markup(cardProps());
|
||||
expect(html).toMatch(/^<div role="region" aria-label="t\(onboarding\.banner\.label\)"/);
|
||||
});
|
||||
|
||||
it("titles itself with an h2 out of the tab order: 'Step n of N', then the step", () => {
|
||||
const html = markup(cardProps());
|
||||
const heading = html.match(/<h2 [^>]*>(.*?)<\/h2>/);
|
||||
expect(heading).not.toBeNull();
|
||||
expect(heading![0]).toContain('tabindex="-1"');
|
||||
const title = heading![1];
|
||||
expect(title).toContain("t(onboarding.card.stepOf[n=3,total=6])");
|
||||
expect(title.indexOf("onboarding.card.stepOf")).toBeLessThan(
|
||||
title.indexOf("t(onboarding.checklist.items.source)"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the title without its counter until the Budget entitlement is known", () => {
|
||||
const html = markup(cardProps({ position: null }));
|
||||
expect(html).not.toContain("onboarding.card.stepOf");
|
||||
expect(html).toMatch(/<h2 [^>]*tabindex="-1"[^>]*>.*t\(onboarding\.checklist\.items\.source\)/);
|
||||
});
|
||||
|
||||
it("hands its ref to the title, which takes the focus", () => {
|
||||
const titleRef = createRef<HTMLHeadingElement>();
|
||||
const [heading] = find(tree(cardProps({ titleRef })), (e) => e.type === "h2");
|
||||
expect(heading.props.ref).toBe(titleRef);
|
||||
expect(heading.props.tabIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it("says the page's sentence, with the labels of the buttons it names translated from their keys", () => {
|
||||
const html = markup(
|
||||
cardProps({
|
||||
text: {
|
||||
key: "onboarding.card.import.noSources",
|
||||
labels: { refresh: "import.empty.refresh", sample: "import.empty.createSample" },
|
||||
values: { folder: "Relevés" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(html).toContain(
|
||||
"t(onboarding.card.import.noSources[folder=Relevés,refresh=t(import.empty.refresh),sample=t(import.empty.createSample)])",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StepCard — before and after the success (#398)", () => {
|
||||
it("renders the status region before the success, empty", () => {
|
||||
const html = markup(cardProps());
|
||||
const status = html.match(STATUS);
|
||||
expect(status).not.toBeNull();
|
||||
expect(status![1]).toBe("");
|
||||
});
|
||||
|
||||
it("puts 'Done.' in the same status region after the success", () => {
|
||||
const status = markup(doneProps()).match(STATUS);
|
||||
expect(status).not.toBeNull();
|
||||
expect(status![1]).toContain("t(onboarding.card.done)");
|
||||
});
|
||||
|
||||
it("offers 'Skip this step' before the success only, wired to onSkip", () => {
|
||||
const props = cardProps();
|
||||
expect(markup(props)).toContain("t(onboarding.card.skip)");
|
||||
const [skip] = find(tree(props), (e) => e.type === "button");
|
||||
expect(texts(skip.props.children)).toEqual(["t(onboarding.card.skip)"]);
|
||||
(skip.props.onClick as () => void)();
|
||||
expect(props.onSkip).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(markup(doneProps())).not.toContain("onboarding.card.skip");
|
||||
});
|
||||
|
||||
it("does not offer to skip a step the data already marks done (a new pass through the wizard)", () => {
|
||||
const html = markup(cardProps({ step: row("import", { ...ZERO, sources: 1, imports: 1 }) }));
|
||||
expect(html).not.toContain("onboarding.card.skip");
|
||||
expect(html).not.toContain("onboarding.card.done");
|
||||
});
|
||||
|
||||
it("offers 'Next step' after the success only, linked to its page with the step's navigation state", () => {
|
||||
expect(markup(cardProps({ next: { kind: "step", step: row("manualCategory") } }))).not.toContain(
|
||||
"onboarding.card.next",
|
||||
);
|
||||
|
||||
const html = markup(doneProps());
|
||||
expect(html).toMatch(
|
||||
/<a [^>]*href="\/transactions"[^>]*>t\(onboarding\.card\.next\[step=t\(onboarding\.checklist\.items\.manualCategory\)\]\)/,
|
||||
);
|
||||
const [link] = find(tree(doneProps()), (e) => e.type === Link);
|
||||
expect(link.props.to).toBe("/transactions");
|
||||
expect(link.props.state).toEqual({ onboarding: { focus: true, guided: true } });
|
||||
});
|
||||
|
||||
it("shows nothing after 'Done.' while the next step waits", () => {
|
||||
const html = markup(doneProps({ next: { kind: "wait" } }));
|
||||
expect(html).toContain("t(onboarding.card.done)");
|
||||
expect(html).not.toContain("onboarding.card.next");
|
||||
expect(html).not.toContain("onboarding.banner.complete");
|
||||
expect(html).not.toContain("<a ");
|
||||
expect(html).not.toContain("<button");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StepCard — end of the path (#398)", () => {
|
||||
it("without a next step: 'Done.', the end message, 'Open the guide' and 'Close'", () => {
|
||||
const props = doneProps({ next: { kind: "end" } });
|
||||
const html = markup(props);
|
||||
expect(html.match(STATUS)![1]).toContain("t(onboarding.card.done)");
|
||||
expect(html).toContain("t(onboarding.banner.complete)");
|
||||
expect(html).toContain(`href="${GETTING_STARTED_GUIDE}"`);
|
||||
expect(html).toMatch(/<a [^>]*>t\(onboarding\.banner\.openGuide\)<\/a>/);
|
||||
expect(html).not.toContain("onboarding.card.next");
|
||||
|
||||
const [close] = find(tree(props), (e) => e.type === "button");
|
||||
expect(texts(close.props.children)).toEqual(["t(common.close)"]);
|
||||
(close.props.onClick as () => void)();
|
||||
expect(props.onHide).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is not the end before the success", () => {
|
||||
const html = markup(cardProps({ next: { kind: "end" } }));
|
||||
expect(html).not.toContain("onboarding.banner.complete");
|
||||
expect(html).toContain("t(onboarding.card.skip)");
|
||||
});
|
||||
});
|
||||
136
src/components/onboarding/StepCard.tsx
Normal file
136
src/components/onboarding/StepCard.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import type { Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CheckCircle2, Rocket } from "lucide-react";
|
||||
import { GETTING_STARTED_GUIDE } from "./OnboardingBanner";
|
||||
import {
|
||||
stepNavState,
|
||||
type StepCardNext,
|
||||
type StepCardText,
|
||||
} from "../../utils/onboardingPath";
|
||||
import type { ChecklistItem } from "../../shared/types";
|
||||
|
||||
export interface StepCardProps {
|
||||
/** The step the card shows (`cardStep`): its label is the title. */
|
||||
step: ChecklistItem;
|
||||
/** "Step n of N", null until the Budget entitlement is known: N depends on its lock. */
|
||||
position: { n: number; total: number } | null;
|
||||
/** The page's sentence, null when "Done." says it all. */
|
||||
text: StepCardText | null;
|
||||
/** The page's steps are done: "Done." in the status, then `next`. */
|
||||
done: boolean;
|
||||
/** What follows "Done." (`cardNext`), read only once `done`. */
|
||||
next: StepCardNext;
|
||||
/** "Skip this step": the provider's `skip`, for `step`. */
|
||||
onSkip: () => void;
|
||||
/** "Close" at the end of the path: the provider's `hide`. */
|
||||
onHide: () => void;
|
||||
/** The title, which takes the focus after an arrival by "Next step". */
|
||||
titleRef?: Ref<HTMLHeadingElement>;
|
||||
}
|
||||
|
||||
const REGION_CLASS =
|
||||
"rounded-xl border border-[var(--primary)]/30 bg-[var(--primary)]/5 px-4 py-3 mb-6";
|
||||
const TEXT_BUTTON_CLASS =
|
||||
"text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors";
|
||||
const LINK_CLASS = "inline-flex items-center gap-1 font-medium text-[var(--primary)] hover:underline";
|
||||
|
||||
/**
|
||||
* Step card (#398): on the page of the step in progress, it takes the place
|
||||
* of the banner, says what to do, follows the success live and leads to the
|
||||
* next step. Driven by props, so it is tested without the provider; the
|
||||
* connected `OnboardingStepCard` feeds it.
|
||||
*
|
||||
* - The title is an `h2` with `tabIndex={-1}`: "Step n of N", then the step.
|
||||
* - The sentence comes from the page's model; the labels of the buttons it
|
||||
* names are translated here, from their own keys.
|
||||
* - The `role="status"` region is always in the page, empty at first, so the
|
||||
* "Done." it receives once the page's steps are done is announced.
|
||||
* - "Skip this step" shows until the page's steps are done, and only while
|
||||
* the step itself is not: skipping a done step would only leave a stale
|
||||
* "skipped" mark behind.
|
||||
* - Once done: "Next step: <step> →", linked to its page with the step's
|
||||
* navigation state; the end message, "Open the guide" and "Close" when no
|
||||
* step is left; nothing yet while `next` waits.
|
||||
*/
|
||||
export default function StepCard({
|
||||
step,
|
||||
position,
|
||||
text,
|
||||
done,
|
||||
next,
|
||||
onSkip,
|
||||
onHide,
|
||||
titleRef,
|
||||
}: StepCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sentence =
|
||||
text === null
|
||||
? null
|
||||
: t(text.key, {
|
||||
...text.values,
|
||||
...Object.fromEntries(
|
||||
Object.entries(text.labels ?? {}).map(([name, key]) => [name, t(key)]),
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<div role="region" aria-label={t("onboarding.banner.label")} className={REGION_CLASS}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Rocket size={18} className="mt-0.5 shrink-0 text-[var(--primary)]" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2
|
||||
ref={titleRef}
|
||||
tabIndex={-1}
|
||||
className="flex flex-wrap items-center gap-x-2 text-sm font-semibold text-[var(--foreground)]"
|
||||
>
|
||||
{position && (
|
||||
<>
|
||||
<span className="text-[var(--muted-foreground)] tabular-nums">
|
||||
{t("onboarding.card.stepOf", { n: position.n, total: position.total })}
|
||||
</span>
|
||||
<span aria-hidden="true" className="h-1 w-1 shrink-0 rounded-full bg-[var(--muted-foreground)]" />
|
||||
</>
|
||||
)}
|
||||
<span>{t(`onboarding.checklist.items.${step.key}`)}</span>
|
||||
</h2>
|
||||
{sentence !== null && <p className="mt-1 text-sm text-[var(--foreground)]">{sentence}</p>}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
|
||||
<p role="status" className="inline-flex items-center gap-1 font-medium text-[var(--foreground)]">
|
||||
{done && (
|
||||
<>
|
||||
<CheckCircle2 size={16} className="text-[var(--positive)]" aria-hidden="true" />
|
||||
{t("onboarding.card.done")}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{!done && !step.done && (
|
||||
<button type="button" onClick={onSkip} className={TEXT_BUTTON_CLASS}>
|
||||
{t("onboarding.card.skip")}
|
||||
</button>
|
||||
)}
|
||||
{done && next.kind === "step" && (
|
||||
<Link to={next.step.to} state={stepNavState(next.step.key)} className={LINK_CLASS}>
|
||||
{t("onboarding.card.next", {
|
||||
step: t(`onboarding.checklist.items.${next.step.key}`),
|
||||
})}
|
||||
</Link>
|
||||
)}
|
||||
{done && next.kind === "end" && (
|
||||
<>
|
||||
<span className="text-[var(--foreground)]">{t("onboarding.banner.complete")}</span>
|
||||
<Link to={GETTING_STARTED_GUIDE} className={LINK_CLASS}>
|
||||
{t("onboarding.banner.openGuide")}
|
||||
</Link>
|
||||
<button type="button" onClick={onHide} className={TEXT_BUTTON_CLASS}>
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,9 +6,10 @@
|
|||
// component: creating the element never needs a router, only rendering would.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Link, MemoryRouter } from "react-router-dom";
|
||||
|
||||
import EmptyState from "./EmptyState";
|
||||
import EmptyState, { EMPTY_STATE_TARGET, type EmptyStateAction } from "./EmptyState";
|
||||
|
||||
function find(node: ReactNode, pred: (el: ReactElement) => boolean): ReactElement[] {
|
||||
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
|
||||
|
|
@ -109,3 +110,40 @@ describe("EmptyState", () => {
|
|||
expect(text.indexOf("CHILD_MARKER")).toBeLessThan(text.indexOf("Go"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("EmptyState — getting-started target (#398)", () => {
|
||||
const HIGHLIGHT = 'data-onboarding-highlight="true"';
|
||||
const count = (html: string) => html.split(HIGHLIGHT).length - 1;
|
||||
const markup = (primaryAction: EmptyStateAction, secondaryAction?: EmptyStateAction) =>
|
||||
renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<EmptyState icon={ICON} title="Title" primaryAction={primaryAction} secondaryAction={secondaryAction} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const highlighted = (el: ReactNode) =>
|
||||
find(el, (e) => (e.props as Record<string, unknown>)["data-onboarding-highlight"] === "true");
|
||||
|
||||
it("highlights the action that carries onboardingTarget, and nothing else, link or button", () => {
|
||||
const secondary: EmptyStateAction = { label: "Other", onClick: vi.fn() };
|
||||
for (const primary of [
|
||||
{ label: "Go", to: "/import", onboardingTarget: true },
|
||||
{ label: "Do", onClick: vi.fn(), onboardingTarget: true },
|
||||
] satisfies EmptyStateAction[]) {
|
||||
const html = markup(primary, secondary);
|
||||
expect(count(html), primary.label).toBe(1);
|
||||
expect(html).toContain(`data-onboarding-target="${EMPTY_STATE_TARGET}"`);
|
||||
|
||||
const [target, ...others] = highlighted(
|
||||
EmptyState({ icon: ICON, title: "Title", primaryAction: primary, secondaryAction: secondary }),
|
||||
);
|
||||
expect(others).toHaveLength(0);
|
||||
expect(texts((target.props as { children?: ReactNode }).children)).toEqual([primary.label]);
|
||||
}
|
||||
});
|
||||
|
||||
it("highlights nothing without it, or with it false", () => {
|
||||
expect(count(markup({ label: "Go", to: "/import" }, { label: "Other", onClick: vi.fn() }))).toBe(0);
|
||||
expect(count(markup({ label: "Do", onClick: vi.fn(), onboardingTarget: false }))).toBe(0);
|
||||
expect(markup({ label: "Do", onClick: vi.fn() })).not.toContain("data-onboarding-target");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
|
||||
|
||||
/**
|
||||
* An action of the empty state: either a route (rendered as a react-router
|
||||
* `<Link>`, never `useNavigate`) or a callback (rendered as a `<button>`).
|
||||
* `onboardingTarget` highlights it for the page's step card (#398).
|
||||
*/
|
||||
export type EmptyStateAction =
|
||||
| { label: string; to: string }
|
||||
| { label: string; onClick: () => void; disabled?: boolean };
|
||||
| { label: string; to: string; onboardingTarget?: boolean }
|
||||
| { label: string; onClick: () => void; disabled?: boolean; onboardingTarget?: boolean };
|
||||
|
||||
/** `data-onboarding-target` of a highlighted action. */
|
||||
export const EMPTY_STATE_TARGET = "empty-state-action";
|
||||
|
||||
export interface EmptyStateProps {
|
||||
/** Centered icon, already sized by the caller. */
|
||||
|
|
@ -28,9 +33,10 @@ const SECONDARY_CLASS =
|
|||
"inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md border border-[var(--border)] text-[var(--foreground)] font-medium hover:bg-[var(--muted)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
|
||||
function renderAction(action: EmptyStateAction, className: string) {
|
||||
const target = onboardingTargetAttributes(EMPTY_STATE_TARGET, action.onboardingTarget);
|
||||
if ("to" in action) {
|
||||
return (
|
||||
<Link to={action.to} className={className}>
|
||||
<Link to={action.to} className={className} {...target}>
|
||||
{action.label}
|
||||
</Link>
|
||||
);
|
||||
|
|
@ -41,6 +47,7 @@ function renderAction(action: EmptyStateAction, className: string) {
|
|||
onClick={action.onClick}
|
||||
disabled={action.disabled ?? false}
|
||||
className={className}
|
||||
{...target}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
|
|
|
|||
128
src/hooks/useImportWizard.mount.test.ts
Normal file
128
src/hooks/useImportWizard.mount.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// useImportWizard — the folder read at mount marks itself done (#384, #398).
|
||||
//
|
||||
// No renderer in this project, and the reducer alone cannot reach the mount
|
||||
// read: it is an effect of the hook. React's hooks used by `useImportWizard`
|
||||
// are stubbed (technique of useImportWizard.executeImport.test.ts): the hook
|
||||
// runs as a plain function, its effect is captured and run by hand, and the
|
||||
// services it calls are mocked. What is pinned here is the `finally`: the
|
||||
// folder counts as read once the read and its scan are over, success or not.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const harness = vi.hoisted(() => ({
|
||||
state: null as unknown,
|
||||
dispatch: vi.fn(),
|
||||
effects: [] as Array<() => void>,
|
||||
}));
|
||||
|
||||
vi.mock("react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react")>();
|
||||
return {
|
||||
...actual,
|
||||
useReducer: () => [harness.state, harness.dispatch],
|
||||
useCallback: (fn: unknown) => fn,
|
||||
useEffect: (fn: () => void) => {
|
||||
harness.effects.push(fn);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
||||
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
||||
vi.mock("../services/userPreferenceService", () => ({
|
||||
getImportFolder: vi.fn(),
|
||||
setImportFolder: vi.fn(),
|
||||
}));
|
||||
vi.mock("../services/importSourceService", () => ({
|
||||
getAllSources: vi.fn(async () => []),
|
||||
getSourceByName: vi.fn(async () => null),
|
||||
createSource: vi.fn(),
|
||||
updateSource: vi.fn(),
|
||||
}));
|
||||
vi.mock("../services/importedFileService", () => ({
|
||||
existsByHash: vi.fn(),
|
||||
createImportedFile: vi.fn(),
|
||||
updateFileStatus: vi.fn(),
|
||||
getFilesBySourceId: vi.fn(async () => []),
|
||||
}));
|
||||
vi.mock("../services/importConfigTemplateService", () => ({
|
||||
getAllTemplates: vi.fn(async () => []),
|
||||
createTemplate: vi.fn(),
|
||||
updateTemplate: vi.fn(),
|
||||
deleteTemplate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useImportWizard, initialWizardState } from "./useImportWizard";
|
||||
import { getImportFolder } from "../services/userPreferenceService";
|
||||
import type { ScannedSource } from "../shared/types";
|
||||
|
||||
const SOURCES: ScannedSource[] = [
|
||||
{ folder_name: "Desjardins", folder_path: "/releves/Desjardins", files: [] },
|
||||
];
|
||||
|
||||
/** Lets the mount read's promises settle. */
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
/** Mounts the hook and runs its mount effect, the first one it registers. */
|
||||
async function mount() {
|
||||
useImportWizard();
|
||||
const [readFolder] = harness.effects;
|
||||
readFolder();
|
||||
await flush();
|
||||
}
|
||||
|
||||
const types = () => harness.dispatch.mock.calls.map((c) => (c[0] as { type: string }).type);
|
||||
|
||||
beforeEach(() => {
|
||||
harness.state = initialWizardState;
|
||||
harness.effects = [];
|
||||
harness.dispatch.mockReset();
|
||||
vi.mocked(getImportFolder).mockReset();
|
||||
vi.mocked(invoke).mockReset();
|
||||
});
|
||||
|
||||
describe("useImportWizard — the mount read marks the folder read (#384, #398)", () => {
|
||||
it("with a folder: after the folder and its first scan, as the last dispatch", async () => {
|
||||
vi.mocked(getImportFolder).mockResolvedValue("/releves");
|
||||
vi.mocked(invoke).mockResolvedValue(SOURCES);
|
||||
await mount();
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("scan_import_folder", { folderPath: "/releves" });
|
||||
const order = types();
|
||||
expect(order[0]).toBe("SET_IMPORT_FOLDER");
|
||||
expect(order.indexOf("SET_SCANNED_SOURCES")).toBeGreaterThan(0);
|
||||
expect(order[order.length - 1]).toBe("SET_IMPORT_FOLDER_LOADED");
|
||||
expect(order.filter((t) => t === "SET_IMPORT_FOLDER_LOADED")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("without a folder: marked read, nothing scanned", async () => {
|
||||
vi.mocked(getImportFolder).mockResolvedValue(null);
|
||||
await mount();
|
||||
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
expect(harness.dispatch.mock.calls.map((c) => c[0])).toEqual([
|
||||
{ type: "SET_IMPORT_FOLDER", payload: null },
|
||||
{ type: "SET_IMPORT_FOLDER_LOADED" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("when the preference cannot be read: still marked read (the empty state then offers a folder)", async () => {
|
||||
vi.mocked(getImportFolder).mockRejectedValue(new Error("database is locked"));
|
||||
await mount();
|
||||
|
||||
expect(types()).toEqual(["SET_IMPORT_FOLDER_LOADED"]);
|
||||
});
|
||||
|
||||
it("when the scan fails: marked read after the error", async () => {
|
||||
vi.mocked(getImportFolder).mockResolvedValue("/releves");
|
||||
vi.mocked(invoke).mockRejectedValue(new Error("folder not found"));
|
||||
await mount();
|
||||
|
||||
const order = types();
|
||||
expect(order).toContain("SET_ERROR");
|
||||
expect(order[order.length - 1]).toBe("SET_IMPORT_FOLDER_LOADED");
|
||||
});
|
||||
});
|
||||
|
|
@ -110,6 +110,46 @@ describe("wizardReducer — sampleCreated (#356)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("wizardReducer — importFolderLoaded (#384, #398)", () => {
|
||||
it("starts unread: the page cannot tell 'no folder' from 'not read yet'", () => {
|
||||
expect(initialWizardState.importFolderLoaded).toBe(false);
|
||||
expect(initialWizardState.importFolder).toBeNull();
|
||||
});
|
||||
|
||||
it("SET_IMPORT_FOLDER_LOADED marks the folder read, and nothing else", () => {
|
||||
const before = afterCreation();
|
||||
const after = wizardReducer(before, { type: "SET_IMPORT_FOLDER_LOADED" });
|
||||
expect(after.importFolderLoaded).toBe(true);
|
||||
expect({ ...after, importFolderLoaded: false }).toEqual(before);
|
||||
});
|
||||
|
||||
it("RESET ('Cancel', 'Done') keeps it: the mount read does not run again to restore it", () => {
|
||||
const loaded = reduce([{ type: "SET_IMPORT_FOLDER_LOADED" }], afterCreation());
|
||||
const inWizard = reduce(
|
||||
[
|
||||
{ type: "SET_STEP", payload: "report" },
|
||||
{ type: "SET_LOADING", payload: true },
|
||||
],
|
||||
loaded,
|
||||
);
|
||||
const after = wizardReducer(inWizard, { type: "RESET" });
|
||||
expect(after.importFolderLoaded).toBe(true);
|
||||
expect(after.step).toBe("source-list");
|
||||
expect(after.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("RESET before the read leaves it unread", () => {
|
||||
expect(wizardReducer(initialWizardState, { type: "RESET" }).importFolderLoaded).toBe(false);
|
||||
});
|
||||
|
||||
it("choosing another folder keeps it read", () => {
|
||||
const loaded = reduce([{ type: "SET_IMPORT_FOLDER_LOADED" }]);
|
||||
expect(
|
||||
wizardReducer(loaded, { type: "SET_IMPORT_FOLDER", payload: "/autre" }).importFolderLoaded,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLocalDate (#356)", () => {
|
||||
// Pin a timezone west of Greenwich: under TZ=UTC (the CI runner) local and
|
||||
// UTC dates coincide, so a `toISOString()` regression would pass unnoticed.
|
||||
|
|
|
|||
|
|
@ -71,6 +71,14 @@ function errorMessage(e: unknown): string {
|
|||
export interface WizardState {
|
||||
step: ImportWizardStep;
|
||||
importFolder: string | null;
|
||||
/**
|
||||
* The stored import folder has been read at mount, and scanned when there is
|
||||
* one (#384, #398). Until then `importFolder` is null for lack of reading,
|
||||
* not for lack of a folder: the empty state and the step card wait for it,
|
||||
* or the "no folder" variant would flash. Set once, success or failure, and
|
||||
* kept by `RESET`, which the mount read never runs again to restore.
|
||||
*/
|
||||
importFolderLoaded: boolean;
|
||||
scannedSources: ScannedSource[];
|
||||
selectedSource: ScannedSource | null;
|
||||
selectedFiles: ScannedFile[];
|
||||
|
|
@ -125,6 +133,7 @@ export type WizardAction =
|
|||
| { type: "SET_ERROR"; payload: string | null }
|
||||
| { type: "SET_STEP"; payload: ImportWizardStep }
|
||||
| { type: "SET_IMPORT_FOLDER"; payload: string | null }
|
||||
| { type: "SET_IMPORT_FOLDER_LOADED" }
|
||||
| { type: "SET_SCANNED_SOURCES"; payload: ScannedSource[] }
|
||||
| { type: "SET_SELECTED_SOURCE"; payload: ScannedSource }
|
||||
| { type: "SET_SELECTED_FILES"; payload: ScannedFile[] }
|
||||
|
|
@ -163,6 +172,7 @@ const defaultConfig: SourceConfig = {
|
|||
const initialState: WizardState = {
|
||||
step: "source-list",
|
||||
importFolder: null,
|
||||
importFolderLoaded: false,
|
||||
scannedSources: [],
|
||||
selectedSource: null,
|
||||
selectedFiles: [],
|
||||
|
|
@ -201,6 +211,8 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS
|
|||
case "SET_IMPORT_FOLDER":
|
||||
// The sample-folder banner describes the previous folder: drop it.
|
||||
return { ...state, importFolder: action.payload, sampleCreated: null };
|
||||
case "SET_IMPORT_FOLDER_LOADED":
|
||||
return { ...state, importFolderLoaded: true };
|
||||
case "SET_SCANNED_SOURCES":
|
||||
return { ...state, scannedSources: action.payload, isLoading: false };
|
||||
case "SET_SELECTED_SOURCE":
|
||||
|
|
@ -289,9 +301,15 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS
|
|||
case "SET_SAMPLE_CREATED":
|
||||
return { ...state, sampleCreated: action.payload };
|
||||
case "RESET":
|
||||
// Rebuilt from `initialState`, minus what the page read once and keeps:
|
||||
// the folder, whether it was read (#384: the mount read does not run
|
||||
// again, so "Cancel" or "Done" would otherwise hide the empty state and
|
||||
// the step card for good), its scan, the configured sources and the
|
||||
// templates.
|
||||
return {
|
||||
...initialState,
|
||||
importFolder: state.importFolder,
|
||||
importFolderLoaded: state.importFolderLoaded,
|
||||
scannedSources: state.scannedSources,
|
||||
configuredSourceNames: state.configuredSourceNames,
|
||||
importedFilesBySource: state.importedFilesBySource,
|
||||
|
|
@ -328,6 +346,11 @@ export function useImportWizard() {
|
|||
}
|
||||
} catch {
|
||||
// No folder configured yet
|
||||
} finally {
|
||||
// Read, and scanned when there is a folder (#384): the empty state and
|
||||
// the step card can tell "no folder" from "not read yet". After the
|
||||
// scan, so the "no source" variant does not flash either.
|
||||
dispatch({ type: "SET_IMPORT_FOLDER_LOADED" });
|
||||
}
|
||||
})();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
|
|
|||
|
|
@ -74,6 +74,23 @@
|
|||
"complete": "Getting started complete. The user guide takes it from here.",
|
||||
"openGuide": "Open the guide"
|
||||
},
|
||||
"card": {
|
||||
"stepOf": "Step {{n}} of {{total}}",
|
||||
"skip": "Skip this step",
|
||||
"done": "Done.",
|
||||
"next": "Next step: {{step}} →",
|
||||
"import": {
|
||||
"noFolder": "Choose the folder where you will drop your bank statements, with the {{button}} button. Simpl'Résultat will look for one sub-folder per bank in it.",
|
||||
"noSources": "In the {{folder}} folder, create one sub-folder per bank and drop your CSV statements in it, then click {{refresh}}. To try first, {{sample}} adds two fictitious banks.",
|
||||
"sourceList": "Click your bank's card. Simpl'Résultat recognises the format of its statements and shows you what it understood.",
|
||||
"sourceConfig": "Check the source name and the selected files, then click {{button}}.",
|
||||
"filePreview": "Check that dates and amounts are read correctly, then click {{button}}.",
|
||||
"duplicateCheck": "Choose what to do with transactions already imported, then click {{button}}.",
|
||||
"confirm": "Click {{button}}: the source is saved and your transactions come in.",
|
||||
"importing": "Importing…",
|
||||
"failed": "The import added no transaction; the report below says why."
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"done": "(done)",
|
||||
"skipped": "(skipped)",
|
||||
|
|
|
|||
|
|
@ -74,6 +74,23 @@
|
|||
"complete": "Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite.",
|
||||
"openGuide": "Ouvrir le guide"
|
||||
},
|
||||
"card": {
|
||||
"stepOf": "Étape {{n}} sur {{total}}",
|
||||
"skip": "Passer cette étape",
|
||||
"done": "Fait.",
|
||||
"next": "Étape suivante : {{step}} →",
|
||||
"import": {
|
||||
"noFolder": "Choisissez le dossier où vous déposerez vos relevés bancaires, avec le bouton {{button}}. Simpl'Résultat y cherchera un sous-dossier par banque.",
|
||||
"noSources": "Dans le dossier {{folder}}, créez un sous-dossier par banque et déposez-y vos relevés CSV, puis cliquez {{refresh}}. Pour essayer d'abord, {{sample}} dépose deux banques fictives.",
|
||||
"sourceList": "Cliquez sur la carte de votre banque. Simpl'Résultat reconnaît le format de ses relevés et vous montre ce qu'il a compris.",
|
||||
"sourceConfig": "Vérifiez le nom de la source et les fichiers cochés, puis cliquez {{button}}.",
|
||||
"filePreview": "Vérifiez que les dates et les montants sont bien lus, puis cliquez {{button}}.",
|
||||
"duplicateCheck": "Choisissez quoi faire des transactions déjà importées, puis cliquez {{button}}.",
|
||||
"confirm": "Cliquez {{button}} : la source est enregistrée et vos transactions arrivent.",
|
||||
"importing": "Import en cours…",
|
||||
"failed": "L'import n'a ajouté aucune transaction ; le rapport ci-dessous dit pourquoi."
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"done": "(faite)",
|
||||
"skipped": "(passée)",
|
||||
|
|
|
|||
283
src/pages/ImportPage.render.test.tsx
Normal file
283
src/pages/ImportPage.render.test.tsx
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
// ImportPage and its getting-started card (#398). No jsdom: react-dom/server
|
||||
// renders in vitest's node environment. The wizard is a fake
|
||||
// `useImportWizard()` over its real initial state, the path a fake
|
||||
// `useOnboarding()` built from the real checklist, and the wizard's heavy
|
||||
// panels are stubbed. What is pinned is the page's wiring: where the card
|
||||
// sits, what it says at each step, which control carries the one highlight,
|
||||
// and what waits for the folder to be read (#384).
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
// Importing the real wizard module (for its initial state) pulls the import
|
||||
// services, then the database: stub the connection.
|
||||
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (k: string, o?: Record<string, unknown>) =>
|
||||
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
|
||||
i18n: { language: "fr" },
|
||||
}),
|
||||
}));
|
||||
|
||||
const harness = vi.hoisted(() => ({ wizard: null as unknown, onboarding: null as unknown }));
|
||||
|
||||
vi.mock("../hooks/useImportWizard", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../hooks/useImportWizard")>()),
|
||||
useImportWizard: () => harness.wizard,
|
||||
}));
|
||||
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
|
||||
|
||||
const { stub } = vi.hoisted(() => ({
|
||||
stub: (name: string) => ({ default: () => <i data-stub={name} /> }),
|
||||
}));
|
||||
vi.mock("../components/import/ImportHistoryPanel", () => stub("history"));
|
||||
vi.mock("../components/import/SourceConfigPanel", () => stub("source-config"));
|
||||
vi.mock("../components/import/FilePreviewTable", () => stub("file-preview"));
|
||||
vi.mock("../components/import/DuplicateCheckPanel", () => stub("duplicate-check"));
|
||||
vi.mock("../components/import/ImportConfirmation", () => stub("confirmation"));
|
||||
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
|
||||
|
||||
import ImportPage from "./ImportPage";
|
||||
import { initialWizardState, type WizardState } from "../hooks/useImportWizard";
|
||||
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
|
||||
import { computeChecklist } from "../utils/onboardingChecklist";
|
||||
import { nextStep } from "../utils/onboardingPath";
|
||||
import type { ImportReport, OnboardingCounters, ScannedSource } from "../shared/types";
|
||||
|
||||
const ZERO: OnboardingCounters = {
|
||||
sources: 0,
|
||||
imports: 0,
|
||||
manualCategorized: 0,
|
||||
budgetEntries: 0,
|
||||
transactions: 0,
|
||||
};
|
||||
/** Source and import checked (the re-read after a successful import). */
|
||||
const IMPORTED: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 41 };
|
||||
|
||||
const SOURCES: ScannedSource[] = ["Desjardins", "Tangerine"].map((name) => ({
|
||||
folder_name: name,
|
||||
folder_path: `/home/max/Relevés/${name}`,
|
||||
files: [],
|
||||
}));
|
||||
|
||||
const report = (importedCount: number): ImportReport => ({
|
||||
totalRows: 41,
|
||||
importedCount,
|
||||
skippedDuplicates: 41 - importedCount,
|
||||
errorCount: 0,
|
||||
categorizedCount: importedCount,
|
||||
uncategorizedCount: 0,
|
||||
errors: [],
|
||||
});
|
||||
|
||||
const DUPLICATES = { fileAlreadyImported: false, duplicateRows: [], newRows: [] };
|
||||
|
||||
/** The wizard after its mount read, with `overrides`. */
|
||||
function wizard(overrides: Partial<WizardState> = {}) {
|
||||
const state: WizardState = { ...initialWizardState, importFolderLoaded: true, ...overrides };
|
||||
const noop = vi.fn();
|
||||
return new Proxy({ state } as Record<string, unknown>, {
|
||||
get: (target, key) => (key in target ? target[key as string] : noop),
|
||||
});
|
||||
}
|
||||
|
||||
/** The path latched on /import (card mode), as the provider shares it. */
|
||||
function path(
|
||||
counters: OnboardingCounters,
|
||||
importFolderSet: boolean,
|
||||
overrides: Partial<OnboardingValue> = {},
|
||||
): OnboardingValue {
|
||||
const checklist = computeChecklist(counters, { importFolderSet, budgetAllowed: true });
|
||||
return {
|
||||
status: "ready",
|
||||
checklist,
|
||||
counters,
|
||||
hidden: false,
|
||||
budgetReady: true,
|
||||
nextStep: nextStep(checklist),
|
||||
mode: "card",
|
||||
cardPage: "/import",
|
||||
hide: vi.fn(async () => undefined),
|
||||
show: vi.fn(async () => undefined),
|
||||
skip: vi.fn(async () => undefined),
|
||||
resume: vi.fn(async () => undefined),
|
||||
refresh: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const render = () =>
|
||||
renderToStaticMarkup(
|
||||
<MemoryRouter initialEntries={["/import"]}>
|
||||
<ImportPage />
|
||||
</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;
|
||||
|
||||
/** The one highlighted control: its tag and its inner markup. */
|
||||
function highlighted(html: string): { tag: string; inner: string } {
|
||||
expect(count(html, HIGHLIGHT)).toBe(1);
|
||||
const match = html.match(/<(button|a) [^>]*data-onboarding-highlight="true"[^>]*>(.*?)<\/\1>/);
|
||||
expect(match).not.toBeNull();
|
||||
return { tag: match![1], inner: match![2] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The card's sentence: the paragraph that follows its title. The card always
|
||||
* hands `t` its interpolations, so a sentence without any reads `t(key[])`.
|
||||
*/
|
||||
const sentence = (html: string) => html.match(/<\/h2><p [^>]*>(.*?)<\/p>/)?.[1] ?? null;
|
||||
|
||||
beforeEach(() => {
|
||||
harness.wizard = wizard();
|
||||
harness.onboarding = path(ZERO, false);
|
||||
});
|
||||
|
||||
describe("ImportPage — the step card (#398)", () => {
|
||||
it("renders the card first, before the page's heading", () => {
|
||||
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=2,total=6])");
|
||||
});
|
||||
|
||||
it("a new profile: 'Choose a folder' is the one target, and the sentence names it", () => {
|
||||
const html = render();
|
||||
expect(sentence(html)).toBe(
|
||||
"t(onboarding.card.import.noFolder[button=t(import.empty.noFolder.cta)])",
|
||||
);
|
||||
const target = highlighted(html);
|
||||
expect(target.tag).toBe("button");
|
||||
expect(target.inner).toBe("t(import.empty.noFolder.cta)");
|
||||
});
|
||||
|
||||
it("a folder without bank sub-folder: 'Create a sample folder', the folder named by its last segment", () => {
|
||||
harness.wizard = wizard({ importFolder: "/home/max/Relevés" });
|
||||
harness.onboarding = path(ZERO, true);
|
||||
const html = render();
|
||||
expect(html).toContain("t(onboarding.card.stepOf[n=3,total=6])");
|
||||
expect(sentence(html)).toBe(
|
||||
"t(onboarding.card.import.noSources[folder=Relevés,refresh=t(import.empty.refresh),sample=t(import.empty.createSample)])",
|
||||
);
|
||||
expect(highlighted(html).inner).toBe("t(import.empty.createSample)");
|
||||
});
|
||||
|
||||
it("sources found: the first source card", () => {
|
||||
harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
|
||||
harness.onboarding = path(ZERO, true);
|
||||
const html = render();
|
||||
expect(sentence(html)).toBe("t(onboarding.card.import.sourceList[])");
|
||||
const target = highlighted(html);
|
||||
expect(target.inner).toContain("Desjardins");
|
||||
expect(target.inner).not.toContain("Tangerine");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["source-config", "sourceConfig", "import.wizard.preview"],
|
||||
["file-preview", "filePreview", "import.wizard.checkDuplicates"],
|
||||
["duplicate-check", "duplicateCheck", "import.wizard.confirm"],
|
||||
["confirm", "confirm", "import.wizard.import"],
|
||||
] as const)("%s: the next button, the one the sentence names", (step, text, label) => {
|
||||
harness.wizard = wizard({
|
||||
step,
|
||||
importFolder: "/home/max/Relevés",
|
||||
scannedSources: SOURCES,
|
||||
selectedSource: SOURCES[0],
|
||||
duplicateResult: DUPLICATES,
|
||||
});
|
||||
harness.onboarding = path({ ...ZERO, sources: 0 }, true);
|
||||
const html = render();
|
||||
expect(html).toContain(`data-stub="${step === "confirm" ? "confirmation" : step}"`);
|
||||
expect(sentence(html)).toBe(`t(onboarding.card.import.${text}[button=t(${label})])`);
|
||||
const target = highlighted(html);
|
||||
expect(target.tag).toBe("button");
|
||||
expect(target.inner).toContain(`t(${label})`);
|
||||
});
|
||||
|
||||
it("importing: the card says so and points at nothing, though the counters already tick", () => {
|
||||
harness.wizard = wizard({ step: "importing", importFolder: "/home/max/Relevés", scannedSources: SOURCES });
|
||||
harness.onboarding = path(IMPORTED, true);
|
||||
const html = render();
|
||||
expect(html).toContain("t(import.progress.title)");
|
||||
expect(sentence(html)).toBe("t(onboarding.card.import.importing[])");
|
||||
expect(html).not.toContain("t(onboarding.card.done)");
|
||||
expect(count(html, HIGHLIGHT)).toBe(0);
|
||||
});
|
||||
|
||||
it("the report of an import that added transactions: 'Done.' and the next step", () => {
|
||||
harness.wizard = wizard({
|
||||
step: "report",
|
||||
importFolder: "/home/max/Relevés",
|
||||
scannedSources: SOURCES,
|
||||
importReport: report(41),
|
||||
});
|
||||
harness.onboarding = path(IMPORTED, true);
|
||||
const html = render();
|
||||
expect(html).toMatch(/<p role="status"[^>]*>.*t\(onboarding\.card\.done\)/);
|
||||
expect(html).toMatch(/href="\/transactions"[^>]*>t\(onboarding\.card\.next\[step=/);
|
||||
expect(html).toContain("t(onboarding.card.stepOf[n=4,total=6])");
|
||||
expect(count(html, HIGHLIGHT)).toBe(0);
|
||||
});
|
||||
|
||||
it("the report of an import that added nothing: the failure sentence, never 'Done.'", () => {
|
||||
harness.wizard = wizard({
|
||||
step: "report",
|
||||
importFolder: "/home/max/Relevés",
|
||||
scannedSources: SOURCES,
|
||||
importReport: report(0),
|
||||
});
|
||||
// Duplicates only: the file row is `completed`, the checklist counts it.
|
||||
harness.onboarding = path(IMPORTED, true);
|
||||
const html = render();
|
||||
expect(sentence(html)).toBe("t(onboarding.card.import.failed[])");
|
||||
expect(html).not.toContain("t(onboarding.card.done)");
|
||||
expect(html).not.toContain("onboarding.card.next");
|
||||
});
|
||||
|
||||
it("after 'Done' or 'Cancel', the wizard at rest: the card still says 'Done.' and points at nothing", () => {
|
||||
harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
|
||||
harness.onboarding = path(IMPORTED, true);
|
||||
const html = render();
|
||||
expect(html).toContain("t(onboarding.card.done)");
|
||||
expect(html).toContain("Desjardins");
|
||||
expect(count(html, HIGHLIGHT)).toBe(0);
|
||||
});
|
||||
|
||||
it("without the card on screen, no control is highlighted", () => {
|
||||
harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
|
||||
for (const overrides of [
|
||||
{ mode: "banner" as const, cardPage: null },
|
||||
{ mode: "none" as const, cardPage: null },
|
||||
{ mode: "card" as const, cardPage: "/transactions" },
|
||||
]) {
|
||||
harness.onboarding = path(ZERO, true, overrides);
|
||||
const html = render();
|
||||
expect(html).not.toContain(CARD);
|
||||
expect(count(html, HIGHLIGHT)).toBe(0);
|
||||
expect(html).toContain("Desjardins");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ImportPage — before the folder is read (#384)", () => {
|
||||
it("shows neither variant of the empty state, nor the list's empty box, nor the card", () => {
|
||||
harness.wizard = wizard({ importFolderLoaded: false });
|
||||
const html = render();
|
||||
expect(html).not.toContain("import.empty.noFolder.title");
|
||||
expect(html).not.toContain("import.empty.noSources.title");
|
||||
expect(html).not.toContain("import.sources.empty");
|
||||
expect(html).not.toContain(CARD);
|
||||
expect(count(html, HIGHLIGHT)).toBe(0);
|
||||
// The page itself is there: its heading, the folder card and the history.
|
||||
expect(html).toContain("t(import.title)");
|
||||
expect(html).toContain('data-stub="history"');
|
||||
});
|
||||
|
||||
it("shows the empty state once read", () => {
|
||||
expect(render()).toContain("t(import.empty.noFolder.title)");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useImportWizard } from "../hooks/useImportWizard";
|
||||
import { useOnboarding } from "../contexts/OnboardingContext";
|
||||
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
|
||||
import { importCardModel, showsStepCard } from "../utils/onboardingPath";
|
||||
import ImportFolderConfig from "../components/import/ImportFolderConfig";
|
||||
import SourceList from "../components/import/SourceList";
|
||||
import ImportEmptyState, {
|
||||
|
|
@ -46,12 +50,36 @@ export default function ImportPage() {
|
|||
toggleDuplicateRow,
|
||||
setSkipAllDuplicates,
|
||||
} = useImportWizard();
|
||||
const onboarding = useOnboarding();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const nextDisabled = state.selectedFiles.length === 0 || !state.sourceConfig.name;
|
||||
const showEmptyState = state.step === "source-list" && state.scannedSources.length === 0;
|
||||
|
||||
// The getting-started card follows the wizard (#398). Its target is
|
||||
// highlighted only while the card is on screen: a ring needs the card that
|
||||
// explains it, and the Import page of a finished path shows none.
|
||||
const cardModel = importCardModel(
|
||||
{
|
||||
wizardStep: state.step,
|
||||
folderLoaded: state.importFolderLoaded,
|
||||
hasFolder: state.importFolder !== null,
|
||||
folderName: state.importFolder ? folderNameFromPath(state.importFolder) : null,
|
||||
sourceCount: state.scannedSources.length,
|
||||
importing: state.step === "importing",
|
||||
importedCount: state.step === "report" ? (state.importReport?.importedCount ?? null) : null,
|
||||
},
|
||||
onboarding.checklist,
|
||||
);
|
||||
const target =
|
||||
cardModel !== null && showsStepCard(onboarding.mode, onboarding.cardPage, pathname)
|
||||
? cardModel.target
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<OnboardingStepCard model={cardModel} />
|
||||
|
||||
<div className="relative flex items-center gap-3 mb-6">
|
||||
<h1 className="text-2xl font-bold">{t("import.title")}</h1>
|
||||
<PageHelp helpKey="import" linkTo={CSV_EXPORT_GUIDE_PATH} />
|
||||
|
|
@ -104,9 +132,12 @@ export default function ImportPage() {
|
|||
{/*
|
||||
No source to list (#356): the empty state takes the place of the
|
||||
list and tells the user what to do next, which depends on whether a
|
||||
folder is configured. SourceList itself stays a pure list.
|
||||
folder is configured. SourceList itself stays a pure list. Neither
|
||||
shows before the folder is read (#384): the "no folder" variant, or
|
||||
the list's own empty box, would flash.
|
||||
*/}
|
||||
{showEmptyState ? (
|
||||
{state.importFolderLoaded &&
|
||||
(showEmptyState ? (
|
||||
<ImportEmptyState
|
||||
hasFolder={state.importFolder !== null}
|
||||
folderName={state.importFolder ? folderNameFromPath(state.importFolder) : null}
|
||||
|
|
@ -114,6 +145,7 @@ export default function ImportPage() {
|
|||
onCreateSample={createSampleFolders}
|
||||
onRefresh={refreshFolder}
|
||||
isCreatingSample={state.isLoading}
|
||||
onboardingTarget={target === "emptyStateAction"}
|
||||
/>
|
||||
) : (
|
||||
<SourceList
|
||||
|
|
@ -121,8 +153,9 @@ export default function ImportPage() {
|
|||
configuredSourceNames={state.configuredSourceNames}
|
||||
importedFileNames={state.importedFilesBySource}
|
||||
onSelectSource={selectSource}
|
||||
onboardingTarget={target === "firstSource"}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
<ImportHistoryPanel onChanged={refreshFolder} />
|
||||
</>
|
||||
)}
|
||||
|
|
@ -161,6 +194,7 @@ export default function ImportPage() {
|
|||
onCancel={reset}
|
||||
nextLabel={t("import.wizard.preview")}
|
||||
nextDisabled={nextDisabled || state.isLoading}
|
||||
onboardingTarget={target === "wizardNext"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -192,6 +226,7 @@ export default function ImportPage() {
|
|||
onCancel={reset}
|
||||
nextLabel={t("import.wizard.checkDuplicates")}
|
||||
nextDisabled={state.isLoading}
|
||||
onboardingTarget={target === "wizardNext"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -210,6 +245,7 @@ export default function ImportPage() {
|
|||
onNext={() => goToStep("confirm")}
|
||||
onCancel={reset}
|
||||
nextLabel={t("import.wizard.confirm")}
|
||||
onboardingTarget={target === "wizardNext"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -230,6 +266,7 @@ export default function ImportPage() {
|
|||
onCancel={reset}
|
||||
nextLabel={t("import.wizard.import")}
|
||||
showCancel={false}
|
||||
onboardingTarget={target === "wizardNext"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1001,3 +1001,23 @@ export interface ChecklistItem {
|
|||
* successful load: a later failed re-read keeps `ready` and the last state.
|
||||
*/
|
||||
export type OnboardingStatus = "loading" | "ready" | "error";
|
||||
|
||||
/**
|
||||
* What the Import page's step card reads of the import wizard (#398), built by
|
||||
* the page from its wizard state; `importCardModel` turns it into the card.
|
||||
*/
|
||||
export interface ImportCardInput {
|
||||
wizardStep: ImportWizardStep;
|
||||
/** The stored import folder has been read at mount (`importFolderLoaded`): no card before. */
|
||||
folderLoaded: boolean;
|
||||
/** An import folder is configured. */
|
||||
hasFolder: boolean;
|
||||
/** Last segment of the import folder (`folderNameFromPath`), null without a folder. */
|
||||
folderName: string | null;
|
||||
/** Bank sub-folders found by the last scan of the folder. */
|
||||
sourceCount: number;
|
||||
/** An import is writing its rows. */
|
||||
importing: boolean;
|
||||
/** Transactions the import added (`ImportReport.importedCount`), null outside the `report` step. */
|
||||
importedCount: number | null;
|
||||
}
|
||||
|
|
|
|||
164
src/styles.contrast.test.ts
Normal file
164
src/styles.contrast.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
// The getting-started highlight in styles.css (#398): the ring a step card
|
||||
// draws around the control to use. Read from the stylesheet itself, so a
|
||||
// change to a theme color or to the rule is checked where it is made:
|
||||
// - WCAG 1.4.11: the ring reaches 3:1 against the page background and the
|
||||
// cards, in the light theme (`:root`) and the dark one (`.dark`);
|
||||
// - WCAG 2.2.2: the pulse stops by itself after three cycles, within 5 s;
|
||||
// - the pulse does not run under `prefers-reduced-motion: reduce`.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
const CSS = readFileSync(resolve(import.meta.dirname, "styles.css"), "utf-8");
|
||||
|
||||
const HIGHLIGHT = '[data-onboarding-highlight="true"]';
|
||||
|
||||
interface Block {
|
||||
selector: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** The top-level blocks of a stylesheet, comments removed; nested braces stay in the body. */
|
||||
function blocks(css: string): Block[] {
|
||||
const text = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
const found: Block[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
let open = -1;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (c === "{") {
|
||||
if (depth === 0) open = i;
|
||||
depth++;
|
||||
} else if (c === "}") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
found.push({ selector: text.slice(start, open).trim(), body: text.slice(open + 1, i) });
|
||||
start = i + 1;
|
||||
}
|
||||
} else if (c === ";" && depth === 0) {
|
||||
start = i + 1; // a statement such as `@import "tailwindcss";`
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The declarations of a block body, `name -> value`, the last one winning. */
|
||||
function declarations(body: string): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
for (const [, name, value] of body.matchAll(/([\w-]+)\s*:\s*([^;{}]+);/g)) {
|
||||
result.set(name, value.trim());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const TOP = blocks(CSS);
|
||||
|
||||
const merged = (selector: string, into = new Map<string, string>()) => {
|
||||
for (const block of TOP.filter((b) => b.selector === selector)) {
|
||||
for (const [name, value] of declarations(block.body)) into.set(name, value);
|
||||
}
|
||||
return into;
|
||||
};
|
||||
|
||||
const THEME_TOKENS = merged("@theme");
|
||||
const LIGHT = merged(":root");
|
||||
/** `.dark` overrides `:root`, which still holds what it does not redefine. */
|
||||
const DARK = merged(".dark", new Map(LIGHT));
|
||||
|
||||
/** A color variable resolved through `var()` down to its hex value. */
|
||||
function color(name: string, theme: Map<string, string>): string {
|
||||
let value = theme.get(name) ?? THEME_TOKENS.get(name);
|
||||
for (let hops = 0; value !== undefined && hops < 10; hops++) {
|
||||
const ref = value.match(/^var\((--[\w-]+)\)$/);
|
||||
if (!ref) break;
|
||||
value = theme.get(ref[1]) ?? THEME_TOKENS.get(ref[1]);
|
||||
}
|
||||
if (value === undefined || !/^#[0-9a-f]{6}$/i.test(value)) {
|
||||
throw new Error(`${name} does not resolve to a #rrggbb color: ${String(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** WCAG relative luminance of a #rrggbb color. */
|
||||
function luminance(hex: string): number {
|
||||
const [r, g, b] = [1, 3, 5].map((i) => {
|
||||
const c = parseInt(hex.slice(i, i + 2), 16) / 255;
|
||||
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
function contrast(a: string, b: string): number {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
}
|
||||
|
||||
const highlightRule = () => {
|
||||
const rule = TOP.find((b) => b.selector === HIGHLIGHT);
|
||||
expect(rule, "the highlight rule").toBeDefined();
|
||||
return declarations(rule!.body);
|
||||
};
|
||||
|
||||
/** The variable the ring is drawn with: `--accent`, or `--onboarding-ring` if the accent failed. */
|
||||
function ringVariable(): string {
|
||||
const outline = highlightRule().get("outline") ?? "";
|
||||
const variable = outline.match(/var\((--[\w-]+)\)/);
|
||||
expect(variable, `outline: ${outline}`).not.toBeNull();
|
||||
return variable![1];
|
||||
}
|
||||
|
||||
describe("styles.css — the contrast helpers", () => {
|
||||
it("compute the WCAG ratios of known pairs", () => {
|
||||
expect(contrast("#000000", "#ffffff")).toBeCloseTo(21, 5);
|
||||
expect(contrast("#ffffff", "#ffffff")).toBeCloseTo(1, 5);
|
||||
// #767676 is the classic 4.54:1 grey on white.
|
||||
expect(contrast("#767676", "#ffffff")).toBeCloseTo(4.54, 2);
|
||||
});
|
||||
|
||||
it("read both themes from the stylesheet", () => {
|
||||
expect(color("--background", LIGHT)).toBe("#FFF8F0");
|
||||
expect(color("--card", LIGHT)).toBe("#FFFFFF");
|
||||
expect(color("--background", DARK)).toBe("#111827");
|
||||
expect(color("--card", DARK)).toBe("#1f2937");
|
||||
});
|
||||
});
|
||||
|
||||
describe("styles.css — the getting-started highlight (#398)", () => {
|
||||
it("draws a 3px outline set off by 2px, in the accent color", () => {
|
||||
const rule = highlightRule();
|
||||
expect(rule.get("outline")).toBe("3px solid var(--accent)");
|
||||
expect(rule.get("outline-offset")).toBe("2px");
|
||||
expect(ringVariable()).toBe("--accent");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["light", LIGHT],
|
||||
["dark", DARK],
|
||||
] as const)("reaches 3:1 against the background and the cards, %s theme", (_name, theme) => {
|
||||
const ring = color(ringVariable(), theme);
|
||||
for (const surface of ["--background", "--card"]) {
|
||||
expect(contrast(ring, color(surface, theme)), surface).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("pulses three times at most, within the 5 s of WCAG 2.2.2", () => {
|
||||
const rule = highlightRule();
|
||||
expect(rule.get("animation-name")).toBe("onboarding-pulse");
|
||||
expect(rule.get("animation-iteration-count")).toBe("3");
|
||||
expect(rule.has("animation")).toBe(false);
|
||||
const duration = rule.get("animation-duration") ?? "";
|
||||
expect(duration).toMatch(/^\d+(\.\d+)?s$/);
|
||||
expect(parseFloat(duration) * 3).toBeLessThanOrEqual(5);
|
||||
const keyframes = TOP.find((b) => b.selector === "@keyframes onboarding-pulse");
|
||||
expect(keyframes?.body).toContain("box-shadow");
|
||||
});
|
||||
|
||||
it("does not pulse when the system asks for less motion", () => {
|
||||
const media = TOP.find((b) => b.selector === "@media (prefers-reduced-motion: reduce)");
|
||||
expect(media, "the reduced-motion block").toBeDefined();
|
||||
const inner = blocks(media!.body).find((b) => b.selector === HIGHLIGHT);
|
||||
expect(inner, "the highlight rule under reduced motion").toBeDefined();
|
||||
expect(declarations(inner!.body).get("animation")).toBe("none");
|
||||
});
|
||||
});
|
||||
|
|
@ -148,6 +148,37 @@ body {
|
|||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Getting-started path (#398): the control a step card points at. An outline
|
||||
* in the accent color, distinct from the primary focus ring and at least 3:1
|
||||
* against the page and the cards in both themes (styles.contrast.test.ts),
|
||||
* plus a pulse that stops after three cycles (3.6 s, under the 5 s of WCAG
|
||||
* 2.2.2) and does not run at all when the system asks for less motion.
|
||||
*/
|
||||
[data-onboarding-highlight="true"] {
|
||||
outline: 3px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
animation-name: onboarding-pulse;
|
||||
animation-duration: 1.2s;
|
||||
animation-timing-function: ease-out;
|
||||
animation-iteration-count: 3;
|
||||
}
|
||||
|
||||
@keyframes onboarding-pulse {
|
||||
from {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
to {
|
||||
box-shadow: 0 0 0 10px transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-onboarding-highlight="true"] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
|
|
|||
|
|
@ -3,17 +3,24 @@ import { computeChecklist, type Checklist } from "./onboardingChecklist";
|
|||
import {
|
||||
CARD_PAGES,
|
||||
bannerMode,
|
||||
cardNext,
|
||||
cardStep,
|
||||
importCardModel,
|
||||
latchCardPage,
|
||||
nextStep,
|
||||
onboardingTargetAttributes,
|
||||
readOnboardingNavState,
|
||||
showsStepCard,
|
||||
stepNavState,
|
||||
stepsOfPage,
|
||||
type BannerModeInput,
|
||||
type ImportCardModel,
|
||||
} from "./onboardingPath";
|
||||
import {
|
||||
CHECKLIST_ITEM_KEYS,
|
||||
type ChecklistItem,
|
||||
type ChecklistItemKey,
|
||||
type ImportCardInput,
|
||||
type OnboardingCounters,
|
||||
} from "../shared/types";
|
||||
|
||||
|
|
@ -334,3 +341,241 @@ describe("stepNavState (#397)", () => {
|
|||
expect(readOnboardingNavState(stepNavState("import"))).toEqual({ focus: true, guided: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("CARD_PAGES (#398)", () => {
|
||||
it("carries the Import page", () => {
|
||||
expect(CARD_PAGES.has("/import")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("showsStepCard (#398)", () => {
|
||||
it("is true in card mode on the latched route only", () => {
|
||||
expect(showsStepCard("card", "/import", "/import")).toBe(true);
|
||||
expect(showsStepCard("card", "/import", "/transactions")).toBe(false);
|
||||
expect(showsStepCard("card", null, "/import")).toBe(false);
|
||||
for (const mode of ["none", "banner", "complete"] as const) {
|
||||
expect(showsStepCard(mode, "/import", "/import"), mode).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cardStep (#398)", () => {
|
||||
const at = (checklist: Checklist, pathname = "/import") => {
|
||||
const found = cardStep(checklist, pathname);
|
||||
return found && { key: found.step.key, n: found.n, total: found.total };
|
||||
};
|
||||
|
||||
it("on /import, shows the first of the page's steps not done yet, ranked among the unlocked rows", () => {
|
||||
expect(at(checklistOf(zero))).toEqual({ key: "importFolder", n: 2, total: 6 });
|
||||
expect(at(checklistOf(zero, { importFolderSet: true }))).toEqual({ key: "source", n: 3, total: 6 });
|
||||
expect(at(checklistOf({ ...zero, sources: 1 }, { importFolderSet: true }))).toEqual({
|
||||
key: "import",
|
||||
n: 4,
|
||||
total: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the import step once the three are done", () => {
|
||||
expect(at(checklistOf(upToManual, { importFolderSet: true }))).toEqual({
|
||||
key: "import",
|
||||
n: 4,
|
||||
total: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes over a skipped step, as the next step does", () => {
|
||||
// After "Skip" on the folder step, the card latches again on /import for
|
||||
// the source step: it must show that step, not the skipped one.
|
||||
expect(at(checklistOf(zero, { skipped: ["importFolder"] }))?.key).toBe("source");
|
||||
expect(at(checklistOf(zero, { skipped: ["importFolder", "source"] }))?.key).toBe("import");
|
||||
expect(at(checklistOf(zero, { skipped: ["importFolder", "source", "import"] }))?.key).toBe(
|
||||
"import",
|
||||
);
|
||||
});
|
||||
|
||||
it("counts the unlocked rows only: five in the Free edition", () => {
|
||||
expect(at(checklistOf(zero, { budgetAllowed: false }))).toEqual({
|
||||
key: "importFolder",
|
||||
n: 2,
|
||||
total: 5,
|
||||
});
|
||||
expect(at(checklistOf(upToManual, { importFolderSet: true }), "/transactions")).toEqual({
|
||||
key: "manualCategory",
|
||||
n: 5,
|
||||
total: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it("is null on a page that guides no unlocked step", () => {
|
||||
expect(cardStep(checklistOf(zero), "/reports")).toBeNull();
|
||||
expect(cardStep(checklistOf(zero, { budgetAllowed: false }), "/budget")).toBeNull();
|
||||
expect(cardStep(checklistOf(zero), "/budget")?.step.key).toBe("budget");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cardNext (#398)", () => {
|
||||
it("links the next step when it lives on another page", () => {
|
||||
expect(cardNext(step("manualCategory"), "/import", true)).toEqual({
|
||||
kind: "step",
|
||||
step: step("manualCategory"),
|
||||
});
|
||||
// The link does not wait for the entitlement: only the end does.
|
||||
expect(cardNext(step("manualCategory"), "/import", false).kind).toBe("step");
|
||||
});
|
||||
|
||||
it("is the end without a next step, once the Budget entitlement is known", () => {
|
||||
expect(cardNext(null, "/import", true)).toEqual({ kind: "end" });
|
||||
expect(cardNext(null, "/import", false)).toEqual({ kind: "wait" });
|
||||
});
|
||||
|
||||
it("waits while the next step is still one of this page's own (re-read not landed)", () => {
|
||||
for (const key of ["importFolder", "source", "import"] as const) {
|
||||
expect(cardNext(step(key), "/import", true), key).toEqual({ kind: "wait" });
|
||||
}
|
||||
expect(cardNext(step("budget"), "/budget", true)).toEqual({ kind: "wait" });
|
||||
expect(cardNext(step("budget"), "/import", true).kind).toBe("step");
|
||||
});
|
||||
});
|
||||
|
||||
describe("importCardModel (#398)", () => {
|
||||
/** A new profile: folder read, none chosen, nothing scanned. */
|
||||
const NEW: ImportCardInput = {
|
||||
wizardStep: "source-list",
|
||||
folderLoaded: true,
|
||||
hasFolder: false,
|
||||
folderName: null,
|
||||
sourceCount: 0,
|
||||
importing: false,
|
||||
importedCount: null,
|
||||
};
|
||||
/** A folder with two bank sub-folders, the wizard at rest. */
|
||||
const AT_REST: ImportCardInput = { ...NEW, hasFolder: true, folderName: "Relevés", sourceCount: 2 };
|
||||
const fresh = checklistOf(zero, { importFolderSet: true });
|
||||
/** Steps 3 and 4 checked: a source and a completed `imported_files` row. */
|
||||
const imported = checklistOf({ ...zero, sources: 1, imports: 1 }, { importFolderSet: true });
|
||||
|
||||
const model = (input: Partial<ImportCardInput>, checklist: Checklist | null = fresh) =>
|
||||
importCardModel({ ...AT_REST, ...input }, checklist);
|
||||
const wizardNext = (text: string, button: string): ImportCardModel => ({
|
||||
text: { key: `onboarding.card.import.${text}`, labels: { button } },
|
||||
done: false,
|
||||
target: "wizardNext",
|
||||
});
|
||||
const DONE: ImportCardModel = { text: null, done: true, target: null };
|
||||
const IMPORTING: ImportCardModel = {
|
||||
text: { key: "onboarding.card.import.importing" },
|
||||
done: false,
|
||||
target: null,
|
||||
};
|
||||
const FAILED: ImportCardModel = {
|
||||
text: { key: "onboarding.card.import.failed" },
|
||||
done: false,
|
||||
target: null,
|
||||
};
|
||||
|
||||
it("1. no card before the folder is read, whatever else is true", () => {
|
||||
expect(importCardModel({ ...NEW, folderLoaded: false }, fresh)).toBeNull();
|
||||
expect(model({ folderLoaded: false, importing: true, wizardStep: "importing" })).toBeNull();
|
||||
});
|
||||
|
||||
it("2. an import running says so, before the counters that already tick", () => {
|
||||
expect(model({ wizardStep: "importing", importing: true })).toEqual(IMPORTING);
|
||||
// The `imported_files` row is written `completed` before the inserts:
|
||||
// steps 3 and 4 read as checked while the import runs.
|
||||
expect(model({ wizardStep: "importing", importing: true }, imported)).toEqual(IMPORTING);
|
||||
// The wizard's own step says it too.
|
||||
expect(model({ wizardStep: "importing", importing: false }, imported)).toEqual(IMPORTING);
|
||||
});
|
||||
|
||||
it("3. a report of an import that added nothing is a failure, never 'Done.'", () => {
|
||||
expect(model({ wizardStep: "report", importedCount: 0 })).toEqual(FAILED);
|
||||
// Duplicates only: the file row is `completed`, so the checklist counts
|
||||
// the import step as done; the card still does not say "Done.".
|
||||
expect(model({ wizardStep: "report", importedCount: 0 }, imported)).toEqual(FAILED);
|
||||
// No count at all is no success either.
|
||||
expect(model({ wizardStep: "report", importedCount: null }, imported)).toEqual(FAILED);
|
||||
});
|
||||
|
||||
it("4. a report of an import that added transactions is 'Done.', even before the re-read", () => {
|
||||
expect(model({ wizardStep: "report", importedCount: 41 }, imported)).toEqual(DONE);
|
||||
expect(model({ wizardStep: "report", importedCount: 1 }, fresh)).toEqual(DONE);
|
||||
});
|
||||
|
||||
it("5. without a folder: choose one, with the empty state's button", () => {
|
||||
const noFolder: ImportCardModel = {
|
||||
text: {
|
||||
key: "onboarding.card.import.noFolder",
|
||||
labels: { button: "import.empty.noFolder.cta" },
|
||||
},
|
||||
done: false,
|
||||
target: "emptyStateAction",
|
||||
};
|
||||
expect(importCardModel(NEW, checklistOf(zero))).toEqual(noFolder);
|
||||
// Before "steps checked": the folder step is the one left.
|
||||
expect(importCardModel(NEW, imported)).toEqual(noFolder);
|
||||
});
|
||||
|
||||
it("6. a folder without bank sub-folder: create them, or the sample folder", () => {
|
||||
expect(model({ sourceCount: 0 })).toEqual({
|
||||
text: {
|
||||
key: "onboarding.card.import.noSources",
|
||||
labels: { refresh: "import.empty.refresh", sample: "import.empty.createSample" },
|
||||
values: { folder: "Relevés" },
|
||||
},
|
||||
done: false,
|
||||
target: "emptyStateAction",
|
||||
});
|
||||
});
|
||||
|
||||
it("7. the wizard: the first source card, then its next button with the label it shows", () => {
|
||||
expect(model({ wizardStep: "source-list" })).toEqual({
|
||||
text: { key: "onboarding.card.import.sourceList" },
|
||||
done: false,
|
||||
target: "firstSource",
|
||||
});
|
||||
expect(model({ wizardStep: "source-config" })).toEqual(
|
||||
wizardNext("sourceConfig", "import.wizard.preview"),
|
||||
);
|
||||
expect(model({ wizardStep: "file-preview" })).toEqual(
|
||||
wizardNext("filePreview", "import.wizard.checkDuplicates"),
|
||||
);
|
||||
expect(model({ wizardStep: "duplicate-check" })).toEqual(
|
||||
wizardNext("duplicateCheck", "import.wizard.confirm"),
|
||||
);
|
||||
expect(model({ wizardStep: "confirm" })).toEqual(wizardNext("confirm", "import.wizard.import"));
|
||||
});
|
||||
|
||||
it("8. steps 3 and 4 checked, the wizard at rest: 'Done.' (after 'Done' or 'Cancel')", () => {
|
||||
expect(model({ wizardStep: "source-list" }, imported)).toEqual(DONE);
|
||||
// Ahead of the rows of the wizard at rest: an emptied folder too.
|
||||
expect(model({ wizardStep: "source-list", sourceCount: 0 }, imported)).toEqual(DONE);
|
||||
});
|
||||
|
||||
it("8. ...but only at rest: a new pass through the wizard is guided again", () => {
|
||||
expect(model({ wizardStep: "source-config" }, imported)).toEqual(
|
||||
wizardNext("sourceConfig", "import.wizard.preview"),
|
||||
);
|
||||
expect(model({ wizardStep: "confirm" }, imported)).toEqual(
|
||||
wizardNext("confirm", "import.wizard.import"),
|
||||
);
|
||||
});
|
||||
|
||||
it("8. ...and only when both are checked, by the data: skipped is not checked", () => {
|
||||
const sourceOnly = checklistOf({ ...zero, sources: 1 }, { importFolderSet: true });
|
||||
expect(model({}, sourceOnly)?.target).toBe("firstSource");
|
||||
const skipped = checklistOf(zero, { importFolderSet: true, skipped: ["source", "import"] });
|
||||
expect(model({}, skipped)?.target).toBe("firstSource");
|
||||
expect(model({}, null)?.target).toBe("firstSource");
|
||||
});
|
||||
});
|
||||
|
||||
describe("onboardingTargetAttributes (#398)", () => {
|
||||
it("marks the target and its highlight when on, nothing otherwise", () => {
|
||||
expect(onboardingTargetAttributes("wizard-next", true)).toEqual({
|
||||
"data-onboarding-target": "wizard-next",
|
||||
"data-onboarding-highlight": "true",
|
||||
});
|
||||
expect(onboardingTargetAttributes("wizard-next", false)).toEqual({});
|
||||
expect(onboardingTargetAttributes("wizard-next", undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import type { Checklist } from "./onboardingChecklist";
|
||||
import type { ChecklistItem, ChecklistItemKey, OnboardingStatus } from "../shared/types";
|
||||
import type {
|
||||
ChecklistItem,
|
||||
ChecklistItemKey,
|
||||
ImportCardInput,
|
||||
OnboardingStatus,
|
||||
} from "../shared/types";
|
||||
|
||||
/**
|
||||
* Pure rules of the guided getting-started path (#396).
|
||||
* Pure rules of the guided getting-started path (#396, #398).
|
||||
*
|
||||
* The provider (`useOnboardingChecklist`) feeds them its state; the banner and
|
||||
* the step cards of the next issues only read what they decide. Nothing here
|
||||
* touches React, the router or the database.
|
||||
* The provider (`useOnboardingChecklist`) feeds them its state; the banner,
|
||||
* the step cards and the pages that carry a card only read what they decide.
|
||||
* Nothing here touches React, the router or the database.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -18,11 +23,11 @@ export function nextStep(checklist: Checklist): ChecklistItem | null {
|
|||
}
|
||||
|
||||
/**
|
||||
* Routes whose page carries a step card. Empty in this issue: each card issue
|
||||
* adds its page, and a page without a delivered card keeps the banner, so a
|
||||
* partly merged stack never removes any guidance.
|
||||
* Routes whose page carries a step card. Each card issue adds its page (#398:
|
||||
* Import), and 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"]);
|
||||
|
||||
const PAGE_STEPS: Readonly<Record<string, readonly ChecklistItemKey[]>> = {
|
||||
"/import": ["importFolder", "source", "import"],
|
||||
|
|
@ -145,3 +150,203 @@ export function readOnboardingNavState(state: unknown): OnboardingNavState {
|
|||
const flags = onboarding as { focus?: unknown; guided?: unknown };
|
||||
return { focus: flags.focus === true, guided: flags.guided === true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step cards (#398)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The step card shows on this page: the path is in card mode, latched on this
|
||||
* very route. The connected card renders only then, and the page highlights
|
||||
* its model's target only then: no ring without the card that explains it.
|
||||
*/
|
||||
export function showsStepCard(mode: BannerMode, cardPage: string | null, pathname: string): boolean {
|
||||
return mode === "card" && cardPage === pathname;
|
||||
}
|
||||
|
||||
/** The step a page's card shows, and its place in the path ("Step n of N"). */
|
||||
export interface CardStep {
|
||||
step: ChecklistItem;
|
||||
/** Rank of the step among the unlocked rows, from 1, in checklist order. */
|
||||
n: number;
|
||||
/** Number of unlocked rows. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step a page's card shows: the first of the page's steps neither done
|
||||
* nor skipped, or the page's last step once none is left (on /import:
|
||||
* `importFolder`, `source`, `import`, then `import`). A skipped step is passed
|
||||
* over as `nextStep` does: after "Skip" the card latches again when the new
|
||||
* next step is on the same page, and must then show that step, not the
|
||||
* skipped one with its "Skip" button again. Null for a page that guides no
|
||||
* unlocked step.
|
||||
*/
|
||||
export function cardStep(checklist: Checklist, pathname: string): CardStep | null {
|
||||
const pageSteps = stepsOfPage(pathname);
|
||||
const unlocked = checklist.items.filter((item) => !item.locked);
|
||||
const ofPage = unlocked.filter((item) => pageSteps.includes(item.key));
|
||||
if (ofPage.length === 0) return null;
|
||||
const step = ofPage.find((item) => !item.done && !item.skipped) ?? ofPage[ofPage.length - 1];
|
||||
return { step, n: unlocked.indexOf(step) + 1, total: unlocked.length };
|
||||
}
|
||||
|
||||
/** What follows "Done." on a step card. */
|
||||
export type StepCardNext =
|
||||
| { kind: "step"; step: ChecklistItem }
|
||||
| { kind: "end" }
|
||||
| { kind: "wait" };
|
||||
|
||||
/**
|
||||
* What follows "Done." on a page's card:
|
||||
* - `step`: the next step, which lives on another page;
|
||||
* - `end`: no step left (end mode), once the Budget entitlement is known:
|
||||
* until then the checklist counts Budget as locked, and the end could be a
|
||||
* sixth step away (the rule of `bannerMode`);
|
||||
* - `wait`: nothing yet, while the entitlement is unknown at the end, or
|
||||
* while the next step is still one of this page's own: the re-read that
|
||||
* follows the write that completed them (ADR 0022) has not landed, and a
|
||||
* link would lead back to this very page.
|
||||
*/
|
||||
export function cardNext(
|
||||
nextStep: ChecklistItem | null,
|
||||
pathname: string,
|
||||
budgetReady: boolean,
|
||||
): StepCardNext {
|
||||
if (nextStep === null) return budgetReady ? { kind: "end" } : { kind: "wait" };
|
||||
if (stepsOfPage(pathname).includes(nextStep.key)) return { kind: "wait" };
|
||||
return { kind: "step", step: nextStep };
|
||||
}
|
||||
|
||||
/** A sentence of a step card: an i18n key and its interpolations. */
|
||||
export interface StepCardText {
|
||||
key: string;
|
||||
/**
|
||||
* Interpolations that name a control: the i18n key of its label, translated
|
||||
* when the card renders, so a button's label is never copied into a sentence.
|
||||
*/
|
||||
labels?: Readonly<Record<string, string>>;
|
||||
/** Interpolations shown as they are (a folder name). */
|
||||
values?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
/** What a page tells its step card. */
|
||||
export interface StepCardModel {
|
||||
/** The sentence; null once the page's steps are done, "Done." then says it. */
|
||||
text: StepCardText | null;
|
||||
/** The page's steps are done: "Done." and what follows it (`cardNext`). */
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
/** The control of the Import page that the card points at. */
|
||||
export type ImportCardTarget = "emptyStateAction" | "firstSource" | "wizardNext";
|
||||
|
||||
export interface ImportCardModel extends StepCardModel {
|
||||
/** The control to highlight, null for none. */
|
||||
target: ImportCardTarget | null;
|
||||
}
|
||||
|
||||
const IMPORT_DONE: ImportCardModel = { text: null, done: true, target: null };
|
||||
|
||||
function importText(name: string, rest: Omit<StepCardText, "key"> = {}): StepCardText {
|
||||
return { key: `onboarding.card.import.${name}`, ...rest };
|
||||
}
|
||||
|
||||
/**
|
||||
* The wizard steps that end on its next button: their sentence and the label
|
||||
* of that button, the one `ImportPage` gives the button at that step.
|
||||
*/
|
||||
const WIZARD_ROWS = {
|
||||
"source-config": { text: "sourceConfig", button: "import.wizard.preview" },
|
||||
"file-preview": { text: "filePreview", button: "import.wizard.checkDuplicates" },
|
||||
"duplicate-check": { text: "duplicateCheck", button: "import.wizard.confirm" },
|
||||
confirm: { text: "confirm", button: "import.wizard.import" },
|
||||
} as const;
|
||||
|
||||
/** The checklist marks the step done (a skipped step is not done). */
|
||||
function checked(checklist: Checklist | null, key: ChecklistItemKey): boolean {
|
||||
return checklist?.items.some((item) => item.key === key && item.done) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Import page's card, read from the wizard and the checklist. The rows
|
||||
* are tried in order, and the first that applies wins:
|
||||
* 1. the folder is not read yet: no card (null);
|
||||
* 2. an import is running: "Importing…". It comes before anything the
|
||||
* counters say, because `executeImport` writes the `imported_files` row
|
||||
* `completed` before inserting a single transaction;
|
||||
* 3. the report: "Done." when the import added transactions, the failure
|
||||
* sentence when it added none (insertion failed, duplicates only), or when
|
||||
* the count is missing: never "Done." without one;
|
||||
* 4. no folder: choose one, with the empty state's button;
|
||||
* 5. the wizard at rest (`source-list`) with the source and import steps
|
||||
* checked: "Done.". This is the table's last row ("steps 3 and 4 already
|
||||
* checked, outside the wizard"): placed after the rows that cover every
|
||||
* wizard step, it could never apply, and "Done" or "Cancel" at the end of
|
||||
* an import would send the card back to "click your bank's card";
|
||||
* 6. a folder without bank sub-folder: create them, or the sample folder;
|
||||
* 7. the wizard: the first source card, then its next button at each step.
|
||||
*/
|
||||
export function importCardModel(
|
||||
input: ImportCardInput,
|
||||
checklist: Checklist | null,
|
||||
): ImportCardModel | null {
|
||||
const step = input.wizardStep;
|
||||
if (!input.folderLoaded) return null;
|
||||
if (input.importing || step === "importing") {
|
||||
return { text: importText("importing"), done: false, target: null };
|
||||
}
|
||||
if (step === "report") {
|
||||
return input.importedCount !== null && input.importedCount > 0
|
||||
? IMPORT_DONE
|
||||
: { text: importText("failed"), done: false, target: null };
|
||||
}
|
||||
if (!input.hasFolder) {
|
||||
return {
|
||||
text: importText("noFolder", { labels: { button: "import.empty.noFolder.cta" } }),
|
||||
done: false,
|
||||
target: "emptyStateAction",
|
||||
};
|
||||
}
|
||||
if (step === "source-list" && checked(checklist, "source") && checked(checklist, "import")) {
|
||||
return IMPORT_DONE;
|
||||
}
|
||||
if (input.sourceCount === 0) {
|
||||
return {
|
||||
text: importText("noSources", {
|
||||
labels: { refresh: "import.empty.refresh", sample: "import.empty.createSample" },
|
||||
values: { folder: input.folderName ?? "" },
|
||||
}),
|
||||
done: false,
|
||||
target: "emptyStateAction",
|
||||
};
|
||||
}
|
||||
if (step === "source-list") {
|
||||
return { text: importText("sourceList"), done: false, target: "firstSource" };
|
||||
}
|
||||
const row = WIZARD_ROWS[step];
|
||||
return {
|
||||
text: importText(row.text, { labels: { button: row.button } }),
|
||||
done: false,
|
||||
target: "wizardNext",
|
||||
};
|
||||
}
|
||||
|
||||
/** The attributes of a highlighted control; `styles.css` draws the ring on the second one. */
|
||||
export interface OnboardingTargetAttributes {
|
||||
"data-onboarding-target"?: string;
|
||||
"data-onboarding-highlight"?: "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* What a target control renders: its key and the highlight when the page
|
||||
* passes `onboardingTarget`, nothing otherwise. The page picks the target
|
||||
* from its card model and hands it down through props, never by a DOM query:
|
||||
* a target that is not on screen leaves the card without a ring.
|
||||
*/
|
||||
export function onboardingTargetAttributes(
|
||||
key: string,
|
||||
active: boolean | undefined,
|
||||
): OnboardingTargetAttributes {
|
||||
return active ? { "data-onboarding-target": key, "data-onboarding-highlight": "true" } : {};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue