feat: Add download CSV button on bookings page (#27107)

* Add download CSV button on bookings page

* fix: localize CSV headers and filename using i18n

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: prevent infinite loop when batch returns empty bookings during CSV download

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: alex@cal.com <me@alexvanandel.com>
This commit is contained in:
Bailey Pumfleet
2026-01-22 16:31:17 +00:00
committed by GitHub
co-authored by alex@cal.com <me@alexvanandel.com> alex@cal.com <me@alexvanandel.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> alex@cal.com <me@alexvanandel.com>
parent aaabcf4430
commit 89ae748681
2 changed files with 146 additions and 69 deletions
@@ -1,19 +1,7 @@
"use client";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
} from "@tanstack/react-table";
import { useRouter } from "next/navigation";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import dayjs from "@calcom/dayjs";
import {
useDataTable,
useDisplayedFilterCount,
} from "@calcom/features/data-table";
import { DataTableSegment, DataTableFilters } from "~/data-table/components";
import { useDataTable, useDisplayedFilterCount } from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
@@ -22,7 +10,9 @@ import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { ToggleGroup } from "@calcom/ui/components/form";
import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton";
import { getCoreRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
import { useRouter } from "next/navigation";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
import { useBookingListColumns } from "~/bookings/hooks/useBookingListColumns";
import { useBookingListData } from "~/bookings/hooks/useBookingListData";
@@ -30,18 +20,15 @@ import { useBookingStatusTab } from "~/bookings/hooks/useBookingStatusTab";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import { useListAutoSelector } from "~/bookings/hooks/useListAutoSelector";
import { useListNavigationCapabilities } from "~/bookings/hooks/useListNavigationCapabilities";
import { DataTableFilters, DataTableSegment } from "~/data-table/components";
import {
BookingDetailsSheetStoreProvider,
useBookingDetailsSheetStore,
} from "../store/bookingDetailsSheetStore";
import type {
RowData,
BookingListingStatus,
BookingsGetOutput,
} from "../types";
import type { BookingListingStatus, BookingsGetOutput, RowData } from "../types";
import { BookingDetailsSheet } from "./BookingDetailsSheet";
import { BookingList } from "./BookingList";
import { BookingsCsvDownload } from "./BookingsCsvDownload";
import { ViewToggleButton } from "./ViewToggleButton";
interface FilterButtonProps {
@@ -50,11 +37,7 @@ interface FilterButtonProps {
setShowFilters: (value: boolean | ((prev: boolean) => boolean)) => void;
}
function FilterButton({
table,
displayedFilterCount,
setShowFilters,
}: FilterButtonProps) {
function FilterButton({ table, displayedFilterCount, setShowFilters }: FilterButtonProps) {
const { t } = useLocale();
if (displayedFilterCount === 0) {
@@ -67,8 +50,7 @@ function FilterButton({
StartIcon="list-filter"
className="h-full"
size="sm"
onClick={() => setShowFilters((value) => !value)}
>
onClick={() => setShowFilters((value) => !value)}>
{t("filter")}
<Badge variant="gray" className="ml-1">
{displayedFilterCount}
@@ -109,9 +91,7 @@ function BookingListInner({
}: BookingListInnerProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const setSelectedBookingUid = useBookingDetailsSheetStore(
(state) => state.setSelectedBookingUid
);
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
const router = useRouter();
const [showFilters, setShowFilters] = useState(true);
@@ -119,11 +99,7 @@ function BookingListInner({
useListAutoSelector(bookings);
const ErrorView = errorMessage ? (
<Alert
severity="error"
title={t("something_went_wrong")}
message={errorMessage}
/>
<Alert severity="error" title={t("something_went_wrong")} message={errorMessage} />
) : undefined;
const handleBookingClick = useCallback(
@@ -190,9 +166,7 @@ function BookingListInner({
value={currentTab}
onValueChange={(value) => {
if (!value) return;
const selectedTab = tabOptions.find(
(tab) => tab.value === value
);
const selectedTab = tabOptions.find((tab) => tab.value === value);
if (selectedTab?.href) {
router.push(selectedTab.href);
}
@@ -213,9 +187,8 @@ function BookingListInner({
<div className="hidden grow md:block" />
<DataTableSegment.Select />
{bookingsV3Enabled && (
<ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />
)}
<BookingsCsvDownload status={status} />
{bookingsV3Enabled && <ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />}
</div>
{displayedFilterCount > 0 && showFilters && (
<div className="mt-3 flex flex-wrap items-center gap-2">
@@ -230,11 +203,7 @@ function BookingListInner({
</div>
)}
{status === "upcoming" && !isEmpty && (
<WipeMyCalActionButton
className="mt-4"
bookingStatus={status}
bookingsEmpty={isEmpty}
/>
<WipeMyCalActionButton className="mt-4" bookingStatus={status} bookingsEmpty={isEmpty} />
)}
<div className="mt-4">
<BookingList
@@ -250,9 +219,7 @@ function BookingListInner({
{bookingsV3Enabled && (
<BookingDetailsSheet
userTimeZone={user?.timeZone}
userTimeFormat={
user?.timeFormat === null ? undefined : user?.timeFormat
}
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
userId={user?.id}
userEmail={user?.email}
bookingAuditEnabled={bookingAuditEnabled}
@@ -264,15 +231,8 @@ function BookingListInner({
export function BookingListContainer(props: BookingListContainerProps) {
const { limit, offset, setPageIndex } = useDataTable();
const {
eventTypeIds,
teamIds,
userIds,
dateRange,
attendeeName,
attendeeEmail,
bookingUid,
} = useBookingFilters();
const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } =
useBookingFilters();
// Build query input once - shared between query and prefetching
const queryInput = useMemo(
@@ -290,9 +250,7 @@ export function BookingListContainer(props: BookingListContainerProps) {
afterStartDate: dateRange?.startDate
? dayjs(dateRange?.startDate).startOf("day").toISOString()
: undefined,
beforeEndDate: dateRange?.endDate
? dayjs(dateRange?.endDate).endOf("day").toISOString()
: undefined,
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
},
}),
[
@@ -314,10 +272,7 @@ export function BookingListContainer(props: BookingListContainerProps) {
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
});
const bookings = useMemo(
() => query.data?.bookings ?? [],
[query.data?.bookings]
);
const bookings = useMemo(() => query.data?.bookings ?? [], [query.data?.bookings]);
// Always call the hook and provide navigation capabilities
// The BookingDetailsSheet is only rendered when bookingsV3Enabled is true (see line 212)
@@ -330,10 +285,7 @@ export function BookingListContainer(props: BookingListContainerProps) {
});
return (
<BookingDetailsSheetStoreProvider
bookings={bookings}
capabilities={capabilities}
>
<BookingDetailsSheetStoreProvider bookings={bookings} capabilities={capabilities}>
<BookingListInner
{...props}
data={query.data}
@@ -0,0 +1,125 @@
"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 { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
import type { BookingListingStatus } from "../types";
type BookingOutput = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
type TranslationFunction = (key: string) => string;
const BATCH_SIZE = 100;
interface BookingsCsvDownloadProps {
status: BookingListingStatus;
}
function transformBookingToCsv(booking: BookingOutput, t: TranslationFunction) {
return {
[t("booking_uid")]: booking.uid,
[t("title")]: booking.title,
[t("status")]: booking.status,
[t("start_time")]: dayjs(booking.startTime).format("YYYY-MM-DD HH:mm:ss"),
[t("end_time")]: dayjs(booking.endTime).format("YYYY-MM-DD HH:mm:ss"),
[t("attendee_name")]: booking.attendees.map((a) => a.name).join("; "),
[t("email")]: booking.attendees.map((a) => a.email).join("; "),
[t("event_type")]: booking.eventType?.title ?? "",
[t("location")]: booking.location ?? "",
};
}
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 } =
useBookingFilters();
// Only show for users who are part of an organization
const isOrgUser = Boolean(user?.organizationId);
if (isUserPending || !isOrgUser) {
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>
);
}