From ed3a4bb9a08ea5c195d3d0a2c29f0e2cb03dd8f6 Mon Sep 17 00:00:00 2001 From: le king fu Date: Tue, 13 Oct 2026 21:05:46 -0400 Subject: [PATCH] fix(import): name a source after its subfolder, read-only The source name typed during configuration was saved, but the next import looks the source and its imported files up by the subfolder name, so a renamed source looked new: format guessed again, every file pre-checked, and a second source created under the folder name. The name field becomes a read-only SourceNameDisplay showing the subfolder name with a hint, and executeImport writes selectedSource.folder_name on create and update, whatever config.name holds, behind a selectedSource guard. The import card sentence no longer asks to check the source name. Co-Authored-By: Claude Opus 5.5 --- src/components/import/SourceConfigPanel.tsx | 15 +---- .../import/SourceNameDisplay.test.tsx | 27 +++++++++ src/components/import/SourceNameDisplay.tsx | 26 +++++++++ .../useImportWizard.executeImport.test.ts | 55 ++++++++++++++++++- src/hooks/useImportWizard.ts | 12 +++- src/i18n/locales/en.json | 6 +- src/i18n/locales/fr.json | 6 +- 7 files changed, 126 insertions(+), 21 deletions(-) create mode 100644 src/components/import/SourceNameDisplay.test.tsx create mode 100644 src/components/import/SourceNameDisplay.tsx 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": {