From d0bfaa479d0d93397f1d279fcadd29a047b96bd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 10:29:16 +0000 Subject: [PATCH 1/4] fix(plugin-grid): a cancelled background import shows the rows the job committed, with Undo (objectui#11650) After the user cancels an async import job, the wizard reads the job back (getImportJobProgress, the GET /api/v1/data/import/jobs/:id read) until its outcome is final, then shows the committed created/updated counts and an Undo button through the History list's own undo action. A cancelled read counts as final once it is undoable or repeats the previous cancelled read's counts: the server marks the row cancelled before its worker stops writing. The poll loop shares the same result builder and settle rule, and a run token keeps the poll loop and the cancel handler from both publishing or calling onComplete twice. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .../ImportWizard.cancelCounts-11650.test.tsx | 252 +++++++++++++++++ packages/plugin-grid/src/ImportWizard.tsx | 264 ++++++++++++++---- 2 files changed, 469 insertions(+), 47 deletions(-) create mode 100644 packages/plugin-grid/src/ImportWizard.cancelCounts-11650.test.tsx diff --git a/packages/plugin-grid/src/ImportWizard.cancelCounts-11650.test.tsx b/packages/plugin-grid/src/ImportWizard.cancelCounts-11650.test.tsx new file mode 100644 index 0000000000..11dec5f2b6 --- /dev/null +++ b/packages/plugin-grid/src/ImportWizard.cancelCounts-11650.test.tsx @@ -0,0 +1,252 @@ +/** + * ObjectUI – Copyright (c) 2024-present ObjectStack Inc. + * Licensed under MIT. + */ + +/** + * Cancelling a background import shows what the server committed, with Undo + * (objectui#11650). + * + * ## The defect + * + * `handleCancelImport` built its result with `importedRows: 0` and never read + * the job back, so the wizard said "Import cancelled" over a "0 imported" badge + * while the job it had just cancelled read `cancelled` with the rows its worker + * had already committed — and the only way to Undo them was the History list, + * which the result screen does not link to. + * + * ## Why one read after the cancel is not the answer + * + * The server's cancel route marks the job row `cancelled` itself, before the + * worker notices. Cancel is cooperative: the worker writes on to its next + * progress boundary, then writes the final counts and (for a job it can undo) + * the undo log. So the first read after the cancel is already terminal, but + * its counts can still grow and it is not yet undoable. The scripted reads + * below reproduce that order: an early terminal read with fewer rows, then the + * settled one. A wizard that trusted the first terminal read shows 1800 here, + * not 2000, and offers no Undo. + * + * Real timers throughout: the wizard polls on its own 800 ms interval, and the + * pins wait on it with explicit timeouts rather than faking the clock under + * React Testing Library's own waits. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import { ImportWizard } from './ImportWizard'; +import type { ImportJobProgressInfo, ImportJobResultsInfo } from '@object-ui/types'; + +const FIELDS = [{ name: 'name', label: 'Name', type: 'text' }]; +const JOB_ID = 'imp_11650'; +const WAIT = { timeout: 6000 }; + +/** A job read with the counters the pins vary; everything else fixed. */ +function jobRead(over: Partial): ImportJobProgressInfo { + return { + jobId: JOB_ID, + object: 'contact', + status: 'running', + total: 5000, + processed: 0, + created: 0, + updated: 0, + skipped: 0, + errors: 0, + percentComplete: 0, + undoable: false, + ...over, + }; +} + +const RUNNING = jobRead({ status: 'running', processed: 1000, created: 1000, percentComplete: 20 }); +// The cancel route's own write: terminal, but the worker is still on its batch. +const CANCELLED_EARLY = jobRead({ status: 'cancelled', processed: 1800, created: 1800, percentComplete: 36 }); +// The worker's final write: the committed counts, and the undo log behind `undoable`. +const CANCELLED_FINAL = jobRead({ status: 'cancelled', processed: 2000, created: 1950, updated: 50, percentComplete: 40, undoable: true }); + +type Step = ImportJobProgressInfo | Error | (() => Promise); + +/** + * A data source whose job reads come from two scripts: `poll` answers the + * wizard's progress reads until the user cancels, `afterCancel` answers every + * read from then on. Each script's last step repeats. + */ +function scriptedDataSource(poll: Step[], afterCancel: Step[]) { + let cancelled = false; + let pollAt = 0; + let cancelAt = 0; + const next = (script: Step[], at: number): Promise => { + const step = script[Math.min(at, script.length - 1)]; + if (typeof step === 'function') return step(); + return step instanceof Error ? Promise.reject(step) : Promise.resolve(step); + }; + const ds = { + createImportJob: vi.fn(async () => ({ jobId: JOB_ID, object: 'contact', status: 'pending', total: 5000 })), + getImportJobProgress: vi.fn((_id: string) => + cancelled ? next(afterCancel, cancelAt++) : next(poll, pollAt++)), + getImportJobResults: vi.fn(async (): Promise => ({ + ...jobRead({ status: 'succeeded', processed: 5000, created: 5000, percentComplete: 100 }), + results: [], + resultsTruncated: false, + })), + cancelImportJob: vi.fn(async (_id: string) => { cancelled = true; }), + undoImportJob: vi.fn(async (id: string) => ({ success: true, jobId: id, object: 'contact', deleted: 1950, restored: 50, failed: 0 })), + }; + return ds; +} + +/** Paste TSV into the upload step via the wizard's window-level handler. */ +function pasteRows(text: string) { + const evt = new Event('paste', { bubbles: true, cancelable: true }) as Event & { + clipboardData: { getData: (type: string) => string }; + }; + evt.clipboardData = { getData: (type: string) => (type === 'text/plain' ? text : '') }; + act(() => { window.dispatchEvent(evt); }); +} + +/** Drive the wizard to a running background job: paste, map, tick + * background import, Run, and wait for the in-flight Cancel button. */ +async function startBackgroundImport(dataSource: unknown, onComplete: (r: unknown) => void) { + render( + {}} + onComplete={onComplete} + />, + ); + pasteRows('Name\nAda\nGrace\nEdsger'); + const next = await screen.findByTestId('import-next-btn'); + await waitFor(() => expect(next).toBeEnabled()); + fireEvent.click(next); + const background = await screen.findByTestId('import-opt-background'); + fireEvent.click(within(background).getByRole('checkbox')); + fireEvent.click(screen.getByTestId('import-run-btn')); + return screen.findByTestId('import-cancel-async'); +} + +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +describe('ImportWizard: a cancelled background import reports the job it cancelled (objectui#11650)', () => { + it('cancel mid-run: the summary equals the settled job counts, with Undo, and onComplete once', async () => { + const ds = scriptedDataSource([RUNNING], [CANCELLED_EARLY, CANCELLED_FINAL]); + const onComplete = vi.fn(); + const cancel = await startBackgroundImport(ds, onComplete); + // Mid-run: the poll loop has read the job at least once before the cancel. + await screen.findByText(/Importing 1000 of 5000 rows/, undefined, WAIT); + + fireEvent.click(cancel); + + expect(await screen.findByText('Import cancelled', undefined, WAIT)).toBeInTheDocument(); + expect(ds.cancelImportJob).toHaveBeenCalledWith(JOB_ID); + expect(screen.getByText('1950 created')).toBeInTheDocument(); + expect(screen.getByText('50 updated')).toBeInTheDocument(); + expect(screen.queryByText('1800 created')).not.toBeInTheDocument(); + expect(screen.queryByText(/0 imported/)).not.toBeInTheDocument(); + expect(screen.getByTestId('import-cancelled-undo')).toHaveTextContent('Undo import'); + + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete.mock.calls[0][0]).toMatchObject({ + cancelled: true, importedRows: 2000, createdRows: 1950, updatedRows: 50, skippedRows: 0, + }); + }, 20000); + + it('a job still running on the first read after the cancel is shown once a later read settles', async () => { + const stillRunning = jobRead({ status: 'running', processed: 1900, created: 1900, percentComplete: 38 }); + const ds = scriptedDataSource([RUNNING], [stillRunning, CANCELLED_FINAL]); + const onComplete = vi.fn(); + const cancel = await startBackgroundImport(ds, onComplete); + + fireEvent.click(cancel); + + expect(await screen.findByText('1950 created', undefined, WAIT)).toBeInTheDocument(); + expect(screen.getByText('Import cancelled')).toBeInTheDocument(); + expect(screen.queryByText('1900 created')).not.toBeInTheDocument(); + expect(onComplete).toHaveBeenCalledTimes(1); + }, 20000); + + it('a job that never reads undoable is final once two cancelled reads agree, and offers no Undo', async () => { + const grown = jobRead({ status: 'cancelled', processed: 2000, created: 2000, percentComplete: 40 }); + const ds = scriptedDataSource([RUNNING], [CANCELLED_EARLY, grown, grown]); + const onComplete = vi.fn(); + const cancel = await startBackgroundImport(ds, onComplete); + + fireEvent.click(cancel); + + expect(await screen.findByText('2000 created', undefined, WAIT)).toBeInTheDocument(); + expect(screen.queryByText('1800 created')).not.toBeInTheDocument(); + expect(screen.queryByTestId('import-cancelled-undo')).not.toBeInTheDocument(); + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete.mock.calls[0][0]).toMatchObject({ cancelled: true, importedRows: 2000, createdRows: 2000 }); + }, 20000); + + it('Undo on the cancelled result rolls the job back and then reads "Undone"', async () => { + const reverted = { ...CANCELLED_FINAL, undoable: false, revertedAt: '2026-10-05T10:00:00.000Z' }; + const ds = scriptedDataSource([RUNNING], [CANCELLED_FINAL]); + // This DOM environment ships no window.confirm; the Undo confirm reads it. + const confirmSpy = vi.fn(() => true); + vi.stubGlobal('confirm', confirmSpy); + const cancel = await startBackgroundImport(ds, vi.fn()); + + fireEvent.click(cancel); + const undo = await screen.findByTestId('import-cancelled-undo', undefined, WAIT); + ds.getImportJobProgress.mockImplementation(async () => reverted); + fireEvent.click(undo); + + expect(await screen.findByTestId('import-cancelled-reverted', undefined, WAIT)).toHaveTextContent('Undone'); + expect(confirmSpy).toHaveBeenCalledTimes(1); + expect(ds.undoImportJob).toHaveBeenCalledTimes(1); + expect(ds.undoImportJob).toHaveBeenCalledWith(JOB_ID); + expect(screen.queryByTestId('import-cancelled-undo')).not.toBeInTheDocument(); + }, 20000); + + it('a Cancel clicked while a poll read is in flight publishes one result and calls onComplete once', async () => { + let releasePoll: (read: ImportJobProgressInfo) => void = () => {}; + const inFlight = () => new Promise((resolve) => { releasePoll = resolve; }); + const ds = scriptedDataSource([inFlight], [CANCELLED_FINAL]); + const onComplete = vi.fn(); + const cancel = await startBackgroundImport(ds, onComplete); + // The poll loop's first read is now pending. + await waitFor(() => expect(ds.getImportJobProgress).toHaveBeenCalledTimes(1), WAIT); + + fireEvent.click(cancel); + expect(await screen.findByText('1950 created', undefined, WAIT)).toBeInTheDocument(); + // The poll loop's read now lands too — already terminal, as the server + // answers it after the cancel route ran. + await act(async () => { releasePoll(CANCELLED_EARLY); }); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 900)); }); + + expect(onComplete).toHaveBeenCalledTimes(1); + expect(screen.queryByText('1800 created')).not.toBeInTheDocument(); + }, 20000); + + it('the poll loop does not take a job cancelled elsewhere as final on its first cancelled read', async () => { + const ds = scriptedDataSource([RUNNING, CANCELLED_EARLY, CANCELLED_FINAL], []); + const onComplete = vi.fn(); + await startBackgroundImport(ds, onComplete); + + expect(await screen.findByText('1950 created', undefined, { timeout: 8000 })).toBeInTheDocument(); + expect(screen.getByText('Import cancelled')).toBeInTheDocument(); + expect(ds.cancelImportJob).not.toHaveBeenCalled(); + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete.mock.calls[0][0]).toMatchObject({ cancelled: true, importedRows: 2000 }); + }, 20000); + + it('when no read settles within the bound, the result shows no count it never read and skips onComplete', async () => { + const ds = scriptedDataSource([RUNNING], [new Error('network down')]); + const onComplete = vi.fn(); + const cancel = await startBackgroundImport(ds, onComplete); + + fireEvent.click(cancel); + + expect(await screen.findByText('Import cancelled', undefined, { timeout: 12000 })).toBeInTheDocument(); + expect(screen.queryByText(/imported/)).not.toBeInTheDocument(); + expect(screen.queryByText(/created/)).not.toBeInTheDocument(); + expect(screen.queryByTestId('import-cancelled-undo')).not.toBeInTheDocument(); + expect(onComplete).not.toHaveBeenCalled(); + }, 20000); +}); diff --git a/packages/plugin-grid/src/ImportWizard.tsx b/packages/plugin-grid/src/ImportWizard.tsx index 6fc2ab5f3b..df96df5d0f 100644 --- a/packages/plugin-grid/src/ImportWizard.tsx +++ b/packages/plugin-grid/src/ImportWizard.tsx @@ -728,6 +728,109 @@ function isImportJobUndoable(job: Pick = new Set(['succeeded', 'failed', 'cancelled']); + +/** How many progress reads the wizard spends, after the user cancels, waiting + * for the cancelled job's outcome (one read per {@link IMPORT_JOB_POLL_INTERVAL}). */ +const IMPORT_JOB_CANCEL_SETTLE_READS = 10; + +/** The wizard's result for a job that reads `cancelled`: the rows the server + * committed before the cancel took effect. One builder for both ways a job + * ends cancelled — the wizard's own Cancel and the poll loop seeing a job + * cancelled elsewhere (e.g. from the History list in another tab). */ +function cancelledJobToImportResult(prog: ImportJobProgressInfo): ImportResult { + return { + totalRows: prog.total, + importedRows: prog.created + prog.updated, + skippedRows: prog.skipped + prog.errors, + createdRows: prog.created, + updatedRows: prog.updated, + errors: [], + cancelled: true, + }; +} + +/** Whether a progress read is the job's final outcome, given the read before it. + * + * A terminal status is enough for `succeeded` and `failed`: the worker writes + * them together with its final counts. It is not enough for `cancelled`. The + * server's cancel route marks the job row `cancelled` itself, before the + * worker notices; the worker keeps writing rows until its next progress + * boundary (cancel is cooperative — see `DataSource.cancelImportJob`), and + * only then writes the final counts and, for a job it can undo, the undo log. + * So a `cancelled` read is final once it is undoable (only that last write + * makes it so), or once it repeats the previous `cancelled` read's counts. */ +function isSettledJobRead(prog: ImportJobProgressInfo, previous: ImportJobProgressInfo | null): boolean { + if (!IMPORT_JOB_TERMINAL_STATUSES.has(prog.status)) return false; + if (prog.status !== 'cancelled') return true; + if (prog.undoable) return true; + return previous !== null + && previous.status === 'cancelled' + && previous.processed === prog.processed + && previous.created === prog.created + && previous.updated === prog.updated + && previous.skipped === prog.skipped + && previous.errors === prog.errors; +} + +/** Read a job until a read is its final outcome ({@link isSettledJobRead}), at + * most `reads` times: the first read at once, then one per `interval`. A read + * that fails counts against the bound and is otherwise skipped. Resolves to + * the settled read, or `null` when none settled or `isStale()` turned true. */ +async function readSettledImportJob( + read: (jobId: string) => Promise, + jobId: string, + reads: number, + interval: number, + isStale: () => boolean, +): Promise { + let previous: ImportJobProgressInfo | null = null; + for (let i = 0; i < reads; i++) { + if (i > 0) await new Promise((resolve) => setTimeout(resolve, interval)); + if (isStale()) return null; + let prog: ImportJobProgressInfo; + try { + prog = await read(jobId); + } catch { + continue; + } + if (isSettledJobRead(prog, previous)) return prog; + previous = prog; + } + return null; +} + +/** The Undo action, shared by the History list and the cancelled-result + * screen: confirm (destructive + irreversible), roll the job back, hand a + * failure to `onError` (cleared to `null` when an undo starts), then call + * `onSettled` so the caller re-reads the job and the Undo button gives way to + * "Undone". */ +function useImportJobUndo( + dataSource: unknown, + t: (key: string, vars?: Record) => string, + onError: (message: string | null) => void, + onSettled: () => void, +): { undoingId: string | null; undo: (jobId: string) => Promise } { + // Job id currently being undone (disables its Undo button + confirm). + const [undoingId, setUndoingId] = useState(null); + const undo = useCallback(async (jobId: string) => { + const ds = dataSource as Partial | undefined; + if (typeof ds?.undoImportJob !== 'function') return; + if (typeof window !== 'undefined' && !window.confirm(t('grid.import.undoConfirm'))) return; + setUndoingId(jobId); onError(null); + try { + await ds.undoImportJob(jobId); + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + } finally { + setUndoingId(null); + onSettled(); + } + }, [dataSource, t, onError, onSettled]); + return { undoingId, undo }; +} + /** Build a CSV blob of failed rows for re-export: the original mapped columns * plus an `_error` column, so a user can fix and re-import just the failures. */ function buildFailedRowsCsv( @@ -1484,8 +1587,6 @@ const ImportHistoryPanel: React.FC<{ const [jobs, setJobs] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - // Job id currently being undone (disables its row's Undo button + confirm). - const [undoingId, setUndoingId] = useState(null); const ds = dataSource as Partial | undefined; const supported = typeof ds?.listImportJobs === 'function'; @@ -1516,19 +1617,8 @@ const ImportHistoryPanel: React.FC<{ // Logical rollback: delete created records + restore updated ones. Confirms // first (destructive + irreversible), then reloads so the row flips to // "reverted" and its Undo button disappears. - const handleUndo = useCallback(async (jobId: string) => { - if (typeof ds?.undoImportJob !== 'function') return; - if (typeof window !== 'undefined' && !window.confirm(t('grid.import.undoConfirm'))) return; - setUndoingId(jobId); setError(null); - try { - await ds.undoImportJob(jobId); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setUndoingId(null); - void load(); - } - }, [ds, load, t]); + const reload = useCallback(() => { void load(); }, [load]); + const { undoingId, undo: handleUndo } = useImportJobUndo(dataSource, t, setError, reload); if (!supported) { return ( @@ -1638,6 +1728,14 @@ export const ImportWizard: React.FC = ({ const [jobId, setJobId] = useState(null); const [asyncCounts, setAsyncCounts] = useState<{ processed: number; total: number } | null>(null); const cancelPollRef = React.useRef(false); + // One import run at a time. The async paths capture this token, and a job + // outcome is published only while it is unchanged: publishing moves it on, so + // the poll loop and a user Cancel can never both publish (or call onComplete + // twice), and an outcome landing after a reset or a newer run is dropped. + const jobRunRef = React.useRef(0); + // The final read of the job the result screen shows, for its Undo button. + const [finishedJob, setFinishedJob] = useState(null); + const [undoError, setUndoError] = useState(null); // Small-file server dry-run pre-check — validates the exact payload without // writing, so the summary/error list reflect real coercion outcomes. const [validating, setValidating] = useState(false); @@ -1945,6 +2043,34 @@ export const ImportWizard: React.FC = ({ setResult(importResult); setImporting(false); onComplete?.(importResult); }, [rows, mapping, fields, dataSource, objectName, onComplete, onErrorMode, corrections, t]); + // Publish a finished job's outcome — the result screen, the job read behind + // its Undo button, and onComplete — at most once per run (see jobRunRef). A + // `cancelled` read is its own result; the other terminal states fetch the + // per-row report. Resolves to whether this call published. + const publishJobOutcome = useCallback(async ( + run: number, + id: string, + prog: ImportJobProgressInfo, + ): Promise => { + const ds = dataSource as Partial | undefined; + let importResult: ImportResult; + if (prog.status === 'cancelled') { + importResult = cancelledJobToImportResult(prog); + } else { + if (typeof ds?.getImportJobResults !== 'function') return false; + importResult = jobResultToImportResult(await ds.getImportJobResults(id)); + if (prog.status === 'failed' && importResult.errors.length === 0) { + importResult.errors.push({ row: 0, field: '', message: prog.error ?? 'Import failed' }); + } + } + if (jobRunRef.current !== run) return false; + jobRunRef.current += 1; + if (prog.status !== 'cancelled') setProgress(100); + setFinishedJob(prog); + setResult(importResult); setImporting(false); onComplete?.(importResult); + return true; + }, [dataSource, onComplete]); + // Large-file path: hand the rows to a server-side background job and poll it // to completion. Returns `true` when the async path handled the import // (success / failure / cancel) and `false` when the data source can't run @@ -1960,6 +2086,7 @@ export const ImportWizard: React.FC = ({ } cancelPollRef.current = false; + const run = jobRunRef.current; let created: CreateImportJobResult; try { created = await ds.createImportJob(objectName, request); @@ -1974,10 +2101,10 @@ export const ImportWizard: React.FC = ({ setJobId(created.jobId); setAsyncCounts({ processed: 0, total: created.total }); - const terminal = new Set(['succeeded', 'failed', 'cancelled']); let consecutivePollErrors = 0; - // Poll until the job reaches a terminal state (or the user cancels, in - // which case the cancel handler owns producing the result). + let previous: ImportJobProgressInfo | null = null; + // Poll until the job's outcome is final (or the user cancels, in which + // case the cancel handler owns producing the result). for (;;) { if (cancelPollRef.current) return true; await new Promise((resolve) => setTimeout(resolve, IMPORT_JOB_POLL_INTERVAL)); @@ -1988,41 +2115,26 @@ export const ImportWizard: React.FC = ({ prog = await ds.getImportJobProgress(created.jobId); consecutivePollErrors = 0; } catch (err) { + if (cancelPollRef.current) return true; // Tolerate transient poll blips; give up only after several in a row so // a network hiccup doesn't abort an import that's still running server-side. if (++consecutivePollErrors >= 5) throw err; continue; } + // A Cancel clicked while this read was in flight owns the outcome now. + if (cancelPollRef.current) return true; setAsyncCounts({ processed: prog.processed, total: prog.total }); setProgress(prog.percentComplete); - if (!terminal.has(prog.status)) continue; - - if (prog.status === 'cancelled') { - const importResult: ImportResult = { - totalRows: prog.total, - importedRows: prog.created + prog.updated, - skippedRows: prog.skipped + prog.errors, - createdRows: prog.created, - updatedRows: prog.updated, - errors: [], - cancelled: true, - }; - setResult(importResult); setImporting(false); onComplete?.(importResult); - return true; - } + const settled = isSettledJobRead(prog, previous); + previous = prog; + if (!settled) continue; - const results = await ds.getImportJobResults(created.jobId); - const importResult = jobResultToImportResult(results); - if (prog.status === 'failed' && importResult.errors.length === 0) { - importResult.errors.push({ row: 0, field: '', message: prog.error ?? 'Import failed' }); - } - setProgress(100); - setResult(importResult); setImporting(false); onComplete?.(importResult); + await publishJobOutcome(run, created.jobId, prog); return true; } - }, [dataSource, objectName, onComplete]); + }, [dataSource, objectName, publishJobOutcome]); // Assemble the server import request from the current mapping + options. // `dryRun` reuses the exact same payload the real import will send, so the @@ -2042,7 +2154,8 @@ export const ImportWizard: React.FC = ({ const handleImport = useCallback(async () => { setImporting(true); setProgress(0); cancelPollRef.current = false; - setJobId(null); setAsyncCounts(null); + jobRunRef.current += 1; + setJobId(null); setAsyncCounts(null); setFinishedJob(null); setUndoError(null); const request = buildImportRequest(); @@ -2182,14 +2295,31 @@ export const ImportWizard: React.FC = ({ }, [mapping, corrections, writeMode, matchFields, createMissingOptions, runAutomations, skipBlankMatchKey]); // User-initiated cancel of an in-flight async job. Stops the poll loop, asks - // the server to cancel (best-effort), and shows a cancelled result. + // the server to cancel (best-effort), then reads the job back until its + // outcome is final (bounded — see readSettledImportJob) and shows what the + // server committed, with Undo when the job can be undone. When no read + // settles within the bound, or the data source cannot read jobs, the result + // says only that the import was cancelled: it shows no count it never read. const handleCancelImport = useCallback(async () => { cancelPollRef.current = true; + const run = jobRunRef.current; + const isStale = () => jobRunRef.current !== run; const id = jobId; const ds = dataSource as Partial | undefined; if (id && typeof ds?.cancelImportJob === 'function') { - try { await ds.cancelImportJob(id); } catch { /* best-effort — the poll loop already stopped */ } + try { await ds.cancelImportJob(id); } catch { /* best-effort — the read below reports what happened */ } } + const read = typeof ds?.getImportJobProgress === 'function' ? ds.getImportJobProgress.bind(ds) : undefined; + if (id && read) { + const settled = await readSettledImportJob( + read, id, IMPORT_JOB_CANCEL_SETTLE_READS, IMPORT_JOB_POLL_INTERVAL, isStale, + ); + try { + if (settled && await publishJobOutcome(run, id, settled)) return; + } catch { /* the per-row report failed — fall through to the bare cancelled result */ } + } + if (isStale()) return; + jobRunRef.current += 1; const importResult: ImportResult = { totalRows: asyncCounts?.total ?? rows.length, importedRows: 0, @@ -2198,10 +2328,26 @@ export const ImportWizard: React.FC = ({ cancelled: true, }; setResult(importResult); setImporting(false); - }, [jobId, dataSource, asyncCounts, rows.length]); + }, [jobId, dataSource, asyncCounts, rows.length, publishJobOutcome]); + + // Re-read the finished job after an Undo so its button gives way to "Undone". + // A read that lands after the wizard moved to another job is dropped. + const rereadFinishedJob = useCallback(() => { + const ds = dataSource as Partial | undefined; + const id = finishedJob?.jobId; + if (!id || typeof ds?.getImportJobProgress !== 'function') return; + ds.getImportJobProgress(id).then( + (job) => setFinishedJob((current) => (current?.jobId === job.jobId ? job : current)), + () => { /* keep the last read */ }, + ); + }, [dataSource, finishedJob?.jobId]); + const { undoingId, undo: undoFinishedJob } = useImportJobUndo(dataSource, t, setUndoError, rereadFinishedJob); + const canUndoJob = typeof (dataSource as Partial | undefined)?.undoImportJob === 'function'; const reset = useCallback(() => { cancelPollRef.current = false; + jobRunRef.current += 1; + setFinishedJob(null); setUndoError(null); setStep('upload'); setHeaders([]); setRows([]); setMapping({}); setProgress(0); setResult(null); setCorrections({}); setSelectedTemplateId(null); setMappingName(null); setWriteMode('insert'); setMatchFields([]); @@ -2443,7 +2589,9 @@ export const ImportWizard: React.FC = ({ )}
{/* Prefer the finer created/updated breakdown when the server - reports it; otherwise fall back to a single "imported" count. */} + reports it; otherwise fall back to a single "imported" count. + A cancelled result without the breakdown is one whose job the + wizard could not read back, so it shows no count at all. */} {result.createdRows !== undefined || result.updatedRows !== undefined ? ( <> {(result.createdRows ?? 0) > 0 && {t('grid.import.createdCount', { count: result.createdRows })}} @@ -2452,11 +2600,33 @@ export const ImportWizard: React.FC = ({ {t('grid.import.imported', { count: result.importedRows })} )} - ) : ( + ) : !result.cancelled && ( {t('grid.import.imported', { count: result.importedRows })} )} {result.skippedRows > 0 && {t('grid.import.skippedCount', { count: result.skippedRows })}}
+ {/* A cancelled job keeps the rows it committed; offer the same + Undo the History list does, once the job reads undoable. */} + {result.cancelled && finishedJob?.revertedAt && ( + + {t('grid.import.reverted')} + + )} + {result.cancelled && finishedJob && isImportJobUndoable(finishedJob, canUndoJob) && ( + + )} + {result.cancelled && undoError && ( +

{undoError}

+ )} {result.degraded && !result.cancelled && (
Date: Mon, 5 Oct 2026 10:37:30 +0000 Subject: [PATCH 2/4] chore(changeset): plugin-grid patch for the cancelled-import counts (objectui#11650) Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .changeset/11650-import-cancel-counts.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/11650-import-cancel-counts.md diff --git a/.changeset/11650-import-cancel-counts.md b/.changeset/11650-import-cancel-counts.md new file mode 100644 index 0000000000..3c6d571f1f --- /dev/null +++ b/.changeset/11650-import-cancel-counts.md @@ -0,0 +1,11 @@ +--- +'@object-ui/plugin-grid': patch +--- + +Cancelling a background import shows the rows the server already committed, with Undo (objectui#11650). `ImportWizard`'s Cancel used to show "Import cancelled · 0 imported" without reading the job back, while the job it had just cancelled read `cancelled` with the rows its worker had written. Users took the zero at its word and imported the file again. + +After the cancel answers, the wizard now reads the job (`getImportJobProgress`, the `GET /api/v1/data/import/jobs/:id` read). It keeps reading until the outcome is final, at most ten reads, one per poll interval. The result then shows the job's created and updated counts. When the job can be undone, it also shows an **Undo import** button. That button runs the same confirm-and-undo action as the History list and then reads "Undone". A `cancelled` read counts as final once it is undoable, or once it repeats the previous `cancelled` read's counts. The server marks the job `cancelled` before its worker stops writing, so the first read after a cancel can still be short. The poll loop applies the same rule to a job cancelled from elsewhere, and both paths build the result the same way. + +**Behaviour change for hosts.** `onComplete` now fires once after a user cancel that reads the job back, with the cancelled result (`cancelled: true` and the committed counts). Before, it fired only when the poll loop itself saw a job end `cancelled`. In the console this refreshes the list, so the committed rows show up, and it raises the usual import toast. If no read settles within the bound, the result still says "Import cancelled", but it shows no count, because the wizard never read one. `onComplete` does not fire in that case, as before. The poll loop and the cancel handler can no longer both publish a result for the same run. + +**Clause-②: no.** No export, prop, type member or i18n key is added or removed. The new copy reuses the existing `grid.import.importCancelled`, `grid.import.createdCount`, `grid.import.updatedCount`, `grid.import.undoImport`, `grid.import.undoing`, `grid.import.undoConfirm` and `grid.import.reverted` strings. From 156fcda65fa2df1d023f677767ae2545617cc885 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 10:57:28 +0000 Subject: [PATCH 3/4] fix(plugin-grid): an import Undo announces the change so the open list refetches (objectui#11650) The cancel now hands its result to onComplete, so the host's list shows the committed rows; an Undo then deleted them while the list kept showing them. The shared undo action (History list and cancelled result) now calls notifyDataChanged for the object after a successful undo, the bus every write path is meant to use. A failed undo announces nothing. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .changeset/11650-import-cancel-counts.md | 2 + .../ImportWizard.cancelCounts-11650.test.tsx | 60 ++++++++++++++++++- packages/plugin-grid/src/ImportWizard.tsx | 19 +++--- 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/.changeset/11650-import-cancel-counts.md b/.changeset/11650-import-cancel-counts.md index 3c6d571f1f..ec66129649 100644 --- a/.changeset/11650-import-cancel-counts.md +++ b/.changeset/11650-import-cancel-counts.md @@ -8,4 +8,6 @@ After the cancel answers, the wizard now reads the job (`getImportJobProgress`, **Behaviour change for hosts.** `onComplete` now fires once after a user cancel that reads the job back, with the cancelled result (`cancelled: true` and the committed counts). Before, it fired only when the poll loop itself saw a job end `cancelled`. In the console this refreshes the list, so the committed rows show up, and it raises the usual import toast. If no read settles within the bound, the result still says "Import cancelled", but it shows no count, because the wizard never read one. `onComplete` does not fire in that case, as before. The poll loop and the cancel handler can no longer both publish a result for the same run. +**Undo refreshes the list it changed.** A successful Undo, from the History list or from the cancelled result, now calls `notifyDataChanged` for the imported object. That is the data-invalidation bus from `@object-ui/react`, so a mounted list of that object refetches in place. Before, the list kept showing the rows the Undo had just deleted until something else refreshed it. A failed Undo announces nothing. + **Clause-②: no.** No export, prop, type member or i18n key is added or removed. The new copy reuses the existing `grid.import.importCancelled`, `grid.import.createdCount`, `grid.import.updatedCount`, `grid.import.undoImport`, `grid.import.undoing`, `grid.import.undoConfirm` and `grid.import.reverted` strings. diff --git a/packages/plugin-grid/src/ImportWizard.cancelCounts-11650.test.tsx b/packages/plugin-grid/src/ImportWizard.cancelCounts-11650.test.tsx index 11dec5f2b6..8b74c2a7bc 100644 --- a/packages/plugin-grid/src/ImportWizard.cancelCounts-11650.test.tsx +++ b/packages/plugin-grid/src/ImportWizard.cancelCounts-11650.test.tsx @@ -26,6 +26,14 @@ * settled one. A wizard that trusted the first terminal read shows 1800 here, * not 2000, and offers no Undo. * + * ## Undo refreshes the list it changed + * + * The cancel now hands the cancelled result to `onComplete`, so the host's + * list refetches and shows the committed rows. An Undo deletes them again, so + * the shared undo action announces the change on the data-invalidation bus + * (`notifyDataChanged`), as every write path should. Without it, the list kept + * showing the undone rows, from the History list and from the result screen. + * * Real timers throughout: the wizard polls on its own 800 ms interval, and the * pins wait on it with explicit timeouts rather than faking the clock under * React Testing Library's own waits. @@ -35,8 +43,9 @@ import { render, screen, fireEvent, waitFor, act, within } from '@testing-librar import '@testing-library/jest-dom'; import React from 'react'; +import { subscribeDataChanges, type DataChange } from '@object-ui/react'; import { ImportWizard } from './ImportWizard'; -import type { ImportJobProgressInfo, ImportJobResultsInfo } from '@object-ui/types'; +import type { ImportJobProgressInfo, ImportJobResultsInfo, ImportJobSummaryInfo } from '@object-ui/types'; const FIELDS = [{ name: 'name', label: 'Name', type: 'text' }]; const JOB_ID = 'imp_11650'; @@ -204,6 +213,55 @@ describe('ImportWizard: a cancelled background import reports the job it cancell expect(screen.queryByTestId('import-cancelled-undo')).not.toBeInTheDocument(); }, 20000); + it('Undo on the cancelled result tells the readers of the object that its data changed', async () => { + const ds = scriptedDataSource([RUNNING], [CANCELLED_FINAL]); + vi.stubGlobal('confirm', vi.fn(() => true)); + const cancel = await startBackgroundImport(ds, vi.fn()); + fireEvent.click(cancel); + const undo = await screen.findByTestId('import-cancelled-undo', undefined, WAIT); + const changes: DataChange[] = []; + const unsubscribe = subscribeDataChanges((change) => { changes.push(change); }); + try { + fireEvent.click(undo); + await waitFor(() => expect(ds.undoImportJob).toHaveBeenCalledTimes(1), WAIT); + await waitFor(() => expect(changes).toEqual([{ objectName: 'contact' }]), WAIT); + } finally { + unsubscribe(); + } + }, 20000); + + it('Undo from the History list tells the readers of the object that its data changed, and a failed undo does not', async () => { + const row: ImportJobSummaryInfo = { + jobId: JOB_ID, object: 'contact', status: 'cancelled', total: 5000, processed: 2000, + created: 1950, updated: 50, skipped: 0, errors: 0, undoable: true, + }; + const undoImportJob = vi.fn() + .mockRejectedValueOnce(new Error('undo failed')) + .mockResolvedValueOnce({ success: true, jobId: JOB_ID, object: 'contact', deleted: 1950, restored: 50, failed: 0 }); + const ds = { listImportJobs: vi.fn(async () => [row]), undoImportJob }; + vi.stubGlobal('confirm', vi.fn(() => true)); + render( + {}} />, + ); + fireEvent.click(screen.getByTestId('import-history-toggle')); + const undo = await screen.findByTestId(`import-history-undo-${JOB_ID}`); + const changes: DataChange[] = []; + const unsubscribe = subscribeDataChanges((change) => { changes.push(change); }); + try { + fireEvent.click(undo); + await waitFor(() => expect(undoImportJob).toHaveBeenCalledTimes(1)); + // The panel reloads after the attempt; wait for the row's Undo to be live again. + await waitFor(() => expect(screen.getByTestId(`import-history-undo-${JOB_ID}`)).toBeEnabled()); + await waitFor(() => expect(ds.listImportJobs).toHaveBeenCalledTimes(2)); + expect(changes).toEqual([]); + fireEvent.click(screen.getByTestId(`import-history-undo-${JOB_ID}`)); + await waitFor(() => expect(undoImportJob).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(changes).toEqual([{ objectName: 'contact' }])); + } finally { + unsubscribe(); + } + }); + it('a Cancel clicked while a poll read is in flight publishes one result and calls onComplete once', async () => { let releasePoll: (read: ImportJobProgressInfo) => void = () => {}; const inFlight = () => new Promise((resolve) => { releasePoll = resolve; }); diff --git a/packages/plugin-grid/src/ImportWizard.tsx b/packages/plugin-grid/src/ImportWizard.tsx index df96df5d0f..7b36299569 100644 --- a/packages/plugin-grid/src/ImportWizard.tsx +++ b/packages/plugin-grid/src/ImportWizard.tsx @@ -11,7 +11,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@object-ui/components'; import { Upload, FileSpreadsheet, CheckCircle2, AlertCircle, X, ArrowRight, ArrowLeft, Save, Trash2, ClipboardPaste, Download, Undo2 } from 'lucide-react'; -import { useObjectTranslation } from '@object-ui/react'; +import { useObjectTranslation, notifyDataChanged } from '@object-ui/react'; import { sanitizeFileNameBase } from '@object-ui/core'; import { useDisplayLocale } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; @@ -802,12 +802,14 @@ async function readSettledImportJob( } /** The Undo action, shared by the History list and the cancelled-result - * screen: confirm (destructive + irreversible), roll the job back, hand a - * failure to `onError` (cleared to `null` when an undo starts), then call - * `onSettled` so the caller re-reads the job and the Undo button gives way to - * "Undone". */ + * screen: confirm (destructive + irreversible), roll the job back, tell the + * object's mounted readers their data changed (the undo deleted and restored + * records, so an open list refetches in place), hand a failure to `onError` + * (cleared to `null` when an undo starts), then call `onSettled` so the + * caller re-reads the job and the Undo button gives way to "Undone". */ function useImportJobUndo( dataSource: unknown, + objectName: string, t: (key: string, vars?: Record) => string, onError: (message: string | null) => void, onSettled: () => void, @@ -821,13 +823,14 @@ function useImportJobUndo( setUndoingId(jobId); onError(null); try { await ds.undoImportJob(jobId); + notifyDataChanged({ objectName }); } catch (err) { onError(err instanceof Error ? err.message : String(err)); } finally { setUndoingId(null); onSettled(); } - }, [dataSource, t, onError, onSettled]); + }, [dataSource, objectName, t, onError, onSettled]); return { undoingId, undo }; } @@ -1618,7 +1621,7 @@ const ImportHistoryPanel: React.FC<{ // first (destructive + irreversible), then reloads so the row flips to // "reverted" and its Undo button disappears. const reload = useCallback(() => { void load(); }, [load]); - const { undoingId, undo: handleUndo } = useImportJobUndo(dataSource, t, setError, reload); + const { undoingId, undo: handleUndo } = useImportJobUndo(dataSource, objectName, t, setError, reload); if (!supported) { return ( @@ -2341,7 +2344,7 @@ export const ImportWizard: React.FC = ({ () => { /* keep the last read */ }, ); }, [dataSource, finishedJob?.jobId]); - const { undoingId, undo: undoFinishedJob } = useImportJobUndo(dataSource, t, setUndoError, rereadFinishedJob); + const { undoingId, undo: undoFinishedJob } = useImportJobUndo(dataSource, objectName, t, setUndoError, rereadFinishedJob); const canUndoJob = typeof (dataSource as Partial | undefined)?.undoImportJob === 'function'; const reset = useCallback(() => { From 02d926648ec3760c37b07af6fbc8de72ef512578 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 11:08:53 +0000 Subject: [PATCH 4/4] fix(plugin-grid): closing the wizard keeps a background job's onComplete (objectui#11650) The run token dropped any outcome that landed after reset(), which also dropped the poll loop's completion of a background job the user had closed the dialog on: the host no longer refreshed or toasted when it finished. Only a newer run now moves the token; closing the wizard does not. Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude --- .../src/ImportWizard.cancelCounts-11650.test.tsx | 15 +++++++++++++++ packages/plugin-grid/src/ImportWizard.tsx | 5 +++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/plugin-grid/src/ImportWizard.cancelCounts-11650.test.tsx b/packages/plugin-grid/src/ImportWizard.cancelCounts-11650.test.tsx index 8b74c2a7bc..7d2c206fef 100644 --- a/packages/plugin-grid/src/ImportWizard.cancelCounts-11650.test.tsx +++ b/packages/plugin-grid/src/ImportWizard.cancelCounts-11650.test.tsx @@ -294,6 +294,21 @@ describe('ImportWizard: a cancelled background import reports the job it cancell expect(onComplete.mock.calls[0][0]).toMatchObject({ cancelled: true, importedRows: 2000 }); }, 20000); + it('closing the wizard mid-run still reports the background job through onComplete once it finishes', async () => { + const succeeded = jobRead({ status: 'succeeded', processed: 5000, created: 5000, percentComplete: 100 }); + const ds = scriptedDataSource([RUNNING, succeeded], []); + const onComplete = vi.fn(); + await startBackgroundImport(ds, onComplete); + // The dialog's own close control: the host unmounts or hides the wizard, + // and the job keeps running server-side. + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + + await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1), WAIT); + expect(onComplete.mock.calls[0][0]).toMatchObject({ importedRows: 5000, createdRows: 5000 }); + expect(onComplete.mock.calls[0][0].cancelled).toBeUndefined(); + expect(ds.cancelImportJob).not.toHaveBeenCalled(); + }, 20000); + it('when no read settles within the bound, the result shows no count it never read and skips onComplete', async () => { const ds = scriptedDataSource([RUNNING], [new Error('network down')]); const onComplete = vi.fn(); diff --git a/packages/plugin-grid/src/ImportWizard.tsx b/packages/plugin-grid/src/ImportWizard.tsx index 7b36299569..c82ede34ba 100644 --- a/packages/plugin-grid/src/ImportWizard.tsx +++ b/packages/plugin-grid/src/ImportWizard.tsx @@ -1734,7 +1734,9 @@ export const ImportWizard: React.FC = ({ // One import run at a time. The async paths capture this token, and a job // outcome is published only while it is unchanged: publishing moves it on, so // the poll loop and a user Cancel can never both publish (or call onComplete - // twice), and an outcome landing after a reset or a newer run is dropped. + // twice), and an outcome landing after a newer run started is dropped. + // Closing the wizard does NOT move it: a background job that finishes after + // the dialog closed still reports through onComplete, so the host refreshes. const jobRunRef = React.useRef(0); // The final read of the job the result screen shows, for its Undo button. const [finishedJob, setFinishedJob] = useState(null); @@ -2349,7 +2351,6 @@ export const ImportWizard: React.FC = ({ const reset = useCallback(() => { cancelPollRef.current = false; - jobRunRef.current += 1; setFinishedJob(null); setUndoError(null); setStep('upload'); setHeaders([]); setRows([]); setMapping({}); setProgress(0); setResult(null); setCorrections({}); setSelectedTemplateId(null); setMappingName(null);