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:
Hariom Balhara
2023-01-04 13:30:46 +00:00
committed by GitHub
parent 37159914cf
commit 38b3563350
25 changed files with 1162 additions and 333 deletions
@@ -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;
};
@@ -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
View File
@@ -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;
+75 -39
View File
@@ -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 },
});
-2
View File
@@ -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
+20 -2
View File
@@ -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"