fix(import): name a source after its subfolder, read-only (#426) #441
7 changed files with 126 additions and 21 deletions
|
|
@ -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">
|
||||
|
|
|
|||
27
src/components/import/SourceNameDisplay.test.tsx
Normal file
27
src/components/import/SourceNameDisplay.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
26
src/components/import/SourceNameDisplay.tsx
Normal file
26
src/components/import/SourceNameDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue