From 38b35633507d21674c0d530afd616d8961874d9e Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Wed, 4 Jan 2023 19:00:46 +0530 Subject: [PATCH] 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 --- .../routing-forms/api/responses/[formId].ts | 2 +- .../routing-forms/components/FormActions.tsx | 23 +- .../components/FormInputFields.tsx | 4 + .../routing-forms/components/SingleForm.tsx | 70 +++- .../routing-forms/lib/createFallbackRoute.ts | 16 + .../ee/routing-forms/lib/getConnectedForms.ts | 20 + .../routing-forms/lib/getSerializableForm.ts | 113 +++++- .../ee/routing-forms/lib/isFallbackRoute.ts | 11 + .../{isAllowed.ts => isFormEditAllowed.ts} | 2 +- .../ee/routing-forms/lib/isRouter.ts | 12 + .../routing-forms/lib/isRouterLinkedField.ts | 12 + .../ee/routing-forms/lib/processRoute.tsx | 19 +- .../pages/form-edit/[...appPages].tsx | 58 ++- .../pages/forms/[...appPages].tsx | 5 +- .../pages/route-builder/[...appPages].tsx | 286 ++++++++++---- .../pages/router/[...appPages].tsx | 2 +- .../pages/routing-link/[...appPages].tsx | 2 +- .../playwright/tests/basic.e2e.ts | 321 +++++++++++----- .../app-store/ee/routing-forms/trpc-router.ts | 350 ++++++++++++++---- .../ee/routing-forms/types/types.d.ts | 18 +- packages/app-store/ee/routing-forms/zod.ts | 114 ++++-- .../typeform/playwright/tests/basic.e2e.ts | 6 +- packages/lib/availability.ts | 2 - .../ui/v2/core/form/BooleanToggleGroup.tsx | 5 + packages/ui/v2/core/form/FormCard.tsx | 22 +- 25 files changed, 1162 insertions(+), 333 deletions(-) create mode 100644 packages/app-store/ee/routing-forms/lib/createFallbackRoute.ts create mode 100644 packages/app-store/ee/routing-forms/lib/getConnectedForms.ts create mode 100644 packages/app-store/ee/routing-forms/lib/isFallbackRoute.ts rename packages/app-store/ee/routing-forms/lib/{isAllowed.ts => isFormEditAllowed.ts} (92%) create mode 100644 packages/app-store/ee/routing-forms/lib/isRouter.ts create mode 100644 packages/app-store/ee/routing-forms/lib/isRouterLinkedField.ts diff --git a/packages/app-store/ee/routing-forms/api/responses/[formId].ts b/packages/app-store/ee/routing-forms/api/responses/[formId].ts index 8688dda8ae..2cdf1846ac 100644 --- a/packages/app-store/ee/routing-forms/api/responses/[formId].ts +++ b/packages/app-store/ee/routing-forms/api/responses/[formId].ts @@ -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", diff --git a/packages/app-store/ee/routing-forms/components/FormActions.tsx b/packages/app-store/ee/routing-forms/components/FormActions.tsx index 5eaf239c7d..cb31bfeeca 100644 --- a/packages/app-store/ee/routing-forms/components/FormActions.tsx +++ b/packages/app-store/ee/routing-forms/components/FormActions.tsx @@ -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; @@ -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" /> + {action === "duplicate" && ( + { + return ( + { + onChange(checked); + }} + /> + ); + }} + /> + )}
+ + {form.routers.length ? ( +
+
+ Routers +
+

+ Modifications in fields and routes of following forms will be reflected in this form. +

+
+ {form.routers.map((router) => { + return ( + + ); + })} +
+
+ ) : null} + + {connectedForms?.length ? ( +
+
+ Connected Forms +
+

+ Following forms would be affected when you modify fields or routes here +

+
+ {connectedForms.map((router) => { + return ( + + ); + })} +
+
+ ) : null} +
} + buttonRaw={ + + } /> - +
); }; @@ -291,7 +319,7 @@ export default function FormEditPage({ } + Page={({ hookForm, form }) => } /> ); } diff --git a/packages/app-store/ee/routing-forms/pages/forms/[...appPages].tsx b/packages/app-store/ee/routing-forms/pages/forms/[...appPages].tsx index 3f26ac4a5a..0507010b75 100644 --- a/packages/app-store/ee/routing-forms/pages/forms/[...appPages].tsx +++ b/packages/app-store/ee/routing-forms/pages/forms/[...appPages].tsx @@ -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: { diff --git a/packages/app-store/ee/routing-forms/pages/route-builder/[...appPages].tsx b/packages/app-store/ee/routing-forms/pages/route-builder/[...appPages].tsx index f646f58c4e..625f887f66 100644 --- a/packages/app-store/ee/routing-forms/pages/route-builder/[...appPages].tsx +++ b/packages/app-store/ee/routing-forms/pages/route-builder/[...appPages].tsx @@ -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; 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 => { 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 & { - queryValue: Route["queryValue"]; - isFallback?: Route["isFallback"]; -}; - -export const RoutingPages: { label: string; value: Route["action"]["type"] }[] = [ +export const RoutingPages: { label: string; value: NonNullable["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>; 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 ( +
+ routes.length !== 1, + fn: () => { + const newRoutes = routes.filter((r) => r.id !== route.id); + setRoutes(newRoutes); + }, + }} + label={ +
+ {`Route ${index + 1}`} +
+ } + className="mb-6"> +
+ + + + {route.name} + + + +

+ Fields available in {route.name} will be added to this form. +

+
+
+
+ ); + } + return ( Send Booker to