fix(ui): pin the import outcome to the preview it started from

The import success callback read importPreview from component state, so a
preview or configuration change landing mid-import could hand it a
different (or no) package's sidebar ordering. The preview now rides along
as the import mutation's variable, previewing is locked while an import
runs (with an inline explanation), and toggling the pause option after a
failure supersedes the failed request like the other payload fields.
Component tests cover the lockout and the pause-toggle reset.
This commit is contained in:
Devin Foley committed 2026-07-30 13:27:47 -07:00
1 parent cdf3601923
commit 6cffe2f085
2 files changed
+49 -10

No files matched your search

+27
View File
@@ -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<HTMLInputElement>(
@@ -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<HTMLInputElement>('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.
+22 -10
View File
@@ -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"}
</Button>
@@ -1477,6 +1481,11 @@ export function CompanyImport() {
Choose a package above to enable the preview.
</span>
)}
{importMutation.isPending && (
<span className="text-xs text-muted-foreground">
Import in progress — previewing unlocks when it finishes.
</span>
)}
{inlineImportBlocked && (
<span className="text-xs text-amber-500">
Package too large for browser import — see the notice above.
@@ -1561,14 +1570,17 @@ export function CompanyImport() {
<input
type="checkbox"
checked={pauseAutomations}
onChange={(e) => setPauseAutomations(e.target.checked)}
onChange={(e) => {
setPauseAutomations(e.target.checked);
resetMutationState();
}}
className="accent-foreground"
/>
Start imported agents and routines paused
</label>
<Button
size="sm"
onClick={() => importMutation.mutate()}
onClick={() => importMutation.mutate(importPreview)}
disabled={importMutation.isPending || hasErrors || selectedCount === 0 || inlineImportBlocked}
>
<Download className="mr-1.5 h-3.5 w-3.5" />