diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index 838f7c25bd..db18f72e4d 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -1,6 +1,5 @@ "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"; @@ -28,11 +27,7 @@ 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 deleted file mode 100644 index d096ca6330..0000000000 --- a/apps/web/lib/components/CsvDownloadButton.tsx +++ /dev/null @@ -1,175 +0,0 @@ -"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 87a374eec0..b925356ccd 100644 --- a/apps/web/modules/bookings/components/BookingsCsvDownload.tsx +++ b/apps/web/modules/bookings/components/BookingsCsvDownload.tsx @@ -1,12 +1,14 @@ "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 { CsvDownloadButton } from "@lib/components/CsvDownloadButton"; +import { Button } from "@calcom/ui/components/button"; +import { hideProgressToast, showProgressToast, showToast } from "@calcom/ui/components/toast"; +import { useState } from "react"; import { useBookingFilters } from "~/bookings/hooks/useBookingFilters"; import type { BookingListingStatus } from "../types"; @@ -36,6 +38,7 @@ 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 } = @@ -48,30 +51,75 @@ 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 ( - { - 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`} - /> + + {t("download")} + ); } diff --git a/apps/web/modules/insights/components/filters/Download/Download.tsx b/apps/web/modules/insights/components/filters/Download/Download.tsx index 1d80da147e..b6db1b490c 100644 --- a/apps/web/modules/insights/components/filters/Download/Download.tsx +++ b/apps/web/modules/insights/components/filters/Download/Download.tsx @@ -1,41 +1,112 @@ -import dayjs from "@calcom/dayjs"; -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 { 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"; 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(); - return ( - - fetchBatch={async (offset) => { - const result = await utils.viewer.insights.rawData.fetch({ - ...insightsBookingParams, - limit: BATCH_SIZE, - offset, - }); + type PaginatedResponse = { + data: RawData[]; + total: number; + }; - 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, - }); - }} - /> + 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")} + + ); }; diff --git a/apps/web/modules/insights/components/filters/Download/RoutingFormResponsesDownload.tsx b/apps/web/modules/insights/components/filters/Download/RoutingFormResponsesDownload.tsx index 86a3e8b024..af99131a36 100644 --- a/apps/web/modules/insights/components/filters/Download/RoutingFormResponsesDownload.tsx +++ b/apps/web/modules/insights/components/filters/Download/RoutingFormResponsesDownload.tsx @@ -1,33 +1,103 @@ +import { useState } from "react"; + import dayjs from "@calcom/dayjs"; import type { SortingState } from "@calcom/features/data-table"; -import { trpc } from "@calcom/trpc/react"; 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 { CsvDownloadButton } from "@lib/components/CsvDownloadButton"; +type RoutingData = RouterOutputs["viewer"]["insights"]["routingFormResponsesForDownload"]["data"][number]; type Props = { sorting: SortingState; }; -const BATCH_SIZE = 100; +const BATCH_SIZE = 100; // Increased batch size for downloads 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 ( - { - 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`} - /> + + + + {t("download")} + + + + {t("as_csv")} + + ); }; diff --git a/apps/web/playwright/insights.e2e.ts b/apps/web/playwright/insights.e2e.ts index 16388be221..aa00615b81 100644 --- a/apps/web/playwright/insights.e2e.ts +++ b/apps/web/playwright/insights.e2e.ts @@ -1,9 +1,11 @@ +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 { expect } from "@playwright/test"; -import { applySelectFilter, clearFilters } from "./filter-helpers"; + +import { clearFilters, applySelectFilter } from "./filter-helpers"; import { test } from "./lib/fixtures"; import { createAllPermissionsArray, enablePBACForTeam } from "./lib/test-helpers/pbac"; @@ -222,13 +224,17 @@ test.describe("Insights", async () => { const downloadPromise = page.waitForEvent("download"); - const downloadButton = page.getByRole("button", { name: "Download" }); - // Expect download button to be visible - await expect(downloadButton).toBeVisible(); + 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(); // Start waiting for download before clicking. Note no await. - await downloadButton.click(); + await page.getByText("as CSV").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 b8d051fdf6..8fa7b7c798 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -4508,8 +4508,5 @@ "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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" }