Feature/Routing Forms: Add the concepts of reusable Router and Router Linked Fields. (#5566)
* WIP * Fix all TS errors * Move to using isRouter everywhere * wip * Add Router Picker and fix TS issues * Fix tests and add option to connect during duplication * Misc changes * Fixes * Fixes * Fix TS errors * Disable certain options in Router * Fix connected Form fields not updating * Some fixes * reert shell.tsx * Disable already used router instead of removing it * Import prisma from correct place * Simplify loop using Object.entries * SimpliAdd playwright test and fix issues found
This commit is contained in:
@@ -71,7 +71,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
if (!form) {
|
||||
throw new Error("Form not found");
|
||||
}
|
||||
const serializableForm = getSerializableForm(form, true);
|
||||
const serializableForm = await getSerializableForm(form, true);
|
||||
res.setHeader("Content-Type", "text/csv; charset=UTF-8");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NextRouter } from "next/router";
|
||||
import { useRouter } from "next/router";
|
||||
import { createContext, forwardRef, useContext, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
Switch,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
SettingsToggle,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||
@@ -77,6 +79,7 @@ function NewFormDialog({ appUrl }: { appUrl: string }) {
|
||||
const hookForm = useForm<{
|
||||
name: string;
|
||||
description: string;
|
||||
shouldConnect: boolean;
|
||||
}>();
|
||||
|
||||
const { action, target } = router.query as z.infer<typeof newFormModalQuerySchema>;
|
||||
@@ -97,6 +100,7 @@ function NewFormDialog({ appUrl }: { appUrl: string }) {
|
||||
form={hookForm}
|
||||
handleSubmit={(values) => {
|
||||
const formId = uuidv4();
|
||||
|
||||
mutation.mutate({
|
||||
id: formId,
|
||||
...values,
|
||||
@@ -115,6 +119,23 @@ function NewFormDialog({ appUrl }: { appUrl: string }) {
|
||||
placeholder="Form Description"
|
||||
/>
|
||||
</div>
|
||||
{action === "duplicate" && (
|
||||
<Controller
|
||||
name="shouldConnect"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<SettingsToggle
|
||||
title="Keep me connected with the form"
|
||||
description="Any changes in Router and Fields of the form being duplicated, would reflect in the duplicate."
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange(checked);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-8 flex flex-row-reverse gap-x-2">
|
||||
<Button loading={mutation.isLoading} data-testid="add-form" type="submit">
|
||||
@@ -186,7 +207,7 @@ function Dialogs({
|
||||
if (context?.previousValue) {
|
||||
utils.viewer.appRoutingForms.forms.setData(undefined, context.previousValue);
|
||||
}
|
||||
showToast("Something went wrong", "error");
|
||||
showToast(err.message || "Something went wrong", "error");
|
||||
},
|
||||
});
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { App_RoutingForms_Form } from "@prisma/client";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import isRouterLinkedField from "../lib/isRouterLinkedField";
|
||||
import { getQueryBuilderConfig } from "../pages/route-builder/[...appPages]";
|
||||
import { SerializableForm, Response } from "../types/types";
|
||||
|
||||
@@ -18,6 +19,9 @@ export default function FormInputFields(props: Props) {
|
||||
return (
|
||||
<>
|
||||
{form.fields?.map((field) => {
|
||||
if (isRouterLinkedField(field)) {
|
||||
field = field.routerField;
|
||||
}
|
||||
const widget = queryBuilderConfig.widgets[field.type];
|
||||
if (!("factory" in widget)) {
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { App_RoutingForms_Form } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm, UseFormReturn } from "react-hook-form";
|
||||
|
||||
@@ -12,8 +13,9 @@ import {
|
||||
AppUser,
|
||||
} from "@calcom/types/AppGetServerSideProps";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Banner,
|
||||
Badge,
|
||||
ButtonGroup,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -42,7 +44,8 @@ import FormInputFields from "./FormInputFields";
|
||||
import RoutingNavBar from "./RoutingNavBar";
|
||||
|
||||
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
|
||||
type RoutingFormWithResponseCount = RoutingForm & {
|
||||
|
||||
export type RoutingFormWithResponseCount = RoutingForm & {
|
||||
_count: {
|
||||
responses: number;
|
||||
};
|
||||
@@ -236,13 +239,19 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
|
||||
onSuccess() {
|
||||
showToast("Form updated successfully.", "success");
|
||||
},
|
||||
onError() {
|
||||
onError(e) {
|
||||
if (e.message) {
|
||||
showToast(e.message, "error");
|
||||
return;
|
||||
}
|
||||
showToast(`Something went wrong`, "error");
|
||||
},
|
||||
onSettled() {
|
||||
utils.viewer.appRoutingForms.formQuery.invalidate({ id: form.id });
|
||||
},
|
||||
});
|
||||
const connectedForms = form.connectedForms;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
@@ -295,6 +304,55 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.routers.length ? (
|
||||
<div className="mt-6">
|
||||
<div className="mb-2 block text-sm font-semibold leading-none text-black ">
|
||||
Routers
|
||||
</div>
|
||||
<p className="-mt-1 text-xs leading-normal text-gray-600">
|
||||
Modifications in fields and routes of following forms will be reflected in this form.
|
||||
</p>
|
||||
<div className="flex">
|
||||
{form.routers.map((router) => {
|
||||
return (
|
||||
<div key={router.id} className="mr-2">
|
||||
<Link href={`/${appUrl}/route-builder/${router.id}`}>
|
||||
<a>
|
||||
<Badge variant="gray">{router.name}</Badge>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{connectedForms?.length ? (
|
||||
<div className="mt-6">
|
||||
<div className="mb-2 block text-sm font-semibold leading-none text-black ">
|
||||
Connected Forms
|
||||
</div>
|
||||
<p className="-mt-1 text-xs leading-normal text-gray-600">
|
||||
Following forms would be affected when you modify fields or routes here
|
||||
</p>
|
||||
<div className="flex">
|
||||
{connectedForms.map((router) => {
|
||||
return (
|
||||
<div key={router.id} className="mr-2">
|
||||
<Link href={`/${appUrl}/route-builder/${router.id}`}>
|
||||
<a>
|
||||
<Badge variant="default">{router.name}</Badge>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
color="secondary"
|
||||
@@ -452,8 +510,8 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP
|
||||
};
|
||||
}
|
||||
|
||||
const isAllowed = (await import("../lib/isAllowed")).isAllowed;
|
||||
if (!(await isAllowed({ userId: user.id, formId }))) {
|
||||
const isFormEditAllowed = (await import("../lib/isFormEditAllowed")).isFormEditAllowed;
|
||||
if (!(await isFormEditAllowed({ userId: user.id, formId }))) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
@@ -480,7 +538,7 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP
|
||||
return {
|
||||
props: {
|
||||
trpcState: ssr.dehydrate(),
|
||||
form: getSerializableForm(form),
|
||||
form: await getSerializableForm(form),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Utils as QbUtils } from "react-awesome-query-builder";
|
||||
|
||||
import { GlobalRoute, SerializableRoute } from "../types/types";
|
||||
|
||||
export const createFallbackRoute = (): Exclude<SerializableRoute, GlobalRoute> => {
|
||||
const uuid = QbUtils.uuid();
|
||||
return {
|
||||
id: uuid,
|
||||
isFallback: true,
|
||||
action: {
|
||||
type: "customPageMessage",
|
||||
value: "Thank you for your interest! We will be in touch soon.",
|
||||
},
|
||||
queryValue: { id: uuid, type: "group" },
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { App_RoutingForms_Form } from "@calcom/prisma/client";
|
||||
|
||||
export default async function getConnectedForms(
|
||||
prisma: typeof import("@calcom/prisma").default,
|
||||
form: Pick<App_RoutingForms_Form, "id" | "userId">
|
||||
) {
|
||||
return await prisma.app_RoutingForms_Form.findMany({
|
||||
where: {
|
||||
userId: form.userId,
|
||||
routes: {
|
||||
array_contains: [
|
||||
{
|
||||
id: form.id,
|
||||
isRouter: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,26 +1,33 @@
|
||||
import { App_RoutingForms_Form } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { SerializableForm } from "../types/types";
|
||||
import { zodFields, zodRoutes } from "../zod";
|
||||
import { zodFields, zodRoutes, zodRoutesView, zodFieldsView } from "../zod";
|
||||
import getConnectedForms from "./getConnectedForms";
|
||||
import isRouter from "./isRouter";
|
||||
import isRouterLinkedField from "./isRouterLinkedField";
|
||||
|
||||
/**
|
||||
* Doesn't have deleted fields by default
|
||||
*/
|
||||
export function getSerializableForm<TForm extends App_RoutingForms_Form>(
|
||||
export async function getSerializableForm<TForm extends App_RoutingForms_Form>(
|
||||
form: TForm,
|
||||
withDeletedFields = false
|
||||
) {
|
||||
const prisma = (await import("@calcom/prisma")).default;
|
||||
const routesParsed = zodRoutes.safeParse(form.routes);
|
||||
if (!routesParsed.success) {
|
||||
throw new Error("Error parsing routes");
|
||||
}
|
||||
|
||||
const fieldsParsed = zodFields.safeParse(form.fields);
|
||||
|
||||
if (!fieldsParsed.success) {
|
||||
throw new Error("Error parsing fields");
|
||||
throw new Error("Error parsing fields" + fieldsParsed.error);
|
||||
}
|
||||
|
||||
const settings = RoutingFormSettings.parse(
|
||||
form.settings || {
|
||||
// Would have really loved to do it using zod. But adding .default(true) throws type error in prisma/zod/app_routingforms_form.ts
|
||||
@@ -28,18 +35,102 @@ export function getSerializableForm<TForm extends App_RoutingForms_Form>(
|
||||
}
|
||||
);
|
||||
|
||||
// Ideally we shouldb't have needed to explicitly type it but due to some reason it's not working reliably with VSCode TypeCheck
|
||||
const parsedFields =
|
||||
(withDeletedFields ? fieldsParsed.data : fieldsParsed.data?.filter((f) => !f.deleted)) || [];
|
||||
const parsedRoutes = routesParsed.data;
|
||||
const fields = parsedFields as NonNullable<z.infer<typeof zodFieldsView>>;
|
||||
|
||||
const fieldsExistInForm: Record<string, true> = {};
|
||||
parsedFields?.forEach((f) => {
|
||||
fieldsExistInForm[f.id] = true;
|
||||
});
|
||||
|
||||
const { routes, routers } = await getEnrichedRoutesAndRouters(parsedRoutes);
|
||||
|
||||
const connectedForms = (await getConnectedForms(prisma, form)).map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
description: f.description,
|
||||
}));
|
||||
const finalFields = fields;
|
||||
|
||||
// Ideally we should't have needed to explicitly type it but due to some reason it's not working reliably with VSCode TypeCheck
|
||||
const serializableForm: SerializableForm<TForm> = {
|
||||
...form,
|
||||
settings: settings,
|
||||
fields: fieldsParsed.data
|
||||
? withDeletedFields
|
||||
? fieldsParsed.data
|
||||
: fieldsParsed.data.filter((f) => !f.deleted)
|
||||
: [],
|
||||
routes: routesParsed.data,
|
||||
settings,
|
||||
fields: finalFields,
|
||||
routes,
|
||||
routers,
|
||||
connectedForms,
|
||||
createdAt: form.createdAt.toString(),
|
||||
updatedAt: form.updatedAt.toString(),
|
||||
};
|
||||
return serializableForm;
|
||||
|
||||
/**
|
||||
* Enriches routes that are actually routers and returns a list of routers separately
|
||||
*/
|
||||
async function getEnrichedRoutesAndRouters(parsedRoutes: z.infer<typeof zodRoutes>) {
|
||||
const routers: { name: string; description: string | null; id: string }[] = [];
|
||||
const routes: z.infer<typeof zodRoutesView> = [];
|
||||
if (!parsedRoutes) {
|
||||
return { routes, routers };
|
||||
}
|
||||
|
||||
for (const [, route] of Object.entries(parsedRoutes)) {
|
||||
if (isRouter(route)) {
|
||||
const router = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: route.id,
|
||||
userId: form.userId,
|
||||
},
|
||||
});
|
||||
if (!router) {
|
||||
throw new Error("Form -" + route.id + ", being used as router, not found");
|
||||
}
|
||||
|
||||
const parsedRouter = await getSerializableForm(router, false);
|
||||
|
||||
routers.push({
|
||||
name: parsedRouter.name,
|
||||
description: parsedRouter.description,
|
||||
id: parsedRouter.id,
|
||||
});
|
||||
|
||||
// Enrichment
|
||||
routes.push({
|
||||
...route,
|
||||
isRouter: true,
|
||||
name: parsedRouter.name,
|
||||
description: parsedRouter.description,
|
||||
routes: parsedRouter.routes || [],
|
||||
});
|
||||
|
||||
parsedRouter.fields?.forEach((field) => {
|
||||
if (!fieldsExistInForm[field.id]) {
|
||||
// Instead of throwing error, Log it instead of breaking entire routing forms feature
|
||||
console.error(
|
||||
"This is an impossible state. A router field must always be present in the connected form."
|
||||
);
|
||||
} else {
|
||||
const currentFormField = fields.find((f) => f.id === field.id);
|
||||
if (!currentFormField || !("routerId" in currentFormField)) {
|
||||
return;
|
||||
}
|
||||
if (!isRouterLinkedField(field)) {
|
||||
currentFormField.routerField = field;
|
||||
}
|
||||
currentFormField.router = {
|
||||
id: parsedRouter.id,
|
||||
name: router.name,
|
||||
description: router.description || "",
|
||||
};
|
||||
}
|
||||
});
|
||||
} else {
|
||||
routes.push(route);
|
||||
}
|
||||
}
|
||||
return { routes, routers };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { zodRoute } from "../zod";
|
||||
import isRouter from "./isRouter";
|
||||
|
||||
export const isFallbackRoute = (route: z.infer<typeof zodRoute>) => {
|
||||
if (isRouter(route)) {
|
||||
return false;
|
||||
}
|
||||
return route.isFallback;
|
||||
};
|
||||
+1
-1
@@ -2,7 +2,7 @@ import prisma from "@calcom/prisma";
|
||||
|
||||
import { App_RoutingForms_Form, User } from ".prisma/client";
|
||||
|
||||
export async function isAllowed({
|
||||
export async function isFormEditAllowed({
|
||||
userId,
|
||||
formId,
|
||||
}: {
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { zodRouterRouteView, zodRoute, zodRouterRoute, zodRouteView } from "../zod";
|
||||
|
||||
export default function isRouter(
|
||||
route: z.infer<typeof zodRouteView> | z.infer<typeof zodRoute>
|
||||
): route is z.infer<typeof zodRouterRouteView> | z.infer<typeof zodRouterRoute> {
|
||||
if ("isRouter" in route) {
|
||||
return route.isRouter;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { zodFieldView, zodField, zodRouterField, zodRouterFieldView } from "../zod";
|
||||
|
||||
export default function isRouterLinkedField(
|
||||
field: z.infer<typeof zodFieldView> | z.infer<typeof zodField>
|
||||
): field is z.infer<typeof zodRouterField> | z.infer<typeof zodRouterFieldView> {
|
||||
if ("routerId" in field) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import jsonLogic from "json-logic-js";
|
||||
import { Utils as QbUtils } from "react-awesome-query-builder";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getQueryBuilderConfig } from "../pages/route-builder/[...appPages]";
|
||||
import { Response, Route, SerializableForm } from "../types/types";
|
||||
import { zodNonRouterRoute } from "../zod";
|
||||
import { isFallbackRoute } from "./isFallbackRoute";
|
||||
import isRouter from "./isRouter";
|
||||
import { App_RoutingForms_Form } from ".prisma/client";
|
||||
|
||||
export function processRoute({
|
||||
@@ -18,15 +22,24 @@ export function processRoute({
|
||||
|
||||
let decidedAction: Route["action"] | null = null;
|
||||
|
||||
const fallbackRoute = routes.find((route) => route.isFallback);
|
||||
const fallbackRoute = routes.find(isFallbackRoute);
|
||||
|
||||
if (!fallbackRoute) {
|
||||
throw new Error("Fallback route is missing");
|
||||
}
|
||||
|
||||
const reorderedRoutes = routes.filter((route) => !route.isFallback).concat([fallbackRoute]);
|
||||
const routesWithFallbackInEnd = routes
|
||||
.flatMap((r) => {
|
||||
// For a router, use it's routes instead.
|
||||
if (isRouter(r)) return r.routes;
|
||||
return r;
|
||||
})
|
||||
// Use only non fallback routes
|
||||
.filter((route) => route && !isFallbackRoute(route))
|
||||
// After above flat map, all routes are non router routes.
|
||||
.concat([fallbackRoute]) as z.infer<typeof zodNonRouterRoute>[];
|
||||
|
||||
reorderedRoutes.some((route) => {
|
||||
routesWithFallbackInEnd.some((route) => {
|
||||
if (!route) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { App_RoutingForms_Form } from "@prisma/client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFieldArray, UseFormReturn } from "react-hook-form";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -21,17 +20,12 @@ import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import SingleForm, {
|
||||
getServerSidePropsForSingleFormView as getServerSideProps,
|
||||
RoutingFormWithResponseCount,
|
||||
} from "../../components/SingleForm";
|
||||
import { SerializableForm } from "../../types/types";
|
||||
|
||||
export { getServerSideProps };
|
||||
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
|
||||
type RoutingFormWithResponseCount = RoutingForm & {
|
||||
_count: {
|
||||
responses: number;
|
||||
};
|
||||
};
|
||||
type HookForm = UseFormReturn<RoutingFormWithResponseCount>;
|
||||
|
||||
export const FieldTypes = [
|
||||
{
|
||||
label: "Short Text",
|
||||
@@ -70,6 +64,7 @@ function Field({
|
||||
fieldIndex,
|
||||
moveUp,
|
||||
moveDown,
|
||||
appUrl,
|
||||
}: {
|
||||
fieldIndex: number;
|
||||
hookForm: HookForm;
|
||||
@@ -86,6 +81,7 @@ function Field({
|
||||
check: () => boolean;
|
||||
fn: () => void;
|
||||
};
|
||||
appUrl: string;
|
||||
}) {
|
||||
const [identifier, _setIdentifier] = useState(hookForm.getValues(`${hookFieldNamespace}.identifier`));
|
||||
|
||||
@@ -102,7 +98,8 @@ function Field({
|
||||
_setIdentifier(label);
|
||||
}
|
||||
}, [label, hookFieldNamespace, hookForm]);
|
||||
|
||||
const router = hookForm.getValues(`${hookFieldNamespace}.router`);
|
||||
const routerField = hookForm.getValues(`${hookFieldNamespace}.routerField`);
|
||||
return (
|
||||
<div
|
||||
data-testid="field"
|
||||
@@ -111,13 +108,22 @@ function Field({
|
||||
label={label || `Field ${fieldIndex + 1}`}
|
||||
moveUp={moveUp}
|
||||
moveDown={moveDown}
|
||||
deleteField={deleteField}>
|
||||
badge={
|
||||
router ? { text: router.name, variant: "gray", href: `${appUrl}/form-edit/${router.id}` } : null
|
||||
}
|
||||
deleteField={router ? null : deleteField}>
|
||||
<div className="w-full">
|
||||
<div className="mb-6 w-full">
|
||||
<TextField
|
||||
disabled={!!router}
|
||||
label="Label"
|
||||
type="text"
|
||||
placeholder="This is what your users would see"
|
||||
/**
|
||||
* This is a bit of a hack to make sure that for routerField, label is shown from there.
|
||||
* For other fields, value property is used because it exists and would take precedence
|
||||
*/
|
||||
defaultValue={routerField?.label}
|
||||
required
|
||||
{...hookForm.register(`${hookFieldNamespace}.label`)}
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
@@ -125,11 +131,13 @@ function Field({
|
||||
</div>
|
||||
<div className="mb-6 w-full">
|
||||
<TextField
|
||||
disabled={!!router}
|
||||
label="Identifier"
|
||||
name="identifier"
|
||||
required
|
||||
placeholder="Identifies field by this name."
|
||||
value={identifier}
|
||||
defaultValue={routerField?.identifier || routerField?.label}
|
||||
onChange={(e) => setUserChangedIdentifier(e.target.value)}
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
/>
|
||||
@@ -138,11 +146,13 @@ function Field({
|
||||
<Controller
|
||||
name={`${hookFieldNamespace}.type`}
|
||||
control={hookForm.control}
|
||||
defaultValue={routerField?.type}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const defaultValue = FieldTypes.find((fieldType) => fieldType.value === value);
|
||||
return (
|
||||
<SelectField
|
||||
label="Type"
|
||||
isDisabled={!!router}
|
||||
containerClassName="data-testid-field-type"
|
||||
options={FieldTypes}
|
||||
onChange={(option) => {
|
||||
@@ -161,8 +171,10 @@ function Field({
|
||||
<div className="mt-2 block items-center sm:flex">
|
||||
<div className="w-full">
|
||||
<TextAreaField
|
||||
disabled={!!router}
|
||||
rows={3}
|
||||
label="Options"
|
||||
defaultValue={routerField?.selectText}
|
||||
placeholder="Add 1 option per line"
|
||||
{...hookForm.register(`${hookFieldNamespace}.selectText`)}
|
||||
/>
|
||||
@@ -174,8 +186,16 @@ function Field({
|
||||
<Controller
|
||||
name={`${hookFieldNamespace}.required`}
|
||||
control={hookForm.control}
|
||||
defaultValue={routerField?.required}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return <BooleanToggleGroupField label="Required" value={value} onValueChange={onChange} />;
|
||||
return (
|
||||
<BooleanToggleGroupField
|
||||
disabled={!!router}
|
||||
label="Required"
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -184,12 +204,15 @@ function Field({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FormEdit = ({
|
||||
hookForm,
|
||||
form,
|
||||
appUrl,
|
||||
}: {
|
||||
hookForm: HookForm;
|
||||
form: inferSSRProps<typeof getServerSideProps>["form"];
|
||||
appUrl: string;
|
||||
}) => {
|
||||
const fieldsNamespace = "fields";
|
||||
const {
|
||||
@@ -228,6 +251,7 @@ const FormEdit = ({
|
||||
{hookFormFields.map((field, key) => {
|
||||
return (
|
||||
<Field
|
||||
appUrl={appUrl}
|
||||
fieldIndex={key}
|
||||
hookForm={hookForm}
|
||||
hookFieldNamespace={`${fieldsNamespace}.${key}`}
|
||||
@@ -272,14 +296,18 @@ const FormEdit = ({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button data-testid="add-field" onClick={addField} className="w-full">
|
||||
<div className="w-full">
|
||||
<EmptyScreen
|
||||
Icon={Icon.FiFileText}
|
||||
headline="Create your first field"
|
||||
description="Fields are the form fields that the booker would see."
|
||||
buttonRaw={<Button>Create Field</Button>}
|
||||
buttonRaw={
|
||||
<Button data-testid="add-field" onClick={addField}>
|
||||
Create Field
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -291,7 +319,7 @@ export default function FormEditPage({
|
||||
<SingleForm
|
||||
form={form}
|
||||
appUrl={appUrl}
|
||||
Page={({ hookForm, form }) => <FormEdit hookForm={hookForm} form={form} />}
|
||||
Page={({ hookForm, form }) => <FormEdit appUrl={appUrl} hookForm={hookForm} form={form} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -215,7 +215,10 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
},
|
||||
});
|
||||
|
||||
const serializableForms = forms.map((form) => getSerializableForm(form));
|
||||
const serializableForms = [];
|
||||
for (const [, form] of Object.entries(forms)) {
|
||||
serializableForms.push(await getSerializableForm(form));
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { App_RoutingForms_Form } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Builder, Config, Query, Utils as QbUtils } from "react-awesome-query-builder";
|
||||
import { Query, Config, Builder, Utils as QbUtils } from "react-awesome-query-builder";
|
||||
// types
|
||||
import { BuilderProps, ImmutableTree, JsonTree } from "react-awesome-query-builder";
|
||||
import { JsonTree, ImmutableTree, BuilderProps } from "react-awesome-query-builder";
|
||||
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import {
|
||||
Button,
|
||||
SelectField,
|
||||
FormCard,
|
||||
Icon,
|
||||
SelectWithValidation as Select,
|
||||
Shell,
|
||||
TextArea,
|
||||
TextField,
|
||||
Badge,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import SingleForm, {
|
||||
@@ -22,7 +23,9 @@ import SingleForm, {
|
||||
} from "../../components/SingleForm";
|
||||
import QueryBuilderInitialConfig from "../../components/react-awesome-query-builder/config/config";
|
||||
import "../../components/react-awesome-query-builder/styles.css";
|
||||
import { SerializableForm } from "../../types/types";
|
||||
import { createFallbackRoute } from "../../lib/createFallbackRoute";
|
||||
import isRouter from "../../lib/isRouter";
|
||||
import { GlobalRoute, LocalRoute, SerializableForm, SerializableRoute } from "../../types/types";
|
||||
import { FieldTypes } from "../form-edit/[...appPages]";
|
||||
|
||||
export { getServerSideProps };
|
||||
@@ -30,8 +33,10 @@ export { getServerSideProps };
|
||||
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
|
||||
|
||||
const InitialConfig = QueryBuilderInitialConfig;
|
||||
const hasRules = (route: Route) =>
|
||||
const hasRules = (route: Route) => {
|
||||
if (isRouter(route)) return false;
|
||||
route.queryValue.children1 && Object.keys(route.queryValue.children1).length;
|
||||
};
|
||||
type QueryBuilderUpdatedConfig = typeof QueryBuilderInitialConfig & { fields: Config["fields"] };
|
||||
export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
|
||||
const fields: Record<
|
||||
@@ -49,6 +54,9 @@ export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
|
||||
}
|
||||
> = {};
|
||||
form.fields?.forEach((field) => {
|
||||
if ("routerField" in field) {
|
||||
field = field.routerField;
|
||||
}
|
||||
if (FieldTypes.map((f) => f.value).includes(field.type)) {
|
||||
const optionValues = field.selectText?.trim().split("\n");
|
||||
const options = optionValues?.map((value) => {
|
||||
@@ -90,7 +98,7 @@ export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const getEmptyRoute = (): SerializableRoute => {
|
||||
const getEmptyRoute = (): Exclude<SerializableRoute, GlobalRoute> => {
|
||||
const uuid = QbUtils.uuid();
|
||||
return {
|
||||
id: uuid,
|
||||
@@ -102,41 +110,19 @@ const getEmptyRoute = (): SerializableRoute => {
|
||||
};
|
||||
};
|
||||
|
||||
const createFallbackRoute = (): SerializableRoute => {
|
||||
const uuid = QbUtils.uuid();
|
||||
return {
|
||||
id: uuid,
|
||||
isFallback: true,
|
||||
action: {
|
||||
type: "customPageMessage",
|
||||
value: "Thank you for your interest! We will be in touch soon.",
|
||||
},
|
||||
queryValue: { id: uuid, type: "group" },
|
||||
};
|
||||
};
|
||||
type Route =
|
||||
| (LocalRoute & {
|
||||
// This is what's persisted
|
||||
queryValue: JsonTree;
|
||||
// `queryValue` is parsed to create state
|
||||
state: {
|
||||
tree: ImmutableTree;
|
||||
config: QueryBuilderUpdatedConfig;
|
||||
};
|
||||
})
|
||||
| GlobalRoute;
|
||||
|
||||
type Route = {
|
||||
id: string;
|
||||
isFallback?: boolean;
|
||||
action: {
|
||||
type: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl";
|
||||
value: string;
|
||||
};
|
||||
// This is what's persisted
|
||||
queryValue: JsonTree;
|
||||
// `queryValue` is parsed to create state
|
||||
state: {
|
||||
tree: ImmutableTree;
|
||||
config: QueryBuilderUpdatedConfig;
|
||||
};
|
||||
};
|
||||
|
||||
type SerializableRoute = Pick<Route, "id" | "action"> & {
|
||||
queryValue: Route["queryValue"];
|
||||
isFallback?: Route["isFallback"];
|
||||
};
|
||||
|
||||
export const RoutingPages: { label: string; value: Route["action"]["type"] }[] = [
|
||||
export const RoutingPages: { label: string; value: NonNullable<LocalRoute["action"]>["type"] }[] = [
|
||||
{
|
||||
label: "Custom Page",
|
||||
value: "customPageMessage",
|
||||
@@ -159,6 +145,8 @@ const Route = ({
|
||||
setRoutes,
|
||||
moveUp,
|
||||
moveDown,
|
||||
appUrl,
|
||||
disabled = false,
|
||||
}: {
|
||||
route: Route;
|
||||
routes: Route[];
|
||||
@@ -167,6 +155,8 @@ const Route = ({
|
||||
setRoutes: React.Dispatch<React.SetStateAction<Route[]>>;
|
||||
moveUp?: { fn: () => void; check: () => boolean } | null;
|
||||
moveDown?: { fn: () => void; check: () => boolean } | null;
|
||||
appUrl: string;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const index = routes.indexOf(route);
|
||||
|
||||
@@ -202,6 +192,42 @@ const Route = ({
|
||||
[]
|
||||
);
|
||||
|
||||
if (isRouter(route)) {
|
||||
return (
|
||||
<div>
|
||||
<FormCard
|
||||
moveUp={moveUp}
|
||||
moveDown={moveDown}
|
||||
deleteField={{
|
||||
check: () => routes.length !== 1,
|
||||
fn: () => {
|
||||
const newRoutes = routes.filter((r) => r.id !== route.id);
|
||||
setRoutes(newRoutes);
|
||||
},
|
||||
}}
|
||||
label={
|
||||
<div>
|
||||
<span className="mr-2">{`Route ${index + 1}`}</span>
|
||||
</div>
|
||||
}
|
||||
className="mb-6">
|
||||
<div className="-mt-3">
|
||||
<Link href={`/${appUrl}/route-builder/${route.id}`}>
|
||||
<a>
|
||||
<Badge variant="gray">
|
||||
<span className="font-semibold">{route.name}</span>
|
||||
</Badge>
|
||||
</a>
|
||||
</Link>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Fields available in <span className="font-bold">{route.name}</span> will be added to this form.
|
||||
</p>
|
||||
</div>
|
||||
</FormCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormCard
|
||||
className="mb-6"
|
||||
@@ -223,14 +249,15 @@ const Route = ({
|
||||
<span>Send Booker to</span>
|
||||
</div>
|
||||
<Select
|
||||
isDisabled={disabled}
|
||||
className="block w-full flex-grow px-2"
|
||||
required
|
||||
value={RoutingPages.find((page) => page.value === route.action.type)}
|
||||
value={RoutingPages.find((page) => page.value === route.action?.type)}
|
||||
onChange={(item) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const action: Route["action"] = {
|
||||
const action: LocalRoute["action"] = {
|
||||
type: item.value,
|
||||
value: "",
|
||||
};
|
||||
@@ -245,10 +272,11 @@ const Route = ({
|
||||
}}
|
||||
options={RoutingPages}
|
||||
/>
|
||||
{route.action.type ? (
|
||||
route.action.type === "customPageMessage" ? (
|
||||
{route.action?.type ? (
|
||||
route.action?.type === "customPageMessage" ? (
|
||||
<TextArea
|
||||
required
|
||||
disabled={disabled}
|
||||
name="customPageMessage"
|
||||
className="flex w-full flex-grow border-gray-300"
|
||||
value={route.action.value}
|
||||
@@ -256,8 +284,9 @@ const Route = ({
|
||||
setRoute(route.id, { action: { ...route.action, value: e.target.value } });
|
||||
}}
|
||||
/>
|
||||
) : route.action.type === "externalRedirectUrl" ? (
|
||||
) : route.action?.type === "externalRedirectUrl" ? (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
name="externalRedirectUrl"
|
||||
className="flex w-full flex-grow border-gray-300 text-sm"
|
||||
containerClassName="w-full mt-2"
|
||||
@@ -274,6 +303,7 @@ const Route = ({
|
||||
<div className="block w-full">
|
||||
<Select
|
||||
required
|
||||
isDisabled={disabled}
|
||||
options={eventOptions}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
@@ -308,7 +338,10 @@ const Route = ({
|
||||
);
|
||||
};
|
||||
|
||||
const deserializeRoute = (route: SerializableRoute, config: QueryBuilderUpdatedConfig): Route => {
|
||||
const deserializeRoute = (
|
||||
route: Exclude<SerializableRoute, GlobalRoute>,
|
||||
config: QueryBuilderUpdatedConfig
|
||||
): Route => {
|
||||
return {
|
||||
...route,
|
||||
state: {
|
||||
@@ -321,18 +354,22 @@ const deserializeRoute = (route: SerializableRoute, config: QueryBuilderUpdatedC
|
||||
const Routes = ({
|
||||
form,
|
||||
hookForm,
|
||||
appUrl,
|
||||
}: {
|
||||
form: inferSSRProps<typeof getServerSideProps>["form"];
|
||||
// Figure out the type
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
hookForm: any;
|
||||
appUrl: string;
|
||||
}) => {
|
||||
const { routes: serializedRoutes } = form;
|
||||
|
||||
const config = getQueryBuilderConfig(form);
|
||||
const [routes, setRoutes] = useState(() => {
|
||||
const transformRoutes = () => {
|
||||
const _routes = serializedRoutes || [getEmptyRoute()];
|
||||
_routes.forEach((r) => {
|
||||
if (isRouter(r)) return;
|
||||
if (!r.queryValue?.id) {
|
||||
r.queryValue = { id: QbUtils.uuid(), type: "group" };
|
||||
}
|
||||
@@ -340,13 +377,74 @@ const Routes = ({
|
||||
return _routes;
|
||||
};
|
||||
|
||||
return transformRoutes().map((route) => deserializeRoute(route, config));
|
||||
return transformRoutes().map((route) => {
|
||||
if (isRouter(route)) return route;
|
||||
return deserializeRoute(route, config);
|
||||
});
|
||||
});
|
||||
const { data: allForms } = trpc.viewer.appRoutingForms.forms.useQuery();
|
||||
|
||||
const availableRouters =
|
||||
allForms
|
||||
?.filter((router) => {
|
||||
return router.id !== form.id;
|
||||
})
|
||||
.map((router) => {
|
||||
return {
|
||||
value: router.id,
|
||||
label: router.name,
|
||||
name: router.name,
|
||||
description: router.description,
|
||||
isDisabled: false,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
const isConnectedForm = (id: string) => form.connectedForms.map((f) => f.id).includes(id);
|
||||
|
||||
const routerOptions = (
|
||||
[
|
||||
{
|
||||
label: "Create a New Route",
|
||||
value: "newRoute",
|
||||
name: null,
|
||||
description: null,
|
||||
},
|
||||
] as {
|
||||
label: string;
|
||||
value: string;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
isDisabled?: boolean;
|
||||
}[]
|
||||
).concat(
|
||||
availableRouters.map((r) => {
|
||||
// Reset disabled state
|
||||
r.isDisabled = false;
|
||||
|
||||
// Can't select a form as router that is already a connected form. It avoids cyclic dependency
|
||||
if (isConnectedForm(r.value)) {
|
||||
r.isDisabled = true;
|
||||
}
|
||||
// A route that's already used, can't be reselected
|
||||
if (routes.find((route) => route.id === r.value)) {
|
||||
r.isDisabled = true;
|
||||
}
|
||||
return r;
|
||||
})
|
||||
);
|
||||
|
||||
const [animationRef] = useAutoAnimate<HTMLDivElement>();
|
||||
|
||||
const mainRoutes = routes.filter((route) => !route.isFallback);
|
||||
let fallbackRoute = routes.find((route) => route.isFallback);
|
||||
const mainRoutes = routes.filter((route) => {
|
||||
if (isRouter(route)) return true;
|
||||
return !route.isFallback;
|
||||
});
|
||||
|
||||
let fallbackRoute = routes.find((route) => {
|
||||
if (isRouter(route)) return false;
|
||||
return route.isFallback;
|
||||
});
|
||||
|
||||
if (!fallbackRoute) {
|
||||
fallbackRoute = deserializeRoute(createFallbackRoute(), config);
|
||||
setRoutes((routes) => {
|
||||
@@ -363,6 +461,7 @@ const Routes = ({
|
||||
return [...routes.filter((route) => route.id !== fallbackRoute!.id), fallbackRoute!];
|
||||
});
|
||||
}
|
||||
|
||||
const setRoute = (id: string, route: Partial<Route>) => {
|
||||
const index = routes.findIndex((route) => route.id === id);
|
||||
const newRoutes = [...routes];
|
||||
@@ -380,19 +479,27 @@ const Routes = ({
|
||||
});
|
||||
};
|
||||
|
||||
const routesToSave: SerializableRoute[] = routes.map((route) => ({
|
||||
id: route.id,
|
||||
action: route.action,
|
||||
isFallback: route.isFallback,
|
||||
queryValue: route.queryValue,
|
||||
}));
|
||||
const routesToSave = routes.map((route) => {
|
||||
if (isRouter(route)) {
|
||||
return route;
|
||||
}
|
||||
return {
|
||||
id: route.id,
|
||||
action: route.action,
|
||||
isFallback: route.isFallback,
|
||||
queryValue: route.queryValue,
|
||||
};
|
||||
});
|
||||
|
||||
hookForm.setValue("routes", routesToSave);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col-reverse md:flex-row">
|
||||
<div ref={animationRef} className="w-full ltr:mr-2 rtl:ml-2">
|
||||
{mainRoutes.map((route, key) => {
|
||||
return (
|
||||
<Route
|
||||
appUrl={appUrl}
|
||||
key={route.id}
|
||||
config={config}
|
||||
route={route}
|
||||
@@ -415,28 +522,50 @@ const Routes = ({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
type="button"
|
||||
className="mb-6"
|
||||
color="secondary"
|
||||
StartIcon={Icon.FiPlus}
|
||||
data-testid="add-route"
|
||||
onClick={() => {
|
||||
const newEmptyRoute = getEmptyRoute();
|
||||
const newRoutes = [
|
||||
...routes,
|
||||
{
|
||||
...newEmptyRoute,
|
||||
state: {
|
||||
tree: QbUtils.checkTree(QbUtils.loadTree(newEmptyRoute.queryValue), config),
|
||||
config,
|
||||
<SelectField
|
||||
placeholder="Select a router"
|
||||
containerClassName="mb-6 data-testid-select-router"
|
||||
isOptionDisabled={(option) => !!option.isDisabled}
|
||||
label="Add a new Route"
|
||||
options={routerOptions}
|
||||
key={mainRoutes.length}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
const router = option.value;
|
||||
if (router === "newRoute") {
|
||||
const newEmptyRoute = getEmptyRoute();
|
||||
const newRoutes = [
|
||||
...routes,
|
||||
{
|
||||
...newEmptyRoute,
|
||||
state: {
|
||||
tree: QbUtils.checkTree(QbUtils.loadTree(newEmptyRoute.queryValue), config),
|
||||
config,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
setRoutes(newRoutes);
|
||||
}}>
|
||||
Add Route
|
||||
</Button>
|
||||
];
|
||||
|
||||
setRoutes(newRoutes);
|
||||
} else {
|
||||
const routerId = router;
|
||||
if (!routerId) {
|
||||
return;
|
||||
}
|
||||
setRoutes([
|
||||
...routes,
|
||||
{
|
||||
isRouter: true,
|
||||
id: routerId,
|
||||
name: option.name,
|
||||
description: option.description,
|
||||
} as Route,
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Route
|
||||
config={config}
|
||||
@@ -444,6 +573,7 @@ const Routes = ({
|
||||
routes={routes}
|
||||
setRoute={setRoute}
|
||||
setRoutes={setRoutes}
|
||||
appUrl={appUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -461,7 +591,7 @@ export default function RouteBuilder({
|
||||
appUrl={appUrl}
|
||||
Page={({ hookForm, form }) => (
|
||||
<div className="route-config">
|
||||
<Routes hookForm={hookForm} form={form} />
|
||||
<Routes hookForm={hookForm} appUrl={appUrl} form={form} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -56,7 +56,7 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
const serializableForm = getSerializableForm(form);
|
||||
const serializableForm = await getSerializableForm(form);
|
||||
|
||||
const response: Record<string, Pick<Response[string], "value">> = {};
|
||||
serializableForm.fields?.forEach((field) => {
|
||||
|
||||
@@ -199,7 +199,7 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
brandColor: form.user.brandColor,
|
||||
darkBrandColor: form.user.darkBrandColor,
|
||||
},
|
||||
form: getSerializableForm(form),
|
||||
form: await getSerializableForm(form),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,88 +2,13 @@ import { expect, Page } from "@playwright/test";
|
||||
|
||||
import { Fixtures, test } from "@calcom/web/playwright/lib/fixtures";
|
||||
|
||||
async function gotoRoutingLink(page: Page, formId: string) {
|
||||
await page.goto(`/forms/${formId}`);
|
||||
// HACK: There seems to be some issue with the inputs to the form getting reset if we don't wait.
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
export async function addForm(page: Page) {
|
||||
await page.click('[data-testid="new-routing-form"]');
|
||||
await page.fill("input[name]", "Test Form Name");
|
||||
await page.click('[data-testid="add-form"]');
|
||||
await page.waitForSelector('[data-testid="add-field"]');
|
||||
const url = page.url();
|
||||
const formId = new URL(url).pathname.split("/").at(-1);
|
||||
if (!formId) {
|
||||
throw new Error("Form ID couldn't be determined from url");
|
||||
}
|
||||
return formId;
|
||||
}
|
||||
|
||||
async function verifySelectOptions(
|
||||
selector: { selector: string; nth: number },
|
||||
expectedOptions: string[],
|
||||
page: Page
|
||||
) {
|
||||
await page.locator(selector.selector).nth(selector.nth).click();
|
||||
const selectOptions = await page
|
||||
.locator(selector.selector)
|
||||
.nth(selector.nth)
|
||||
.locator('[id*="react-select-"][aria-disabled]')
|
||||
.allInnerTexts();
|
||||
|
||||
const sortedSelectOptions = [...selectOptions].sort();
|
||||
const sortedExpectedOptions = [...expectedOptions].sort();
|
||||
expect(sortedSelectOptions).toEqual(sortedExpectedOptions);
|
||||
return {
|
||||
optionsInUi: selectOptions,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fillForm(
|
||||
page: Page,
|
||||
form: { description: string; field?: { typeIndex: number; label: string } }
|
||||
) {
|
||||
await page.click('[data-testid="add-field"]');
|
||||
await page.fill('[data-testid="description"]', form.description);
|
||||
|
||||
// Verify all Options of SelectBox
|
||||
const { optionsInUi: types } = await verifySelectOptions(
|
||||
{ selector: ".data-testid-field-type", nth: 0 },
|
||||
["Email", "Long Text", "MultiSelect", "Number", "Phone", "Select", "Short Text"],
|
||||
page
|
||||
);
|
||||
|
||||
if (form.field) {
|
||||
await page.fill('[name="fields.0.label"]', form.field.label);
|
||||
await page.click(".data-testid-field-type");
|
||||
await page.locator('[id*="react-select-"][aria-disabled]').nth(form.field.typeIndex).click();
|
||||
}
|
||||
await page.click('[data-testid="update-form"]');
|
||||
await page.waitForSelector(".data-testid-toast-success");
|
||||
return {
|
||||
types,
|
||||
};
|
||||
function todo(title: string) {
|
||||
// eslint-disable-next-line playwright/no-skipped-test, @typescript-eslint/no-empty-function
|
||||
test.skip(title, () => {});
|
||||
}
|
||||
|
||||
test.describe("Routing Forms", () => {
|
||||
test.afterEach(async ({ users }) => {
|
||||
// This also delete forms on cascade
|
||||
await users.deleteAll();
|
||||
});
|
||||
test.describe("Zero State Routing Forms", () => {
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
const user = await users.create({ username: "routing-forms" });
|
||||
await user.login();
|
||||
// Install app
|
||||
await page.goto(`/apps/routing-forms`);
|
||||
await page.click('[data-testid="install-app-button"]');
|
||||
await page.waitForNavigation({
|
||||
url: (url) => url.pathname === `/apps/routing-forms/forms`,
|
||||
});
|
||||
});
|
||||
|
||||
test("should be able to add a new form and view it", async ({ page }) => {
|
||||
await page.waitForSelector('[data-testid="empty-screen"]');
|
||||
|
||||
@@ -107,7 +32,7 @@ test.describe("Routing Forms", () => {
|
||||
});
|
||||
|
||||
test("should be able to edit the form", async ({ page }) => {
|
||||
await addForm(page);
|
||||
const formId = await addForm(page);
|
||||
const description = "Test Description";
|
||||
|
||||
const field = {
|
||||
@@ -115,7 +40,7 @@ test.describe("Routing Forms", () => {
|
||||
typeIndex: 1,
|
||||
};
|
||||
|
||||
const { types } = await fillForm(page, {
|
||||
const { types } = await addOneFieldAndDescriptionAndSaveForm(formId, page, {
|
||||
description,
|
||||
field: field,
|
||||
});
|
||||
@@ -124,26 +49,91 @@ test.describe("Routing Forms", () => {
|
||||
|
||||
expect(await page.inputValue(`[data-testid="description"]`), description);
|
||||
expect(await page.locator('[data-testid="field"]').count()).toBe(1);
|
||||
expect(await page.inputValue('[name="fields.0.label"]')).toBe(field.label);
|
||||
expect(await page.locator(".data-testid-field-type").first().locator("div").nth(1).innerText()).toBe(
|
||||
types[field.typeIndex]
|
||||
);
|
||||
|
||||
await expectCurrentFormToHaveFields(page, { 0: field }, types);
|
||||
|
||||
await page.click('[href*="/apps/routing-forms/route-builder/"]');
|
||||
await page.click('[data-testid="add-route"]');
|
||||
await selectNewRoute(page);
|
||||
|
||||
await page.click('[data-testid="add-rule"]');
|
||||
await verifySelectOptions(
|
||||
{
|
||||
selector: ".rule-container .data-testid-field-select",
|
||||
nth: 0,
|
||||
},
|
||||
[field.label],
|
||||
page
|
||||
);
|
||||
|
||||
await verifyFieldOptionsInRule([field.label], page);
|
||||
});
|
||||
|
||||
test.describe("F1<-F2 Relationship", () => {
|
||||
test("Create relationship by adding F1 as route.Editing F1 should update F2", async ({ page }) => {
|
||||
const form1Id = await addForm(page, { name: "F1" });
|
||||
const form2Id = await addForm(page, { name: "F2" });
|
||||
|
||||
await addOneFieldAndDescriptionAndSaveForm(form1Id, page, {
|
||||
description: "Form 1 Description",
|
||||
field: {
|
||||
label: "F1 Field1",
|
||||
typeIndex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const { types } = await addOneFieldAndDescriptionAndSaveForm(form2Id, page, {
|
||||
description: "Form 2 Description",
|
||||
field: {
|
||||
label: "F2 Field1",
|
||||
//TODO: Maybe choose some other type and choose type by it's name and not index
|
||||
typeIndex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// Add F1 as Router to F2
|
||||
await page.goto(`/apps/routing-forms/route-builder/${form2Id}`);
|
||||
await selectNewRoute(page, {
|
||||
// It should be F1. TODO: Verify that it's F1
|
||||
routeSelectNumber: 2,
|
||||
});
|
||||
await saveCurrentForm(page);
|
||||
|
||||
// Expect F1 fields to be available in F2
|
||||
await page.goto(`/apps/routing-forms/form-edit/${form2Id}`);
|
||||
expect(await page.locator('[data-testid="field"]').count()).toBe(2);
|
||||
await expectCurrentFormToHaveFields(page, { 1: { label: "F1 Field1", typeIndex: 1 } }, types);
|
||||
// Add 1 more field in F1
|
||||
await addOneFieldAndDescriptionAndSaveForm(form1Id, page, {
|
||||
field: {
|
||||
label: "F1 Field2",
|
||||
typeIndex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/apps/routing-forms/form-edit/${form2Id}`);
|
||||
expect(await page.locator('[data-testid="field"]').count()).toBe(3);
|
||||
await expectCurrentFormToHaveFields(page, { 2: { label: "F1 Field2", typeIndex: 1 } }, types);
|
||||
});
|
||||
todo("Create relationship by using duplicate with live connect");
|
||||
});
|
||||
|
||||
// TODO: How to install the app just once?
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
const user = await users.create({ username: "routing-forms" });
|
||||
await user.login();
|
||||
// Install app
|
||||
await page.goto(`/apps/routing-forms`);
|
||||
await page.click('[data-testid="install-app-button"]');
|
||||
await page.waitForNavigation({
|
||||
url: (url) => url.pathname === `/apps/routing-forms/forms`,
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({ users }) => {
|
||||
// This also delete forms on cascade
|
||||
await users.deleteAll();
|
||||
});
|
||||
});
|
||||
|
||||
todo("should be able to duplicate form");
|
||||
|
||||
test.describe("Seeded Routing Form ", () => {
|
||||
test.afterEach(async ({ users }) => {
|
||||
// This also delete forms on cascade
|
||||
await users.deleteAll();
|
||||
});
|
||||
const createUserAndLoginAndInstallApp = async function ({
|
||||
users,
|
||||
page,
|
||||
@@ -324,6 +314,27 @@ test.describe("Routing Forms", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function expectCurrentFormToHaveFields(
|
||||
page: Page,
|
||||
fields: {
|
||||
[key: number]: { label: string; typeIndex: number };
|
||||
},
|
||||
types: string[]
|
||||
) {
|
||||
for (const [index, field] of Object.entries(fields)) {
|
||||
expect(await page.inputValue(`[name="fields.${index}.label"]`)).toBe(field.label);
|
||||
expect(
|
||||
await page
|
||||
.locator(".data-testid-field-type")
|
||||
.nth(+index)
|
||||
.locator("div")
|
||||
.nth(1)
|
||||
.innerText()
|
||||
).toBe(types[field.typeIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
async function fillSeededForm(page: Page, routingFormId: string) {
|
||||
await gotoRoutingLink(page, routingFormId);
|
||||
await page.fill('[data-testid="form-field"]', "event-routing");
|
||||
@@ -348,3 +359,127 @@ async function fillSeededForm(page: Page, routingFormId: string) {
|
||||
await page.click('button[type="submit"]');
|
||||
await page.isVisible("text=Custom Page Result");
|
||||
}
|
||||
|
||||
export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
|
||||
await page.goto("/apps/routing-forms/forms");
|
||||
await page.click('[data-testid="new-routing-form"]');
|
||||
await page.fill("input[name]", name);
|
||||
await page.click('[data-testid="add-form"]');
|
||||
await page.waitForSelector('[data-testid="add-field"]');
|
||||
const url = page.url();
|
||||
const formId = new URL(url).pathname.split("/").at(-1);
|
||||
if (!formId) {
|
||||
throw new Error("Form ID couldn't be determined from url");
|
||||
}
|
||||
return formId;
|
||||
}
|
||||
|
||||
export async function addOneFieldAndDescriptionAndSaveForm(
|
||||
formId: string,
|
||||
page: Page,
|
||||
form: { description?: string; field?: { typeIndex: number; label: string } }
|
||||
) {
|
||||
await page.goto(`apps/routing-forms/form-edit/${formId}`);
|
||||
await page.click('[data-testid="add-field"]');
|
||||
if (form.description) {
|
||||
await page.fill('[data-testid="description"]', form.description);
|
||||
}
|
||||
|
||||
// Verify all Options of SelectBox
|
||||
const { optionsInUi: types } = await verifySelectOptions(
|
||||
{ selector: ".data-testid-field-type", nth: 0 },
|
||||
["Email", "Long Text", "MultiSelect", "Number", "Phone", "Select", "Short Text"],
|
||||
page
|
||||
);
|
||||
|
||||
const nextFieldIndex = (await page.locator('[data-testid="field"]').count()) - 1;
|
||||
|
||||
if (form.field) {
|
||||
await page.fill(`[name="fields.${nextFieldIndex}.label"]`, form.field.label);
|
||||
await page
|
||||
.locator('[data-testid="field"]')
|
||||
.nth(nextFieldIndex)
|
||||
.locator(".data-testid-field-type")
|
||||
.click();
|
||||
await page
|
||||
.locator('[data-testid="field"]')
|
||||
.nth(nextFieldIndex)
|
||||
.locator('[id*="react-select-"][aria-disabled]')
|
||||
.nth(form.field.typeIndex)
|
||||
.click();
|
||||
}
|
||||
await saveCurrentForm(page);
|
||||
return {
|
||||
types,
|
||||
};
|
||||
}
|
||||
|
||||
async function selectOption({
|
||||
page,
|
||||
selector,
|
||||
option,
|
||||
}: {
|
||||
page: Page;
|
||||
selector: { selector: string; nth: number };
|
||||
option: number;
|
||||
}) {
|
||||
const locatorForSelect = page.locator(selector.selector).nth(selector.nth);
|
||||
await locatorForSelect.click();
|
||||
await locatorForSelect
|
||||
.locator('[id*="react-select-"][aria-disabled]')
|
||||
.nth(option - 1)
|
||||
.click();
|
||||
}
|
||||
|
||||
async function verifyFieldOptionsInRule(options: string[], page: Page) {
|
||||
await verifySelectOptions(
|
||||
{
|
||||
selector: ".rule-container .data-testid-field-select",
|
||||
nth: 0,
|
||||
},
|
||||
options,
|
||||
page
|
||||
);
|
||||
}
|
||||
|
||||
async function verifySelectOptions(
|
||||
selector: { selector: string; nth: number },
|
||||
expectedOptions: string[],
|
||||
page: Page
|
||||
) {
|
||||
await page.locator(selector.selector).nth(selector.nth).click();
|
||||
const selectOptions = await page
|
||||
.locator(selector.selector)
|
||||
.nth(selector.nth)
|
||||
.locator('[id*="react-select-"][aria-disabled]')
|
||||
.allInnerTexts();
|
||||
|
||||
const sortedSelectOptions = [...selectOptions].sort();
|
||||
const sortedExpectedOptions = [...expectedOptions].sort();
|
||||
expect(sortedSelectOptions).toEqual(sortedExpectedOptions);
|
||||
return {
|
||||
optionsInUi: selectOptions,
|
||||
};
|
||||
}
|
||||
|
||||
async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) {
|
||||
await selectOption({
|
||||
selector: {
|
||||
selector: ".data-testid-select-router",
|
||||
nth: 0,
|
||||
},
|
||||
option: routeSelectNumber,
|
||||
page,
|
||||
});
|
||||
}
|
||||
|
||||
async function gotoRoutingLink(page: Page, formId: string) {
|
||||
await page.goto(`/forms/${formId}`);
|
||||
// HACK: There seems to be some issue with the inputs to the form getting reset if we don't wait.
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
async function saveCurrentForm(page: Page) {
|
||||
await page.click('[data-testid="update-form"]');
|
||||
await page.waitForSelector(".data-testid-toast-success");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { App_RoutingForms_Form, Prisma, User, WebhookTriggerEvents } from "@prisma/client";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
@@ -12,10 +11,15 @@ import { Ensure } from "@calcom/types/utils";
|
||||
|
||||
import ResponseEmail from "./emails/templates/response-email";
|
||||
import { jsonLogicToPrisma } from "./jsonLogicToPrisma";
|
||||
import { createFallbackRoute } from "./lib/createFallbackRoute";
|
||||
import getConnectedForms from "./lib/getConnectedForms";
|
||||
import { getSerializableForm } from "./lib/getSerializableForm";
|
||||
import { isAllowed } from "./lib/isAllowed";
|
||||
import { isFallbackRoute } from "./lib/isFallbackRoute";
|
||||
import { isFormEditAllowed } from "./lib/isFormEditAllowed";
|
||||
import isRouter from "./lib/isRouter";
|
||||
import isRouterLinkedField from "./lib/isRouterLinkedField";
|
||||
import { Response, SerializableForm } from "./types/types";
|
||||
import { zodFields, zodRoutes } from "./zod";
|
||||
import { zodFields, zodRouterRoute, zodRoutes } from "./zod";
|
||||
|
||||
async function onFormSubmission(
|
||||
form: Ensure<SerializableForm<App_RoutingForms_Form> & { user: User }, "fields">,
|
||||
@@ -106,7 +110,7 @@ const appRoutingForms = router({
|
||||
});
|
||||
}
|
||||
|
||||
const serializableForm = getSerializableForm(form);
|
||||
const serializableForm = await getSerializableForm(form);
|
||||
if (!serializableForm.fields) {
|
||||
// There is no point in submitting a form that doesn't have fields defined
|
||||
throw new TRPCError({
|
||||
@@ -191,7 +195,10 @@ const appRoutingForms = router({
|
||||
},
|
||||
});
|
||||
|
||||
const serializableForms = forms.map((form) => getSerializableForm(form));
|
||||
const serializableForms = [];
|
||||
for (let i = 0; i < forms.length; i++) {
|
||||
serializableForms.push(await getSerializableForm(forms[i]));
|
||||
}
|
||||
return serializableForms;
|
||||
}),
|
||||
formQuery: authedProcedure
|
||||
@@ -220,7 +227,7 @@ const appRoutingForms = router({
|
||||
return null;
|
||||
}
|
||||
|
||||
return getSerializableForm(form);
|
||||
return await getSerializableForm(form);
|
||||
}),
|
||||
formMutation: authedProcedure
|
||||
.input(
|
||||
@@ -233,54 +240,29 @@ const appRoutingForms = router({
|
||||
routes: zodRoutes,
|
||||
addFallback: z.boolean().optional(),
|
||||
duplicateFrom: z.string().nullable().optional(),
|
||||
shouldConnect: z.boolean().optional(),
|
||||
settings: RoutingFormSettings.optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { user, prisma } = ctx;
|
||||
const { name, id, description, settings, disabled, addFallback, duplicateFrom } = input;
|
||||
if (!(await isAllowed({ userId: user.id, formId: id }))) {
|
||||
const { name, id, description, settings, disabled, addFallback, duplicateFrom, shouldConnect } = input;
|
||||
if (!(await isFormEditAllowed({ userId: user.id, formId: id }))) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
let { routes } = input;
|
||||
let { fields } = input;
|
||||
let { routes: inputRoutes } = input;
|
||||
let { fields: inputFields } = input;
|
||||
inputFields = inputFields || [];
|
||||
inputRoutes = inputRoutes || [];
|
||||
type InputFields = typeof inputFields;
|
||||
type InputRoutes = typeof inputRoutes;
|
||||
let routes: InputRoutes;
|
||||
let fields: InputFields;
|
||||
type DuplicateFrom = NonNullable<typeof duplicateFrom>;
|
||||
|
||||
if (duplicateFrom) {
|
||||
const sourceForm = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
id: duplicateFrom,
|
||||
},
|
||||
select: {
|
||||
fields: true,
|
||||
routes: true,
|
||||
},
|
||||
});
|
||||
if (!sourceForm) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Form to duplicate: ${duplicateFrom} not found`,
|
||||
});
|
||||
}
|
||||
const fieldParsed = zodFields.safeParse(sourceForm.fields);
|
||||
const routesParsed = zodRoutes.safeParse(sourceForm.routes);
|
||||
if (!fieldParsed.success || !routesParsed.success) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Could not parse source form's fields or routes",
|
||||
});
|
||||
}
|
||||
// Duplicate just routes and fields
|
||||
// We don't want name, description and responses to be copied
|
||||
routes = routesParsed.data;
|
||||
fields = fieldParsed.data;
|
||||
}
|
||||
|
||||
fields = fields || [];
|
||||
|
||||
const form = await prisma.app_RoutingForms_Form.findUnique({
|
||||
const dbForm = await prisma.app_RoutingForms_Form.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
@@ -299,35 +281,28 @@ const appRoutingForms = router({
|
||||
},
|
||||
});
|
||||
|
||||
// Add back deleted fields in the end. Fields can't be deleted, to make sure columns never decrease which hugely simplifies CSV generation
|
||||
if (form) {
|
||||
const serializedForm = getSerializableForm(form, true);
|
||||
// Find all fields that are in DB(including deleted) but not in the mutation
|
||||
const deletedFields =
|
||||
serializedForm.fields?.filter((f) => !fields!.find((field) => field.id === f.id)) || [];
|
||||
const dbSerializedForm = dbForm ? await getSerializableForm(dbForm, true) : null;
|
||||
|
||||
fields = fields.concat(
|
||||
deletedFields.map((f) => {
|
||||
f.deleted = true;
|
||||
return f;
|
||||
})
|
||||
);
|
||||
if (duplicateFrom) {
|
||||
({ routes, fields } = await getRoutesAndFieldsForDuplication(duplicateFrom));
|
||||
} else {
|
||||
[fields, routes] = [inputFields, inputRoutes];
|
||||
if (dbSerializedForm) {
|
||||
fields = markMissingFieldsDeleted(dbSerializedForm, fields);
|
||||
}
|
||||
}
|
||||
|
||||
if (dbSerializedForm) {
|
||||
// If it's an existing form being mutated, update fields in the connected forms(if any).
|
||||
await updateFieldsInConnectedForms(dbSerializedForm, inputFields);
|
||||
}
|
||||
|
||||
fields = await getUpdatedRouterLinkedFields(fields, routes);
|
||||
|
||||
if (addFallback) {
|
||||
const uuid = uuidv4();
|
||||
routes = routes || [];
|
||||
// Add a fallback route if there is none
|
||||
if (!routes.find((route) => route.isFallback)) {
|
||||
routes.push({
|
||||
id: uuid,
|
||||
isFallback: true,
|
||||
action: {
|
||||
type: "customPageMessage",
|
||||
value: "Thank you for your interest! We will be in touch soon.",
|
||||
},
|
||||
queryValue: { id: uuid, type: "group" },
|
||||
});
|
||||
if (!routes.find(isFallbackRoute)) {
|
||||
routes.push(createFallbackRoute());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +316,7 @@ const appRoutingForms = router({
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
fields: fields,
|
||||
fields,
|
||||
name: name,
|
||||
description,
|
||||
// Prisma doesn't allow setting null value directly for JSON. It recommends using JsonNull for that case.
|
||||
@@ -350,13 +325,230 @@ const appRoutingForms = router({
|
||||
},
|
||||
update: {
|
||||
disabled: disabled,
|
||||
fields: fields,
|
||||
fields,
|
||||
name: name,
|
||||
description,
|
||||
settings: settings === null ? Prisma.JsonNull : settings,
|
||||
routes: routes === null ? Prisma.JsonNull : routes,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* If Form has Router Linked fields, enrich them with the latest info from the Router
|
||||
* If Form doesn't have Router fields but there is a Router used in routes, add all the fields from the Router
|
||||
*/
|
||||
async function getUpdatedRouterLinkedFields(fields: InputFields, routes: InputRoutes) {
|
||||
const routerLinkedFields: Record<string, boolean> = {};
|
||||
for (const [, field] of Object.entries(fields)) {
|
||||
if (!isRouterLinkedField(field)) {
|
||||
continue;
|
||||
}
|
||||
routerLinkedFields[field.routerId] = true;
|
||||
|
||||
if (!routes.some((route) => route.id === field.routerId)) {
|
||||
// If the field is from a router that is not available anymore, mark it as deleted
|
||||
field.deleted = true;
|
||||
continue;
|
||||
}
|
||||
// Get back deleted field as now the Router is there for it.
|
||||
if (field.deleted) field.deleted = false;
|
||||
const router = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: field.routerId,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
if (router) {
|
||||
assertIfInvalidRouter(router);
|
||||
const parsedRouterFields = zodFields.parse(router.fields);
|
||||
|
||||
// There is a field from some router available, make sure that the field has up-to-date info from the router
|
||||
const routerField = parsedRouterFields?.find((f) => f.id === field.id);
|
||||
// Update local field(cache) with router field on every mutation
|
||||
Object.assign(field, routerField);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, route] of Object.entries(routes)) {
|
||||
if (!isRouter(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If there is a field that belongs to router, then all fields must be there already. So, need to add Router fields
|
||||
if (routerLinkedFields[route.id]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const router = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: route.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
if (router) {
|
||||
assertIfInvalidRouter(router);
|
||||
const parsedRouterFields = zodFields.parse(router.fields);
|
||||
const fieldsFromRouter = parsedRouterFields
|
||||
?.filter((f) => !f.deleted)
|
||||
.map((f) => {
|
||||
return {
|
||||
...f,
|
||||
routerId: route.id,
|
||||
};
|
||||
});
|
||||
|
||||
if (fieldsFromRouter) {
|
||||
fields = fields.concat(fieldsFromRouter);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function findFieldWithId(id: string, fields: InputFields) {
|
||||
return fields.find((field) => field.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update fields in connected forms as per the inputFields
|
||||
*/
|
||||
async function updateFieldsInConnectedForms(
|
||||
serializedForm: SerializableForm<App_RoutingForms_Form>,
|
||||
inputFields: InputFields
|
||||
) {
|
||||
for (const [, connectedForm] of Object.entries(serializedForm.connectedForms)) {
|
||||
const connectedFormDb = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: connectedForm.id,
|
||||
},
|
||||
});
|
||||
if (!connectedFormDb) {
|
||||
continue;
|
||||
}
|
||||
const connectedFormFields = zodFields.parse(connectedFormDb.fields);
|
||||
|
||||
const fieldsThatAreNotInConnectedForm = (
|
||||
inputFields?.filter((f) => !findFieldWithId(f.id, connectedFormFields || [])) || []
|
||||
).map((f) => ({
|
||||
...f,
|
||||
routerId: serializedForm.id,
|
||||
}));
|
||||
|
||||
const updatedConnectedFormFields = connectedFormFields
|
||||
// Update fields that are already in connected form
|
||||
?.map((field) => {
|
||||
if (isRouterLinkedField(field) && field.routerId === serializedForm.id) {
|
||||
return {
|
||||
...field,
|
||||
...findFieldWithId(field.id, inputFields || []),
|
||||
};
|
||||
}
|
||||
return field;
|
||||
})
|
||||
// Add fields that are not there
|
||||
.concat(fieldsThatAreNotInConnectedForm);
|
||||
|
||||
await prisma.app_RoutingForms_Form.update({
|
||||
where: {
|
||||
id: connectedForm.id,
|
||||
},
|
||||
data: {
|
||||
fields: updatedConnectedFormFields,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getRoutesAndFieldsForDuplication(duplicateFrom: DuplicateFrom) {
|
||||
const sourceForm = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
id: duplicateFrom,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
fields: true,
|
||||
routes: true,
|
||||
},
|
||||
});
|
||||
if (!sourceForm) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Form to duplicate: ${duplicateFrom} not found`,
|
||||
});
|
||||
}
|
||||
//TODO: Instead of parsing separately, use getSerializableForm. That would automatically remove deleted fields as well.
|
||||
const fieldsParsed = zodFields.safeParse(sourceForm.fields);
|
||||
const routesParsed = zodRoutes.safeParse(sourceForm.routes);
|
||||
if (!fieldsParsed.success || !routesParsed.success) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Could not parse source form's fields or routes",
|
||||
});
|
||||
}
|
||||
|
||||
let fields, routes;
|
||||
if (shouldConnect) {
|
||||
routes = [
|
||||
// This connected route would automatically link the fields
|
||||
zodRouterRoute.parse({
|
||||
id: sourceForm.id,
|
||||
isRouter: true,
|
||||
}),
|
||||
];
|
||||
fields =
|
||||
fieldsParsed.data
|
||||
// Deleted fields in the form shouldn't be added to the new form
|
||||
?.filter((f) => !f.deleted)
|
||||
.map((f) => {
|
||||
return {
|
||||
id: f.id,
|
||||
routerId: sourceForm.id,
|
||||
label: "",
|
||||
type: "",
|
||||
};
|
||||
}) || [];
|
||||
} else {
|
||||
// Duplicate just routes and fields
|
||||
// We don't want name, description and responses to be copied
|
||||
routes = routesParsed.data || [];
|
||||
// FIXME: Deleted fields shouldn't come in duplicate
|
||||
fields = fieldsParsed.data || [];
|
||||
}
|
||||
return { routes, fields };
|
||||
}
|
||||
|
||||
function markMissingFieldsDeleted(
|
||||
serializedForm: SerializableForm<App_RoutingForms_Form>,
|
||||
fields: InputFields
|
||||
) {
|
||||
// Find all fields that are in DB(including deleted) but not in the mutation
|
||||
// e.g. inputFields is [A,B,C]. DB is [A,B,C,D,E,F]. It means D,E,F got deleted
|
||||
const deletedFields =
|
||||
serializedForm.fields?.filter((f) => !fields.find((field) => field.id === f.id)) || [];
|
||||
|
||||
// Add back deleted fields in the end and mark them deleted.
|
||||
// Fields mustn't be deleted, to make sure columns never decrease which hugely simplifies CSV generation
|
||||
fields = fields.concat(
|
||||
deletedFields.map((f) => {
|
||||
f.deleted = true;
|
||||
return f;
|
||||
})
|
||||
);
|
||||
return fields;
|
||||
}
|
||||
function assertIfInvalidRouter(router: App_RoutingForms_Form) {
|
||||
const routesOfRouter = zodRoutes.parse(router.routes);
|
||||
if (routesOfRouter) {
|
||||
if (routesOfRouter.find(isRouter)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"A form being used as a Router must be a Origin form. It must not be using any other Router.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
deleteForm: authedProcedure
|
||||
.input(
|
||||
@@ -366,11 +558,24 @@ const appRoutingForms = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { user, prisma } = ctx;
|
||||
if (!(await isAllowed({ userId: user.id, formId: input.id }))) {
|
||||
if (!(await isFormEditAllowed({ userId: user.id, formId: input.id }))) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
const areFormsUsingIt = (
|
||||
await getConnectedForms(prisma, {
|
||||
id: input.id,
|
||||
userId: user.id,
|
||||
})
|
||||
).length;
|
||||
if (areFormsUsingIt) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This form is being used by other forms. Please remove it's usage from there first.",
|
||||
});
|
||||
}
|
||||
return await prisma.app_RoutingForms_Form.deleteMany({
|
||||
where: {
|
||||
id: input.id,
|
||||
@@ -378,6 +583,7 @@ const appRoutingForms = router({
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
report: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -414,7 +620,7 @@ const appRoutingForms = router({
|
||||
});
|
||||
}
|
||||
// TODO: Second argument is required to return deleted operators.
|
||||
const serializedForm = getSerializableForm(form, true);
|
||||
const serializedForm = await getSerializableForm(form, true);
|
||||
|
||||
const rows = await prisma.app_RoutingForms_FormResponse.findMany({
|
||||
where: {
|
||||
|
||||
+15
-3
@@ -3,7 +3,7 @@ import z from "zod";
|
||||
|
||||
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { zodFields, zodRoutes } from "../zod";
|
||||
import { zodRouterRouteView, zodNonRouterRoute, zodFieldsView, zodRoutesView } from "../zod";
|
||||
|
||||
export type Response = Record<
|
||||
// Field ID
|
||||
@@ -14,9 +14,9 @@ export type Response = Record<
|
||||
}
|
||||
>;
|
||||
|
||||
export type Fields = z.infer<typeof zodFields>;
|
||||
export type Fields = z.infer<typeof zodFieldsView>;
|
||||
export type Field = Fields[number];
|
||||
export type Routes = z.infer<typeof zodRoutes>;
|
||||
export type Routes = z.infer<typeof zodRoutesView>;
|
||||
export type Route = Routes[0];
|
||||
export type SerializableForm<T extends App_RoutingForms_Form> = Omit<
|
||||
T,
|
||||
@@ -27,4 +27,16 @@ export type SerializableForm<T extends App_RoutingForms_Form> = Omit<
|
||||
settings: z.infer<typeof RoutingFormSettings>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
connectedForms: { name: string; description: string | null; id: string }[];
|
||||
routers: { name: string; description: string | null; id: string }[];
|
||||
};
|
||||
|
||||
export type LocalRoute = z.infer<typeof zodNonRouterRoute>;
|
||||
export type GlobalRoute = z.infer<typeof zodRouterRouteView>;
|
||||
|
||||
export type SerializableRoute =
|
||||
| (LocalRoute & {
|
||||
queryValue: LocalRoute["queryValue"];
|
||||
isFallback?: LocalRoute["isFallback"];
|
||||
})
|
||||
| GlobalRoute;
|
||||
|
||||
@@ -1,45 +1,81 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const zodFields = z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
identifier: z.string().optional(),
|
||||
type: z.string(),
|
||||
selectText: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
deleted: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.optional();
|
||||
export const zodNonRouterField = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
identifier: z.string().optional(),
|
||||
type: z.string(),
|
||||
selectText: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
deleted: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const zodRoutes = z
|
||||
.union([
|
||||
z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
queryValue: z.object({
|
||||
id: z.string().optional(),
|
||||
type: z.union([z.literal("group"), z.literal("switch_group")]),
|
||||
children1: z.any(),
|
||||
properties: z.any(),
|
||||
}),
|
||||
isFallback: z.boolean().optional(),
|
||||
action: z.object({
|
||||
// TODO: Make it a union type of "customPageMessage" and ..
|
||||
type: z.union([
|
||||
z.literal("customPageMessage"),
|
||||
z.literal("externalRedirectUrl"),
|
||||
z.literal("eventTypeRedirectUrl"),
|
||||
]),
|
||||
value: z.string(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
z.null(),
|
||||
])
|
||||
.optional();
|
||||
export const zodRouterField = zodNonRouterField.extend({
|
||||
routerId: z.string(),
|
||||
});
|
||||
|
||||
// This ordering is important - If routerId is present then it should be in the parsed object. Moving zodNonRouterField to first position doesn't do that
|
||||
export const zodField = z.union([zodRouterField, zodNonRouterField]);
|
||||
export const zodFields = z.array(zodField).optional();
|
||||
|
||||
export const zodNonRouterFieldView = zodNonRouterField;
|
||||
export const zodRouterFieldView = zodRouterField.extend({
|
||||
routerField: zodNonRouterFieldView,
|
||||
router: z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
id: z.string(),
|
||||
}),
|
||||
});
|
||||
/**
|
||||
* Has some additional fields that are not supposed to be saved to DB but are required for the UI
|
||||
*/
|
||||
export const zodFieldView = z.union([zodNonRouterFieldView, zodRouterFieldView]);
|
||||
|
||||
export const zodFieldsView = z.array(zodFieldView).optional();
|
||||
|
||||
export const zodNonRouterRoute = z.object({
|
||||
id: z.string(),
|
||||
queryValue: z.object({
|
||||
id: z.string().optional(),
|
||||
type: z.union([z.literal("group"), z.literal("switch_group")]),
|
||||
children1: z.any(),
|
||||
properties: z.any(),
|
||||
}),
|
||||
isFallback: z.boolean().optional(),
|
||||
action: z.object({
|
||||
// TODO: Make it a union type of "customPageMessage" and ..
|
||||
type: z.union([
|
||||
z.literal("customPageMessage"),
|
||||
z.literal("externalRedirectUrl"),
|
||||
z.literal("eventTypeRedirectUrl"),
|
||||
]),
|
||||
value: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const zodNonRouterRouteView = zodNonRouterRoute;
|
||||
|
||||
export const zodRouterRoute = z.object({
|
||||
// This is the id of the Form being used as router
|
||||
id: z.string(),
|
||||
isRouter: z.literal(true),
|
||||
});
|
||||
|
||||
export const zodRoute = z.union([zodNonRouterRoute, zodRouterRoute]);
|
||||
|
||||
export const zodRouterRouteView = zodRouterRoute.extend({
|
||||
//TODO: Extend it from form
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
routes: z.array(z.union([zodRoute, z.null()])),
|
||||
});
|
||||
|
||||
export const zodRoutes = z.union([z.array(zodRoute), z.null()]).optional();
|
||||
|
||||
export const zodRouteView = z.union([zodNonRouterRouteView, zodRouterRouteView]);
|
||||
|
||||
export const zodRoutesView = z.union([z.array(zodRouteView), z.null()]).optional();
|
||||
|
||||
// TODO: This is a requirement right now that zod.ts file (if it exists) must have appDataSchema export(which is only required by apps having EventTypeAppCard interface)
|
||||
// This is a temporary solution and will be removed in future
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Page, expect } from "@playwright/test";
|
||||
|
||||
import {
|
||||
addForm as addRoutingForm,
|
||||
fillForm as fillRoutingForm,
|
||||
addOneFieldAndDescriptionAndSaveForm,
|
||||
} from "@calcom/app-store/ee/routing-forms/playwright/tests/basic.e2e";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { Fixtures, test } from "@calcom/web/playwright/lib/fixtures";
|
||||
@@ -35,7 +35,7 @@ test.describe("Typeform App", () => {
|
||||
|
||||
await page.goto(`/apps/routing-forms/forms`);
|
||||
const formId = await addRoutingForm(page);
|
||||
await fillRoutingForm(page, {
|
||||
await addOneFieldAndDescriptionAndSaveForm(formId, page, {
|
||||
description: "",
|
||||
field: { label: "test", typeIndex: 1 },
|
||||
});
|
||||
@@ -54,7 +54,7 @@ test.describe("Typeform App", () => {
|
||||
|
||||
await page.goto("/apps/routing-forms/forms");
|
||||
const formId = await addRoutingForm(page);
|
||||
await fillRoutingForm(page, {
|
||||
await addOneFieldAndDescriptionAndSaveForm(formId, page, {
|
||||
description: "",
|
||||
field: { label: "test", typeIndex: 1 },
|
||||
});
|
||||
|
||||
@@ -68,7 +68,6 @@ export function getWorkingHours(
|
||||
if (!availability.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const utcOffset =
|
||||
relativeTimeUnit.utcOffset ??
|
||||
(relativeTimeUnit.timeZone ? dayjs().tz(relativeTimeUnit.timeZone).utcOffset() : 0);
|
||||
@@ -124,7 +123,6 @@ export function getWorkingHours(
|
||||
}, []);
|
||||
|
||||
workingHours.sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
return workingHours;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,14 @@ const yesNo = (boolean?: boolean) => (boolean === true ? "yes" : boolean === fal
|
||||
export const BooleanToggleGroup = function BooleanToggleGroup({
|
||||
defaultValue = true,
|
||||
value,
|
||||
disabled = false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onValueChange = () => {},
|
||||
}: {
|
||||
defaultValue?: boolean;
|
||||
value?: boolean;
|
||||
onValueChange?: (value?: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
// Maintain a state because it is not necessary that onValueChange the parent component would re-render. Think react-hook-form
|
||||
// Also maintain a string as boolean isn't accepted as ToggleGroupPrimitive value
|
||||
@@ -37,6 +39,7 @@ export const BooleanToggleGroup = function BooleanToggleGroup({
|
||||
<ToggleGroupPrimitive
|
||||
value={yesNoValue}
|
||||
type="single"
|
||||
disabled={disabled}
|
||||
className="space-x-2 rounded-sm rtl:space-x-reverse"
|
||||
onValueChange={(yesNoValue: "yes" | "no") => {
|
||||
setYesNoValue(yesNoValue);
|
||||
@@ -44,10 +47,12 @@ export const BooleanToggleGroup = function BooleanToggleGroup({
|
||||
}}>
|
||||
<ToggleGroupItemPrimitive
|
||||
className={classNames(boolean(yesNoValue) ? selectedClass : unselectedClass)}
|
||||
disabled={disabled}
|
||||
value="yes">
|
||||
Yes
|
||||
</ToggleGroupItemPrimitive>
|
||||
<ToggleGroupItemPrimitive
|
||||
disabled={disabled}
|
||||
className={classNames(!boolean(yesNoValue) ? selectedClass : unselectedClass)}
|
||||
value="no">
|
||||
No
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
import { Icon } from "../../..";
|
||||
import { Badge, BadgeProps } from "../../../components";
|
||||
import Divider from "../Divider";
|
||||
|
||||
type Action = { check: () => boolean; fn: () => void };
|
||||
@@ -11,14 +14,16 @@ export default function FormCard({
|
||||
moveUp,
|
||||
moveDown,
|
||||
className,
|
||||
badge,
|
||||
...restProps
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
label?: string;
|
||||
label?: React.ReactNode;
|
||||
deleteField?: Action | null;
|
||||
moveUp?: Action | null;
|
||||
moveDown?: Action | null;
|
||||
className?: string;
|
||||
badge?: { text: string; href?: string; variant: BadgeProps["variant"] } | null;
|
||||
} & JSX.IntrinsicElements["div"]) {
|
||||
className = classNames(
|
||||
className,
|
||||
@@ -47,7 +52,20 @@ export default function FormCard({
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold leading-none">{label}</span>
|
||||
<div>
|
||||
<span className="text-sm font-semibold leading-none">{label}</span>
|
||||
{badge && (
|
||||
<Badge className="ml-2" variant={badge.variant}>
|
||||
{badge.href ? (
|
||||
<Link href={badge.href}>
|
||||
<a>{badge.text}</a>
|
||||
</Link>
|
||||
) : (
|
||||
badge.text
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{deleteField?.check() ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user