feat(insights): add progress indicator to download button on /insights and /insights/routing (CAL-5408) (#20631)
* feat(insights): add progress indicator to routing form responses download button (CAL-5408) Co-Authored-By: eunjae@cal.com <eunjae@cal.com> * fix: address PR feedback - remove initial progress update and ensure no decimal parts in percentage Co-Authored-By: eunjae@cal.com <eunjae@cal.com> * feat(insights): add progress indicator to Download component Co-Authored-By: eunjae@cal.com <eunjae@cal.com> * feat: paginate rawData endpoint and implement batch fetching in Download component Co-Authored-By: eunjae@cal.com <eunjae@cal.com> * refactor: simplify pagination logic in trpc-router.ts Co-Authored-By: eunjae@cal.com <eunjae@cal.com> * show progress on tooltip * fix download for /insights * fix download when there is no data * feat: replace tooltip progress indicator with toast notification Co-Authored-By: eunjae@cal.com <eunjae@cal.com> * fix: remove duplicate ProgressToast component from showToast.tsx Co-Authored-By: eunjae@cal.com <eunjae@cal.com> * fix: export ProgressToast component from index.ts Co-Authored-By: eunjae@cal.com <eunjae@cal.com> * fix: add missing translation for downloading_data Co-Authored-By: eunjae@cal.com <eunjae@cal.com> * update progress toast implementation * fix error --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: eunjae@cal.com <eunjae@cal.com> Co-authored-by: Eunjae Lee <hey@eunjae.dev>
This commit is contained in:
co-authored by
eunjae@cal.com
Eunjae Lee
parent
c6cb3423fa
commit
e29f6af540
@@ -1159,10 +1159,10 @@
|
||||
"enabled_after_update": "Enabled after update",
|
||||
"enabled_after_update_description": "The private link will work after saving",
|
||||
"confirm_delete_api_key": "Revoke this API key",
|
||||
"disable_rescheduling":"Disable Rescheduling",
|
||||
"description_disable_rescheduling":"Guests can no longer reschedule the event with calendar invite or email",
|
||||
"disable_cancelling":"Disable Cancelling",
|
||||
"description_disable_cancelling":"Guests can no longer cancel the event with calendar invite or email",
|
||||
"disable_rescheduling": "Disable Rescheduling",
|
||||
"description_disable_rescheduling": "Guests can no longer reschedule the event with calendar invite or email",
|
||||
"disable_cancelling": "Disable Cancelling",
|
||||
"description_disable_cancelling": "Guests can no longer cancel the event with calendar invite or email",
|
||||
"revoke_api_key": "Revoke API key",
|
||||
"api_key_copied": "API key copied!",
|
||||
"api_key_expires_on": "The API key will expire on",
|
||||
@@ -1562,6 +1562,7 @@
|
||||
"download_responses": "Download responses",
|
||||
"download_responses_description": "Download all responses to your form in CSV format.",
|
||||
"download": "Download",
|
||||
"downloading": "Downloading",
|
||||
"download_recording": "Download Recording",
|
||||
"transcription_enabled": "Transcriptions are enabled now",
|
||||
"transcription_stopped": "Transcriptions are stopped now",
|
||||
@@ -1872,8 +1873,7 @@
|
||||
"not_verified": "Not yet verified",
|
||||
"no_availability_in_month": "No availability in {{month}}",
|
||||
"no_availability_rolling": "Scheduling is only available up to {{days}} in advance. Please check again soon.",
|
||||
"no_availability_range":
|
||||
"Scheduling ended on {{date}}. Please check again soon.",
|
||||
"no_availability_range": "Scheduling ended on {{date}}. Please check again soon.",
|
||||
"view_previous_month": "View previous month",
|
||||
"view_next_month": "View next month",
|
||||
"send_code": "Send code",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import {
|
||||
Dropdown,
|
||||
@@ -10,20 +12,28 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { showToast, showProgressToast, hideProgressToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
|
||||
|
||||
type RawData = RouterOutputs["viewer"]["insights"]["rawData"]["data"][number];
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
const Download = () => {
|
||||
const { t } = useLocale();
|
||||
const { startDate, endDate, teamId, userId, eventTypeId, memberUserId, isAll } = useInsightsParameters();
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const handleDownloadClick = async () => {
|
||||
type PaginatedResponse = {
|
||||
data: RawData[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
const fetchBatch = async (offset: number): Promise<PaginatedResponse | null> => {
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
const data = await utils.viewer.insights.rawData.fetch({
|
||||
const result = await utils.viewer.insights.rawData.fetch({
|
||||
startDate,
|
||||
endDate,
|
||||
teamId,
|
||||
@@ -31,15 +41,56 @@ const Download = () => {
|
||||
eventTypeId,
|
||||
memberUserId,
|
||||
isAll,
|
||||
limit: BATCH_SIZE,
|
||||
offset,
|
||||
});
|
||||
|
||||
if (!data) return;
|
||||
const { data: csvRaw, filename } = data;
|
||||
downloadAsCsv(csvRaw, filename);
|
||||
if (result && "data" in result && "total" in result) {
|
||||
return result as PaginatedResponse;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadClick = async () => {
|
||||
try {
|
||||
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 (error) {
|
||||
showToast(t("unexpected_error_try_again"), "error");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
hideProgressToast(); // Reset progress
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,9 +6,14 @@ import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui/components/dropdown";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
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 { useInsightsParameters } from "../../hooks/useInsightsParameters";
|
||||
|
||||
@@ -53,6 +58,7 @@ export const RoutingFormResponsesDownload = ({ sorting }: Props) => {
|
||||
const handleDownloadClick = async () => {
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
showProgressToast(0); // Reset progress
|
||||
let allData: RoutingData[] = [];
|
||||
let offset = 0;
|
||||
|
||||
@@ -62,13 +68,17 @@ export const RoutingFormResponsesDownload = ({ sorting }: Props) => {
|
||||
const totalRecords = firstBatch.total;
|
||||
|
||||
// Continue fetching remaining batches
|
||||
while (allData.length < totalRecords) {
|
||||
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 > 0) {
|
||||
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`;
|
||||
@@ -78,6 +88,7 @@ export const RoutingFormResponsesDownload = ({ sorting }: Props) => {
|
||||
showToast(t("error_downloading_data"), "error");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
hideProgressToast(); // Reset progress
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -382,8 +382,14 @@ class EventsInsights {
|
||||
) => {
|
||||
// Obtain the where conditional
|
||||
const whereConditional = await this.obtainWhereConditionalForDownload(props);
|
||||
const limit = props.limit ?? 100; // Default batch size
|
||||
const offset = props.offset ?? 0;
|
||||
|
||||
const csvData = await prisma.bookingTimeStatus.findMany({
|
||||
const totalCountPromise = prisma.bookingTimeStatus.count({
|
||||
where: whereConditional,
|
||||
});
|
||||
|
||||
const csvDataPromise = prisma.bookingTimeStatus.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
@@ -402,12 +408,16 @@ class EventsInsights {
|
||||
noShowHost: true,
|
||||
},
|
||||
where: whereConditional,
|
||||
skip: offset,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const [totalCount, csvData] = await Promise.all([totalCountPromise, csvDataPromise]);
|
||||
|
||||
const uids = csvData.filter((b) => b.uid !== null).map((b) => b.uid as string);
|
||||
|
||||
if (uids.length === 0) {
|
||||
return csvData;
|
||||
return { data: csvData, total: totalCount };
|
||||
}
|
||||
|
||||
const bookings = await prisma.booking.findMany({
|
||||
@@ -430,7 +440,7 @@ class EventsInsights {
|
||||
|
||||
const bookingMap = new Map(bookings.map((booking) => [booking.uid, booking.attendees[0] || null]));
|
||||
|
||||
return csvData.map((bookingTimeStatus) => {
|
||||
const data = csvData.map((bookingTimeStatus) => {
|
||||
if (!bookingTimeStatus.uid) {
|
||||
// should not be reached because we filtered above
|
||||
return bookingTimeStatus;
|
||||
@@ -449,6 +459,8 @@ class EventsInsights {
|
||||
bookerName: booker.name,
|
||||
};
|
||||
});
|
||||
|
||||
return { data, total: totalCount };
|
||||
};
|
||||
|
||||
/*
|
||||
@@ -633,16 +645,6 @@ class EventsInsights {
|
||||
|
||||
return !!isOwnerAdminOfParentTeam;
|
||||
};
|
||||
|
||||
static objectToCsv(data: Record<string, unknown>[]) {
|
||||
// if empty data return empty string
|
||||
if (!data.length) {
|
||||
return "";
|
||||
}
|
||||
const header = `${Object.keys(data[0]).join(",")}\n`;
|
||||
const rows = data.map((obj: any) => `${Object.values(obj).join(",")}\n`);
|
||||
return header + rows.join("");
|
||||
}
|
||||
}
|
||||
|
||||
export { EventsInsights };
|
||||
|
||||
@@ -11,6 +11,8 @@ export type RawDataInput = {
|
||||
memberUserId?: number | null;
|
||||
isAll?: boolean;
|
||||
eventTypeId?: number | null;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export const rawDataInputSchema = z.object({
|
||||
@@ -21,6 +23,8 @@ export const rawDataInputSchema = z.object({
|
||||
memberUserId: z.coerce.number().optional().nullable(),
|
||||
isAll: z.boolean().optional(),
|
||||
eventTypeId: z.coerce.number().optional().nullable(),
|
||||
offset: z.number().optional(),
|
||||
limit: z.number().max(100).optional(),
|
||||
}) satisfies z.ZodType<RawDataInput>;
|
||||
|
||||
export type RoutingFormStatsInput = {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
routingFormResponsesInputSchema,
|
||||
routingFormStatsInputSchema,
|
||||
} from "@calcom/features/insights/server/raw-data.schema";
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
|
||||
@@ -1540,7 +1539,7 @@ export const insightsRouter = router({
|
||||
return result;
|
||||
}),
|
||||
rawData: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => {
|
||||
const { startDate, endDate, teamId, userId, memberUserId, isAll } = input;
|
||||
const { startDate, endDate, teamId, userId, memberUserId, isAll, limit, offset } = input;
|
||||
|
||||
const eventTypeList = await getEventTypeList({
|
||||
prisma: ctx.prisma,
|
||||
@@ -1559,8 +1558,7 @@ export const insightsRouter = router({
|
||||
|
||||
const isOrgAdminOrOwner = ctx.user.isOwnerAdminOfParentTeam;
|
||||
try {
|
||||
// Get the data
|
||||
const csvData = await EventsInsights.getCsvData({
|
||||
return await EventsInsights.getCsvData({
|
||||
startDate,
|
||||
endDate,
|
||||
teamId,
|
||||
@@ -1570,14 +1568,9 @@ export const insightsRouter = router({
|
||||
isOrgAdminOrOwner,
|
||||
eventTypeId,
|
||||
organizationId: ctx.user.organizationId || null,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
const csvAsString = EventsInsights.objectToCsv(csvData);
|
||||
const downloadAs = `Insights-${dayjs(startDate).format("YYYY-MM-DD")}-${dayjs(endDate).format(
|
||||
"YYYY-MM-DD"
|
||||
)}-${randomString(10)}.csv`;
|
||||
|
||||
return { data: csvAsString, filename: downloadAs };
|
||||
} catch (e) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import { Icon } from "../icon";
|
||||
|
||||
type ProgressToastProps = {
|
||||
message?: string;
|
||||
progress: number;
|
||||
toastId: string | number;
|
||||
onClose: (toastId: string | number) => void;
|
||||
};
|
||||
|
||||
export const ProgressToast = ({ message, progress, onClose, toastId }: ProgressToastProps) => {
|
||||
const { t } = useLocale();
|
||||
const defaultMessage = t("downloading");
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in-up bg-subtle shadow-elevation-low border-subtle mb-2 flex h-auto flex-col space-y-2 rounded-lg border px-3 py-2.5 text-sm font-semibold md:max-w-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="mt-0.5">
|
||||
<Icon name="file-down" className="h-4 w-4" />
|
||||
</span>
|
||||
<p className="m-0 w-full text-left">{message || defaultMessage}</p>
|
||||
<button onClick={() => onClose(toastId)}>
|
||||
<Icon name="x" className="h-4 w-4 hover:cursor-pointer" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className="bg-inverted h-2 rounded-full opacity-50 transition-all duration-300 ease-in-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right text-xs font-normal">{Math.floor(progress)}%</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function showProgressToast(progress: number, message?: string, toastId = "download-progress") {
|
||||
const onClose = (id: string | number) => {
|
||||
toast.dismiss(id);
|
||||
};
|
||||
|
||||
return toast.custom(
|
||||
(id) => <ProgressToast message={message} progress={progress} onClose={onClose} toastId={id} />,
|
||||
{
|
||||
id: toastId,
|
||||
duration: Infinity, // Keep the toast visible until dismissed
|
||||
position: "bottom-center",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function hideProgressToast(toastId = "download-progress") {
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { ErrorToast, SuccessToast, WarningToast, showToast } from "./showToast";
|
||||
export { ProgressToast, showProgressToast, hideProgressToast } from "./ProgressToast";
|
||||
|
||||
Reference in New Issue
Block a user