feat(onboarding): Import empty states, sample folder button and guide links (#356) #382

Merged
maximus merged 1 commit from issue-356-import-empty-state into main 2026-10-04 12:51:53 +00:00
Owner

Fixes #356

Stacked on PR #381 (issue-355-docs-bank-guides), itself on #380 → #379 → #378 → #377. Base branch of this PR is issue-355-docs-bank-guides; merge the stack bottom-up.

What

The import page without a source no longer shows the bare "no source found" card. ImportEmptyState (built on the shared EmptyState from #352) replaces the empty SourceList in both situations:

  • No import folder → title + « Commencez par choisir le dossier où vous déposerez vos relevés », one primary button « Choisir un dossier » (browseFolder), link « Comment exporter un CSV depuis ma banque ? » → /settings/users#import-export (anchor from #355).
  • Folder without bank sub-folder → « Créez un sous-dossier par banque dans {folderName} et déposez-y vos fichiers CSV », primary « Créer un dossier d'exemple » (disabled while in flight), secondary « Actualiser », same guide link. folderName is the last path segment (/ and \ handled, trailing separators ignored), never the full path.

useImportWizard gains createSampleFolders() → invoke("create_sample_import_folders", { importFolder, today }) with today = the webview's local date as YYYY-MM-DD (from the local getters, not toISOString()), then rescans the folder so both cards show up without another click, then dispatches SET_SAMPLE_CREATED(names). A closable success banner (same shape as the error banner, var(--positive) border) reads the names from the new sampleCreated state; RESET and SET_IMPORT_FOLDER (what browseFolder dispatches) clear it. A refusal from Rust (sample folder already exists, nothing written) lands in the existing error banner via SET_ERROR.

Also: PageHelp gets an optional linkTo prop rendering a <Link> under the tips labelled ${helpKey}.help.linkLabel (no explicit close: navigating unmounts the page); « Parcourir » drops to the secondary style when no folder is configured so a single primary button is on screen. SourceList.tsx is untouched. No CHANGELOG change (centralised in #357). No DB migration.

Why

Onboarding wave planned-2026-10-03-onboarding-phase1: a new user landing on an empty import page had no next action and no way to try the wizard before exporting a real statement.

Privacy

logInfo carries the two folder names only ([import] Sample folders created: Exemple Desjardins, Exemple Tangerine), never the absolute path — the logs are attachable to a feedback report (review caveat made mandatory in planning). The invoke passes only the folder and the date.

Tests

  • src/hooks/useImportWizard.test.ts (new) on the exported wizardReducer / initialWizardState: SET_SAMPLE_CREATED sets the names without touching the sources; RESET and SET_IMPORT_FOLDER clear it; an error leaves scannedSources untouched (same reference) and raises no banner; formatLocalDate is TZ-independent.
  • src/components/import/ImportEmptyState.test.tsx (new), direct call with EmptyState expanded: both variants, button wiring, disabled create button while isCreatingSample, guide link target, folderName shown without the path, folderNameFromPath for POSIX/Windows/trailing separators.
  • npm test: 79 files, 1440 passed. npm run build (tsc + vite): green.
  • Hard-coded string scan on the four touched components: none; FR/EN key parity under import.* verified by script.

Generated autonomously by /autopilot run of 2026-10-03

🤖 Generated with Claude Code

Fixes #356 **Stacked on PR #381** (`issue-355-docs-bank-guides`), itself on #380 → #379 → #378 → #377. Base branch of this PR is `issue-355-docs-bank-guides`; merge the stack bottom-up. ## What The import page without a source no longer shows the bare "no source found" card. `ImportEmptyState` (built on the shared `EmptyState` from #352) replaces the empty `SourceList` in both situations: - **No import folder** → title + « Commencez par choisir le dossier où vous déposerez vos relevés », one primary button « Choisir un dossier » (`browseFolder`), link « Comment exporter un CSV depuis ma banque ? » → `/settings/users#import-export` (anchor from #355). - **Folder without bank sub-folder** → « Créez un sous-dossier par banque dans {folderName} et déposez-y vos fichiers CSV », primary « Créer un dossier d'exemple » (disabled while in flight), secondary « Actualiser », same guide link. `folderName` is the last path segment (`/` and `\` handled, trailing separators ignored), never the full path. `useImportWizard` gains `createSampleFolders()` → `invoke("create_sample_import_folders", { importFolder, today })` with `today` = the webview's **local** date as `YYYY-MM-DD` (from the local getters, not `toISOString()`), then rescans the folder so both cards show up without another click, then dispatches `SET_SAMPLE_CREATED(names)`. A closable success banner (same shape as the error banner, `var(--positive)` border) reads the names from the new `sampleCreated` state; `RESET` and `SET_IMPORT_FOLDER` (what `browseFolder` dispatches) clear it. A refusal from Rust (sample folder already exists, nothing written) lands in the existing error banner via `SET_ERROR`. Also: `PageHelp` gets an optional `linkTo` prop rendering a `<Link>` under the tips labelled `${helpKey}.help.linkLabel` (no explicit close: navigating unmounts the page); « Parcourir » drops to the secondary style when no folder is configured so a single primary button is on screen. `SourceList.tsx` is untouched. No CHANGELOG change (centralised in #357). No DB migration. ## Why Onboarding wave `planned-2026-10-03-onboarding-phase1`: a new user landing on an empty import page had no next action and no way to try the wizard before exporting a real statement. ## Privacy `logInfo` carries the two folder **names** only (`[import] Sample folders created: Exemple Desjardins, Exemple Tangerine`), never the absolute path — the logs are attachable to a feedback report (review caveat made mandatory in planning). The `invoke` passes only the folder and the date. ## Tests - `src/hooks/useImportWizard.test.ts` (new) on the exported `wizardReducer` / `initialWizardState`: `SET_SAMPLE_CREATED` sets the names without touching the sources; `RESET` and `SET_IMPORT_FOLDER` clear it; an error leaves `scannedSources` untouched (same reference) and raises no banner; `formatLocalDate` is TZ-independent. - `src/components/import/ImportEmptyState.test.tsx` (new), direct call with `EmptyState` expanded: both variants, button wiring, disabled create button while `isCreatingSample`, guide link target, `folderName` shown without the path, `folderNameFromPath` for POSIX/Windows/trailing separators. - `npm test`: 79 files, **1440 passed**. `npm run build` (tsc + vite): green. - Hard-coded string scan on the four touched components: none; FR/EN key parity under `import.*` verified by script. Generated autonomously by /autopilot run of 2026-10-03 🤖 Generated with [Claude Code](https://claude.com/claude-code)
maximus added 1 commit 2026-10-04 00:10:56 +00:00
feat(onboarding): Import empty states, sample folder button and guide links (#356)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m59s
624eef7ba8
The import page without a source no longer shows a bare "no source found"
card. `ImportEmptyState` (built on the shared `EmptyState`) takes the place
of the empty `SourceList` in both situations: without an import folder it
offers "Choose a folder" as the single primary action; with a folder and no
bank sub-folder it offers "Create a sample folder" (primary, disabled while
in flight) and "Refresh" (secondary). Both variants, and the "?" help
popover through the new optional `linkTo` prop of `PageHelp`, link to the
bank CSV export guide at `/settings/users#import-export`. `SourceList`
stays an untouched pure list.

`useImportWizard` gains `createSampleFolders`: it invokes the Rust command
`create_sample_import_folders` with the folder and the webview's LOCAL date
(built from the local getters, not `toISOString()`), rescans the folder so
the two cards appear without another click, then raises a closable success
banner from the new `sampleCreated` state, which `reset` and a folder change
clear. A refusal from Rust (a sample folder already exists) lands in the
existing error banner. The log line carries the folder NAMES only, never
the absolute path, since the logs travel with a feedback report.

"Browse" on the folder card drops to the secondary style while no folder is
configured, so a single primary button is on screen. The reducer and its
initial state are exported as `wizardReducer` / `initialWizardState` and
unit-tested; `ImportEmptyState` is tested by direct call.

Resolves #356

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
maximus added the
autopilot:pending-human
label 2026-10-04 00:11:14 +00:00
Author
Owner

Self-review (autopilot, inline on the full diff of #382)

Verdict: APPROVE

Summary

9 files, +~500 / −~15, all in scope. The empty SourceList card is replaced by ImportEmptyState in both situations of the issue; SourceList.tsx is byte-identical. The hook's new action invokes create_sample_import_folders with exactly two arguments (the persisted import folder and the webview's local YYYY-MM-DD), rescans, then raises the banner. Reducer and component are covered by direct tests; 1440 vitest and npm run build green on the branch.

Security / privacy

  • Logs: the single logInfo carries names.join(", ") — the two folder names returned by Rust — never state.importFolder. Checked every + line of the diff touching importFolder, folder_path, logInfo, invoke: the only non-test occurrences are the reducer, the invoke argument, and the folderNameFromPath(...) call in ImportPage (which yields the last segment only, so the empty state never shows the full path either).
  • invoke payload: { importFolder, today } only; the Rust side already refuses a non-directory and any pre-existing sample folder before writing.
  • No new dependency, no DB change, no CHANGELOG edit (#357).

Acceptance criteria

Criterion Status Evidence
useImportWizard.test.ts: SET_SAMPLE_CREATED sets names ✅ test « stores the folder names and nothing else » (sources same reference)
reset and SET_IMPORT_FOLDER clear it ✅ two tests; RESET keeps folder + sources, SET_IMPORT_FOLDER drops the banner
creation error leaves scannedSources untouched ✅ SET_ERROR test, same reference + sampleCreated null + isLoading false
ImportEmptyState.test.tsx: no-folder variant (button + guide link) ✅ 1 button → onBrowse, 1 Link to /settings/users#import-export
folder variant (create disabled while creating, refresh, link) ✅ 2 buttons in order, disabled propagated, refresh stays enabled
folderName without the full path ✅ Windows path → Relevés, assert not.toContain("Users")
Empty folder → « Créer » → both sources appear without extra click + closable banner ✅ (by construction) scanFolderInternal(folder) runs inside createSampleFolders before SET_SAMPLE_CREATED; banner has an X → dismissSampleCreated
Re-run with an existing sample folder → existing error banner, nothing written ✅ (by construction) Rust Err("Folder already exists: …") → SET_ERROR, rendered through the existing t(state.error, { defaultValue }) banner
≥1 scanned source → ImportEmptyState never rendered ✅ showEmptyState = step === "source-list" && scannedSources.length === 0
Journals show names, no absolute path ✅ see Security
npm test, npm run build green ✅ 79 files / 1440 tests; tsc + vite OK

i18n

FR/EN key sets under import.* are identical (script diff empty). French sentences are the issue's verbatim. Zero JSX text literal in the four touched components (grep). import.help.linkLabel reuses the guide-link sentence — the issue gives no distinct label for the popover.

Concerns (non-blocking)

  1. Mount flash, pre-existing shape. Before getImportFolder() resolves on mount, importFolder is null and scannedSources empty, so the « no folder » variant shows for one tick on every visit, then flips to the list. The previous code flashed the « Aucune source trouvée » card in the same window, and the folder card above already flashes « Aucun dossier configuré »; a loaded flag would remove all three at once — out of this issue's scope, worth a small follow-up.
  2. Banner after a failed rescan. If scanFolderInternal fails after a successful write, the error banner and the success banner show together. The folders were written, so the success message is true; the error names the scan. Acceptable.
  3. isCreatingSample is fed by the shared state.isLoading, so the button is also disabled during an ordinary rescan — harmless, and avoids a second loading flag.

Generated autonomously by /autopilot run of 2026-10-03

## Self-review (autopilot, inline on the full diff of #382) **Verdict: APPROVE** ### Summary 9 files, +~500 / −~15, all in scope. The empty `SourceList` card is replaced by `ImportEmptyState` in both situations of the issue; `SourceList.tsx` is byte-identical. The hook's new action invokes `create_sample_import_folders` with exactly two arguments (the persisted import folder and the webview's local `YYYY-MM-DD`), rescans, then raises the banner. Reducer and component are covered by direct tests; 1440 vitest and `npm run build` green on the branch. ### Security / privacy - Logs: the single `logInfo` carries `names.join(", ")` — the two folder names returned by Rust — never `state.importFolder`. Checked every `+` line of the diff touching `importFolder`, `folder_path`, `logInfo`, `invoke`: the only non-test occurrences are the reducer, the `invoke` argument, and the `folderNameFromPath(...)` call in `ImportPage` (which yields the last segment only, so the empty state never shows the full path either). - `invoke` payload: `{ importFolder, today }` only; the Rust side already refuses a non-directory and any pre-existing sample folder before writing. - No new dependency, no DB change, no CHANGELOG edit (#357). ### Acceptance criteria | Criterion | Status | Evidence | |---|---|---| | `useImportWizard.test.ts`: `SET_SAMPLE_CREATED` sets names | ✅ | test « stores the folder names and nothing else » (sources same reference) | | `reset` and `SET_IMPORT_FOLDER` clear it | ✅ | two tests; `RESET` keeps folder + sources, `SET_IMPORT_FOLDER` drops the banner | | creation error leaves `scannedSources` untouched | ✅ | `SET_ERROR` test, same reference + `sampleCreated` null + `isLoading` false | | `ImportEmptyState.test.tsx`: no-folder variant (button + guide link) | ✅ | 1 button → `onBrowse`, 1 `Link` to `/settings/users#import-export` | | folder variant (create disabled while creating, refresh, link) | ✅ | 2 buttons in order, `disabled` propagated, refresh stays enabled | | `folderName` without the full path | ✅ | Windows path → `Relevés`, assert `not.toContain("Users")` | | Empty folder → « Créer » → both sources appear without extra click + closable banner | ✅ (by construction) | `scanFolderInternal(folder)` runs inside `createSampleFolders` before `SET_SAMPLE_CREATED`; banner has an `X` → `dismissSampleCreated` | | Re-run with an existing sample folder → existing error banner, nothing written | ✅ (by construction) | Rust `Err("Folder already exists: …")` → `SET_ERROR`, rendered through the existing `t(state.error, { defaultValue })` banner | | ≥1 scanned source → `ImportEmptyState` never rendered | ✅ | `showEmptyState = step === "source-list" && scannedSources.length === 0` | | Journals show names, no absolute path | ✅ | see Security | | `npm test`, `npm run build` green | ✅ | 79 files / 1440 tests; tsc + vite OK | ### i18n FR/EN key sets under `import.*` are identical (script diff empty). French sentences are the issue's verbatim. Zero JSX text literal in the four touched components (grep). `import.help.linkLabel` reuses the guide-link sentence — the issue gives no distinct label for the popover. ### Concerns (non-blocking) 1. **Mount flash, pre-existing shape.** Before `getImportFolder()` resolves on mount, `importFolder` is null and `scannedSources` empty, so the « no folder » variant shows for one tick on every visit, then flips to the list. The previous code flashed the « Aucune source trouvée » card in the same window, and the folder card above already flashes « Aucun dossier configuré »; a `loaded` flag would remove all three at once — out of this issue's scope, worth a small follow-up. 2. **Banner after a failed rescan.** If `scanFolderInternal` fails after a successful write, the error banner and the success banner show together. The folders were written, so the success message is true; the error names the scan. Acceptable. 3. `isCreatingSample` is fed by the shared `state.isLoading`, so the button is also disabled during an ordinary rescan — harmless, and avoids a second loading flag. Generated autonomously by /autopilot run of 2026-10-03
Author
Owner

Revue adversariale — PR #382 (/pr-review, passe indépendante)

Verdict : APPROVE

Revue contre la base de la PR (issue-355-docs-bank-guides), diff complet, un seul commit 624eef7 au format conventionnel. Tout ce qui est hors diff et dont la PR dépend est présent sur la tête : EmptyState (#352, src/components/shared/EmptyState.tsx), la commande Rust create_sample_import_folders (#354, fs_commands.rs:207, enregistrée dans lib.rs:442, arguments import_folder/today → camelCase côté invoke conforme), l'ancre #import-export (#355, DocsSubsections.tsx) et la clé common.close. SourceList.tsx est intact, import.sources.empty reste utilisée.

Ce qui a été exécuté (copie hors dépôt, git archive de 624eef7)

  • tsc --noEmit : exit 0. vitest run : 79 fichiers, 1440 tests verts (le chiffre de la PR est exact). Aucun skip/only.
  • Parité FR/EN sous import.* : aucune clé orpheline. Aucune chaîne JSX en dur dans les 4 composants touchés.
  • CI de la tête : PR Check — Frontend success (1m59s). check-rust non déclenché, à raison (aucun fichier src-tauri/** dans ce maillon).
  • Timeline de #356 : un seul pull_ref closes venant de cette PR → l'issue se fermera au merge.

Mutations du relecteur (chacune restaurée, copie vérifiée identique à l'octet)

# Mutation Tests rouges
M1 SET_IMPORT_FOLDER garde sampleCreated 1 / 7
M2 formatLocalDate → toISOString().slice(0,10) 1 / 7 sous TZ=America/Toronto — 0 / 7 sous TZ=UTC
M3 folderNameFromPath ne coupe que sur / 3 / 8
M4 bouton « Créer » jamais désactivé 1 / 8
M5 lien du guide retiré de la variante sans dossier 1 / 8

Vie privée

logInfo ne porte que les deux noms retournés par Rust (constantes Exemple Desjardins / Exemple Tangerine), jamais le chemin. Le catch de createSampleFolders ne logge rien ; les messages Rust qui contiennent le chemin (Folder does not exist: …) vont à la bannière, pas au journal. Conforme à la contrainte de planification.

Suggestions (non bloquantes)

  1. Le test formatLocalDate est aveugle en UTC. Il est bien TZ-indépendant pour l'implémentation correcte, mais la régression qu'il vise (toISOString) ne tombe que si le processus tourne hors UTC — c'est le fuseau probable du conteneur CI. Poser process.env.TZ = "America/Toronto" en tête du fichier de test (ou vi.stubEnv) pour que la CI attrape ce que ma machine en EDT a attrapé.
  2. hasFolder et folderName sont redondants dans ImportEmptyStateProps : folderName !== null suffirait. Une seule source de vérité évite un état hasFolder: true, folderName: null que le composant accepte (folderName ?? "").
  3. Si invoke réussit mais que scanFolderInternal échoue, il dispatch SET_ERROR en interne puis SET_SAMPLE_CREATED est quand même dispatché → les deux bannières s'affichent ensemble. Les deux énoncés sont vrais (dossiers écrits, rescan raté), donc acceptable, mais à connaître.
  4. Les refus Rust (Folder already exists: Exemple Desjardins) arrivent en anglais brut dans la bannière via defaultValue. Motif préexistant pour toutes les erreurs du scan ; le cas « existe déjà » est le seul refus prévisible pour un utilisateur, une clé i18n dédiée serait plus propre. Chemin peu atteignable toutefois : après un succès, le rescan fait disparaître l'empty state et le bouton avec.
  5. isCreatingSample={state.isLoading} partage le drapeau avec le scan initial et « Actualiser » : pendant le scan de montage, l'empty state « aucune source » apparaît brièvement avec « Créer » désactivé. Même comportement transitoire qu'avant (la carte vide de SourceList s'affichait aussi), pas une régression.

Actions faites

  • Revue postée (1 seul commentaire de verdict).
  • Label de #356 : déjà status:approved (posé par la self-review de l'autopilot) → inchangé.
  • Copies de travail scratchpad/pr382 et pr382-full supprimées.

Revue indépendante sur la base de la PR ; valable pour ce maillon, le tip cumulé de la pile reste à valider en local avant le merge bottom-up.

## Revue adversariale — PR #382 (`/pr-review`, passe indépendante) **Verdict : APPROVE** Revue contre la base de la PR (`issue-355-docs-bank-guides`), diff complet, un seul commit `624eef7` au format conventionnel. Tout ce qui est hors diff et dont la PR dépend est **présent sur la tête** : `EmptyState` (#352, `src/components/shared/EmptyState.tsx`), la commande Rust `create_sample_import_folders` (#354, `fs_commands.rs:207`, enregistrée dans `lib.rs:442`, arguments `import_folder`/`today` → camelCase côté `invoke` conforme), l'ancre `#import-export` (#355, `DocsSubsections.tsx`) et la clé `common.close`. `SourceList.tsx` est intact, `import.sources.empty` reste utilisée. ### Ce qui a été exécuté (copie hors dépôt, `git archive` de `624eef7`) - `tsc --noEmit` : exit 0. `vitest run` : **79 fichiers, 1440 tests verts** (le chiffre de la PR est exact). Aucun `skip`/`only`. - Parité FR/EN sous `import.*` : aucune clé orpheline. Aucune chaîne JSX en dur dans les 4 composants touchés. - CI de la tête : `PR Check — Frontend` **success** (1m59s). `check-rust` non déclenché, à raison (aucun fichier `src-tauri/**` dans ce maillon). - Timeline de #356 : un seul `pull_ref` `closes` venant de cette PR → l'issue se fermera au merge. ### Mutations du relecteur (chacune restaurée, copie vérifiée identique à l'octet) | # | Mutation | Tests rouges | |---|---|---| | M1 | `SET_IMPORT_FOLDER` garde `sampleCreated` | 1 / 7 | | M2 | `formatLocalDate` → `toISOString().slice(0,10)` | 1 / 7 sous `TZ=America/Toronto` — **0 / 7 sous `TZ=UTC`** | | M3 | `folderNameFromPath` ne coupe que sur `/` | 3 / 8 | | M4 | bouton « Créer » jamais désactivé | 1 / 8 | | M5 | lien du guide retiré de la variante sans dossier | 1 / 8 | ### Vie privée `logInfo` ne porte que les deux **noms** retournés par Rust (constantes `Exemple Desjardins` / `Exemple Tangerine`), jamais le chemin. Le `catch` de `createSampleFolders` ne logge rien ; les messages Rust qui contiennent le chemin (`Folder does not exist: …`) vont à la bannière, pas au journal. Conforme à la contrainte de planification. ### Suggestions (non bloquantes) 1. **Le test `formatLocalDate` est aveugle en UTC.** Il est bien TZ-indépendant pour l'implémentation correcte, mais la régression qu'il vise (`toISOString`) ne tombe que si le processus tourne hors UTC — c'est le fuseau probable du conteneur CI. Poser `process.env.TZ = "America/Toronto"` en tête du fichier de test (ou `vi.stubEnv`) pour que la CI attrape ce que ma machine en EDT a attrapé. 2. `hasFolder` et `folderName` sont redondants dans `ImportEmptyStateProps` : `folderName !== null` suffirait. Une seule source de vérité évite un état `hasFolder: true, folderName: null` que le composant accepte (`folderName ?? ""`). 3. Si `invoke` réussit mais que `scanFolderInternal` échoue, il dispatch `SET_ERROR` en interne puis `SET_SAMPLE_CREATED` est quand même dispatché → les deux bannières s'affichent ensemble. Les deux énoncés sont vrais (dossiers écrits, rescan raté), donc acceptable, mais à connaître. 4. Les refus Rust (`Folder already exists: Exemple Desjardins`) arrivent en anglais brut dans la bannière via `defaultValue`. Motif préexistant pour toutes les erreurs du scan ; le cas « existe déjà » est le seul refus prévisible pour un utilisateur, une clé i18n dédiée serait plus propre. Chemin peu atteignable toutefois : après un succès, le rescan fait disparaître l'empty state et le bouton avec. 5. `isCreatingSample={state.isLoading}` partage le drapeau avec le scan initial et « Actualiser » : pendant le scan de montage, l'empty state « aucune source » apparaît brièvement avec « Créer » désactivé. Même comportement transitoire qu'avant (la carte vide de `SourceList` s'affichait aussi), pas une régression. ### Actions faites - Revue postée (1 seul commentaire de verdict). - Label de #356 : déjà `status:approved` (posé par la self-review de l'autopilot) → inchangé. - Copies de travail `scratchpad/pr382` et `pr382-full` supprimées. --- *Revue indépendante sur la base de la PR ; valable pour ce maillon, le tip cumulé de la pile reste à valider en local avant le merge bottom-up.*
maximus changed target branch from issue-355-docs-bank-guides to main 2026-10-04 12:51:47 +00:00
maximus merged commit 624eef7ba8 into main 2026-10-04 12:51:53 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#382
No description provided.