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
parent
acb6f292ec
commit
bb7815464c
|
@ -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:
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {};
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue