diff --git a/frontend/src/components/ImportPreviewModal.test.tsx b/frontend/src/components/ImportPreviewModal.test.tsx index 7d6e45ca..16564ec4 100644 --- a/frontend/src/components/ImportPreviewModal.test.tsx +++ b/frontend/src/components/ImportPreviewModal.test.tsx @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ previewImportFile: vi.fn(), dbGetColumns: vi.fn(), importDataWithProgressOptions: vi.fn(), + cancelQuery: vi.fn(), progressHandler: null as ((data: any) => void) | null, eventsOn: vi.fn((_event: string, handler: (data: any) => void) => { mocks.progressHandler = handler; @@ -47,6 +48,7 @@ vi.mock("../../wailsjs/go/app/App", () => ({ PreviewImportFile: mocks.previewImportFile, DBGetColumns: mocks.dbGetColumns, ImportDataWithProgressOptions: mocks.importDataWithProgressOptions, + CancelQuery: mocks.cancelQuery, })); vi.mock("../../wailsjs/runtime/runtime", () => ({ @@ -144,6 +146,7 @@ vi.mock("@ant-design/icons", async () => { return { CheckCircleOutlined: Icon, CloseCircleOutlined: Icon, + StopOutlined: Icon, }; }); @@ -220,6 +223,8 @@ describe("ImportPreviewModal i18n", () => { ], }); mocks.importDataWithProgressOptions.mockReset(); + mocks.cancelQuery.mockReset(); + mocks.cancelQuery.mockResolvedValue({ success: true }); mocks.progressHandler = null; mocks.eventsOn.mockClear(); mocks.eventsOff.mockClear(); @@ -496,6 +501,160 @@ describe("ImportPreviewModal i18n", () => { expect(textContent(renderer.toJSON())).toContain("Row 12: duplicate key"); }); + it("stops an active import by its job id and preserves the partial result", async () => { + let resolveImport!: (value: any) => void; + mocks.importDataWithProgressOptions.mockImplementation( + () => new Promise((resolve) => { + resolveImport = resolve; + }), + ); + const renderer = await renderImportPreview(); + const startButton = renderer.root + .findAllByType("button") + .find((node) => textContent(node.props.children) === "Start import"); + + await act(async () => { + startButton?.props.onClick(); + await Promise.resolve(); + }); + + const importJobId = mocks.importDataWithProgressOptions.mock.calls[0][4].jobId; + const stopButton = renderer.root + .findAllByType("button") + .find((node) => textContent(node.props.children) === "Stop import"); + expect(stopButton).toBeDefined(); + + await act(async () => { + stopButton?.props.onClick(); + stopButton?.props.onClick(); + await Promise.resolve(); + }); + expect(mocks.cancelQuery).toHaveBeenCalledTimes(1); + expect(mocks.cancelQuery).toHaveBeenCalledWith(importJobId); + + await act(async () => { + resolveImport({ + success: false, + message: "Import stopped", + data: { + success: 10, + failed: 2, + total: 12, + errorLogs: ["Row 11: duplicate key", "Row 12: duplicate key"], + cancelled: true, + }, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + const renderedText = textContent(renderer.toJSON()); + expect(renderedText).toContain("Import stopped"); + expect(renderedText).toContain("Successfully imported 10 rows"); + expect(renderedText).toContain("Failed 2 rows"); + }); + + it("ignores a late stop failure after the import already completed", async () => { + let resolveImport!: (value: any) => void; + let resolveCancel!: (value: any) => void; + mocks.importDataWithProgressOptions.mockImplementation( + () => new Promise((resolve) => { + resolveImport = resolve; + }), + ); + mocks.cancelQuery.mockImplementation( + () => new Promise((resolve) => { + resolveCancel = resolve; + }), + ); + const renderer = await renderImportPreview(); + const startButton = renderer.root + .findAllByType("button") + .find((node) => textContent(node.props.children) === "Start import"); + + await act(async () => { + startButton?.props.onClick(); + await Promise.resolve(); + }); + const stopButton = renderer.root + .findAllByType("button") + .find((node) => textContent(node.props.children) === "Stop import"); + await act(async () => { + stopButton?.props.onClick(); + await Promise.resolve(); + }); + + await act(async () => { + resolveImport({ + success: true, + data: { success: 12, failed: 0, total: 12, errorLogs: [] }, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + await act(async () => { + resolveCancel({ success: false, message: "No running query" }); + await Promise.resolve(); + }); + + const renderedText = textContent(renderer.toJSON()); + expect(renderedText).toContain("Import completed"); + expect(renderedText).not.toContain("No running query"); + }); + + it("clears an earlier stop failure when stop is retried", async () => { + let resolveImport!: (value: any) => void; + mocks.importDataWithProgressOptions.mockImplementation( + () => new Promise((resolve) => { + resolveImport = resolve; + }), + ); + mocks.cancelQuery.mockResolvedValue({ success: false, message: "No running query" }); + const renderer = await renderImportPreview(); + const startButton = renderer.root + .findAllByType("button") + .find((node) => textContent(node.props.children) === "Start import"); + + await act(async () => { + startButton?.props.onClick(); + await Promise.resolve(); + }); + const stopButton = renderer.root + .findAllByType("button") + .find((node) => textContent(node.props.children) === "Stop import"); + await act(async () => { + stopButton?.props.onClick(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(textContent(renderer.toJSON())).toContain("No running query"); + + mocks.cancelQuery.mockResolvedValue({ success: true }); + const retryStopButton = renderer.root + .findAllByType("button") + .find((node) => textContent(node.props.children) === "Stop import"); + await act(async () => { + retryStopButton?.props.onClick(); + await Promise.resolve(); + }); + expect(mocks.cancelQuery).toHaveBeenCalledTimes(2); + expect(textContent(renderer.toJSON())).not.toContain("No running query"); + + await act(async () => { + resolveImport({ + success: false, + message: "Import stopped", + data: { success: 10, failed: 2, total: 12, errorLogs: [], cancelled: true }, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + const renderedText = textContent(renderer.toJSON()); + expect(renderedText).toContain("Import stopped"); + expect(renderedText).not.toContain("No running query"); + }); + it("preserves an RPC failure when connection state changes during import", async () => { let resolveImport!: (value: any) => void; mocks.importDataWithProgressOptions.mockImplementation( diff --git a/frontend/src/components/ImportPreviewModal.tsx b/frontend/src/components/ImportPreviewModal.tsx index f75f7215..2baa4795 100644 --- a/frontend/src/components/ImportPreviewModal.tsx +++ b/frontend/src/components/ImportPreviewModal.tsx @@ -1,8 +1,9 @@ import Modal from './common/ResizableDraggableModal'; import React, { useState, useEffect, useRef } from "react"; import { Table, Alert, Progress, Button, Space, Select } from 'antd'; -import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"; +import { CheckCircleOutlined, CloseCircleOutlined, StopOutlined } from "@ant-design/icons"; import { + CancelQuery, DBGetColumns, PreviewImportFile, ImportDataWithProgressOptions, @@ -69,11 +70,13 @@ const ImportPreviewModal: React.FC = ({ const [columnMappings, setColumnMappings] = useState>({}); const [error, setError] = useState(null); const [importing, setImporting] = useState(false); + const [stopping, setStopping] = useState(false); const [progress, setProgress] = useState(null); const [importResult, setImportResult] = useState(null); const previewRequestRef = useRef(0); const importRequestRef = useRef(0); const importingRef = useRef(false); + const stoppingRef = useRef(false); const activeImportJobIdRef = useRef(""); const previewConnectionConfigRef = useRef(null); const secondaryTextColor = darkMode ? "rgba(255,255,255,0.65)" : "rgba(0,0,0,0.45)"; @@ -131,9 +134,11 @@ const ImportPreviewModal: React.FC = ({ const loadPreview = async (requestId: number) => { importRequestRef.current += 1; importingRef.current = false; + stoppingRef.current = false; activeImportJobIdRef.current = ""; previewConnectionConfigRef.current = null; setImporting(false); + setStopping(false); setLoading(true); setError(null); setPreviewData(null); @@ -240,8 +245,11 @@ const ImportPreviewModal: React.FC = ({ const importJobId = createImportJobId(); importRequestRef.current = importRequestId; importingRef.current = true; + stoppingRef.current = false; activeImportJobIdRef.current = importJobId; setImporting(true); + setStopping(false); + setError(null); setProgress({ current: 0, total: previewData.totalRows, @@ -269,7 +277,10 @@ const ImportPreviewModal: React.FC = ({ ); if (importRequestRef.current !== importRequestId) return; - if (res.success && res.data) { + setError(null); + if (res.data?.cancelled) { + setImportResult(res.data); + } else if (res.success && res.data) { setImportResult(res.data); if (res.data.failed === 0) { onSuccess(); @@ -287,12 +298,43 @@ const ImportPreviewModal: React.FC = ({ } finally { if (importRequestRef.current === importRequestId) { importingRef.current = false; + stoppingRef.current = false; activeImportJobIdRef.current = ""; setImporting(false); + setStopping(false); } } }; + const handleStopImport = async () => { + const importJobId = activeImportJobIdRef.current; + if (!importJobId || stoppingRef.current) return; + + stoppingRef.current = true; + setStopping(true); + setError(null); + try { + const res = await CancelQuery(importJobId); + if (!importingRef.current || activeImportJobIdRef.current !== importJobId) { + return; + } + if (!res.success) { + stoppingRef.current = false; + setStopping(false); + setError(res.message || t("import_preview.error.stop_failed")); + } + } catch (e: any) { + if (!importingRef.current || activeImportJobIdRef.current !== importJobId) { + return; + } + stoppingRef.current = false; + setStopping(false); + setError(t("import_preview.error.stop_failed_detail", { + detail: String(e?.message || e), + })); + } + }; + const columns = previewData?.columns.map((col) => ({ title: col, @@ -311,7 +353,19 @@ const ImportPreviewModal: React.FC = ({ - ) : importing ? null : ( + ) : importing ? ( + + + + ) : (