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 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-13 21:05:46 -04:00
parent 2de4d9d255
commit ed3a4bb9a0
7 changed files with 126 additions and 21 deletions

View file

@ -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({
)}
</div>
{/* Source name */}
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.sourceName")}
</label>
<input
type="text"
value={config.name}
onChange={(e) => updateConfig({ name: e.target.value })}
className={inputClass}
/>
</div>
{/* Source name: the subfolder name, read-only (#426) */}
<SourceNameDisplay folderName={source.folder_name} />
{/* Basic settings */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">

View file

@ -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(<SourceNameDisplay folderName={folderName} />);
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("<input");
expect(html).not.toContain("<textarea");
expect(html).not.toContain("contenteditable");
});
});

View file

@ -0,0 +1,26 @@
import { useTranslation } from "react-i18next";
interface SourceNameDisplayProps {
/** Name of the source subfolder, which is the source name (#426). */
folderName: string;
}
/**
* Read-only source name in the source configuration (#426). A source is
* looked up by its subfolder name on the next import, so its name is that
* of the subfolder and cannot be edited.
*/
export default function SourceNameDisplay({ folderName }: SourceNameDisplayProps) {
const { t } = useTranslation();
return (
<div>
<span className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.sourceName")}
</span>
<p className="text-sm font-medium text-[var(--foreground)] break-words">{folderName}</p>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
{t("import.config.sourceNameHint")}
</p>
</div>
);
}

View file

@ -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();
});
});

View file

@ -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,

View file

@ -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": {

View file

@ -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": {