diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index db18f72e4d..838f7c25bd 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -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 && } {/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */} - {children} + + + {children} + + diff --git a/apps/web/lib/components/CsvDownloadButton.tsx b/apps/web/lib/components/CsvDownloadButton.tsx new file mode 100644 index 0000000000..d096ca6330 --- /dev/null +++ b/apps/web/lib/components/CsvDownloadButton.tsx @@ -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 { + data: TData[]; + total: number; +} + +interface CsvDownloadButtonProps { + fetchBatch: (offset: number) => Promise | null>; + transformData?: (data: TData[]) => TTransformed[]; + filename: string | (() => string); + onDownloadStart?: () => void; +} + +function wrapWithAbort(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.reject(new DOMException("Cancelled", "AbortError")); + } + + return new Promise((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({ + fetchBatch, + transformData, + filename, + onDownloadStart, +}: CsvDownloadButtonProps) { + const { t } = useLocale(); + const [isDownloading, setIsDownloading] = useState(false); + const [progress, setProgress] = useState(0); + const abortControllerRef = useRef(null); + const infoToastIdRef = useRef(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[], 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 ( + + + + + + {t("download")} + + + + + + + + {progress.toString().padStart(2, "\u2007")}% + + + {t("downloading")}, {t("download_progress", { progress })} + + + + + + }> + + + {t("cancel")} + + + + + + ); +} diff --git a/apps/web/modules/bookings/components/BookingsCsvDownload.tsx b/apps/web/modules/bookings/components/BookingsCsvDownload.tsx index b925356ccd..87a374eec0 100644 --- a/apps/web/modules/bookings/components/BookingsCsvDownload.tsx +++ b/apps/web/modules/bookings/components/BookingsCsvDownload.tsx @@ -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 ( - - {t("download")} - + { + 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`} + /> ); } diff --git a/apps/web/modules/insights/components/filters/Download/Download.tsx b/apps/web/modules/insights/components/filters/Download/Download.tsx index b6db1b490c..1d80da147e 100644 --- a/apps/web/modules/insights/components/filters/Download/Download.tsx +++ b/apps/web/modules/insights/components/filters/Download/Download.tsx @@ -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 => { - 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[], filename); - } - } catch { - showToast(t("unexpected_error_try_again"), "error"); - } finally { - setIsDownloading(false); - hideProgressToast(); // Reset progress - } - }; - return ( - - - - {t("download")} - - - - {t("as_csv")} - - + + 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, + }); + }} + /> ); }; diff --git a/apps/web/modules/insights/components/filters/Download/RoutingFormResponsesDownload.tsx b/apps/web/modules/insights/components/filters/Download/RoutingFormResponsesDownload.tsx index af99131a36..86a3e8b024 100644 --- a/apps/web/modules/insights/components/filters/Download/RoutingFormResponsesDownload.tsx +++ b/apps/web/modules/insights/components/filters/Download/RoutingFormResponsesDownload.tsx @@ -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[], filename); - } - } catch (error) { - showToast(t("error_downloading_data"), "error"); - } finally { - setIsDownloading(false); - hideProgressToast(); // Reset progress - } - }; - return ( - - - - {t("download")} - - - - {t("as_csv")} - - + { + 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`} + /> ); }; diff --git a/apps/web/playwright/insights.e2e.ts b/apps/web/playwright/insights.e2e.ts index aa00615b81..16388be221 100644 --- a/apps/web/playwright/insights.e2e.ts +++ b/apps/web/playwright/insights.e2e.ts @@ -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. diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 8fa7b7c798..b8d051fdf6 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" }