Feature/Routing-Forms/Reporting (#5375)

* Add very basic reporting logic

* Support more logical operators

* Add tests

* Fix types

* Add playwright test for reporting

* Fix table UI

* Remove only

* Better name variable

* Increase max_old_space to allow tests to run without reaciing limit

* Update test name

* Reuse getServerSideProps

* Variable renaming

* PR Suggestions

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
pull/5482/head
Hariom Balhara 2022-11-11 15:27:44 +05:30 committed by GitHub
parent acb6f292ec
commit bb7815464c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 781 additions and 175 deletions

View File

@ -1,7 +1,7 @@
name: Unit tests
on:
push:
branches: [fixes/e2e-consolidation] # TODO: Remove this after merged in main
branches: [ feat/routing-forms/reporting ] # TODO: Remove this after merged in main
pull_request_target: # So we can test on forks
branches:
- main
@ -21,7 +21,8 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }} # So we can test on forks
fetch-depth: 2
- run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV
# Should be an 8GB machine as per https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners
- run: echo 'NODE_OPTIONS="--max_old_space_size=6144"' >> $GITHUB_ENV
- name: Use Node 16.x
uses: actions/setup-node@v3
with:

View File

@ -50,6 +50,16 @@ const config: Config = {
testEnvironment: "jsdom",
setupFiles: ["<rootDir>/packages/app-store/closecomothercalendar/test/globals.ts"],
},
{
displayName: "@calcom/routing-forms",
roots: ["<rootDir>/packages/app-store/ee/routing-forms"],
testMatch: ["**/test/lib/**/*.(spec|test).(ts|tsx|js)"],
transform: {
"^.+\\.ts?$": "ts-jest",
},
transformIgnorePatterns: ["/node_modules/", "^.+\\.module\\.(css|sass|scss)$"],
testEnvironment: "jsdom",
},
{
displayName: "@calcom/api",
roots: ["<rootDir>/apps/api"],

View File

@ -19,6 +19,11 @@ export default function RoutingNavBar({
href: `${appUrl}/route-builder/${form?.id}`,
className: "pointer-events-none opacity-30 lg:pointer-events-auto lg:opacity-100",
},
{
name: "Reporting",
href: `${appUrl}/reporting/${form?.id}`,
className: "pointer-events-none opacity-30 lg:pointer-events-auto lg:opacity-100",
},
];
return (
<div className="mb-4">

View File

@ -5,6 +5,12 @@ import { useForm, UseFormReturn, Controller } from "react-hook-form";
import useApp from "@calcom/lib/hooks/useApp";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
AppGetServerSidePropsContext,
AppPrisma,
AppUser,
AppSsrInit,
} from "@calcom/types/AppGetServerSideProps";
import { Icon } from "@calcom/ui";
import { Dialog, DialogContent, DialogClose, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
import { Button, ButtonGroup } from "@calcom/ui/components";
@ -15,6 +21,7 @@ import SettingsToggle from "@calcom/ui/v2/core/SettingsToggle";
import { ShellMain } from "@calcom/ui/v2/core/Shell";
import Banner from "@calcom/ui/v2/core/banner";
import { getSerializableForm } from "../lib/getSerializableForm";
import { processRoute } from "../lib/processRoute";
import { RoutingPages } from "../pages/route-builder/[...appPages]";
import { SerializableForm } from "../types/types";
@ -392,3 +399,65 @@ export default function SingleFormWrapper({ form: _form, ...props }: SingleFormC
}
return <SingleForm form={form} {...props} />;
}
export const getServerSidePropsForSingleFormView = async function getServerSidePropsForSingleFormView(
context: AppGetServerSidePropsContext,
prisma: AppPrisma,
user: AppUser,
ssrInit: AppSsrInit
) {
const ssr = await ssrInit(context);
if (!user) {
return {
redirect: {
permanent: false,
destination: "/auth/login",
},
};
}
const { params } = context;
if (!params) {
return {
notFound: true,
};
}
const formId = params.appPages[0];
if (!formId || params.appPages.length > 1) {
return {
notFound: true,
};
}
const isAllowed = (await import("../lib/isAllowed")).isAllowed;
if (!(await isAllowed({ userId: user.id, formId }))) {
return {
notFound: true,
};
}
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: formId,
},
include: {
_count: {
select: {
responses: true,
},
},
},
});
if (!form) {
return {
notFound: true,
};
}
return {
props: {
trpcState: ssr.dehydrate(),
form: getSerializableForm(form),
},
};
};

View File

@ -150,7 +150,7 @@ function SelectWidget({
);
}
function Button({ type, label, onClick, readonly }: ButtonProps) {
function Button({ config, type, label, onClick, readonly }: ButtonProps) {
if (type === "delRule" || type == "delGroup") {
return (
<button className="ml-5">
@ -160,7 +160,7 @@ function Button({ type, label, onClick, readonly }: ButtonProps) {
}
let dataTestId = "";
if (type === "addRule") {
label = "Add rule";
label = config?.operators.__calReporting ? "Add Filter" : "Add rule";
dataTestId = "add-rule";
} else if (type == "addGroup") {
label = "Add rule group";
@ -185,11 +185,15 @@ function ButtonGroup({ children }: ButtonGroupProps) {
}
return (
<>
{children.map((button) => {
{children.map((button, key) => {
if (!button) {
return null;
}
return button;
return (
<div key={key} className="mb-2">
{button}
</div>
);
})}
</>
);
@ -222,10 +226,10 @@ function Conjs({ not, setNot, config, conjunctionOptions, setConjunction, disabl
value = value == "any" ? "none" : "all";
}
const selectValue = options.find((option) => option.value === value);
const summary = !config.operators.__calReporting ? "Rule group when" : "Query where";
return (
<div className="flex items-center text-sm">
<span>Rule group when</span>
<span>{summary}</span>
<Select
className="flex px-2"
defaultValue={selectValue}
@ -267,7 +271,7 @@ const FieldSelect = function FieldSelect(props: FieldProps) {
return (
<Select
className="data-testid-field-select"
className="data-testid-field-select mb-2"
menuPosition="fixed"
onChange={(item) => {
if (!item) {

View File

@ -0,0 +1,168 @@
// It can have many shapes, so just use any and we rely on unit tests to test all those scenarios.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type LogicData = Partial<Record<keyof typeof OPERATOR_MAP, any>>;
type NegatedLogicData = {
"!": LogicData;
};
export type JsonLogicQuery = {
logic: {
and?: LogicData[];
or?: LogicData[];
"!"?: {
and?: LogicData[];
or?: LogicData[];
};
} | null;
};
type PrismaWhere = {
AND?: ReturnType<typeof convertQueriesToPrismaWhereClause>[];
OR?: ReturnType<typeof convertQueriesToPrismaWhereClause>[];
NOT?: PrismaWhere;
};
const OPERATOR_MAP = {
"==": {
operator: "equals",
secondaryOperand: null,
},
in: {
operator: "string_contains",
secondaryOperand: null,
},
"!=": {
operator: "NOT.equals",
secondaryOperand: null,
},
"!": {
operator: "equals",
secondaryOperand: "",
},
"!!": {
operator: "NOT.equals",
secondaryOperand: "",
},
all: {
operator: "array_contains",
secondaryOperand: null,
},
};
/**
* Operators supported on array of basic queries
*/
const GROUP_OPERATOR_MAP = {
and: "AND",
or: "OR",
"!": "NOT",
} as const;
const convertSingleQueryToPrismaWhereClause = (
operatorName: keyof typeof OPERATOR_MAP,
logicData: LogicData,
isNegation: boolean
) => {
const mappedOperator = OPERATOR_MAP[operatorName].operator;
const staticSecondaryOperand = OPERATOR_MAP[operatorName].secondaryOperand;
isNegation = isNegation || mappedOperator.startsWith("NOT.");
const prismaOperator = mappedOperator.replace("NOT.", "");
const operands =
logicData[operatorName] instanceof Array ? logicData[operatorName] : [logicData[operatorName]];
const mainOperand = operatorName !== "in" ? operands[0].var : operands[1].var;
let secondaryOperand = staticSecondaryOperand || (operatorName !== "in" ? operands[1] : operands[0]) || "";
if (operatorName === "all") {
secondaryOperand = secondaryOperand.in[1];
}
const prismaWhere = {
response: { path: [mainOperand, "value"], [`${prismaOperator}`]: secondaryOperand },
};
if (isNegation) {
return {
NOT: {
...prismaWhere,
},
};
}
return prismaWhere;
};
const isNegation = (logicData: LogicData | NegatedLogicData) => {
if ("!" in logicData) {
const negatedLogicData = logicData["!"];
for (const [operatorName] of Object.entries(OPERATOR_MAP)) {
if (negatedLogicData[operatorName]) {
return true;
}
}
}
return false;
};
const convertQueriesToPrismaWhereClause = (logicData: LogicData) => {
const _isNegation = isNegation(logicData);
if (_isNegation) {
logicData = logicData["!"];
}
for (const [key] of Object.entries(OPERATOR_MAP)) {
const operatorName = key as keyof typeof OPERATOR_MAP;
if (logicData[operatorName]) {
return convertSingleQueryToPrismaWhereClause(operatorName, logicData, _isNegation);
}
}
};
export const jsonLogicToPrisma = (query: JsonLogicQuery) => {
try {
let logic = query.logic;
if (!logic) {
return {};
}
let prismaWhere: PrismaWhere = {};
let negateLogic = false;
// Case: Negation of "Any of these"
// Example: {"logic":{"!":{"or":[{"==":[{"var":"505d3c3c-aa71-4220-93a9-6fd1e1087939"},"1"]},{"==":[{"var":"505d3c3c-aa71-4220-93a9-6fd1e1087939"},"1"]}]}}}
if (logic["!"]) {
logic = logic["!"];
negateLogic = true;
}
// Case: All of these
if (logic.and) {
const where: PrismaWhere["AND"] = (prismaWhere[GROUP_OPERATOR_MAP["and"]] = []);
logic.and.forEach((and) => {
const res = convertQueriesToPrismaWhereClause(and);
if (!res) {
return;
}
where.push(res);
});
}
// Case: Any of these
else if (logic.or) {
const where: PrismaWhere["OR"] = (prismaWhere[GROUP_OPERATOR_MAP["or"]] = []);
logic.or.forEach((or) => {
const res = convertQueriesToPrismaWhereClause(or);
if (!res) {
return;
}
where.push(res);
});
}
if (negateLogic) {
prismaWhere = { NOT: { ...prismaWhere } };
}
return prismaWhere;
} catch (e) {
console.log("Error converting to prisma `where`", JSON.stringify(query), "Error is ", e);
return {};
}
};

View File

@ -1,6 +1,7 @@
//TODO: Generate this file automatically so that like in Next.js file based routing can work automatically
import * as formEdit from "./form-edit/[...appPages]";
import * as forms from "./forms/[...appPages]";
import * as Reporting from "./reporting/[...appPages]";
import * as RouteBuilder from "./route-builder/[...appPages]";
import * as Router from "./router/[...appPages]";
import * as RoutingLink from "./routing-link/[...appPages]";
@ -11,6 +12,7 @@ const routingConfig = {
forms: forms,
"routing-link": RoutingLink,
router: Router,
reporting: Reporting,
};
export default routingConfig;

View File

@ -6,12 +6,6 @@ import { UseFormReturn } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
import classNames from "@calcom/lib/classNames";
import {
AppGetServerSidePropsContext,
AppPrisma,
AppUser,
AppSsrInit,
} from "@calcom/types/AppGetServerSideProps";
import { Icon } from "@calcom/ui";
import { Button, TextAreaField, TextField } from "@calcom/ui/components";
import { EmptyScreen, SelectField, Shell } from "@calcom/ui/v2";
@ -20,10 +14,11 @@ import FormCard from "@calcom/ui/v2/core/form/FormCard";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { getServerSidePropsForSingleFormView as getServerSideProps } from "../../components/SingleForm";
import SingleForm from "../../components/SingleForm";
import { getSerializableForm } from "../../lib/getSerializableForm";
import { SerializableForm } from "../../types/types";
export { getServerSideProps };
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
type RoutingFormWithResponseCount = RoutingForm & {
_count: {
@ -302,64 +297,3 @@ FormEditPage.getLayout = (page: React.ReactElement) => {
</Shell>
);
};
export const getServerSideProps = async function getServerSideProps(
context: AppGetServerSidePropsContext,
prisma: AppPrisma,
user: AppUser,
ssrInit: AppSsrInit
) {
const ssr = await ssrInit(context);
if (!user) {
return {
redirect: {
permanent: false,
destination: "/auth/login",
},
};
}
const { params } = context;
if (!params) {
return {
notFound: true,
};
}
const formId = params.appPages[0];
if (!formId || params.appPages.length > 1) {
return {
notFound: true,
};
}
const isAllowed = (await import("../../lib/isAllowed")).isAllowed;
if (!(await isAllowed({ userId: user.id, formId }))) {
return {
notFound: true,
};
}
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: formId,
},
include: {
_count: {
select: {
responses: true,
},
},
},
});
if (!form) {
return {
notFound: true,
};
}
return {
props: {
trpcState: ssr.dehydrate(),
form: getSerializableForm(form),
},
};
};

View File

@ -0,0 +1,195 @@
import React, { useRef, useState, useCallback } from "react";
import { Query, Config, Builder, Utils as QbUtils, JsonLogicResult } from "react-awesome-query-builder";
import { JsonTree, ImmutableTree, BuilderProps } from "react-awesome-query-builder";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Button } from "@calcom/ui";
import { Shell } from "@calcom/ui/v2";
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
import SingleForm from "../../components/SingleForm";
import { getServerSidePropsForSingleFormView as getServerSideProps } from "../../components/SingleForm";
import QueryBuilderInitialConfig from "../../components/react-awesome-query-builder/config/config";
import "../../components/react-awesome-query-builder/styles.css";
import { JsonLogicQuery } from "../../jsonLogicToPrisma";
import { getQueryBuilderConfig } from "../route-builder/[...appPages]";
export { getServerSideProps };
type QueryBuilderUpdatedConfig = typeof QueryBuilderInitialConfig & { fields: Config["fields"] };
const Result = ({ formId, jsonLogicQuery }: { formId: string; jsonLogicQuery: JsonLogicQuery | null }) => {
const { t } = useLocale();
const { isLoading, status, data, isFetching, error, isFetchingNextPage, hasNextPage, fetchNextPage } =
trpc.viewer.appRoutingForms.report.useInfiniteQuery(
{
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,
}
);
const buttonInView = useInViewObserver(() => {
if (!isFetching && hasNextPage && status === "success") {
fetchNextPage();
}
});
const headers = useRef<string[] | null>(null);
if (!isLoading && !data) {
return <div>Error loading report {error?.message} </div>;
}
headers.current = (data?.pages && data?.pages[0]?.headers) || headers.current;
return (
<div className="w-full max-w-[2000px] overflow-x-scroll">
<table
data-testid="reporting-table"
className="table-fixed border-separate border-spacing-0 rounded-md border border-gray-300 bg-gray-100">
<tr data-testid="reporting-header" className="border-b border-gray-300 bg-gray-200">
{headers.current?.map((header, index) => (
<th
className={classNames(
"border-b border-gray-300 py-3 px-2 text-left text-base font-medium",
index !== (headers.current?.length || 0) - 1 ? "border-r" : ""
)}
key={index}>
{header}
</th>
))}
</tr>
{!isLoading &&
data?.pages.map((page) => {
return page.responses?.map((responses, rowIndex) => {
const isLastRow = page.responses.length - 1 === rowIndex;
return (
<tr
key={rowIndex}
data-testid="reporting-row"
className={classNames(
"text-center text-sm",
rowIndex % 2 ? "" : "bg-white",
isLastRow ? "" : "border-b"
)}>
{responses.map((r, columnIndex) => {
const isLastColumn = columnIndex === responses.length - 1;
return (
<td
className={classNames(
"overflow-x-hidden border-gray-300 py-3 px-2 text-left",
isLastRow ? "" : "border-b",
isLastColumn ? "" : "border-r"
)}
key={columnIndex}>
{r}
</td>
);
})}
</tr>
);
});
})}
</table>
{isLoading ? <div className="p-2">Report is loading</div> : ""}
<Button
type="button"
color="minimal"
ref={buttonInView.ref}
loading={isFetchingNextPage}
disabled={!hasNextPage}
onClick={() => fetchNextPage()}>
{hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
);
};
const getInitialQuery = (config: ReturnType<typeof getQueryBuilderConfig>) => {
const uuid = QbUtils.uuid();
const queryValue: JsonTree = { id: uuid, type: "group" } as JsonTree;
const tree = QbUtils.checkTree(QbUtils.loadTree(queryValue), config);
return {
state: { tree, config },
queryValue,
};
};
const Reporter = ({ form }: { form: inferSSRProps<typeof getServerSideProps>["form"] }) => {
const config = getQueryBuilderConfig(form, true);
const [query, setQuery] = useState(getInitialQuery(config));
const [jsonLogicQuery, setJsonLogicQuery] = useState<JsonLogicResult | null>(null);
const onChange = (immutableTree: ImmutableTree, config: QueryBuilderUpdatedConfig) => {
const jsonTree = QbUtils.getTree(immutableTree);
setQuery(() => {
const newValue = {
state: { tree: immutableTree, config: config },
queryValue: jsonTree,
};
setJsonLogicQuery(QbUtils.jsonLogicFormat(newValue.state.tree, config));
return newValue;
});
};
const renderBuilder = useCallback(
(props: BuilderProps) => (
<div className="query-builder-container">
<div className="query-builder qb-lite">
<Builder {...props} />
</div>
</div>
),
[]
);
return (
<div className="flex flex-col-reverse md:flex-row">
<div className="cal-query-builder w-full ltr:mr-2 rtl:ml-2">
<Query
{...config}
value={query.state.tree}
onChange={(immutableTree, config) => {
onChange(immutableTree, config as QueryBuilderUpdatedConfig);
}}
renderBuilder={renderBuilder}
/>
<Result formId={form.id} jsonLogicQuery={jsonLogicQuery as JsonLogicQuery} />
</div>
</div>
);
};
export default function ReporterWrapper({
form,
appUrl,
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
return (
<SingleForm
form={form}
appUrl={appUrl}
Page={({ form }) => (
<div className="route-config">
<Reporter form={form} />
</div>
)}
/>
);
}
ReporterWrapper.getLayout = (page: React.ReactElement) => {
return (
<Shell backPath="/apps/routing-forms/forms" withoutMain={true}>
{page}
</Shell>
);
};

View File

@ -6,32 +6,28 @@ import { Query, Config, Builder, Utils as QbUtils } from "react-awesome-query-bu
import { JsonTree, ImmutableTree, BuilderProps } from "react-awesome-query-builder";
import { trpc } from "@calcom/trpc/react";
import {
AppGetServerSidePropsContext,
AppPrisma,
AppUser,
AppSsrInit,
} from "@calcom/types/AppGetServerSideProps";
import { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Icon } from "@calcom/ui";
import { Button, TextField, TextArea } from "@calcom/ui/components";
import { SelectWithValidation as Select, Shell } from "@calcom/ui/v2";
import FormCard from "@calcom/ui/v2/core/form/FormCard";
import { getServerSidePropsForSingleFormView as getServerSideProps } from "../../components/SingleForm";
import SingleForm from "../../components/SingleForm";
import QueryBuilderInitialConfig from "../../components/react-awesome-query-builder/config/config";
import "../../components/react-awesome-query-builder/styles.css";
import { getSerializableForm } from "../../lib/getSerializableForm";
import { SerializableForm } from "../../types/types";
import { FieldTypes } from "../form-edit/[...appPages]";
export { getServerSideProps };
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
const InitialConfig = QueryBuilderInitialConfig;
const hasRules = (route: Route) =>
route.queryValue.children1 && Object.keys(route.queryValue.children1).length;
type QueryBuilderUpdatedConfig = typeof QueryBuilderInitialConfig & { fields: Config["fields"] };
export function getQueryBuilderConfig(form: RoutingForm) {
export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
const fields: Record<
string,
{
@ -74,9 +70,15 @@ export function getQueryBuilderConfig(form: RoutingForm) {
}
});
const initialConfigCopy = { ...InitialConfig };
if (forReporting) {
delete initialConfigCopy.operators.is_empty;
delete initialConfigCopy.operators.is_not_empty;
initialConfigCopy.operators.__calReporting = true;
}
// You need to provide your own config. See below 'Config format'
const config: QueryBuilderUpdatedConfig = {
...InitialConfig,
...initialConfigCopy,
fields: fields,
};
return config;
@ -467,65 +469,3 @@ RouteBuilder.getLayout = (page: React.ReactElement) => {
</Shell>
);
};
export const getServerSideProps = async function getServerSideProps(
context: AppGetServerSidePropsContext,
prisma: AppPrisma,
user: AppUser,
ssrInit: AppSsrInit
) {
const ssr = await ssrInit(context);
if (!user) {
return {
redirect: {
permanent: false,
destination: "/auth/login",
},
};
}
const { params } = context;
if (!params) {
return {
notFound: true,
};
}
const formId = params.appPages[0];
if (!formId || params.appPages.length > 1) {
return {
notFound: true,
};
}
const isAllowed = (await import("../../lib/isAllowed")).isAllowed;
if (!(await isAllowed({ userId: user.id, formId }))) {
return {
notFound: true,
};
}
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: formId,
},
include: {
_count: {
select: {
responses: true,
},
},
},
});
if (!form) {
return {
notFound: true,
};
}
return {
props: {
trpcState: ssr.dehydrate(),
form: getSerializableForm(form),
},
};
};

View File

@ -162,42 +162,54 @@ test.describe("Routing Forms", () => {
return user;
};
test("Routing Link should accept submission while routing works and responses can be downloaded", async ({
page,
users,
}) => {
test("Routing Link - Reporting and CSV Download ", async ({ page, users }) => {
const user = await createUserAndLoginAndInstallApp({ users, page });
const routingForm = user.routingForms[0];
test.setTimeout(120000);
// Fill form when you are logged out
await users.logout();
await gotoRoutingLink(page, routingForm.id);
await page.fill('[data-testid="field"]', "event-routing");
page.click('button[type="submit"]');
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/pro/30min");
},
});
await gotoRoutingLink(page, routingForm.id);
await page.fill('[data-testid="field"]', "external-redirect");
page.click('button[type="submit"]');
await page.waitForNavigation({
url(url) {
return url.hostname.includes("google.com");
},
});
await gotoRoutingLink(page, routingForm.id);
await page.fill('[data-testid="field"]', "custom-page");
await page.click('button[type="submit"]');
await page.isVisible("text=Custom Page Result");
await fillSeededForm(page, routingForm.id);
// Log back in to view form responses.
await user.login();
await page.goto(`/apps/routing-forms/route-builder/${routingForm.id}`);
await page.goto(`/apps/routing-forms/reporting/${routingForm.id}`);
// Can't keep waiting forever. So, added a timeout of 5000ms
await page.waitForResponse((response) => response.url().includes("viewer.appRoutingForms.report"), {
timeout: 5000,
});
const headerEls = page.locator("[data-testid='reporting-header'] th");
// Once the response is there, React would soon render it, so 500ms is enough
await headerEls.first().waitFor({
timeout: 500,
});
const numHeaderEls = await headerEls.count();
const headers = [];
for (let i = 0; i < numHeaderEls; i++) {
headers.push(await headerEls.nth(i).innerText());
}
const responses = [];
const responseRows = page.locator("[data-testid='reporting-row']");
const numResponseRows = await responseRows.count();
for (let i = 0; i < numResponseRows; i++) {
const rowLocator = responseRows.nth(i).locator("td");
const numRowEls = await rowLocator.count();
const rowResponses = [];
for (let j = 0; j < numRowEls; j++) {
rowResponses.push(await rowLocator.nth(j).innerText());
}
responses.push(rowResponses);
}
expect(headers).toEqual(["Test field", "Multi Select"]);
expect(responses).toEqual([
["event-routing", ""],
["external-redirect", ""],
["custom-page", ""],
]);
const [download] = await Promise.all([
// Start waiting for the download
page.waitForEvent("download"),
@ -273,3 +285,27 @@ test.describe("Routing Forms", () => {
});
});
});
async function fillSeededForm(page: Page, routingFormId: string) {
await gotoRoutingLink(page, routingFormId);
await page.fill('[data-testid="field"]', "event-routing");
page.click('button[type="submit"]');
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/pro/30min");
},
});
await gotoRoutingLink(page, routingFormId);
await page.fill('[data-testid="field"]', "external-redirect");
page.click('button[type="submit"]');
await page.waitForNavigation({
url(url) {
return url.hostname.includes("google.com");
},
});
await gotoRoutingLink(page, routingFormId);
await page.fill('[data-testid="field"]', "custom-page");
await page.click('button[type="submit"]');
await page.isVisible("text=Custom Page Result");
}

View File

@ -0,0 +1,167 @@
import { expect, it, describe } from "@jest/globals";
import { jsonLogicToPrisma } from "../../jsonLogicToPrisma";
afterEach(() => {
jest.resetAllMocks();
});
describe("jsonLogicToPrisma - Single Query", () => {
it("should support Short 'Equals' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "A"] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "A",
},
},
],
});
});
it("should support Short 'Not Equals' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "!=": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abc"] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
NOT: {
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "abc",
},
},
},
],
});
});
it("should support Short 'Contains' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ in: ["A", { var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
string_contains: "A",
},
},
],
});
});
it("should support Short 'Not Contains' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "!": { in: ["a", { var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }] } }] },
});
expect(prismaWhere).toEqual({
AND: [
{
NOT: {
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
string_contains: "a",
},
},
},
],
});
});
it("should support 'MultiSelect' 'Equals' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
and: [{ all: [{ var: "267c7817-81a5-4bef-9d5b-d0faa4cd0d71" }, { in: [{ var: "" }, ["C", "D"]] }] }],
},
});
expect(prismaWhere).toEqual({
AND: [
{ response: { path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"], array_contains: ["C", "D"] } },
],
});
});
});
describe("jsonLogicToPrisma - Single Query", () => {
it("should support where All Match ['Equals', 'Equals'] operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "a"] },
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "b"] },
],
},
});
expect(prismaWhere).toEqual({
AND: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "a",
},
},
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "b",
},
},
],
});
});
it("should support where Any Match ['Equals', 'Equals'] operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
or: [
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "a"] },
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "b"] },
],
},
});
expect(prismaWhere).toEqual({
OR: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "a",
},
},
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "b",
},
},
],
});
});
it("should support where None Match ['Equals', 'Equals'] operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
"!": {
or: [
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abc"] },
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abcd"] },
],
},
},
});
expect(prismaWhere).toEqual({
NOT: {
OR: [
{ response: { path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"], equals: "abc" } },
{ response: { path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"], equals: "abcd" } },
],
},
});
});
});

View File

@ -11,6 +11,7 @@ import { authedProcedure, publicProcedure, router } from "@calcom/trpc/server/tr
import { Ensure } from "@calcom/types/utils";
import ResponseEmail from "./emails/templates/response-email";
import { jsonLogicToPrisma } from "./jsonLogicToPrisma";
import { getSerializableForm } from "./lib/getSerializableForm";
import { isAllowed } from "./lib/isAllowed";
import { Response, SerializableForm } from "./types/types";
@ -377,6 +378,80 @@ const appRoutingForms = router({
},
});
}),
report: authedProcedure
.input(
z.object({
formId: z.string(),
jsonLogicQuery: z.object({
logic: z.union([z.record(z.any()), z.null()]),
}),
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
})
)
.query(async ({ ctx: { prisma }, input }) => {
// Can be any prisma `where` clause
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const prismaWhere: Record<string, any> = input.jsonLogicQuery
? jsonLogicToPrisma(input.jsonLogicQuery)
: {};
const skip = input.cursor ?? 0;
const take = 50;
logger.debug(
`Built Prisma where ${JSON.stringify(prismaWhere)} from jsonLogicQuery ${JSON.stringify(
input.jsonLogicQuery
)}`
);
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: input.formId,
},
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
// TODO: Second argument is required to return deleted operators.
const serializedForm = getSerializableForm(form, true);
const rows = await prisma.app_RoutingForms_FormResponse.findMany({
where: {
formId: input.formId,
...prismaWhere,
},
take,
skip,
});
const fields = serializedForm?.fields || [];
const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : ""));
const responses: string[][] = [];
rows.forEach((r) => {
const rowResponses: string[] = [];
responses.push(rowResponses);
fields.forEach((field) => {
if (!r.response) {
return;
}
const response = r.response as Response;
const value = response[field.id]?.value || "";
let stringValue = "";
if (value instanceof Array) {
stringValue = value.join(", ");
} else {
stringValue = value;
}
rowResponses.push(stringValue);
});
});
const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take;
return {
headers,
responses,
nextCursor: areThereNoResultsOrLessThanAskedFor ? null : skip + rows.length,
};
}),
});
export default appRoutingForms;