Import: parent folder, one subfolder per account, signs at the preview, quoted controls (#422) #439
15 changed files with 327 additions and 67 deletions
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
- Le champ « Catégorie parente » du formulaire de catégorie et le champ « Catégorie » du dialogue « Nouveau mot-clé » (clic droit sur une transaction) acceptent désormais la saisie, comme la cellule de catégorie de la page Transactions : tapez quelques lettres pour filtrer la liste, qui montre la hiérarchie avec son indentation et les noms des catégories standard dans votre langue. La liste des parents ne propose plus la catégorie en cours d'édition, ses propres sous-catégories, ni un parent qui créerait un quatrième niveau ; un parent déjà enregistré reste affiché. Le dialogue de mot-clé propose les mêmes catégories que la page Transactions (actives et autorisées à la saisie) et ne garde la catégorie pré-remplie que si elle en fait partie ; ses deux champs sont placés au-dessus de la liste défilante des correspondances, pour que la liste de catégories ouverte ne soit jamais coupée (#423).
|
- Le champ « Catégorie parente » du formulaire de catégorie et le champ « Catégorie » du dialogue « Nouveau mot-clé » (clic droit sur une transaction) acceptent désormais la saisie, comme la cellule de catégorie de la page Transactions : tapez quelques lettres pour filtrer la liste, qui montre la hiérarchie avec son indentation et les noms des catégories standard dans votre langue. La liste des parents ne propose plus la catégorie en cours d'édition, ses propres sous-catégories, ni un parent qui créerait un quatrième niveau ; un parent déjà enregistré reste affiché. Le dialogue de mot-clé propose les mêmes catégories que la page Transactions (actives et autorisées à la saisie) et ne garde la catégorie pré-remplie que si elle en fait partie ; ses deux champs sont placés au-dessus de la liste défilante des correspondances, pour que la liste de catégories ouverte ne soit jamais coupée (#423).
|
||||||
- Une seule copie est gardée par profil dans le dossier `migration-backups` : la copie prise avant une mise à jour de la base est désormais refaite à chaque mise à jour, et une fois la mise à jour réussie, les copies plus anciennes de ce profil sont supprimées. Une copie laissée par une mise à jour interrompue n'est plus réutilisée telle quelle, puisqu'elle précédait ce que vous aviez saisi depuis. Quand une mise à jour s'arrête, le Journal nomme désormais la copie prise avant elle (#395).
|
- Une seule copie est gardée par profil dans le dossier `migration-backups` : la copie prise avant une mise à jour de la base est désormais refaite à chaque mise à jour, et une fois la mise à jour réussie, les copies plus anciennes de ce profil sont supprimées. Une copie laissée par une mise à jour interrompue n'est plus réutilisée telle quelle, puisqu'elle précédait ce que vous aviez saisi depuis. Quand une mise à jour s'arrête, le Journal nomme désormais la copie prise avant elle (#395).
|
||||||
|
- Import : l'état vide montre la structure attendue (un dossier parent, un sous-dossier par compte), et les cartes du parcours demandent de choisir le dossier parent et de vérifier qu'une dépense compte parmi les sorties (#422).
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
- The "Parent category" field of the category form and the "Category" field of the "New keyword" dialog (right-click on a transaction) now take typing, like the category cell of the Transactions page: type a few letters to filter the list, which shows the hierarchy with indentation and the names of the standard categories in your language. The parent list no longer offers the category being edited, its own sub-categories, or a parent that would create a fourth level; a parent already stored stays shown. The keyword dialog offers the same categories as the Transactions page (active and allowed for input) and keeps the pre-filled category only when it is among them; its two fields sit above the scrolling list of matches, so the open category list is never cut (#423).
|
- The "Parent category" field of the category form and the "Category" field of the "New keyword" dialog (right-click on a transaction) now take typing, like the category cell of the Transactions page: type a few letters to filter the list, which shows the hierarchy with indentation and the names of the standard categories in your language. The parent list no longer offers the category being edited, its own sub-categories, or a parent that would create a fourth level; a parent already stored stays shown. The keyword dialog offers the same categories as the Transactions page (active and allowed for input) and keeps the pre-filled category only when it is among them; its two fields sit above the scrolling list of matches, so the open category list is never cut (#423).
|
||||||
- One copy is kept per profile in the `migration-backups` folder: the copy taken before a database update is now taken again at each update, and once the update succeeds, the older copies of that profile are deleted. A copy left by an update that stopped is no longer reused as is, since it predated what you entered after it. When an update stops, the Journal now names the copy taken before it (#395).
|
- One copy is kept per profile in the `migration-backups` folder: the copy taken before a database update is now taken again at each update, and once the update succeeds, the older copies of that profile are deleted. A copy left by an update that stopped is no longer reused as is, since it predated what you entered after it. When an update stops, the Journal now names the copy taken before it (#395).
|
||||||
|
- Import: the empty state shows the expected structure (a parent folder, one subfolder per account), and the getting-started cards ask you to choose the parent folder and to check that an expense counts among the outflows (#422).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ simpl-resultat/
|
||||||
│ │ ├── budget/ # 5 composants
|
│ │ ├── budget/ # 5 composants
|
||||||
│ │ ├── categories/ # 5 composants
|
│ │ ├── categories/ # 5 composants
|
||||||
│ │ ├── dashboard/ # 7 composants (dont DashboardEmptyState et OnboardingChecklist)
|
│ │ ├── dashboard/ # 7 composants (dont DashboardEmptyState et OnboardingChecklist)
|
||||||
│ │ ├── import/ # 15 composants (wizard d'import, dont FormatDriftPanel, RepairPathNotice et ImportEmptyState)
|
│ │ ├── import/ # 16 composants (wizard d'import, dont FormatDriftPanel, RepairPathNotice, ImportEmptyState et ImportFolderStructure)
|
||||||
│ │ ├── layout/ # AppShell, Sidebar
|
│ │ ├── layout/ # AppShell, Sidebar
|
||||||
│ │ ├── onboarding/ # OnboardingBanner (bandeau de démarrage, #397), StepCard et OnboardingStepCard (carte d'étape, #398)
|
│ │ ├── onboarding/ # OnboardingBanner (bandeau de démarrage, #397), StepCard et OnboardingStepCard (carte d'étape, #398)
|
||||||
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
||||||
|
|
@ -327,7 +327,7 @@ Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `d
|
||||||
| Page | Signal | Composant | Action proposée |
|
| Page | Signal | Composant | Action proposée |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Tableau de bord `/` | `counters.transactions === 0`, compteur lu par `useOnboardingChecklist` | `DashboardEmptyState` (carte d'accueil + liste détaillée) | « Choisir un dossier d'import » → `/import` |
|
| Tableau de bord `/` | `counters.transactions === 0`, compteur lu par `useOnboardingChecklist` | `DashboardEmptyState` (carte d'accueil + liste détaillée) | « Choisir un dossier d'import » → `/import` |
|
||||||
| Import `/import` | aucun dossier d'import, ou dossier sans aucun sous-dossier, 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 |
|
| Import `/import` | aucun dossier d'import, ou dossier sans aucun sous-dossier, une fois le dossier lu (`importFolderLoaded`, #384) | `ImportEmptyState` (deux variantes, chacune avec l'encadré « Structure attendue » d'`ImportFolderStructure` : dossier parent, un sous-dossier par compte, fichiers à la racine ignorés, #422) | « Choisir un dossier » ; ou « Créer un dossier d'exemple » + « Actualiser » ; lien vers le guide `/settings/users#import-export` dans les deux cas |
|
||||||
| Transactions `/transactions` | `showsEmptyState(state)` = `totalInDb === 0 && !isLoading`, `totalInDb` via `getTransactionCount` | `EmptyState` dans la page | « Importer des transactions » → `/import` |
|
| Transactions `/transactions` | `showsEmptyState(state)` = `totalInDb === 0 && !isLoading`, `totalInDb` via `getTransactionCount` | `EmptyState` dans la page | « Importer des transactions » → `/import` |
|
||||||
| Budget `/budget` | `showsEmptyState(state)` = `totalInDb === 0`, même compteur | `EmptyState` dans la page | « Importer des transactions » → `/import` |
|
| Budget `/budget` | `showsEmptyState(state)` = `totalInDb === 0`, même compteur | `EmptyState` dans la page | « Importer des transactions » → `/import` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -137,12 +137,12 @@ Importez des relevés bancaires à partir de fichiers CSV à l'aide d'un assista
|
||||||
### Comment faire
|
### Comment faire
|
||||||
|
|
||||||
1. Définissez votre dossier d'import via le sélecteur de dossier en haut de la page
|
1. Définissez votre dossier d'import via le sélecteur de dossier en haut de la page
|
||||||
2. Créez un sous-dossier pour chaque banque/source et placez-y les fichiers CSV — tant que le dossier est vide, le bouton « Créer un dossier d'exemple » dépose deux sous-dossiers fictifs (« Exemple Desjardins », « Exemple Tangerine ») pour essayer l'assistant sans attendre vos vrais relevés ; supprimez-les ensuite
|
2. Créez un sous-dossier par compte et placez-y ses fichiers CSV : la carte de crédit et le compte chèques d'une même banque font deux sources, et les fichiers posés directement dans le dossier parent sont ignorés — tant que le dossier est vide, le bouton « Créer un dossier d'exemple » dépose deux sous-dossiers fictifs (« Exemple Desjardins », « Exemple Tangerine ») pour essayer l'assistant sans attendre vos vrais relevés ; supprimez-les ensuite
|
||||||
3. Cliquez sur une source pour ouvrir l'assistant d'import — la première fois, le format est détecté automatiquement et un bandeau annonce le résultat
|
3. Cliquez sur une source pour ouvrir l'assistant d'import — la première fois, le format est détecté automatiquement et un bandeau annonce le résultat
|
||||||
4. Vérifiez la configuration proposée (délimiteur, encodage, format de date, mapping des colonnes) et corrigez-la si le bandeau signale un format incertain
|
4. Vérifiez la configuration proposée (délimiteur, encodage, format de date, mapping des colonnes) et corrigez-la si le bandeau signale un format incertain
|
||||||
5. Sélectionnez les fichiers à importer
|
5. Sélectionnez les fichiers à importer
|
||||||
6. **Examinez l'aperçu** : le récapitulatif indique combien de sorties et d'entrées le fichier produira, et pour quels montants
|
6. **Examinez l'aperçu** : le récapitulatif indique combien de sorties et d'entrées le fichier produira, et pour quels montants ; une dépense doit compter parmi les sorties, en négatif
|
||||||
7. Si les entrées et les sorties sont inversées, cliquez sur « Inverser les signes » — la correction est enregistrée avec la source, les prochains fichiers de cette banque se liront correctement d'eux-mêmes
|
7. Si les entrées et les sorties sont inversées, cliquez sur « Inverser les signes » — la correction est enregistrée avec la source, les prochains fichiers de ce compte se liront correctement d'eux-mêmes
|
||||||
8. Vérifiez les doublons, examinez le résumé, puis confirmez l'import
|
8. Vérifiez les doublons, examinez le résumé, puis confirmez l'import
|
||||||
|
|
||||||
### Exporter un CSV depuis votre banque
|
### Exporter un CSV depuis votre banque
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@
|
||||||
{ id: "guided-import-card", title: "Étape 2 : la carte Import, son contour et le focus",
|
{ id: "guided-import-card", title: "Étape 2 : la carte Import, son contour et le focus",
|
||||||
expect: "Suis le lien « Choisir un dossier d'import » du bandeau ou de la liste. La page Import s'ouvre sur la carte « Étape 2 sur 6 · Choisir un dossier d'import », avant le titre et à la place du bandeau, et le focus du clavier est sur le titre de la carte. La phrase nomme le bouton « Choisir un dossier », entouré d'un contour de couleur d'accent qui pulse trois fois, puis reste." },
|
expect: "Suis le lien « Choisir un dossier d'import » du bandeau ou de la liste. La page Import s'ouvre sur la carte « Étape 2 sur 6 · Choisir un dossier d'import », avant le titre et à la place du bandeau, et le focus du clavier est sur le titre de la carte. La phrase nomme le bouton « Choisir un dossier », entouré d'un contour de couleur d'accent qui pulse trois fois, puis reste." },
|
||||||
{ id: "guided-import-folder", title: "Étape 3 : dossier choisi, puis dossier d'exemple",
|
{ id: "guided-import-folder", title: "Étape 3 : dossier choisi, puis dossier d'exemple",
|
||||||
expect: "Choisis un dossier vide : sans quitter la page, la carte passe à « Étape 3 sur 6 · Configurer une source d'import », nomme le dossier et entoure « Créer un dossier d'exemple ». Après la création, elle demande de cliquer sur la carte de ta banque et entoure la première source." },
|
expect: "Choisis un dossier vide : sans quitter la page, la carte passe à « Étape 3 sur 6 · Configurer une source d'import », nomme le dossier et entoure « Créer un dossier d'exemple ». Après la création, elle demande de cliquer sur la carte d'un compte et entoure la première source." },
|
||||||
{ id: "guided-import-wizard", title: "Étape 4 : l'assistant, écran par écran",
|
{ id: "guided-import-wizard", title: "Étape 4 : l'assistant, écran par écran",
|
||||||
expect: "Clique la source entourée (« Exemple Desjardins »). À chaque écran, la carte dit quoi vérifier et entoure le bouton suivant : « Aperçu », « Vérifier les doublons », « Confirmer », puis « Importer ». Pendant l'import, elle affiche « Import en cours… ». Sur le rapport, elle annonce « Fait. » et propose « Étape suivante : Classer une transaction à la main → » ; après « Terminé », elle garde « Fait. »." },
|
expect: "Clique la source entourée (« Exemple Desjardins »). À chaque écran, la carte dit quoi vérifier et entoure le bouton suivant : « Aperçu », « Vérifier les doublons », « Confirmer », puis « Importer ». Pendant l'import, elle affiche « Import en cours… ». Sur le rapport, elle annonce « Fait. » et propose « Étape suivante : Classer une transaction à la main → » ; après « Terminé », elle garde « Fait. »." },
|
||||||
{ id: "guided-transactions", title: "Étape 5 : Transactions, entrée guidée",
|
{ id: "guided-transactions", title: "Étape 5 : Transactions, entrée guidée",
|
||||||
|
|
@ -273,6 +273,21 @@
|
||||||
expect: "À l'ouverture de la page Import, la carte du dossier peut afficher un très bref instant « Aucun dossier configuré » avant la lecture du dossier ; l'état vide et la carte de démarrage, eux, attendent cette lecture. À juger." }
|
expect: "À l'ouverture de la page Import, la carte du dossier peut afficher un très bref instant « Aucun dossier configuré » avant la lecture du dossier ; l'état vide et la carte de démarrage, eux, attendent cette lecture. À juger." }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "retours-v0171",
|
||||||
|
title: "Retours de la v0.17.1 : textes et cartes du parcours",
|
||||||
|
intro: "Jalon planned-2026-10-12-retours-onboarding-v0171. Sur un profil neuf, en français, puis en anglais pour relire les mêmes phrases.",
|
||||||
|
steps: [
|
||||||
|
{ id: "v0171-import-structure", title: "Import : l'encadré « Structure attendue » (#422)",
|
||||||
|
expect: "Sans dossier choisi (« Aucun dossier d'import »), puis dans un dossier vide (« Aucune source trouvée »), l'état vide montre l'encadré « Structure attendue » au-dessus du lien « Comment exporter un CSV depuis ma banque ? » : « Dossier parent : choisissez celui-ci », dessous « Carte de crédit : une source » avec releve-2026-08.csv et releve-2026-09.csv, puis « Compte chèques : une autre source », et la note « Les fichiers posés directement dans le dossier parent sont ignorés. ». Les niveaux se lisent en retrait, dans les deux thèmes ; un lecteur d'écran lit une liste imbriquée, sans annoncer les icônes." },
|
||||||
|
{ id: "v0171-import-card-folder", title: "Import : cartes « Choisir un dossier d'import » et « Configurer une source d'import » (#422)",
|
||||||
|
expect: "Sans dossier, la carte dit : « Choisissez avec le bouton « Choisir un dossier » le dossier parent de vos relevés. Simpl'Résultat y lit un sous-dossier par compte : un pour la carte de crédit, un pour le compte chèques, et ainsi de suite. » Dans un dossier vide, elle nomme le dossier entre guillemets, demande un sous-dossier par compte et cite « Actualiser » et « Créer un dossier d'exemple » entre guillemets (« deux comptes fictifs »). Une fois les sources présentes : « Cliquez sur la carte d'un compte… ». Nulle part la carte ne parle d'un sous-dossier par banque." },
|
||||||
|
{ id: "v0171-import-card-preview", title: "Import : la carte de l'aperçu parle des signes (#422)",
|
||||||
|
expect: "À l'écran d'aperçu de l'assistant, la carte dit qu'une dépense doit compter parmi les sorties, en négatif, cite « Inverser les signes » comme correction, puis « Vérifier les doublons », chacun entre guillemets ; le contour reste sur « Vérifier les doublons ». Aux écrans suivants, « Confirmer » puis « Importer » sont aussi entre guillemets. En anglais, les mêmes libellés sont entre “ ”." },
|
||||||
|
{ id: "v0171-import-help", title: "Import : l'aide de la page et le guide (#422)",
|
||||||
|
expect: "L'aide de la page Import (icône ?) commence par « Choisissez le dossier parent de vos relevés… » et porte, après « Prévisualisez vos données… », la règle des signes avec « Inverser les signes ». Dans le guide d'utilisation (section Import), l'étape 2 dit « Créez un sous-dossier par compte… », l'étape 6 qu'une dépense doit compter parmi les sorties, et l'étape 7 parle des prochains fichiers « de ce compte »." }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "parcours-guide-a11y",
|
id: "parcours-guide-a11y",
|
||||||
title: "Parcours guidé : accessibilité et rendu",
|
title: "Parcours guidé : accessibilité et rendu",
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,11 @@ vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nM
|
||||||
vi.mock("lucide-react", () => ({
|
vi.mock("lucide-react", () => ({
|
||||||
FolderOpen: () => null,
|
FolderOpen: () => null,
|
||||||
Inbox: () => null,
|
Inbox: () => null,
|
||||||
|
Folder: () => null,
|
||||||
|
FileText: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { FolderOpen, Inbox } from "lucide-react";
|
import { FileText, Folder, FolderOpen, Inbox } from "lucide-react";
|
||||||
import ImportEmptyState, {
|
import ImportEmptyState, {
|
||||||
CSV_EXPORT_GUIDE_PATH,
|
CSV_EXPORT_GUIDE_PATH,
|
||||||
folderNameFromPath,
|
folderNameFromPath,
|
||||||
|
|
@ -40,7 +42,7 @@ function texts(node: ReactNode): string[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leaves of the walk: react-router's Link and the mocked icons keep their type.
|
// Leaves of the walk: react-router's Link and the mocked icons keep their type.
|
||||||
const LEAVES = new Set<unknown>([Link, FolderOpen, Inbox]);
|
const LEAVES = new Set<unknown>([Link, FolderOpen, Inbox, Folder, FileText]);
|
||||||
|
|
||||||
/** Resolves the inner `EmptyState` component so its buttons and links are visible. */
|
/** Resolves the inner `EmptyState` component so its buttons and links are visible. */
|
||||||
function expand(node: ReactNode): ReactNode {
|
function expand(node: ReactNode): ReactNode {
|
||||||
|
|
@ -139,6 +141,30 @@ describe("ImportEmptyState — folder without sources (#356)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ImportEmptyState — expected structure of the folder (#422)", () => {
|
||||||
|
const STRUCTURE = [
|
||||||
|
"title",
|
||||||
|
"parent",
|
||||||
|
"account1",
|
||||||
|
"file1",
|
||||||
|
"file2",
|
||||||
|
"account2",
|
||||||
|
"rootNote",
|
||||||
|
].map((name) => `t(import.empty.structure.${name})`);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["no folder", { hasFolder: false }],
|
||||||
|
["a folder without sources", { hasFolder: true, folderName: "Relevés" }],
|
||||||
|
] as const)("shows the expected structure with %s, above the guide link", (_, overrides) => {
|
||||||
|
const { tree } = render(overrides);
|
||||||
|
const all = texts(tree);
|
||||||
|
for (const text of STRUCTURE) expect(all).toContain(text);
|
||||||
|
expect(all.indexOf("t(import.empty.structure.rootNote)")).toBeLessThan(
|
||||||
|
all.indexOf("t(import.empty.guideLink)"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("ImportEmptyState — getting-started target (#398)", () => {
|
describe("ImportEmptyState — getting-started target (#398)", () => {
|
||||||
const highlighted = (tree: ReactNode) =>
|
const highlighted = (tree: ReactNode) =>
|
||||||
find(tree, (e) => e.props["data-onboarding-highlight"] === "true");
|
find(tree, (e) => e.props["data-onboarding-highlight"] === "true");
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { FolderOpen, Inbox } from "lucide-react";
|
import { FolderOpen, Inbox } from "lucide-react";
|
||||||
import EmptyState from "../shared/EmptyState";
|
import EmptyState from "../shared/EmptyState";
|
||||||
|
import ImportFolderStructure from "./ImportFolderStructure";
|
||||||
|
|
||||||
/** Where the user guide explains how to export a CSV from each bank (#355). */
|
/** Where the user guide explains how to export a CSV from each bank (#355). */
|
||||||
export const CSV_EXPORT_GUIDE_PATH = "/settings/users#import-export";
|
export const CSV_EXPORT_GUIDE_PATH = "/settings/users#import-export";
|
||||||
|
|
@ -36,9 +37,10 @@ export function folderNameFromPath(path: string): string {
|
||||||
/**
|
/**
|
||||||
* Empty state of the import page (#356), replacing the empty `SourceList` in
|
* Empty state of the import page (#356), replacing the empty `SourceList` in
|
||||||
* both of its situations: no import folder yet (choose one), or a folder with
|
* both of its situations: no import folder yet (choose one), or a folder with
|
||||||
* no bank sub-folder (create the sample folders, or refresh after dropping
|
* no account sub-folder (create the sample folders, or refresh after dropping
|
||||||
* files). Both variants link to the bank CSV export guide. Pure: no hook
|
* files). Both variants show the expected structure of the folder (#422) and
|
||||||
* besides `useTranslation`, so it is tested by direct call.
|
* link to the bank CSV export guide. Pure: no hook besides `useTranslation`,
|
||||||
|
* so it is tested by direct call.
|
||||||
*/
|
*/
|
||||||
export default function ImportEmptyState({
|
export default function ImportEmptyState({
|
||||||
hasFolder,
|
hasFolder,
|
||||||
|
|
@ -51,8 +53,10 @@ export default function ImportEmptyState({
|
||||||
}: ImportEmptyStateProps) {
|
}: ImportEmptyStateProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const guideLink = (
|
const details = (
|
||||||
<p className="text-center">
|
<>
|
||||||
|
<ImportFolderStructure />
|
||||||
|
<p className="mt-4 text-center">
|
||||||
<Link
|
<Link
|
||||||
to={CSV_EXPORT_GUIDE_PATH}
|
to={CSV_EXPORT_GUIDE_PATH}
|
||||||
className="text-sm text-[var(--primary)] hover:underline"
|
className="text-sm text-[var(--primary)] hover:underline"
|
||||||
|
|
@ -60,6 +64,7 @@ export default function ImportEmptyState({
|
||||||
{t("import.empty.guideLink")}
|
{t("import.empty.guideLink")}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasFolder) {
|
if (!hasFolder) {
|
||||||
|
|
@ -70,7 +75,7 @@ export default function ImportEmptyState({
|
||||||
description={t("import.empty.noFolder.description")}
|
description={t("import.empty.noFolder.description")}
|
||||||
primaryAction={{ label: t("import.empty.noFolder.cta"), onClick: onBrowse, onboardingTarget }}
|
primaryAction={{ label: t("import.empty.noFolder.cta"), onClick: onBrowse, onboardingTarget }}
|
||||||
>
|
>
|
||||||
{guideLink}
|
{details}
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -88,7 +93,7 @@ export default function ImportEmptyState({
|
||||||
}}
|
}}
|
||||||
secondaryAction={{ label: t("import.empty.refresh"), onClick: onRefresh }}
|
secondaryAction={{ label: t("import.empty.refresh"), onClick: onRefresh }}
|
||||||
>
|
>
|
||||||
{guideLink}
|
{details}
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
62
src/components/import/ImportFolderStructure.test.tsx
Normal file
62
src/components/import/ImportFolderStructure.test.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
// ImportFolderStructure — render tests (#422).
|
||||||
|
//
|
||||||
|
// `renderToStaticMarkup`, no jsdom; the shared marked `t` (#389), so every
|
||||||
|
// text is asserted in its `t(key)` form and a string that skipped `t` fails.
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
|
|
||||||
|
import ImportFolderStructure from "./ImportFolderStructure";
|
||||||
|
|
||||||
|
const KEYS = [
|
||||||
|
"title",
|
||||||
|
"parent",
|
||||||
|
"account1",
|
||||||
|
"file1",
|
||||||
|
"file2",
|
||||||
|
"account2",
|
||||||
|
"rootNote",
|
||||||
|
].map((name) => `import.empty.structure.${name}`);
|
||||||
|
|
||||||
|
/** The markup with the class attributes dropped, so the structure reads. */
|
||||||
|
function bare(): string {
|
||||||
|
return renderToStaticMarkup(<ImportFolderStructure />)
|
||||||
|
.replace(/ class="[^"]*"/g, "")
|
||||||
|
.replace(/<svg[^>]*>.*?<\/svg>/g, "<svg/>");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ImportFolderStructure (#422)", () => {
|
||||||
|
it("renders its seven texts, each through t, in reading order", () => {
|
||||||
|
const html = renderToStaticMarkup(<ImportFolderStructure />);
|
||||||
|
const positions = KEYS.map((key) => html.indexOf(`t(${key})`));
|
||||||
|
for (const [i, position] of positions.entries()) {
|
||||||
|
expect(position, KEYS[i]).toBeGreaterThanOrEqual(0);
|
||||||
|
}
|
||||||
|
expect([...positions].sort((a, b) => a - b)).toEqual(positions);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nests the levels: parent folder > account folders > files", () => {
|
||||||
|
// The two files sit in a list inside the first account's item, itself in
|
||||||
|
// a list inside the parent's item; the second account is the first's
|
||||||
|
// sibling, and the root note is outside the tree.
|
||||||
|
expect(bare()).toBe(
|
||||||
|
"<div><h3>t(import.empty.structure.title)</h3>" +
|
||||||
|
"<ul><li><span><svg/>t(import.empty.structure.parent)</span>" +
|
||||||
|
"<ul>" +
|
||||||
|
"<li><span><svg/>t(import.empty.structure.account1)</span>" +
|
||||||
|
"<ul><li><svg/>t(import.empty.structure.file1)</li><li><svg/>t(import.empty.structure.file2)</li></ul>" +
|
||||||
|
"</li>" +
|
||||||
|
"<li><span><svg/>t(import.empty.structure.account2)</span></li>" +
|
||||||
|
"</ul></li></ul>" +
|
||||||
|
"<p>t(import.empty.structure.rootNote)</p></div>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides every icon from assistive technologies", () => {
|
||||||
|
const html = renderToStaticMarkup(<ImportFolderStructure />);
|
||||||
|
const icons = html.match(/<svg[^>]*>/g) ?? [];
|
||||||
|
expect(icons).toHaveLength(5);
|
||||||
|
for (const icon of icons) expect(icon).toContain('aria-hidden="true"');
|
||||||
|
});
|
||||||
|
});
|
||||||
59
src/components/import/ImportFolderStructure.tsx
Normal file
59
src/components/import/ImportFolderStructure.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FileText, Folder } from "lucide-react";
|
||||||
|
|
||||||
|
const ITEM_CLASS = "flex items-center gap-2";
|
||||||
|
const NESTED_CLASS = "mt-1 ml-5 space-y-1 border-l border-[var(--border)] pl-3";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Expected structure" box of the import page's empty state (#422): the
|
||||||
|
* folder to choose is the parent one, each of its sub-folders is one source
|
||||||
|
* (one account), and only the files inside a sub-folder are read. It mirrors
|
||||||
|
* `scan_import_folder` (src-tauri/src/commands/fs_commands.rs): direct,
|
||||||
|
* non-hidden sub-folders become sources, files at the root are ignored.
|
||||||
|
*
|
||||||
|
* A nested list rather than a picture, so a screen reader reads the levels;
|
||||||
|
* the icons are decorative. Pure: no hook besides `useTranslation`, so it is
|
||||||
|
* tested by direct call.
|
||||||
|
*/
|
||||||
|
export default function ImportFolderStructure() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-[var(--border)] bg-[var(--muted)] p-4 text-sm text-[var(--foreground)]">
|
||||||
|
<h3 className="mb-2 font-semibold">{t("import.empty.structure.title")}</h3>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
<li>
|
||||||
|
<span className={`${ITEM_CLASS} font-medium`}>
|
||||||
|
<Folder size={16} aria-hidden="true" className="shrink-0 text-[var(--primary)]" />
|
||||||
|
{t("import.empty.structure.parent")}
|
||||||
|
</span>
|
||||||
|
<ul className={NESTED_CLASS}>
|
||||||
|
<li>
|
||||||
|
<span className={ITEM_CLASS}>
|
||||||
|
<Folder size={16} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
|
||||||
|
{t("import.empty.structure.account1")}
|
||||||
|
</span>
|
||||||
|
<ul className={NESTED_CLASS}>
|
||||||
|
<li className={ITEM_CLASS}>
|
||||||
|
<FileText size={16} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
|
||||||
|
{t("import.empty.structure.file1")}
|
||||||
|
</li>
|
||||||
|
<li className={ITEM_CLASS}>
|
||||||
|
<FileText size={16} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
|
||||||
|
{t("import.empty.structure.file2")}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className={ITEM_CLASS}>
|
||||||
|
<Folder size={16} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
|
||||||
|
{t("import.empty.structure.account2")}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-3 text-[var(--muted-foreground)]">{t("import.empty.structure.rootNote")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -80,13 +80,13 @@
|
||||||
"done": "Done.",
|
"done": "Done.",
|
||||||
"next": "Next step: {{step}} →",
|
"next": "Next step: {{step}} →",
|
||||||
"import": {
|
"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.",
|
"noFolder": "With the “{{button}}” button, choose the parent folder of your statements. Simpl'Résultat reads one sub-folder per account in it: one for the credit card, one for the chequing account, and so on.",
|
||||||
"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.",
|
"noSources": "In the “{{folder}}” folder, create one sub-folder per account and drop its CSV statements in it, then click “{{refresh}}”. To try first, “{{sample}}” adds two fictitious accounts.",
|
||||||
"sourceList": "Click your bank's card. Simpl'Résultat recognises the format of its statements and shows you what it understood.",
|
"sourceList": "Click an account'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}}.",
|
"sourceConfig": "Check the source name and the selected files, then click “{{button}}”.",
|
||||||
"filePreview": "Check that dates and amounts are read correctly, then click {{button}}.",
|
"filePreview": "Check that dates and amounts are read correctly: an expense must count among the outflows, as a negative amount. If not, click “{{flip}}”. Then click “{{button}}”.",
|
||||||
"duplicateCheck": "Choose what to do with transactions already imported, 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.",
|
"confirm": "Click “{{button}}”: the source is saved and your transactions come in.",
|
||||||
"importing": "Importing…",
|
"importing": "Importing…",
|
||||||
"failed": "The import added no transaction; the report below says why."
|
"failed": "The import added no transaction; the report below says why."
|
||||||
},
|
},
|
||||||
|
|
@ -291,9 +291,10 @@
|
||||||
"help": {
|
"help": {
|
||||||
"title": "How to import bank statements",
|
"title": "How to import bank statements",
|
||||||
"tips": [
|
"tips": [
|
||||||
"Set your import folder, then create one subfolder per bank/source with CSV files inside",
|
"Choose the parent folder of your statements, then create one subfolder per account in it with its CSV files; files placed at its root are ignored",
|
||||||
"Click a source to configure column mapping, delimiter, and date format",
|
"Click a source to configure column mapping, delimiter, and date format",
|
||||||
"Preview your data before importing to catch formatting issues",
|
"Preview your data before importing to catch formatting issues",
|
||||||
|
"In the preview, an expense must count among the outflows, as a negative amount; if not, “Flip the signs” fixes the source for future imports",
|
||||||
"Duplicate detection prevents the same transactions from being imported twice"
|
"Duplicate detection prevents the same transactions from being imported twice"
|
||||||
],
|
],
|
||||||
"linkLabel": "How do I export a CSV from my bank?"
|
"linkLabel": "How do I export a CSV from my bank?"
|
||||||
|
|
@ -306,11 +307,20 @@
|
||||||
},
|
},
|
||||||
"noSources": {
|
"noSources": {
|
||||||
"title": "No source found",
|
"title": "No source found",
|
||||||
"description": "Create one subfolder per bank in {{folderName}} and drop your CSV files there"
|
"description": "Create one subfolder per account in {{folderName}} and drop its CSV files there"
|
||||||
},
|
},
|
||||||
"createSample": "Create a sample folder",
|
"createSample": "Create a sample folder",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"guideLink": "How do I export a CSV from my bank?",
|
"guideLink": "How do I export a CSV from my bank?",
|
||||||
|
"structure": {
|
||||||
|
"title": "Expected structure",
|
||||||
|
"parent": "Parent folder: choose this one",
|
||||||
|
"account1": "Credit card: one source",
|
||||||
|
"file1": "statement-2026-08.csv",
|
||||||
|
"file2": "statement-2026-09.csv",
|
||||||
|
"account2": "Chequing account: another source",
|
||||||
|
"rootNote": "Files placed directly in the parent folder are ignored."
|
||||||
|
},
|
||||||
"sampleCreated": "Two sample folders were created: {{names}}. Click one of them to start the import. Delete them once your real statements are in place."
|
"sampleCreated": "Two sample folders were created: {{names}}. Click one of them to start the import. Delete them once your real statements are in place."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -988,12 +998,12 @@
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Set your import folder via the folder picker at the top of the page",
|
"Set your import folder via the folder picker at the top of the page",
|
||||||
"Create a subfolder for each bank/source and place CSV files inside — while the folder is empty, the \"Create a sample folder\" button writes two fictitious subfolders (\"Exemple Desjardins\", \"Exemple Tangerine\") so you can try the wizard without waiting for your real statements; delete them afterwards",
|
"Create one subfolder per account and place its CSV files inside: the credit card and the chequing account of the same bank are two sources, and files placed directly in the parent folder are ignored — while the folder is empty, the \"Create a sample folder\" button writes two fictitious subfolders (\"Exemple Desjardins\", \"Exemple Tangerine\") so you can try the wizard without waiting for your real statements; delete them afterwards",
|
||||||
"Click on a source to open the import wizard — the first time, the format is detected automatically and a banner announces the result",
|
"Click on a source to open the import wizard — the first time, the format is detected automatically and a banner announces the result",
|
||||||
"Check the proposed configuration (delimiter, encoding, date format, column mapping) and correct it if the banner reports an uncertain format",
|
"Check the proposed configuration (delimiter, encoding, date format, column mapping) and correct it if the banner reports an uncertain format",
|
||||||
"Select which files to import",
|
"Select which files to import",
|
||||||
"Read the preview: the recap tells you how many outflows and inflows the file will produce, and for what amounts",
|
"Read the preview: the recap tells you how many outflows and inflows the file will produce, and for what amounts; an expense must count among the outflows, as a negative amount",
|
||||||
"If inflows and outflows are swapped, click “Flip the signs” — the correction is saved with the source, so the next files from that bank read correctly on their own",
|
"If inflows and outflows are swapped, click “Flip the signs” — the correction is saved with the source, so the next files from that account read correctly on their own",
|
||||||
"Check for duplicates, review the summary, then confirm the import"
|
"Check for duplicates, review the summary, then confirm the import"
|
||||||
],
|
],
|
||||||
"subsections": [
|
"subsections": [
|
||||||
|
|
|
||||||
|
|
@ -80,13 +80,13 @@
|
||||||
"done": "Fait.",
|
"done": "Fait.",
|
||||||
"next": "Étape suivante : {{step}} →",
|
"next": "Étape suivante : {{step}} →",
|
||||||
"import": {
|
"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.",
|
"noFolder": "Choisissez avec le bouton « {{button}} » le dossier parent de vos relevés. Simpl'Résultat y lit un sous-dossier par compte : un pour la carte de crédit, un pour le compte chèques, et ainsi de suite.",
|
||||||
"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.",
|
"noSources": "Dans le dossier « {{folder}} », créez un sous-dossier par compte et déposez-y ses relevés CSV, puis cliquez « {{refresh}} ». Pour essayer d'abord, « {{sample}} » dépose deux comptes fictifs.",
|
||||||
"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.",
|
"sourceList": "Cliquez sur la carte d'un compte. 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}}.",
|
"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}}.",
|
"filePreview": "Vérifiez que les dates et les montants sont bien lus : une dépense doit compter parmi les sorties, en négatif. Sinon, cliquez « {{flip}} ». Puis cliquez « {{button}} ».",
|
||||||
"duplicateCheck": "Choisissez quoi faire des transactions déjà importées, 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.",
|
"confirm": "Cliquez « {{button}} » : la source est enregistrée et vos transactions arrivent.",
|
||||||
"importing": "Import en cours…",
|
"importing": "Import en cours…",
|
||||||
"failed": "L'import n'a ajouté aucune transaction ; le rapport ci-dessous dit pourquoi."
|
"failed": "L'import n'a ajouté aucune transaction ; le rapport ci-dessous dit pourquoi."
|
||||||
},
|
},
|
||||||
|
|
@ -291,9 +291,10 @@
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Comment importer des relevés bancaires",
|
"title": "Comment importer des relevés bancaires",
|
||||||
"tips": [
|
"tips": [
|
||||||
"Configurez votre dossier d'import, puis créez un sous-dossier par banque/source avec des fichiers CSV",
|
"Choisissez le dossier parent de vos relevés, puis créez-y un sous-dossier par compte avec ses fichiers CSV ; les fichiers posés à la racine sont ignorés",
|
||||||
"Cliquez sur une source pour configurer le mapping des colonnes, le délimiteur et le format de date",
|
"Cliquez sur une source pour configurer le mapping des colonnes, le délimiteur et le format de date",
|
||||||
"Prévisualisez vos données avant l'import pour détecter les problèmes de formatage",
|
"Prévisualisez vos données avant l'import pour détecter les problèmes de formatage",
|
||||||
|
"À l'aperçu, une dépense doit compter parmi les sorties, en négatif ; sinon, « Inverser les signes » corrige la source pour les prochains imports",
|
||||||
"La détection des doublons empêche d'importer les mêmes transactions deux fois"
|
"La détection des doublons empêche d'importer les mêmes transactions deux fois"
|
||||||
],
|
],
|
||||||
"linkLabel": "Comment exporter un CSV depuis ma banque ?"
|
"linkLabel": "Comment exporter un CSV depuis ma banque ?"
|
||||||
|
|
@ -306,11 +307,20 @@
|
||||||
},
|
},
|
||||||
"noSources": {
|
"noSources": {
|
||||||
"title": "Aucune source trouvée",
|
"title": "Aucune source trouvée",
|
||||||
"description": "Créez un sous-dossier par banque dans {{folderName}} et déposez-y vos fichiers CSV"
|
"description": "Créez un sous-dossier par compte dans {{folderName}} et déposez-y ses fichiers CSV"
|
||||||
},
|
},
|
||||||
"createSample": "Créer un dossier d'exemple",
|
"createSample": "Créer un dossier d'exemple",
|
||||||
"refresh": "Actualiser",
|
"refresh": "Actualiser",
|
||||||
"guideLink": "Comment exporter un CSV depuis ma banque ?",
|
"guideLink": "Comment exporter un CSV depuis ma banque ?",
|
||||||
|
"structure": {
|
||||||
|
"title": "Structure attendue",
|
||||||
|
"parent": "Dossier parent : choisissez celui-ci",
|
||||||
|
"account1": "Carte de crédit : une source",
|
||||||
|
"file1": "releve-2026-08.csv",
|
||||||
|
"file2": "releve-2026-09.csv",
|
||||||
|
"account2": "Compte chèques : une autre source",
|
||||||
|
"rootNote": "Les fichiers posés directement dans le dossier parent sont ignorés."
|
||||||
|
},
|
||||||
"sampleCreated": "Deux dossiers d'exemple ont été créés : {{names}}. Cliquez sur l'un d'eux pour lancer l'import. Supprimez-les quand vos vrais relevés sont en place."
|
"sampleCreated": "Deux dossiers d'exemple ont été créés : {{names}}. Cliquez sur l'un d'eux pour lancer l'import. Supprimez-les quand vos vrais relevés sont en place."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -988,12 +998,12 @@
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Définissez votre dossier d'import via le sélecteur de dossier en haut de la page",
|
"Définissez votre dossier d'import via le sélecteur de dossier en haut de la page",
|
||||||
"Créez un sous-dossier pour chaque banque/source et placez-y les fichiers CSV — tant que le dossier est vide, le bouton « Créer un dossier d'exemple » dépose deux sous-dossiers fictifs (« Exemple Desjardins », « Exemple Tangerine ») pour essayer l'assistant sans attendre vos vrais relevés ; supprimez-les ensuite",
|
"Créez un sous-dossier par compte et placez-y ses fichiers CSV : la carte de crédit et le compte chèques d'une même banque font deux sources, et les fichiers posés directement dans le dossier parent sont ignorés — tant que le dossier est vide, le bouton « Créer un dossier d'exemple » dépose deux sous-dossiers fictifs (« Exemple Desjardins », « Exemple Tangerine ») pour essayer l'assistant sans attendre vos vrais relevés ; supprimez-les ensuite",
|
||||||
"Cliquez sur une source pour ouvrir l'assistant d'import — la première fois, le format est détecté automatiquement et un bandeau annonce le résultat",
|
"Cliquez sur une source pour ouvrir l'assistant d'import — la première fois, le format est détecté automatiquement et un bandeau annonce le résultat",
|
||||||
"Vérifiez la configuration proposée (délimiteur, encodage, format de date, mapping des colonnes) et corrigez-la si le bandeau signale un format incertain",
|
"Vérifiez la configuration proposée (délimiteur, encodage, format de date, mapping des colonnes) et corrigez-la si le bandeau signale un format incertain",
|
||||||
"Sélectionnez les fichiers à importer",
|
"Sélectionnez les fichiers à importer",
|
||||||
"Examinez l'aperçu : le récapitulatif indique combien de sorties et d'entrées le fichier produira, et pour quels montants",
|
"Examinez l'aperçu : le récapitulatif indique combien de sorties et d'entrées le fichier produira, et pour quels montants ; une dépense doit compter parmi les sorties, en négatif",
|
||||||
"Si les entrées et les sorties sont inversées, cliquez sur « Inverser les signes » — la correction est enregistrée avec la source, les prochains fichiers de cette banque se liront correctement d'eux-mêmes",
|
"Si les entrées et les sorties sont inversées, cliquez sur « Inverser les signes » — la correction est enregistrée avec la source, les prochains fichiers de ce compte se liront correctement d'eux-mêmes",
|
||||||
"Vérifiez les doublons, examinez le résumé, puis confirmez l'import"
|
"Vérifiez les doublons, examinez le résumé, puis confirmez l'import"
|
||||||
],
|
],
|
||||||
"subsections": [
|
"subsections": [
|
||||||
|
|
|
||||||
|
|
@ -147,9 +147,11 @@ describe("ImportPage — the step card (#398)", () => {
|
||||||
const target = highlighted(html);
|
const target = highlighted(html);
|
||||||
expect(target.tag).toBe("button");
|
expect(target.tag).toBe("button");
|
||||||
expect(target.inner).toBe("t(import.empty.noFolder.cta)");
|
expect(target.inner).toBe("t(import.empty.noFolder.cta)");
|
||||||
|
// The empty state shows the expected structure of the folder (#422).
|
||||||
|
expect(html).toContain("t(import.empty.structure.title)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a folder without bank sub-folder: 'Create a sample folder', the folder named by its last segment", () => {
|
it("a folder without account sub-folder: 'Create a sample folder', the folder named by its last segment", () => {
|
||||||
harness.wizard = wizard({ importFolder: "/home/max/Relevés" });
|
harness.wizard = wizard({ importFolder: "/home/max/Relevés" });
|
||||||
harness.onboarding = path(ZERO, true);
|
harness.onboarding = path(ZERO, true);
|
||||||
const html = render();
|
const html = render();
|
||||||
|
|
@ -158,6 +160,7 @@ describe("ImportPage — the step card (#398)", () => {
|
||||||
"t(onboarding.card.import.noSources[folder=Relevés,refresh=t(import.empty.refresh),sample=t(import.empty.createSample)])",
|
"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)");
|
expect(highlighted(html).inner).toBe("t(import.empty.createSample)");
|
||||||
|
expect(html).toContain("t(import.empty.structure.title)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sources found: the first source card", () => {
|
it("sources found: the first source card", () => {
|
||||||
|
|
@ -171,11 +174,12 @@ describe("ImportPage — the step card (#398)", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["source-config", "sourceConfig", "import.wizard.preview"],
|
["source-config", "sourceConfig", "import.wizard.preview", ""],
|
||||||
["file-preview", "filePreview", "import.wizard.checkDuplicates"],
|
// The preview names "Flip the signs" too (#422).
|
||||||
["duplicate-check", "duplicateCheck", "import.wizard.confirm"],
|
["file-preview", "filePreview", "import.wizard.checkDuplicates", ",flip=t(import.preview.flipSigns)"],
|
||||||
["confirm", "confirm", "import.wizard.import"],
|
["duplicate-check", "duplicateCheck", "import.wizard.confirm", ""],
|
||||||
] as const)("%s: the next button, the one the sentence names", (step, text, label) => {
|
["confirm", "confirm", "import.wizard.import", ""],
|
||||||
|
] as const)("%s: the next button, the one the sentence names", (step, text, label, more) => {
|
||||||
harness.wizard = wizard({
|
harness.wizard = wizard({
|
||||||
step,
|
step,
|
||||||
importFolder: "/home/max/Relevés",
|
importFolder: "/home/max/Relevés",
|
||||||
|
|
@ -186,7 +190,7 @@ describe("ImportPage — the step card (#398)", () => {
|
||||||
harness.onboarding = path({ ...ZERO, sources: 0 }, true);
|
harness.onboarding = path({ ...ZERO, sources: 0 }, true);
|
||||||
const html = render();
|
const html = render();
|
||||||
expect(html).toContain(`data-stub="${step === "confirm" ? "confirmation" : step}"`);
|
expect(html).toContain(`data-stub="${step === "confirm" ? "confirmation" : step}"`);
|
||||||
expect(sentence(html)).toBe(`t(onboarding.card.import.${text}[button=t(${label})])`);
|
expect(sentence(html)).toBe(`t(onboarding.card.import.${text}[button=t(${label})${more}])`);
|
||||||
const target = highlighted(html);
|
const target = highlighted(html);
|
||||||
expect(target.tag).toBe("button");
|
expect(target.tag).toBe("button");
|
||||||
expect(target.inner).toContain(`t(${label})`);
|
expect(target.inner).toContain(`t(${label})`);
|
||||||
|
|
|
||||||
47
src/utils/onboardingPath.locales.test.ts
Normal file
47
src/utils/onboardingPath.locales.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
// The Import step card's sentences, as the locale files write them (#422).
|
||||||
|
//
|
||||||
|
// A sentence names a control through an interpolation (`StepCardText.labels`,
|
||||||
|
// translated by `StepCard`); the label then reads between quotation marks,
|
||||||
|
// « » in French and “ ” in English (D11), so the reader tells the button's
|
||||||
|
// name from the sentence around it. The folder name of `noSources` is quoted
|
||||||
|
// the same way. These tests hold the real files to that rule.
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import fr from "../i18n/locales/fr.json";
|
||||||
|
import en from "../i18n/locales/en.json";
|
||||||
|
|
||||||
|
type Cards = Record<string, string>;
|
||||||
|
|
||||||
|
const IMPORT_CARDS: Record<"fr" | "en", Cards> = {
|
||||||
|
fr: (fr as { onboarding: { card: { import: Cards } } }).onboarding.card.import,
|
||||||
|
en: (en as { onboarding: { card: { import: Cards } } }).onboarding.card.import,
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUOTED: Record<"fr" | "en", (name: string) => RegExp> = {
|
||||||
|
fr: (name) => new RegExp(`« \\{\\{${name}\\}\\} »`),
|
||||||
|
en: (name) => new RegExp(`“\\{\\{${name}\\}\\}”`),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The names of the plain interpolations of a sentence (`{{name}}`). */
|
||||||
|
function interpolations(sentence: string): string[] {
|
||||||
|
return [...sentence.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Import step card sentences quote every control they name (#422)", () => {
|
||||||
|
it.each(["fr", "en"] as const)("%s: each interpolation sits between quotation marks", (lang) => {
|
||||||
|
const cards = IMPORT_CARDS[lang];
|
||||||
|
let named = 0;
|
||||||
|
for (const [key, sentence] of Object.entries(cards)) {
|
||||||
|
for (const name of interpolations(sentence)) {
|
||||||
|
named += 1;
|
||||||
|
expect(sentence, `onboarding.card.import.${key}: ${name}`).toMatch(QUOTED[lang](name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// noFolder 1, noSources 3, sourceConfig 1, filePreview 2, duplicateCheck 1, confirm 1.
|
||||||
|
expect(named).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["fr", "en"] as const)("%s: the preview names the flip and the next button", (lang) => {
|
||||||
|
expect(interpolations(IMPORT_CARDS[lang].filePreview).sort()).toEqual(["button", "flip"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -481,8 +481,12 @@ describe("importCardModel (#398)", () => {
|
||||||
|
|
||||||
const model = (input: Partial<ImportCardInput>, checklist: Checklist | null = fresh) =>
|
const model = (input: Partial<ImportCardInput>, checklist: Checklist | null = fresh) =>
|
||||||
importCardModel({ ...AT_REST, ...input }, checklist);
|
importCardModel({ ...AT_REST, ...input }, checklist);
|
||||||
const wizardNext = (text: string, button: string): ImportCardModel => ({
|
const wizardNext = (
|
||||||
text: { key: `onboarding.card.import.${text}`, labels: { button } },
|
text: string,
|
||||||
|
button: string,
|
||||||
|
more: Record<string, string> = {},
|
||||||
|
): ImportCardModel => ({
|
||||||
|
text: { key: `onboarding.card.import.${text}`, labels: { button, ...more } },
|
||||||
done: false,
|
done: false,
|
||||||
target: "wizardNext",
|
target: "wizardNext",
|
||||||
});
|
});
|
||||||
|
|
@ -540,7 +544,7 @@ describe("importCardModel (#398)", () => {
|
||||||
expect(importCardModel(NEW, imported)).toEqual(noFolder);
|
expect(importCardModel(NEW, imported)).toEqual(noFolder);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("6. a folder without bank sub-folder: create them, or the sample folder", () => {
|
it("6. a folder without account sub-folder: create them, or the sample folder", () => {
|
||||||
expect(model({ sourceCount: 0 })).toEqual({
|
expect(model({ sourceCount: 0 })).toEqual({
|
||||||
text: {
|
text: {
|
||||||
key: "onboarding.card.import.noSources",
|
key: "onboarding.card.import.noSources",
|
||||||
|
|
@ -561,9 +565,15 @@ describe("importCardModel (#398)", () => {
|
||||||
expect(model({ wizardStep: "source-config" })).toEqual(
|
expect(model({ wizardStep: "source-config" })).toEqual(
|
||||||
wizardNext("sourceConfig", "import.wizard.preview"),
|
wizardNext("sourceConfig", "import.wizard.preview"),
|
||||||
);
|
);
|
||||||
|
// The preview also names "Flip the signs" (#422): an expense must read as
|
||||||
|
// an outflow, and the flip is the fix when it does not.
|
||||||
expect(model({ wizardStep: "file-preview" })).toEqual(
|
expect(model({ wizardStep: "file-preview" })).toEqual(
|
||||||
wizardNext("filePreview", "import.wizard.checkDuplicates"),
|
wizardNext("filePreview", "import.wizard.checkDuplicates", { flip: "import.preview.flipSigns" }),
|
||||||
);
|
);
|
||||||
|
expect(model({ wizardStep: "file-preview" })?.text?.labels).toEqual({
|
||||||
|
button: "import.wizard.checkDuplicates",
|
||||||
|
flip: "import.preview.flipSigns",
|
||||||
|
});
|
||||||
expect(model({ wizardStep: "duplicate-check" })).toEqual(
|
expect(model({ wizardStep: "duplicate-check" })).toEqual(
|
||||||
wizardNext("duplicateCheck", "import.wizard.confirm"),
|
wizardNext("duplicateCheck", "import.wizard.confirm"),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -260,15 +260,25 @@ function importText(name: string, rest: Omit<StepCardText, "key"> = {}): StepCar
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The wizard steps that end on its next button: their sentence and the label
|
* The wizard steps that end on its next button: their sentence and the labels
|
||||||
* of that button, the one `ImportPage` gives the button at that step.
|
* of the controls it names. `button` is the label of that next button, the one
|
||||||
|
* `ImportPage` gives it at that step; the preview also names "Flip the signs"
|
||||||
|
* (#422), the fix when an expense does not read as an outflow.
|
||||||
*/
|
*/
|
||||||
const WIZARD_ROWS = {
|
const WIZARD_ROWS: Readonly<
|
||||||
"source-config": { text: "sourceConfig", button: "import.wizard.preview" },
|
Record<
|
||||||
"file-preview": { text: "filePreview", button: "import.wizard.checkDuplicates" },
|
"source-config" | "file-preview" | "duplicate-check" | "confirm",
|
||||||
"duplicate-check": { text: "duplicateCheck", button: "import.wizard.confirm" },
|
{ text: string; labels: Readonly<Record<string, string>> }
|
||||||
confirm: { text: "confirm", button: "import.wizard.import" },
|
>
|
||||||
} as const;
|
> = {
|
||||||
|
"source-config": { text: "sourceConfig", labels: { button: "import.wizard.preview" } },
|
||||||
|
"file-preview": {
|
||||||
|
text: "filePreview",
|
||||||
|
labels: { button: "import.wizard.checkDuplicates", flip: "import.preview.flipSigns" },
|
||||||
|
},
|
||||||
|
"duplicate-check": { text: "duplicateCheck", labels: { button: "import.wizard.confirm" } },
|
||||||
|
confirm: { text: "confirm", labels: { button: "import.wizard.import" } },
|
||||||
|
};
|
||||||
|
|
||||||
/** The checklist marks the step done (a skipped step is not done). */
|
/** The checklist marks the step done (a skipped step is not done). */
|
||||||
function checked(checklist: Checklist | null, key: ChecklistItemKey): boolean {
|
function checked(checklist: Checklist | null, key: ChecklistItemKey): boolean {
|
||||||
|
|
@ -291,7 +301,7 @@ function checked(checklist: Checklist | null, key: ChecklistItemKey): boolean {
|
||||||
* checked, outside the wizard"): placed after the rows that cover every
|
* 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
|
* 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";
|
* 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;
|
* 6. a folder without account sub-folder: create them, or the sample folder;
|
||||||
* 7. the wizard: the first source card, then its next button at each step.
|
* 7. the wizard: the first source card, then its next button at each step.
|
||||||
*/
|
*/
|
||||||
export function importCardModel(
|
export function importCardModel(
|
||||||
|
|
@ -333,7 +343,7 @@ export function importCardModel(
|
||||||
}
|
}
|
||||||
const row = WIZARD_ROWS[step];
|
const row = WIZARD_ROWS[step];
|
||||||
return {
|
return {
|
||||||
text: importText(row.text, { labels: { button: row.button } }),
|
text: importText(row.text, { labels: row.labels }),
|
||||||
done: false,
|
done: false,
|
||||||
target: "wizardNext",
|
target: "wizardNext",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue