diff --git a/frontend/src/lib/components/ImportWizardSteps.svelte b/frontend/src/lib/components/ImportWizardSteps.svelte index dd86aecebc..5712ede180 100644 --- a/frontend/src/lib/components/ImportWizardSteps.svelte +++ b/frontend/src/lib/components/ImportWizardSteps.svelte @@ -17,9 +17,17 @@ step: WizardStep /** Whether this import has a setup step at all — most projects do not. */ hasSetup?: boolean + /** + * The lowest step still worth returning to. Defaults to the first. The page raises it + * past the import once that import has landed and the run behind it can no longer be + * recovered — after a reload on the setup step, where the executor was in memory and + * the parking a clean finish cleared. Step 3 would otherwise mount with nothing to + * resume and offer to run the whole bundle again. + */ + lowestStep?: number } - let { step, hasSetup = false }: Props = $props() + let { step, hasSetup = false, lowestStep = 1 }: Props = $props() // Most projects ship no data table migrations, so the wizard is three steps and // says so. A fourth appears only once there is something to configure. @@ -32,6 +40,10 @@ // skip ahead, since each step decides what the next one asks. function onStepClick(index: number) { if (index >= step - 1) return + if (index + 1 < lowestStep) { + sendUserToast('The project is already imported. There is nothing to go back to.', true) + return + } // An import in flight owns the page: stepping back unmounts the step that is // awaiting the migration review, which would leave the run with no controls // and no way to resolve. diff --git a/frontend/src/lib/importWizard/abandon.test.ts b/frontend/src/lib/importWizard/abandon.test.ts index 8129888e7f..942788c66f 100644 --- a/frontend/src/lib/importWizard/abandon.test.ts +++ b/frontend/src/lib/importWizard/abandon.test.ts @@ -104,6 +104,19 @@ describe('abandoning mid-import', () => { expect(resumableImport('calendly', 'ws-a')).toBe(true) }) + it('stops the migrate row spinning when it is abandoned mid-migration', async () => { + const run = new ImportExecution(PLAN, deps) + // Abandon once the write loop has started, which is where `onMigrationsStart` has + // already flipped the row to running in a real run. + hooks.afterFirstItem = () => { + run.abandon() + } + await run.run() + // A row left on `running` reads as work still in progress on a run that has stopped. + expect(run.tasks.some((t) => t.status === 'running')).toBe(false) + expect(run.done).toBe(false) + }) + it('clears it on a clean finish, so a later import reaches its own create', async () => { parkImport({ slug: 'calendly', workspaceId: 'ws-a' }) const run = new ImportExecution(PLAN, deps) diff --git a/frontend/src/routes/(root)/(logged)/projects/import/+page@(root).svelte b/frontend/src/routes/(root)/(logged)/projects/import/+page@(root).svelte index 0486e5259c..a1bc2ab970 100644 --- a/frontend/src/routes/(root)/(logged)/projects/import/+page@(root).svelte +++ b/frontend/src/routes/(root)/(logged)/projects/import/+page@(root).svelte @@ -328,7 +328,15 @@ {/if} {/snippet} - + + {#if step === 1}
@@ -513,7 +521,13 @@ setupPending={setupNeeded} {setupUndecided} onFolderChange={(folder) => go({ folder }, 3, { replace: true })} - onFinish={() => (setupNeeded ? go({}, 4) : finish())} + onFinish={() => + setupNeeded + ? // Replaces rather than pushes: after a reload on step 4 the run is gone, and + // a step-3 entry in history is a browser-Back route to the same fresh import + // the stepper is now blocked from reaching. + go({}, 4, { replace: true }) + : finish()} onBack={() => go({}, 2)} onExecution={(e) => (execution = e)} resume={execution}