diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index eab75a5..196bec4 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -18,6 +18,7 @@ - Le petit badge à côté du bouton « Récupérer le prix » d'un titre boursier, dans le Bilan, affiche désormais « non garanti » au lieu de l'anglais « best-effort » (#389). - Les installations faites depuis le paquet `.rpm` reçoivent désormais les mises à jour automatiques. Le manifeste de mise à jour ne listait que le `.deb`, qu'une installation `.rpm` téléchargeait puis refusait ; il liste maintenant chaque paquet Linux sous sa propre clé. Les installations `.rpm` existantes n'ont rien à réinstaller : elles recevront la prochaine version (vérifié sur le paquet v0.17.1 publié). Les instructions de téléchargement de la page de release ne mentionnent plus d'AppImage, qui n'est pas construite, et nomment désormais le `.rpm`. Le manifeste n'a plus d'entrée Linux générique : les AppImage des versions 0.4.3 à 0.4.5, qui ne savaient pas installer le `.deb` qu'elle désignait et en étaient cassées, affichent désormais une erreur à la vérification des mises à jour ; installez plutôt le paquet `.deb` ou `.rpm` (#320). - Budget : une sous-catégorie dépliée s'affiche sous sa catégorie parente, et non plus en fin de groupe. Avec les sous-totaux en bas, chaque sous-total suit ses propres lignes, dans la grille Budget comme dans les rapports Budget vs réel et Comparables (#424). +- Import : le nom d'une source est celui de son sous-dossier, en lecture seule ; une source renommée à la configuration n'est plus prise pour une nouvelle source au prochain import (#426). ### Sécurité diff --git a/CHANGELOG.md b/CHANGELOG.md index 6abeaa8..38aef71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - In French, the small badge next to the "Fetch price" button of a stock in the Balance sheet now reads "non garanti" instead of the English "best-effort" (#389). - Installs from the `.rpm` package now receive automatic updates. The update manifest only listed the `.deb`, which an `.rpm` install downloaded and then refused; it now lists each Linux package under its own key. Existing `.rpm` installs need no reinstall: they will receive the next version (checked on the published v0.17.1 package). The download instructions of the release page no longer mention an AppImage, which is not built, and now name the `.rpm`. The manifest no longer has a generic Linux entry: the AppImage builds of 0.4.3 to 0.4.5, which could not install the `.deb` it pointed to and were broken by it, now report an error when checking for updates instead; install the `.deb` or `.rpm` package (#320). - Budget: an expanded subcategory now shows under its parent category instead of at the end of the group. With subtotals at the bottom, each subtotal follows its own rows, in the Budget grid as in the Budget vs Actual and Compare reports (#424). +- Import: a source is named after its subfolder, read-only; a source renamed during configuration is no longer taken for a new source at the next import (#426). ### Security diff --git a/docs/architecture.md b/docs/architecture.md index 5202796..ff0fdaf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,7 +32,7 @@ simpl-resultat/ │ │ ├── budget/ # 5 composants │ │ ├── categories/ # 5 composants │ │ ├── dashboard/ # 7 composants (dont DashboardEmptyState et OnboardingChecklist) -│ │ ├── import/ # 16 composants (wizard d'import, dont FormatDriftPanel, RepairPathNotice, ImportEmptyState et ImportFolderStructure) +│ │ ├── import/ # 18 composants (wizard d'import, dont FormatDriftPanel, RepairPathNotice, ImportEmptyState, ImportFolderStructure, RootFilesWarning et SourceNameDisplay) │ │ ├── layout/ # AppShell, Sidebar │ │ ├── onboarding/ # OnboardingBanner (bandeau de démarrage, #397), StepCard et OnboardingStepCard (carte d'étape, #398) │ │ ├── profile/ # 3 composants (PIN, formulaire, switcher) @@ -235,7 +235,7 @@ Chaque hook encapsule la logique d'état via `useReducer` : | `useCategories` | Catégories avec hiérarchie | | `useTransactions` | Transactions et filtrage. `useTransactions(initialFilters?)` part de filtres de départ posés sur les filtres par défaut et lus au montage seulement (`initTransactionsState`) : l'entrée guidée de l'étape « Classer une transaction à la main » passe `GUIDED_ENTRY_FILTERS` (non catégorisées, sans borne de date). Si sa première page revient vide, le reducer retire le filtre « non catégorisées » une seule fois (`guidedFallback`) et la page relit (#399) | | `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). 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). Expose aussi `rootFiles`, les CSV/TXT posés à la racine du dossier d'import que le scan (`ImportFolderScan`) rend à côté des sources : posé avec elles par la même action `SET_SCANNED_SOURCES`, gardé par `RESET` comme elles, nommé à l'écran par `RootFilesWarning` (au plus cinq noms, `formatRootFiles`) et jamais écrit au Journal (#427) | +| `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). Expose aussi `rootFiles`, les CSV/TXT posés à la racine du dossier d'import que le scan (`ImportFolderScan`) rend à côté des sources : posé avec elles par la même action `SET_SCANNED_SOURCES`, gardé par `RESET` comme elles, nommé à l'écran par `RootFilesWarning` (au plus cinq noms, `formatRootFiles`) et jamais écrit au Journal (#427). `executeImport` enregistre la source sous `selectedSource.folder_name`, à la création comme à la mise à jour, jamais sous `config.name` : la source et ses fichiers importés sont retrouvés par le nom du sous-dossier, que `SourceNameDisplay` affiche en lecture seule (#426) | | `useImportHistory` | Historique des imports | | `useAdjustments` | Ajustements | | `useBudget` | Budget | diff --git a/docs/guide-utilisateur.md b/docs/guide-utilisateur.md index 8f39df4..dcbad13 100644 --- a/docs/guide-utilisateur.md +++ b/docs/guide-utilisateur.md @@ -223,6 +223,7 @@ L'historique téléchargeable est limité à **123 jours** au maximum, et à env - Vous pouvez supprimer un import de l'historique pour retirer toutes ses transactions - Vos sources et vos modèles d'import font désormais partie de la sauvegarde chiffrée : restaurer un backup ne vous oblige plus à tout reconfigurer - Si des fichiers CSV sont posés directement dans le dossier d'import, un avertissement les nomme : déplacez-les dans un sous-dossier par compte +- Le nom d'une source est celui de son sous-dossier --- diff --git a/docs/qa-onboarding.html b/docs/qa-onboarding.html index d1aef03..585f9ce 100644 --- a/docs/qa-onboarding.html +++ b/docs/qa-onboarding.html @@ -291,7 +291,11 @@ { id: "v0171-import-root-files-no-source", title: "Import : trois fichiers à la racine, aucune source (#427)", expect: "Dans un dossier sans sous-dossier mais avec trois CSV à la racine, « Actualiser » : l'état vide « Aucune source trouvée » s'affiche, et sous lui l'avertissement au pluriel nomme les trois fichiers, séparés par des virgules. Avec sept fichiers, il en nomme cinq puis « et 2 autres ». En anglais : “… sit directly in “” and are ignored: … and 2 more.”" }, { id: "v0171-import-root-files-lifecycle", title: "Import : l'avertissement suit le dossier (#427)", - expect: "Déplace les fichiers de la racine dans un sous-dossier puis « Actualiser » : l'avertissement disparaît. Remets-en un, « Actualiser » : il revient. Ouvre une source puis « Annuler » (ou va au bout d'un import puis « Terminé ») : l'avertissement est toujours là. Dans Paramètres → Systèmes → Journal, aucune ligne ne contient le nom d'un de ces fichiers ni le chemin du dossier." } + expect: "Déplace les fichiers de la racine dans un sous-dossier puis « Actualiser » : l'avertissement disparaît. Remets-en un, « Actualiser » : il revient. Ouvre une source puis « Annuler » (ou va au bout d'un import puis « Terminé ») : l'avertissement est toujours là. Dans Paramètres → Systèmes → Journal, aucune ligne ne contient le nom d'un de ces fichiers ni le chemin du dossier." }, + { id: "v0171-import-source-name", title: "Import : le nom de la source est celui de son sous-dossier (#426)", + expect: "Ouvre une source : la configuration affiche sous « Nom de la source » le nom du sous-dossier, en texte, sans champ de saisie, et dessous « Le nom de la source est celui de son sous-dossier. ». La carte de démarrage dit « Vérifiez les fichiers cochés, puis cliquez « Aperçu ». » et ne parle plus du nom. En anglais : “A source is named after its subfolder.” et “Check the selected files, then click “Preview”.”" }, + { id: "v0171-import-source-reopen", title: "Import : la source est retrouvée au prochain import (#426)", + expect: "Importe un fichier d'une source, puis « Terminé » et rouvre la même source : aucun bandeau de détection (le format enregistré est restauré tel quel), le fichier déjà importé porte le badge « Importé » et n'est pas coché. Sur une base créée avant ce correctif, une source déjà renommée garde ce défaut (hors périmètre)." } ] }, { diff --git a/src/components/import/SourceConfigPanel.test.tsx b/src/components/import/SourceConfigPanel.test.tsx new file mode 100644 index 0000000..db41129 --- /dev/null +++ b/src/components/import/SourceConfigPanel.test.tsx @@ -0,0 +1,44 @@ +// SourceConfigPanel — the source name is read-only, the subfolder's (#426). +// No jsdom: rendered to static markup with the shared marked `t`. +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock()); + +import SourceConfigPanel from "./SourceConfigPanel"; +import { initialWizardState } from "../../hooks/useImportWizard"; + +const noop = () => undefined; + +const render = () => + renderToStaticMarkup( + , + ); + +describe("SourceConfigPanel — source name (#426)", () => { + it("shows the subfolder name with its hint", () => { + const html = render(); + expect(html).toContain("t(import.config.sourceNameHint)"); + expect(html).toMatch(/]*>Desjardins<\/p>/); + }); + + it("has no field holding the configured name", () => { + const html = render(); + expect(html).not.toContain("Mon compte chèques"); + }); +}); diff --git a/src/components/import/SourceConfigPanel.tsx b/src/components/import/SourceConfigPanel.tsx index 0d81fd6..82a9ebd 100644 --- a/src/components/import/SourceConfigPanel.tsx +++ b/src/components/import/SourceConfigPanel.tsx @@ -15,6 +15,7 @@ import { type BankSignatureId, } from "../../utils/bankSignatures"; import ColumnMappingEditor from "./ColumnMappingEditor"; +import SourceNameDisplay from "./SourceNameDisplay"; interface SourceConfigPanelProps { source: ScannedSource; @@ -249,18 +250,8 @@ export default function SourceConfigPanel({ )} - {/* Source name */} -
- - updateConfig({ name: e.target.value })} - className={inputClass} - /> -
+ {/* Source name: the subfolder name, read-only (#426) */} + {/* Basic settings */}
diff --git a/src/components/import/SourceNameDisplay.test.tsx b/src/components/import/SourceNameDisplay.test.tsx new file mode 100644 index 0000000..1769323 --- /dev/null +++ b/src/components/import/SourceNameDisplay.test.tsx @@ -0,0 +1,27 @@ +// SourceNameDisplay (#426). No jsdom: rendered to static markup with the shared +// marked `t`, so the label and hint keys are read as written. +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock()); + +import SourceNameDisplay from "./SourceNameDisplay"; + +const render = (folderName: string) => + renderToStaticMarkup(); + +describe("SourceNameDisplay (#426)", () => { + it("shows the subfolder name, the label and the hint", () => { + const html = render("Desjardins chèques"); + expect(html).toContain("Desjardins chèques"); + expect(html).toContain("t(import.config.sourceName)"); + expect(html).toContain("t(import.config.sourceNameHint)"); + }); + + it("has no input field: the name cannot be edited", () => { + const html = render("Tangerine"); + expect(html).not.toContain(" + + {t("import.config.sourceName")} + +

{folderName}

+

+ {t("import.config.sourceNameHint")} +

+
+ ); +} diff --git a/src/hooks/useImportWizard.executeImport.test.ts b/src/hooks/useImportWizard.executeImport.test.ts index abb04a8..02267cf 100644 --- a/src/hooks/useImportWizard.executeImport.test.ts +++ b/src/hooks/useImportWizard.executeImport.test.ts @@ -64,10 +64,10 @@ vi.mock("../services/importConfigTemplateService", () => ({ import { useImportWizard, initialWizardState, type WizardState } from "./useImportWizard"; import { notifyDataChanged } from "../services/dataEvents"; -import { createSource } from "../services/importSourceService"; +import { createSource, updateSource } from "../services/importSourceService"; import { createImportedFile, updateFileStatus } from "../services/importedFileService"; import { insertBatch } from "../services/transactionService"; -import type { ParsedRow } from "../shared/types"; +import type { ImportSource, ParsedRow } from "../shared/types"; const rows: ParsedRow[] = [1, 2].map((i) => ({ rowIndex: i, @@ -81,6 +81,13 @@ const readyToImport = (): WizardState => ({ ...initialWizardState, step: "confirm", sourceConfig: { ...initialWizardState.sourceConfig, name: "Desjardins" }, + selectedSource: { + folder_name: "Desjardins", + folder_path: "/releves/Desjardins", + files: [ + { filename: "releve.csv", file_path: "/releves/Desjardins/releve.csv", size_bytes: 10, modified_at: "" }, + ], + }, selectedFiles: [ { filename: "releve.csv", file_path: "/releves/Desjardins/releve.csv", size_bytes: 10, modified_at: "" }, ], @@ -96,6 +103,7 @@ const order = (mock: unknown): number => vi.mocked(mock as () => void).mock.invo beforeEach(() => { vi.mocked(notifyDataChanged).mockClear(); vi.mocked(createSource).mockClear(); + vi.mocked(updateSource).mockClear(); vi.mocked(createImportedFile).mockClear(); vi.mocked(updateFileStatus).mockClear(); vi.mocked(insertBatch).mockClear(); @@ -142,3 +150,46 @@ describe("executeImport — final data-change notification (#396)", () => { expect(notifyDataChanged).not.toHaveBeenCalled(); }); }); + +describe("executeImport — a source is named after its subfolder (#426)", () => { + /** A name typed in the configuration that differs from the subfolder. */ + const renamed = (): WizardState => { + const base = readyToImport(); + return { ...base, sourceConfig: { ...base.sourceConfig, name: "Mon compte chèques" } }; + }; + + it("creates a new source under the subfolder name, not the configured name", async () => { + harness.state = renamed(); + + await useImportWizard().executeImport(); + + expect(createSource).toHaveBeenCalledTimes(1); + expect(vi.mocked(createSource).mock.calls[0][0]).toMatchObject({ name: "Desjardins" }); + expect(updateSource).not.toHaveBeenCalled(); + }); + + it("updates an existing source under the subfolder name, not the configured name", async () => { + harness.state = { + ...renamed(), + existingSource: { id: 3, name: "Desjardins" } as ImportSource, + }; + + await useImportWizard().executeImport(); + + expect(updateSource).toHaveBeenCalledTimes(1); + expect(vi.mocked(updateSource).mock.calls[0][0]).toBe(3); + expect(vi.mocked(updateSource).mock.calls[0][1]).toMatchObject({ name: "Desjardins" }); + expect(createSource).not.toHaveBeenCalled(); + }); + + it("writes nothing without a selected source", async () => { + harness.state = { ...renamed(), selectedSource: null }; + + await useImportWizard().executeImport(); + + expect(createSource).not.toHaveBeenCalled(); + expect(updateSource).not.toHaveBeenCalled(); + expect(insertBatch).not.toHaveBeenCalled(); + expect(harness.dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useImportWizard.ts b/src/hooks/useImportWizard.ts index bc66415..0ebfb10 100644 --- a/src/hooks/useImportWizard.ts +++ b/src/hooks/useImportWizard.ts @@ -1034,7 +1034,7 @@ export function useImportWizard() { }, [state.sourceConfig, parseFilesInternal]); const executeImport = useCallback(async () => { - if (!state.duplicateResult) return; + if (!state.duplicateResult || !state.selectedSource) return; dispatch({ type: "SET_STEP", payload: "importing" }); dispatch({ type: "SET_ERROR", payload: null }); @@ -1058,18 +1058,23 @@ export function useImportWizard() { config.hasHeader ? state.previewHeaders : null ); + // A source is named after its subfolder (#426): the next import looks it + // up, and its imported files, by `folder_name`, so a name typed during + // configuration would make it look new. `config.name` is never written. + const sourceName = state.selectedSource.folder_name; + let sourceId: number; if (state.existingSource) { sourceId = state.existingSource.id; await updateSource(sourceId, { - name: config.name, + name: sourceName, ...formatRow, header_signature: headerSignature, template_id: state.selectedTemplateId, }); } else { sourceId = await createSource({ - name: config.name, + name: sourceName, ...formatRow, header_signature: headerSignature, template_id: state.selectedTemplateId, @@ -1205,6 +1210,7 @@ export function useImportWizard() { } }, [ state.duplicateResult, + state.selectedSource, state.sourceConfig, state.existingSource, state.selectedTemplateId, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2f3693d..2729abb 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -83,7 +83,7 @@ "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 account and drop its CSV statements in it, then click “{{refresh}}”. To try first, “{{sample}}” adds two fictitious accounts.", "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 selected files, 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}}”.", "confirm": "Click “{{button}}”: the source is saved and your transactions come in.", @@ -146,6 +146,7 @@ "config": { "title": "Source Configuration", "sourceName": "Source name", + "sourceNameHint": "A source is named after its subfolder.", "delimiter": "Delimiter", "semicolon": "Semicolon", "comma": "Comma", @@ -1097,7 +1098,8 @@ "Files already imported are marked with a badge — re-importing them will trigger duplicate detection", "You can delete an import from the history to remove all its transactions", "Your import sources and templates are now part of the encrypted backup: restoring a backup no longer forces you to reconfigure everything", - "If CSV files sit directly in the import folder, a warning names them: move them into a subfolder per account" + "If CSV files sit directly in the import folder, a warning names them: move them into a subfolder per account", + "A source is named after its subfolder" ] }, "transactions": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 4f32bc2..0cad0a1 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -83,7 +83,7 @@ "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 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 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 les fichiers cochés, 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}} ».", "confirm": "Cliquez « {{button}} » : la source est enregistrée et vos transactions arrivent.", @@ -146,6 +146,7 @@ "config": { "title": "Configuration de la source", "sourceName": "Nom de la source", + "sourceNameHint": "Le nom de la source est celui de son sous-dossier.", "delimiter": "Délimiteur", "semicolon": "Point-virgule", "comma": "Virgule", @@ -1097,7 +1098,8 @@ "Les fichiers déjà importés sont marqués d'un badge — les ré-importer déclenchera la détection de doublons", "Vous pouvez supprimer un import de l'historique pour retirer toutes ses transactions", "Vos sources et vos modèles d'import font désormais partie de la sauvegarde chiffrée : restaurer un backup ne vous oblige plus à tout reconfigurer", - "Si des fichiers CSV sont posés directement dans le dossier d'import, un avertissement les nomme : déplacez-les dans un sous-dossier par compte" + "Si des fichiers CSV sont posés directement dans le dossier d'import, un avertissement les nomme : déplacez-les dans un sous-dossier par compte", + "Le nom d'une source est celui de son sous-dossier" ] }, "transactions": {