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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user