diff --git a/ui/src/pages/CompanyImport.test.tsx b/ui/src/pages/CompanyImport.test.tsx index d9d32abdfaa..95341d32764 100644 --- a/ui/src/pages/CompanyImport.test.tsx +++ b/ui/src/pages/CompanyImport.test.tsx @@ -396,6 +396,11 @@ describe("CompanyImport", () => { expect(container.textContent).toContain("Uploading and importing"); expect(container.textContent).toContain("Keep this page open."); + // While the import runs, previewing is locked (and explained) so a + // concurrent preview cannot replace the plan the import started from. + expect(findButton((text) => text === "Preview import")?.disabled).toBe(true); + expect(container.textContent).toContain("Import in progress — previewing unlocks when it finishes."); + // Config edits while the import is in flight must not detach it from // the UI: the progress panel keeps reporting it until it settles. const midFlightNameInput = container.querySelector( @@ -434,6 +439,28 @@ describe("CompanyImport", () => { expect(container.textContent).not.toContain("Import failed:"); expect(findButton((text) => text.startsWith("Import 3 file"))).toBeTruthy(); + expect(findButton((text) => text === "Preview import")?.disabled).toBe(false); + + // The pause toggle feeds the request payload too: after another failed + // attempt, toggling it also supersedes the request and clears the panel. + await clickButton((text) => text.startsWith("Import 3 file")); + await act(async () => { + rejectImport(new Error("second failure")); + }); + await flushReact(); + + expect(container.textContent).toContain("Import failed: second failure"); + + const pauseInput = Array.from(container.querySelectorAll("label")) + .find((label) => label.textContent?.includes("Start imported agents and routines paused")) + ?.querySelector('input[type="checkbox"]'); + expect(pauseInput).toBeTruthy(); + await act(async () => { + pauseInput!.click(); + }); + await flushReact(); + + expect(container.textContent).not.toContain("Import failed:"); // Changing the package supersedes the failed import: previewing a fresh // package must not resurface the stale import error panel. diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index 0f22c4d7061..4bb7fec8b11 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -883,9 +883,11 @@ export function CompanyImport() { return selected.length > 0 ? selected : undefined; } - // Apply mutation + // Apply mutation. The preview the import was started from rides along as + // the mutation variable so the success callback never reads state that a + // later preview or configuration change may have replaced. const importMutation = useMutation({ - mutationFn: () => { + mutationFn: (_previewForImport: CompanyPortabilityPreviewResult | null) => { const source = buildSource(); if (!source) throw new Error("No source configured."); return companiesApi.importBundle({ @@ -902,7 +904,7 @@ export function CompanyImport() { pauseAutomations, }); }, - onSuccess: async (result) => { + onSuccess: async (result, previewForImport) => { await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); const importedCompany = await companiesApi.get(result.company.id); const refreshedSession = currentUserId @@ -916,7 +918,7 @@ export function CompanyImport() { ?? refreshedSession?.user?.id ?? refreshedSession?.session?.userId ?? null; - await applyImportedSidebarOrder(importPreview, result, sidebarOrderUserId); + await applyImportedSidebarOrder(previewForImport, result, sidebarOrderUserId); setSelectedCompanyId(importedCompany.id); setActivationChecked(new Set(buildActivationItems(result).map((item) => item.key))); setActivatedKeys(new Set()); @@ -939,9 +941,9 @@ export function CompanyImport() { // Any change to the import configuration supersedes the request a settled // progress/error panel describes, so it clears settled mutation state. A // pending request is never detached: its panel keeps reporting it (and the - // action buttons stay disabled) until it settles. The new-company name is - // typed against a rendered preview, so it resets only the panels; - // structural changes also discard the preview itself. + // action buttons stay disabled) until it settles. The new-company name and + // pause toggle are set against a rendered preview, so they reset only the + // panels; structural changes also discard the preview itself. function resetMutationState() { if (!previewMutation.isPending) previewMutation.reset(); if (!importMutation.isPending) importMutation.reset(); @@ -1468,7 +1470,9 @@ export function CompanyImport() { size="sm" variant="outline" onClick={() => previewMutation.mutate(previewGenerationRef.current)} - disabled={previewMutation.isPending || !hasSource || inlineImportBlocked} + disabled={ + previewMutation.isPending || importMutation.isPending || !hasSource || inlineImportBlocked + } > {previewMutation.isPending ? "Previewing..." : "Preview import"} @@ -1477,6 +1481,11 @@ export function CompanyImport() { Choose a package above to enable the preview. )} + {importMutation.isPending && ( + + Import in progress — previewing unlocks when it finishes. + + )} {inlineImportBlocked && ( Package too large for browser import — see the notice above. @@ -1561,14 +1570,17 @@ export function CompanyImport() { setPauseAutomations(e.target.checked)} + onChange={(e) => { + setPauseAutomations(e.target.checked); + resetMutationState(); + }} className="accent-foreground" /> Start imported agents and routines paused