feat: Add feature to download routing form reports (#17400)

* remove unused code

* add i18n for some strings in widget

* improve UI and add download button to routing forms reporting page

* add more margin

* add downloadAsCsv util

* integrate download button to routing forms report

* refactor

* address comment

* escape comma multiselect items

* fix

* preserve new lines and wrapping

* refactor

* ensure fetching all rows
This commit is contained in:
Benny Joo
2024-11-05 11:21:13 -05:00
committed by GitHub
parent 423b284278
commit 803fc488c5
7 changed files with 97 additions and 35 deletions
@@ -2039,7 +2039,9 @@
"looking_for_more_analytics": "Looking for more analytics?",
"looking_for_more_insights": "Looking for more Insights?",
"filters": "Filters",
"add_filter": "Add filter",
"add_filter": "Add filter",
"add_rule": "Add rule",
"add_rule_group": "Add rule group",
"remove_filters": "Clear all filters",
"email_verified": "Email Verified",
"select_user": "Select User",
@@ -8,6 +8,7 @@ import type {
ProviderProps,
} from "react-awesome-query-builder";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button as CalButton, TextField, TextArea } from "@calcom/ui";
import { Icon } from "@calcom/ui";
@@ -224,6 +225,7 @@ function SelectWidget({ listValues, setValue, value, ...remainingProps }: Select
}
function Button({ config, type, label, onClick, readonly }: ButtonProps) {
const { t } = useLocale();
if (type === "delRule" || type == "delGroup") {
return (
<button className="ml-5">
@@ -233,10 +235,10 @@ function Button({ config, type, label, onClick, readonly }: ButtonProps) {
}
let dataTestId = "";
if (type === "addRule") {
label = config?.operators.__calReporting ? "Add Filter" : "Add rule";
label = config?.operators.__calReporting ? t("add_filter") : t("add_rule");
dataTestId = "add-rule";
} else if (type == "addGroup") {
label = "Add rule group";
label = t("add_rule_group");
dataTestId = "add-rule-group";
}
return (
@@ -12,11 +12,12 @@ import { Builder, Query, Utils as QbUtils } from "react-awesome-query-builder";
import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { downloadAsCsv, sanitizeValue } from "@calcom/lib/csvUtils";
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Button } from "@calcom/ui";
import { Button, showToast } from "@calcom/ui";
import SingleForm, {
getServerSidePropsForSingleFormView as getServerSideProps,
@@ -30,8 +31,17 @@ import {
export { getServerSideProps };
const Result = ({ formId, jsonLogicQuery }: { formId: string; jsonLogicQuery: JsonLogicQuery | null }) => {
const Result = ({
formName,
formId,
jsonLogicQuery,
}: {
formName: string;
formId: string;
jsonLogicQuery: JsonLogicQuery | null;
}) => {
const { t } = useLocale();
const [isDownloading, setIsDownloading] = useState(false);
const { isPending, status, data, isFetching, error, isFetchingNextPage, hasNextPage, fetchNextPage } =
trpc.viewer.appRoutingForms.report.useInfiniteQuery(
@@ -48,6 +58,22 @@ const Result = ({ formId, jsonLogicQuery }: { formId: string; jsonLogicQuery: Js
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const exportQuery = trpc.viewer.appRoutingForms.report.useInfiniteQuery(
{
limit: 100, // 100 is max
formId: formId,
// Send jsonLogicQuery only if it's a valid logic, otherwise send a logic with no query.
jsonLogicQuery: jsonLogicQuery?.logic
? jsonLogicQuery
: {
logic: {},
},
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
enabled: false,
}
);
const buttonInView = useInViewObserver(() => {
if (!isFetching && hasNextPage && status === "success") {
fetchNextPage();
@@ -60,25 +86,76 @@ const Result = ({ formId, jsonLogicQuery }: { formId: string; jsonLogicQuery: Js
return <div>Error loading report {error?.message} </div>;
}
headers.current = (data?.pages && data?.pages[0]?.headers) || headers.current;
const numberOfRows = data?.pages.reduce((total, page) => total + (page.responses?.length || 0), 0);
const downloadButtonDisabled = !numberOfRows || !data || !headers.current || isDownloading;
const handleDownload = async () => {
try {
setIsDownloading(true);
if (downloadButtonDisabled) {
return;
}
const result = await exportQuery.refetch();
if (!result.data) {
throw new Error("There are no routing forms found.");
}
const allRows = result.data.pages.flatMap((page) =>
page.responses.map((response) => `${response.map((value) => sanitizeValue(value)).join(",")}\n`)
);
let lastPage = result.data.pages[result.data.pages.length - 1];
while (lastPage.nextCursor) {
const nextPage = await exportQuery.fetchNextPage();
if (!nextPage.data) {
break;
}
const latestPageItems = nextPage.data.pages[nextPage.data.pages.length - 1].responses ?? [];
allRows.push(
...latestPageItems.map((response) => `${response.map((value) => sanitizeValue(value)).join(",")}\n`)
);
lastPage = nextPage.data.pages[nextPage.data.pages.length - 1];
}
const header = `${(headers.current ?? []).map((value) => sanitizeValue(value)).join(",")}\n`;
const csvRaw = header + allRows.join("");
const filename = `${formName}_${new Date().toISOString().split("T")[0]}.csv`; // e.g., ${FormName}_2024-10-30.csv
downloadAsCsv(csvRaw, filename);
} catch (error) {
showToast(`Error: ${error}`, "error");
} finally {
setIsDownloading(false);
}
};
return (
<div className="w-full max-w-[2000px] overflow-x-scroll">
{!isPending && (
<div className="text-default text-md mx-4 mb-2">
{`${numberOfRows} ${numberOfRows === 1 ? t("row") : t("rows")}`}
<div className="mb-4 inline-block flex min-w-full items-center px-3">
<Button
disabled={downloadButtonDisabled}
StartIcon="file-down"
color="secondary"
className="mr-3"
onClick={() => handleDownload()}>
{t("download")}
</Button>
<div className="text-default text-md">
{`${numberOfRows} ${numberOfRows === 1 ? t("row") : t("rows")}`}
</div>
</div>
)}
<table
data-testid="reporting-table"
className="border-default bg-subtle mx-3 mb-4 table-fixed border-separate border-spacing-0 overflow-hidden rounded-md border">
className="border-default bg-subtle mx-3 mb-4 min-w-full table-fixed border-separate border-spacing-0 overflow-hidden rounded-md border">
<tr
data-testid="reporting-header"
className="border-default text-default bg-emphasis rounded-md border-b">
{headers.current?.map((header, index) => (
<th
className={classNames(
"border-default border-b px-2 py-3 text-left text-base font-medium",
"border-default border-b px-2 py-3 text-left text-base font-medium",
index !== (headers.current?.length || 0) - 1 ? "border-r" : ""
)}
key={index}>
@@ -104,7 +181,7 @@ const Result = ({ formId, jsonLogicQuery }: { formId: string; jsonLogicQuery: Js
return (
<td
className={classNames(
"border-default overflow-x-hidden px-2 py-3 text-left",
"border-default overflow-x-hidden whitespace-pre-line px-2 py-3 text-left",
isLastRow ? "" : "border-b",
isLastColumn ? "" : "border-r"
)}
@@ -180,7 +257,7 @@ const Reporter = ({ form }: { form: inferSSRProps<typeof getServerSideProps>["fo
}}
renderBuilder={renderBuilder}
/>
<Result formId={form.id} jsonLogicQuery={jsonLogicQuery as JsonLogicQuery} />
<Result formName={form.name} formId={form.id} jsonLogicQuery={jsonLogicQuery as JsonLogicQuery} />
</div>
);
};
@@ -39,7 +39,8 @@ const getRows = async ({ ctx: { prisma }, input }: ReportHandlerOptions) => {
? jsonLogicToPrisma(input.jsonLogicQuery)
: {};
const skip = input.cursor ?? 0;
const take = 50;
const take = input.limit ? input.limit + 1 : 50;
logger.debug(
`Built Prisma where ${JSON.stringify(prismaWhere)} from jsonLogicQuery ${JSON.stringify(
input.jsonLogicQuery
@@ -1,6 +1,7 @@
import z from "zod";
export const ZReportInputSchema = z.object({
limit: z.number().default(50),
formId: z.string(),
jsonLogicQuery: z.object({
logic: z.union([z.record(z.any()), z.null()]),
@@ -1,4 +1,5 @@
import { useFilterContext } from "@calcom/features/insights/context/provider";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
@@ -33,23 +34,7 @@ const Download = () => {
const handleDownloadClick = async (data: RawData) => {
if (!data) return;
const { data: csvRaw, filename } = data;
// Create a Blob from the text data
const blob = new Blob([csvRaw], { type: "text/plain" });
// Create an Object URL for the Blob
const url = window.URL.createObjectURL(blob);
// Create a download link
const a = document.createElement("a");
a.href = url;
a.download = filename; // Specify the filename
// Simulate a click event to trigger the download
a.click();
// Release the Object URL to free up memory
window.URL.revokeObjectURL(url);
downloadAsCsv(csvRaw, filename);
};
return (
@@ -7,12 +7,6 @@ import type { RawDataInput } from "./raw-data.schema";
type TimeViewType = "week" | "month" | "year" | "day";
type DateRange = {
startDate: string; // ISO string format
endDate: string; // ISO string format
formattedDate: string;
};
type StatusAggregate = {
completed: number;
rescheduled: number;