Skip to content

Commit fd476bf

Browse files
fix: cancel download fetch when progress toast is closed (calcom#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 3c64b84. * 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>
1 parent 7ce1ee5 commit fd476bf

7 files changed

Lines changed: 255 additions & 267 deletions

File tree

apps/web/app/providers.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { AnchoredToastProvider, ToastProvider } from "@coss/ui/components/toast";
34
import { TrpcProvider } from "app/_trpc/trpc-provider";
45
import { SessionProvider } from "next-auth/react";
56
import CacheProvider from "react-inlinesvg/provider";
@@ -27,7 +28,11 @@ export function Providers({ isEmbed, children, country }: ProvidersProps) {
2728
{!isEmbed && !isBookingPage && <NotificationSoundHandler />}
2829
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
2930
<CacheProvider>
30-
<WebPushProvider>{children}</WebPushProvider>
31+
<WebPushProvider>
32+
<ToastProvider>
33+
<AnchoredToastProvider>{children}</AnchoredToastProvider>
34+
</ToastProvider>
35+
</WebPushProvider>
3136
</CacheProvider>
3237
</TrpcProvider>
3338
</SessionProvider>
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"use client";
2+
3+
import { downloadAsCsv } from "@calcom/lib/csvUtils";
4+
import { useLocale } from "@calcom/lib/hooks/useLocale";
5+
import { Button } from "@coss/ui/components/button";
6+
import { Group, GroupSeparator, GroupText } from "@coss/ui/components/group";
7+
import { Spinner } from "@coss/ui/components/spinner";
8+
import { toastManager } from "@coss/ui/components/toast";
9+
import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "@coss/ui/components/tooltip";
10+
import { DownloadIcon, XIcon } from "lucide-react";
11+
import { useCallback, useRef, useState } from "react";
12+
13+
interface PaginatedResponse<TData> {
14+
data: TData[];
15+
total: number;
16+
}
17+
18+
interface CsvDownloadButtonProps<TData, TTransformed = TData> {
19+
fetchBatch: (offset: number) => Promise<PaginatedResponse<TData> | null>;
20+
transformData?: (data: TData[]) => TTransformed[];
21+
filename: string | (() => string);
22+
onDownloadStart?: () => void;
23+
}
24+
25+
function wrapWithAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
26+
if (signal.aborted) {
27+
return Promise.reject(new DOMException("Cancelled", "AbortError"));
28+
}
29+
30+
return new Promise<T>((resolve, reject) => {
31+
const handleAbort = () => {
32+
reject(new DOMException("Cancelled", "AbortError"));
33+
};
34+
35+
signal.addEventListener("abort", handleAbort, { once: true });
36+
37+
promise.then(resolve, reject).finally(() => {
38+
signal.removeEventListener("abort", handleAbort);
39+
});
40+
});
41+
}
42+
43+
export function CsvDownloadButton<TData, TTransformed = TData>({
44+
fetchBatch,
45+
transformData,
46+
filename,
47+
onDownloadStart,
48+
}: CsvDownloadButtonProps<TData, TTransformed>) {
49+
const { t } = useLocale();
50+
const [isDownloading, setIsDownloading] = useState(false);
51+
const [progress, setProgress] = useState(0);
52+
const abortControllerRef = useRef<AbortController | null>(null);
53+
const infoToastIdRef = useRef<string | null>(null);
54+
55+
const handleDownload = useCallback(async () => {
56+
if (isDownloading) return;
57+
58+
onDownloadStart?.();
59+
setIsDownloading(true);
60+
setProgress(0);
61+
abortControllerRef.current = new AbortController();
62+
const { signal } = abortControllerRef.current;
63+
64+
infoToastIdRef.current = toastManager.add({
65+
title: t("downloading"),
66+
type: "info",
67+
});
68+
69+
try {
70+
const firstBatch = await wrapWithAbort(fetchBatch(0), signal);
71+
if (signal.aborted) return;
72+
if (!firstBatch) {
73+
throw new Error("Failed to download data.");
74+
}
75+
76+
let allData = firstBatch.data;
77+
const totalRecords = firstBatch.total;
78+
79+
while (totalRecords > 0 && allData.length < totalRecords && !signal.aborted) {
80+
const batch = await wrapWithAbort(fetchBatch(allData.length), signal);
81+
if (signal.aborted) return;
82+
if (!batch) {
83+
throw new Error("Failed to download data.");
84+
}
85+
allData = [...allData, ...batch.data];
86+
87+
const currentProgress = Math.min(Math.round((allData.length / totalRecords) * 100), 99);
88+
setProgress(currentProgress);
89+
}
90+
91+
if (signal.aborted) return;
92+
if (allData.length < totalRecords) {
93+
throw new Error("Failed to download data.");
94+
}
95+
96+
setProgress(100);
97+
98+
if (infoToastIdRef.current) {
99+
toastManager.close(infoToastIdRef.current);
100+
infoToastIdRef.current = null;
101+
}
102+
103+
const csvData = transformData ? transformData(allData) : allData;
104+
const resolvedFilename = typeof filename === "function" ? filename() : filename;
105+
downloadAsCsv(csvData as Record<string, unknown>[], resolvedFilename);
106+
} catch (err) {
107+
if (infoToastIdRef.current) {
108+
toastManager.close(infoToastIdRef.current);
109+
infoToastIdRef.current = null;
110+
}
111+
112+
if (err instanceof DOMException && err.name === "AbortError") {
113+
toastManager.add({
114+
title: t("cancelled"),
115+
type: "error",
116+
});
117+
} else {
118+
toastManager.add({
119+
title: t("failed_to_download"),
120+
type: "error",
121+
});
122+
}
123+
} finally {
124+
setIsDownloading(false);
125+
setProgress(0);
126+
abortControllerRef.current = null;
127+
infoToastIdRef.current = null;
128+
}
129+
}, [isDownloading, fetchBatch, transformData, filename, onDownloadStart, t]);
130+
131+
function handleCancel() {
132+
abortControllerRef.current?.abort();
133+
}
134+
135+
return (
136+
<TooltipProvider delay={0}>
137+
<div className="inline-grid">
138+
<div className={isDownloading ? "col-start-1 row-start-1 invisible" : "col-start-1 row-start-1"}>
139+
<Button onClick={handleDownload} variant="outline">
140+
<DownloadIcon aria-hidden="true" />
141+
{t("download")}
142+
</Button>
143+
</div>
144+
<div className={isDownloading ? "col-start-1 row-start-1" : "col-start-1 row-start-1 invisible"}>
145+
<Group>
146+
<GroupText aria-live="polite" className="cursor-default gap-2" role="status">
147+
<Spinner />
148+
<span aria-hidden="true" className="font-medium text-foreground tabular-nums">
149+
{progress.toString().padStart(2, "\u2007")}%
150+
</span>
151+
<span className="sr-only">
152+
{t("downloading")}, {t("download_progress", { progress })}
153+
</span>
154+
</GroupText>
155+
<GroupSeparator />
156+
<Tooltip>
157+
<TooltipTrigger
158+
render={
159+
<Button
160+
aria-label={t("cancel_download")}
161+
onClick={handleCancel}
162+
size="icon"
163+
variant="outline"
164+
/>
165+
}>
166+
<XIcon aria-hidden="true" />
167+
</TooltipTrigger>
168+
<TooltipPopup>{t("cancel")}</TooltipPopup>
169+
</Tooltip>
170+
</Group>
171+
</div>
172+
</div>
173+
</TooltipProvider>
174+
);
175+
}
Lines changed: 26 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
"use client";
22

33
import dayjs from "@calcom/dayjs";
4-
import { downloadAsCsv } from "@calcom/lib/csvUtils";
54
import { useLocale } from "@calcom/lib/hooks/useLocale";
65
import type { RouterOutputs } from "@calcom/trpc/react";
76
import { trpc } from "@calcom/trpc/react";
87
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
9-
import { Button } from "@calcom/ui/components/button";
10-
import { hideProgressToast, showProgressToast, showToast } from "@calcom/ui/components/toast";
11-
import { useState } from "react";
8+
9+
import { CsvDownloadButton } from "@lib/components/CsvDownloadButton";
1210
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
1311
import type { BookingListingStatus } from "../types";
1412

@@ -38,7 +36,6 @@ function transformBookingToCsv(booking: BookingOutput, t: TranslationFunction) {
3836
export function BookingsCsvDownload({ status }: BookingsCsvDownloadProps) {
3937
const { t } = useLocale();
4038
const { data: user, isPending: isUserPending } = useMeQuery();
41-
const [isDownloading, setIsDownloading] = useState(false);
4239
const utils = trpc.useUtils();
4340

4441
const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } =
@@ -51,75 +48,30 @@ export function BookingsCsvDownload({ status }: BookingsCsvDownloadProps) {
5148
return null;
5249
}
5350

54-
const fetchBatch = async (offset: number) => {
55-
const result = await utils.viewer.bookings.get.fetch({
56-
limit: BATCH_SIZE,
57-
offset,
58-
filters: {
59-
statuses: [status],
60-
eventTypeIds,
61-
teamIds,
62-
userIds,
63-
attendeeName,
64-
attendeeEmail,
65-
bookingUid,
66-
afterStartDate: dateRange?.startDate
67-
? dayjs(dateRange?.startDate).startOf("day").toISOString()
68-
: undefined,
69-
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
70-
},
71-
});
72-
73-
return {
74-
bookings: result.bookings,
75-
totalCount: result.totalCount,
76-
};
77-
};
78-
79-
const handleDownload = async () => {
80-
try {
81-
setIsDownloading(true);
82-
showProgressToast(0);
83-
84-
// Fetch first batch to get total count
85-
const firstBatch = await fetchBatch(0);
86-
let allBookings = firstBatch.bookings;
87-
const totalCount = firstBatch.totalCount;
88-
89-
// Continue fetching remaining batches
90-
while (allBookings.length < totalCount) {
91-
const offset = allBookings.length;
92-
const batch = await fetchBatch(offset);
93-
if (batch.bookings.length === 0) break; // Prevent infinite loop if batch returns empty
94-
allBookings = [...allBookings, ...batch.bookings];
95-
96-
const currentProgress = Math.min(Math.round((allBookings.length / totalCount) * 100), 99);
97-
showProgressToast(currentProgress);
98-
}
99-
100-
showProgressToast(100);
101-
102-
// Transform and download
103-
const csvData = allBookings.map((booking) => transformBookingToCsv(booking, t));
104-
const filename = `${t("bookings").toLowerCase()}-${status}-${dayjs().format("YYYY-MM-DD")}.csv`;
105-
downloadAsCsv(csvData, filename);
106-
} catch {
107-
showToast(t("unexpected_error_try_again"), "error");
108-
} finally {
109-
setIsDownloading(false);
110-
hideProgressToast();
111-
}
112-
};
113-
11451
return (
115-
<Button
116-
color="secondary"
117-
StartIcon="download"
118-
loading={isDownloading}
119-
onClick={handleDownload}
120-
size="sm"
121-
className="h-full">
122-
{t("download")}
123-
</Button>
52+
<CsvDownloadButton
53+
fetchBatch={async (offset) => {
54+
const result = await utils.viewer.bookings.get.fetch({
55+
limit: BATCH_SIZE,
56+
offset,
57+
filters: {
58+
statuses: [status],
59+
eventTypeIds,
60+
teamIds,
61+
userIds,
62+
attendeeName,
63+
attendeeEmail,
64+
bookingUid,
65+
afterStartDate: dateRange?.startDate
66+
? dayjs(dateRange?.startDate).startOf("day").toISOString()
67+
: undefined,
68+
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
69+
},
70+
});
71+
return { data: result.bookings, total: result.totalCount };
72+
}}
73+
transformData={(bookings) => bookings.map((booking) => transformBookingToCsv(booking, t))}
74+
filename={`${t("bookings").toLowerCase()}-${status}-${dayjs().format("YYYY-MM-DD")}.csv`}
75+
/>
12476
);
12577
}

0 commit comments

Comments
 (0)