fix: cancel download fetch when progress toast is closed (#27152)

* fix: cancel download fetch when progress toast is closed

- Create new progress-toast utility using @coss/ui toastManager
- Add ToastProvider to app providers
- Update download components to use AbortController for cancellation
- When cancel button is clicked, abort signal stops the fetch loop

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: track active toasts with Set instead of accessing toastManager.toasts

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat: add AnchoredToastProvider to app providers

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: extract common CSV download logic into useCsvDownload hook

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: improve progress toast with i18n support and Progress component

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: replace useCsvDownload hook with DownloadButton component

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: rename DownloadButton to CsvDownloadButton

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* clean up i18n

* fix: properly throw AbortError when download is cancelled

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* Revert "fix: properly throw AbortError when download is cancelled"

This reverts commit 3c64b848140271e4dcd2e45dba5302eef9ca562c.

* fix abort controller

* fix layout shift

* better handle error

* fix: address Cubic AI review feedback

- Fix typo 'copmlete' -> 'complete' in download_progress i18n string
- Treat null batch as failure during CSV download to prevent partial exports

Co-Authored-By: unknown <>

* remove redundant try - catch

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Eunjae Lee
2026-01-27 16:46:37 +00:00
committed by GitHub
co-authored by unknown <> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 7ce1ee5cea
commit fd476bf7f7
7 changed files with 256 additions and 268 deletions
+6 -1
View File
@@ -1,5 +1,6 @@
"use client";
import { AnchoredToastProvider, ToastProvider } from "@coss/ui/components/toast";
import { TrpcProvider } from "app/_trpc/trpc-provider";
import { SessionProvider } from "next-auth/react";
import CacheProvider from "react-inlinesvg/provider";
@@ -27,7 +28,11 @@ export function Providers({ isEmbed, children, country }: ProvidersProps) {
{!isEmbed && !isBookingPage && <NotificationSoundHandler />}
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
<CacheProvider>
<WebPushProvider>{children}</WebPushProvider>
<WebPushProvider>
<ToastProvider>
<AnchoredToastProvider>{children}</AnchoredToastProvider>
</ToastProvider>
</WebPushProvider>
</CacheProvider>
</TrpcProvider>
</SessionProvider>
@@ -0,0 +1,175 @@
"use client";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@coss/ui/components/button";
import { Group, GroupSeparator, GroupText } from "@coss/ui/components/group";
import { Spinner } from "@coss/ui/components/spinner";
import { toastManager } from "@coss/ui/components/toast";
import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "@coss/ui/components/tooltip";
import { DownloadIcon, XIcon } from "lucide-react";
import { useCallback, useRef, useState } from "react";
interface PaginatedResponse<TData> {
data: TData[];
total: number;
}
interface CsvDownloadButtonProps<TData, TTransformed = TData> {
fetchBatch: (offset: number) => Promise<PaginatedResponse<TData> | null>;
transformData?: (data: TData[]) => TTransformed[];
filename: string | (() => string);
onDownloadStart?: () => void;
}
function wrapWithAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) {
return Promise.reject(new DOMException("Cancelled", "AbortError"));
}
return new Promise<T>((resolve, reject) => {
const handleAbort = () => {
reject(new DOMException("Cancelled", "AbortError"));
};
signal.addEventListener("abort", handleAbort, { once: true });
promise.then(resolve, reject).finally(() => {
signal.removeEventListener("abort", handleAbort);
});
});
}
export function CsvDownloadButton<TData, TTransformed = TData>({
fetchBatch,
transformData,
filename,
onDownloadStart,
}: CsvDownloadButtonProps<TData, TTransformed>) {
const { t } = useLocale();
const [isDownloading, setIsDownloading] = useState(false);
const [progress, setProgress] = useState(0);
const abortControllerRef = useRef<AbortController | null>(null);
const infoToastIdRef = useRef<string | null>(null);
const handleDownload = useCallback(async () => {
if (isDownloading) return;
onDownloadStart?.();
setIsDownloading(true);
setProgress(0);
abortControllerRef.current = new AbortController();
const { signal } = abortControllerRef.current;
infoToastIdRef.current = toastManager.add({
title: t("downloading"),
type: "info",
});
try {
const firstBatch = await wrapWithAbort(fetchBatch(0), signal);
if (signal.aborted) return;
if (!firstBatch) {
throw new Error("Failed to download data.");
}
let allData = firstBatch.data;
const totalRecords = firstBatch.total;
while (totalRecords > 0 && allData.length < totalRecords && !signal.aborted) {
const batch = await wrapWithAbort(fetchBatch(allData.length), signal);
if (signal.aborted) return;
if (!batch) {
throw new Error("Failed to download data.");
}
allData = [...allData, ...batch.data];
const currentProgress = Math.min(Math.round((allData.length / totalRecords) * 100), 99);
setProgress(currentProgress);
}
if (signal.aborted) return;
if (allData.length < totalRecords) {
throw new Error("Failed to download data.");
}
setProgress(100);
if (infoToastIdRef.current) {
toastManager.close(infoToastIdRef.current);
infoToastIdRef.current = null;
}
const csvData = transformData ? transformData(allData) : allData;
const resolvedFilename = typeof filename === "function" ? filename() : filename;
downloadAsCsv(csvData as Record<string, unknown>[], resolvedFilename);
} catch (err) {
if (infoToastIdRef.current) {
toastManager.close(infoToastIdRef.current);
infoToastIdRef.current = null;
}
if (err instanceof DOMException && err.name === "AbortError") {
toastManager.add({
title: t("cancelled"),
type: "error",
});
} else {
toastManager.add({
title: t("failed_to_download"),
type: "error",
});
}
} finally {
setIsDownloading(false);
setProgress(0);
abortControllerRef.current = null;
infoToastIdRef.current = null;
}
}, [isDownloading, fetchBatch, transformData, filename, onDownloadStart, t]);
function handleCancel() {
abortControllerRef.current?.abort();
}
return (
<TooltipProvider delay={0}>
<div className="inline-grid">
<div className={isDownloading ? "col-start-1 row-start-1 invisible" : "col-start-1 row-start-1"}>
<Button onClick={handleDownload} variant="outline">
<DownloadIcon aria-hidden="true" />
{t("download")}
</Button>
</div>
<div className={isDownloading ? "col-start-1 row-start-1" : "col-start-1 row-start-1 invisible"}>
<Group>
<GroupText aria-live="polite" className="cursor-default gap-2" role="status">
<Spinner />
<span aria-hidden="true" className="font-medium text-foreground tabular-nums">
{progress.toString().padStart(2, "\u2007")}%
</span>
<span className="sr-only">
{t("downloading")}, {t("download_progress", { progress })}
</span>
</GroupText>
<GroupSeparator />
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label={t("cancel_download")}
onClick={handleCancel}
size="icon"
variant="outline"
/>
}>
<XIcon aria-hidden="true" />
</TooltipTrigger>
<TooltipPopup>{t("cancel")}</TooltipPopup>
</Tooltip>
</Group>
</div>
</div>
</TooltipProvider>
);
}
@@ -1,14 +1,12 @@
"use client";
import dayjs from "@calcom/dayjs";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Button } from "@calcom/ui/components/button";
import { hideProgressToast, showProgressToast, showToast } from "@calcom/ui/components/toast";
import { useState } from "react";
import { CsvDownloadButton } from "@lib/components/CsvDownloadButton";
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
import type { BookingListingStatus } from "../types";
@@ -38,7 +36,6 @@ function transformBookingToCsv(booking: BookingOutput, t: TranslationFunction) {
export function BookingsCsvDownload({ status }: BookingsCsvDownloadProps) {
const { t } = useLocale();
const { data: user, isPending: isUserPending } = useMeQuery();
const [isDownloading, setIsDownloading] = useState(false);
const utils = trpc.useUtils();
const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } =
@@ -51,75 +48,30 @@ export function BookingsCsvDownload({ status }: BookingsCsvDownloadProps) {
return null;
}
const fetchBatch = async (offset: number) => {
const result = await utils.viewer.bookings.get.fetch({
limit: BATCH_SIZE,
offset,
filters: {
statuses: [status],
eventTypeIds,
teamIds,
userIds,
attendeeName,
attendeeEmail,
bookingUid,
afterStartDate: dateRange?.startDate
? dayjs(dateRange?.startDate).startOf("day").toISOString()
: undefined,
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
},
});
return {
bookings: result.bookings,
totalCount: result.totalCount,
};
};
const handleDownload = async () => {
try {
setIsDownloading(true);
showProgressToast(0);
// Fetch first batch to get total count
const firstBatch = await fetchBatch(0);
let allBookings = firstBatch.bookings;
const totalCount = firstBatch.totalCount;
// Continue fetching remaining batches
while (allBookings.length < totalCount) {
const offset = allBookings.length;
const batch = await fetchBatch(offset);
if (batch.bookings.length === 0) break; // Prevent infinite loop if batch returns empty
allBookings = [...allBookings, ...batch.bookings];
const currentProgress = Math.min(Math.round((allBookings.length / totalCount) * 100), 99);
showProgressToast(currentProgress);
}
showProgressToast(100);
// Transform and download
const csvData = allBookings.map((booking) => transformBookingToCsv(booking, t));
const filename = `${t("bookings").toLowerCase()}-${status}-${dayjs().format("YYYY-MM-DD")}.csv`;
downloadAsCsv(csvData, filename);
} catch {
showToast(t("unexpected_error_try_again"), "error");
} finally {
setIsDownloading(false);
hideProgressToast();
}
};
return (
<Button
color="secondary"
StartIcon="download"
loading={isDownloading}
onClick={handleDownload}
size="sm"
className="h-full">
{t("download")}
</Button>
<CsvDownloadButton
fetchBatch={async (offset) => {
const result = await utils.viewer.bookings.get.fetch({
limit: BATCH_SIZE,
offset,
filters: {
statuses: [status],
eventTypeIds,
teamIds,
userIds,
attendeeName,
attendeeEmail,
bookingUid,
afterStartDate: dateRange?.startDate
? dayjs(dateRange?.startDate).startOf("day").toISOString()
: undefined,
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
},
});
return { data: result.bookings, total: result.totalCount };
}}
transformData={(bookings) => bookings.map((booking) => transformBookingToCsv(booking, t))}
filename={`${t("bookings").toLowerCase()}-${status}-${dayjs().format("YYYY-MM-DD")}.csv`}
/>
);
}
@@ -1,112 +1,41 @@
import { useState } from "react";
import posthog from "posthog-js";
import dayjs from "@calcom/dayjs";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
import { showToast, showProgressToast, hideProgressToast } from "@calcom/ui/components/toast";
import { useInsightsBookingParameters } from "../../../hooks/useInsightsBookingParameters";
import { extractDateRangeFromColumnFilters } from "@calcom/features/insights/lib/bookingUtils";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { CsvDownloadButton } from "@lib/components/CsvDownloadButton";
import posthog from "posthog-js";
import { useInsightsBookingParameters } from "../../../hooks/useInsightsBookingParameters";
type RawData = RouterOutputs["viewer"]["insights"]["rawData"]["data"][number];
const BATCH_SIZE = 100;
const Download = () => {
const { t } = useLocale();
const insightsBookingParams = useInsightsBookingParameters();
const { startDate, endDate } = extractDateRangeFromColumnFilters(insightsBookingParams.columnFilters);
const [isDownloading, setIsDownloading] = useState(false);
const utils = trpc.useUtils();
type PaginatedResponse = {
data: RawData[];
total: number;
};
const fetchBatch = async (offset: number): Promise<PaginatedResponse | null> => {
try {
const result = await utils.viewer.insights.rawData.fetch({
...insightsBookingParams,
limit: BATCH_SIZE,
offset,
});
if (result && "data" in result && "total" in result) {
return result as PaginatedResponse;
}
return null;
} catch {
return null;
}
};
const handleDownloadClick = async () => {
try {
posthog.capture("insights_bookings_download_clicked", { teamId: insightsBookingParams.selectedTeamId });
setIsDownloading(true);
showProgressToast(0);
let allData: RawData[] = [];
let offset = 0;
// Get first batch to get total count
const firstBatch = await fetchBatch(0);
if (!firstBatch) return;
allData = firstBatch.data;
const totalRecords = firstBatch.total;
// Continue fetching remaining batches
while (totalRecords > 0 && allData.length < totalRecords) {
offset += BATCH_SIZE;
const result = await fetchBatch(offset);
if (!result) break;
allData = [...allData, ...result.data];
const currentProgress = Math.min(Math.round((allData.length / totalRecords) * 100), 99);
showProgressToast(currentProgress);
}
if (allData.length >= totalRecords) {
showProgressToast(100); // Set to 100% before actual download
const filename = `Insights-${dayjs(startDate).format("YYYY-MM-DD")}-${dayjs(endDate).format(
"YYYY-MM-DD"
)}.csv`;
downloadAsCsv(allData as Record<string, unknown>[], filename);
}
} catch {
showToast(t("unexpected_error_try_again"), "error");
} finally {
setIsDownloading(false);
hideProgressToast(); // Reset progress
}
};
return (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button
EndIcon="file-down"
color="secondary"
loading={isDownloading}
className="h-full self-end sm:self-baseline">
{t("download")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownItem onClick={handleDownloadClick}>{t("as_csv")}</DropdownItem>
</DropdownMenuContent>
</Dropdown>
<CsvDownloadButton<RawData>
fetchBatch={async (offset) => {
const result = await utils.viewer.insights.rawData.fetch({
...insightsBookingParams,
limit: BATCH_SIZE,
offset,
});
if (result && "data" in result && "total" in result) {
return { data: result.data as RawData[], total: result.total };
}
return null;
}}
filename={`Insights-${dayjs(startDate).format("YYYY-MM-DD")}-${dayjs(endDate).format("YYYY-MM-DD")}.csv`}
onDownloadStart={() => {
posthog.capture("insights_bookings_download_clicked", {
teamId: insightsBookingParams.selectedTeamId,
});
}}
/>
);
};
@@ -1,103 +1,33 @@
import { useState } from "react";
import dayjs from "@calcom/dayjs";
import type { SortingState } from "@calcom/features/data-table";
import { useInsightsRoutingParameters } from "@calcom/web/modules/insights/hooks/useInsightsRoutingParameters";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
import { showToast, showProgressToast, hideProgressToast } from "@calcom/ui/components/toast";
import { useInsightsRoutingParameters } from "@calcom/web/modules/insights/hooks/useInsightsRoutingParameters";
type RoutingData = RouterOutputs["viewer"]["insights"]["routingFormResponsesForDownload"]["data"][number];
import { CsvDownloadButton } from "@lib/components/CsvDownloadButton";
type Props = {
sorting: SortingState;
};
const BATCH_SIZE = 100; // Increased batch size for downloads
const BATCH_SIZE = 100;
export const RoutingFormResponsesDownload = ({ sorting }: Props) => {
const { t } = useLocale();
const [isDownloading, setIsDownloading] = useState(false);
const insightsRoutingParameters = useInsightsRoutingParameters();
const { startDate, endDate } = insightsRoutingParameters;
const utils = trpc.useUtils();
const fetchBatch = async (
offset: number
): Promise<{
data: RoutingData[];
total: number;
}> => {
const result = await utils.viewer.insights.routingFormResponsesForDownload.fetch({
...insightsRoutingParameters,
sorting,
limit: BATCH_SIZE,
offset,
});
return result;
};
const handleDownloadClick = async () => {
try {
setIsDownloading(true);
showProgressToast(0); // Reset progress
let allData: RoutingData[] = [];
let offset = 0;
// Get first batch to get total count
const firstBatch = await fetchBatch(0);
allData = [...firstBatch.data];
const totalRecords = firstBatch.total;
// Continue fetching remaining batches
while (totalRecords > 0 && allData.length < totalRecords) {
offset += BATCH_SIZE;
const result = await fetchBatch(offset);
allData = [...allData, ...result.data];
const currentProgress = Math.min(Math.round((allData.length / totalRecords) * 100), 99);
showProgressToast(currentProgress);
}
if (allData.length >= totalRecords) {
showProgressToast(100); // Set to 100% before actual download
const filename = `RoutingFormResponses-${dayjs(startDate).format("YYYY-MM-DD")}-${dayjs(
endDate
).format("YYYY-MM-DD")}.csv`;
downloadAsCsv(allData as Record<string, unknown>[], filename);
}
} catch (error) {
showToast(t("error_downloading_data"), "error");
} finally {
setIsDownloading(false);
hideProgressToast(); // Reset progress
}
};
return (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button
EndIcon="file-down"
color="secondary"
className="self-end sm:self-baseline"
loading={isDownloading}>
{t("download")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownItem onClick={handleDownloadClick}>{t("as_csv")}</DropdownItem>
</DropdownMenuContent>
</Dropdown>
<CsvDownloadButton
fetchBatch={async (offset) => {
const result = await utils.viewer.insights.routingFormResponsesForDownload.fetch({
...insightsRoutingParameters,
sorting,
limit: BATCH_SIZE,
offset,
});
return result;
}}
filename={`RoutingFormResponses-${dayjs(startDate).format("YYYY-MM-DD")}-${dayjs(endDate).format("YYYY-MM-DD")}.csv`}
/>
);
};
+6 -12
View File
@@ -1,11 +1,9 @@
import { expect } from "@playwright/test";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { randomString } from "@calcom/lib/random";
import { prisma } from "@calcom/prisma";
import { clearFilters, applySelectFilter } from "./filter-helpers";
import { expect } from "@playwright/test";
import { applySelectFilter, clearFilters } from "./filter-helpers";
import { test } from "./lib/fixtures";
import { createAllPermissionsArray, enablePBACForTeam } from "./lib/test-helpers/pbac";
@@ -224,17 +222,13 @@ test.describe("Insights", async () => {
const downloadPromise = page.waitForEvent("download");
const downloadButton = page.getByRole("button", { name: "Download" });
// Expect download button to be visible
await expect(page.locator("text=Download")).toBeVisible();
// Click on Download button
await page.getByText("Download").click();
// Expect as csv option to be visible
await expect(page.locator("text=as CSV")).toBeVisible();
await expect(downloadButton).toBeVisible();
// Start waiting for download before clicking. Note no await.
await page.getByText("as CSV").click();
await downloadButton.click();
const download = await downloadPromise;
// Wait for the download process to complete and save the downloaded file somewhere.
@@ -4508,5 +4508,8 @@
"error_enabling_feature": "Error enabling feature. Please try again.",
"set_organizer_as_contact_owner": "Set booking organizer as contact owner",
"overwrite_existing_contact_owner": "Overwrite existing contact owner",
"failed_to_download": "Failed to download",
"cancel_download": "Cancel Download",
"download_progress": "{{progress}}% complete",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS":"↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}