diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index b5eef23..11952bd 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -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). diff --git a/CHANGELOG.md b/CHANGELOG.md index e4b9dec..2a7b449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/docs/architecture.md b/docs/architecture.md index 0c91ef2..d2d0ef2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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` | diff --git a/src/components/import/ImportEmptyState.test.tsx b/src/components/import/ImportEmptyState.test.tsx index f07e896..f830a03 100644 --- a/src/components/import/ImportEmptyState.test.tsx +++ b/src/components/import/ImportEmptyState.test.tsx @@ -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"); diff --git a/src/components/import/ImportEmptyState.tsx b/src/components/import/ImportEmptyState.tsx index ffb5847..bd76318 100644 --- a/src/components/import/ImportEmptyState.tsx +++ b/src/components/import/ImportEmptyState.tsx @@ -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={} 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} @@ -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 }} > diff --git a/src/components/import/SourceCard.tsx b/src/components/import/SourceCard.tsx index cf0114f..a1e69c2 100644 --- a/src/components/import/SourceCard.tsx +++ b/src/components/import/SourceCard.tsx @@ -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({ + )} + {done && next.kind === "step" && ( + + {t("onboarding.card.next", { + step: t(`onboarding.checklist.items.${next.step.key}`), + })} + + )} + {done && next.kind === "end" && ( + <> + {t("onboarding.banner.complete")} + + {t("onboarding.banner.openGuide")} + + + + )} + + + + + ); +} diff --git a/src/components/shared/EmptyState.test.tsx b/src/components/shared/EmptyState.test.tsx index 54ab299..df6f4d3 100644 --- a/src/components/shared/EmptyState.test.tsx +++ b/src/components/shared/EmptyState.test.tsx @@ -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( + + + , + ); + const highlighted = (el: ReactNode) => + find(el, (e) => (e.props as Record)["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"); + }); +}); diff --git a/src/components/shared/EmptyState.tsx b/src/components/shared/EmptyState.tsx index 0309053..7dfa463 100644 --- a/src/components/shared/EmptyState.tsx +++ b/src/components/shared/EmptyState.tsx @@ -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 * ``, never `useNavigate`) or a callback (rendered as a ` diff --git a/src/hooks/useImportWizard.mount.test.ts b/src/hooks/useImportWizard.mount.test.ts new file mode 100644 index 0000000..8dc802e --- /dev/null +++ b/src/hooks/useImportWizard.mount.test.ts @@ -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(); + 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"); + }); +}); diff --git a/src/hooks/useImportWizard.test.ts b/src/hooks/useImportWizard.test.ts index 0f29cf3..3081ec1 100644 --- a/src/hooks/useImportWizard.test.ts +++ b/src/hooks/useImportWizard.test.ts @@ -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. diff --git a/src/hooks/useImportWizard.ts b/src/hooks/useImportWizard.ts index 65b8ace..1af0bbf 100644 --- a/src/hooks/useImportWizard.ts +++ b/src/hooks/useImportWizard.ts @@ -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 diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 45b5849..cb5de2c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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)", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 15908d0..b55bca3 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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)", diff --git a/src/pages/ImportPage.render.test.tsx b/src/pages/ImportPage.render.test.tsx new file mode 100644 index 0000000..0ded6ae --- /dev/null +++ b/src/pages/ImportPage.render.test.tsx @@ -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) => + 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()), + useImportWizard: () => harness.wizard, +})); +vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding })); + +const { stub } = vi.hoisted(() => ({ + stub: (name: string) => ({ default: () => }), +})); +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 = {}) { + const state: WizardState = { ...initialWizardState, importFolderLoaded: true, ...overrides }; + const noop = vi.fn(); + return new Proxy({ state } as Record, { + 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 { + 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( + + + , + ); + +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>/)?.[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(" { + 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(/

]*>.*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)"); + }); +}); diff --git a/src/pages/ImportPage.tsx b/src/pages/ImportPage.tsx index 98678a2..c18f69e 100644 --- a/src/pages/ImportPage.tsx +++ b/src/pages/ImportPage.tsx @@ -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 (

+ +

{t("import.title")}

@@ -104,25 +132,30 @@ 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 ? ( + + ) : ( + + ))} )} @@ -161,6 +194,7 @@ export default function ImportPage() { onCancel={reset} nextLabel={t("import.wizard.preview")} nextDisabled={nextDisabled || state.isLoading} + onboardingTarget={target === "wizardNext"} />
)} @@ -192,6 +226,7 @@ export default function ImportPage() { onCancel={reset} nextLabel={t("import.wizard.checkDuplicates")} nextDisabled={state.isLoading} + onboardingTarget={target === "wizardNext"} />
)} @@ -210,6 +245,7 @@ export default function ImportPage() { onNext={() => goToStep("confirm")} onCancel={reset} nextLabel={t("import.wizard.confirm")} + onboardingTarget={target === "wizardNext"} /> )} @@ -230,6 +266,7 @@ export default function ImportPage() { onCancel={reset} nextLabel={t("import.wizard.import")} showCancel={false} + onboardingTarget={target === "wizardNext"} /> )} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 0fad653..38a950a 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -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; +} diff --git a/src/styles.contrast.test.ts b/src/styles.contrast.test.ts new file mode 100644 index 0000000..b21369b --- /dev/null +++ b/src/styles.contrast.test.ts @@ -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 { + const result = new Map(); + 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()) => { + 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 { + 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"); + }); +}); diff --git a/src/styles.css b/src/styles.css index 561610b..48250c7 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; diff --git a/src/utils/onboardingPath.test.ts b/src/utils/onboardingPath.test.ts index bbbc0af..510b051 100644 --- a/src/utils/onboardingPath.test.ts +++ b/src/utils/onboardingPath.test.ts @@ -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, 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({}); + }); +}); diff --git a/src/utils/onboardingPath.ts b/src/utils/onboardingPath.ts index f65d282..2984ce6 100644 --- a/src/utils/onboardingPath.ts +++ b/src/utils/onboardingPath.ts @@ -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 = new Set(); +export const CARD_PAGES: ReadonlySet = new Set(["/import"]); const PAGE_STEPS: Readonly> = { "/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>; + /** Interpolations shown as they are (a folder name). */ + values?: Readonly>; +} + +/** 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 { + 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" } : {}; +}