Test Preview for routing forms (#5436)

* add first version of testing routing forms

* small design changes

* use new shared component

* readd deleted code for showing result route

* add form validation for required fields

* design fixes

* add scroll

* fix design of select

* use old dialog with correct overflow behaviour

* code clean up

* remove unused import

* fix mobile view

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
pull/5415/head^2
Carina Wollendorfer 2022-11-10 13:58:07 +01:00 committed by GitHub
parent 29c4efe4a8
commit 31875f7535
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 252 additions and 138 deletions

View File

@ -1344,5 +1344,9 @@
"attendee_email_info": "The person booking's email",
"invalid_credential": "Oh no! Looks like permission expired or was revoked. Please reinstall again.",
"choose_common_schedule_team_event": "Choose a common schedule",
"choose_common_schedule_team_event_description": "Enable this if you want to use a common schedule between hosts. When disabled, each host will be booked based on their default schedule."
"choose_common_schedule_team_event_description": "Enable this if you want to use a common schedule between hosts. When disabled, each host will be booked based on their default schedule.",
"test_routing_form": "Test Routing Form",
"test_preview": "Test Preview",
"route_to": "Route to",
"test_preview_description": "Test your routing form without submitting any data"
}

View File

@ -0,0 +1,73 @@
import { App_RoutingForms_Form } from "@prisma/client";
import { Dispatch, SetStateAction } from "react";
import { getQueryBuilderConfig } from "../pages/route-builder/[...appPages]";
import { SerializableForm, Response } from "../types/types";
type Props = {
form: SerializableForm<App_RoutingForms_Form>;
response: Response;
setResponse: Dispatch<SetStateAction<Response>>;
};
export default function FormInputFields(props: Props) {
const { form, response, setResponse } = props;
const queryBuilderConfig = getQueryBuilderConfig(form);
return (
<>
{form.fields?.map((field) => {
const widget = queryBuilderConfig.widgets[field.type];
if (!("factory" in widget)) {
return null;
}
const Component = widget.factory;
const optionValues = field.selectText?.trim().split("\n");
const options = optionValues?.map((value) => {
const title = value;
return {
value,
title,
};
});
return (
<div key={field.id} className="mb-4 block flex-col sm:flex ">
<div className="min-w-48 mb-2 flex-grow">
<label
id="slug-label"
htmlFor="slug"
className="flex text-sm font-medium text-neutral-700 dark:text-white">
{field.label}
</label>
</div>
<div className="flex rounded-sm">
<Component
value={response[field.id]?.value}
// required property isn't accepted by query-builder types
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
/* @ts-ignore */
required={!!field.required}
listValues={options}
data-testid="field"
setValue={(value) => {
setResponse((response) => {
response = response || {};
return {
...response,
[field.id]: {
label: field.label,
value,
},
};
});
}}
/>
</div>
</div>
);
})}
</>
);
}

View File

@ -1,11 +1,12 @@
import { App_RoutingForms_Form } from "@prisma/client";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm, UseFormReturn, Controller } from "react-hook-form";
import useApp from "@calcom/lib/hooks/useApp";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import { Dialog, DialogContent, DialogClose, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
import { Button, ButtonGroup } from "@calcom/ui/components";
import { Form, TextAreaField, TextField } from "@calcom/ui/components/form";
import { showToast, DropdownMenuSeparator, Tooltip, VerticalDivider } from "@calcom/ui/v2";
@ -14,8 +15,12 @@ import SettingsToggle from "@calcom/ui/v2/core/SettingsToggle";
import { ShellMain } from "@calcom/ui/v2/core/Shell";
import Banner from "@calcom/ui/v2/core/banner";
import { processRoute } from "../lib/processRoute";
import { RoutingPages } from "../pages/route-builder/[...appPages]";
import { SerializableForm } from "../types/types";
import { Response, Route } from "../types/types";
import { FormAction, FormActionsDropdown, FormActionsProvider } from "./FormActions";
import FormInputFields from "./FormInputFields";
import RoutingNavBar from "./RoutingNavBar";
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
@ -190,6 +195,15 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
const utils = trpc.useContext();
const { t } = useLocale();
const [isTestPreviewOpen, setIsTestPreviewOpen] = useState(false);
const [response, setResponse] = useState<Response>({});
const [decidedAction, setDecidedAction] = useState<Route["action"] | null>(null);
function testRouting() {
const action = processRoute({ form, response });
setDecidedAction(action);
}
const hookForm = useForm({
defaultValues: form,
});
@ -210,76 +224,151 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
},
});
return (
<Form
form={hookForm}
handleSubmit={(data) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
mutation.mutate({
...data,
});
}}>
<FormActionsProvider appUrl={appUrl}>
<Meta title={form.name} description={form.description || ""} />
<ShellMain
heading={form.name}
subtitle={form.description || ""}
backPath={`/${appUrl}/forms`}
CTA={<Actions form={form} mutation={mutation} />}>
<div className="-mx-4 px-4 sm:px-6 md:-mx-8 md:px-8">
<div className="flex flex-col items-center md:flex-row md:items-start">
<div className="lg:min-w-72 lg:max-w-72 mb-6 md:mr-6">
<TextField
type="text"
containerClassName="mb-6"
placeholder="Title"
{...hookForm.register("name")}
/>
<TextAreaField
rows={3}
id="description"
data-testid="description"
placeholder="Form Description"
{...hookForm.register("description")}
defaultValue={form.description || ""}
/>
<>
<Form
form={hookForm}
handleSubmit={(data) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
mutation.mutate({
...data,
});
}}>
<FormActionsProvider appUrl={appUrl}>
<Meta title={form.name} description={form.description || ""} />
<ShellMain
heading={form.name}
subtitle={form.description || ""}
backPath={`/${appUrl}/forms`}
CTA={<Actions form={form} mutation={mutation} />}>
<div className="-mx-4 px-4 sm:px-6 md:-mx-8 md:px-8">
<div className="flex flex-col items-center md:flex-row md:items-start">
<div className="lg:min-w-72 lg:max-w-72 mb-6 md:mr-6">
<TextField
type="text"
containerClassName="mb-6"
placeholder="Title"
{...hookForm.register("name")}
/>
<TextAreaField
rows={3}
id="description"
data-testid="description"
placeholder="Form Description"
{...hookForm.register("description")}
defaultValue={form.description || ""}
/>
<div className="mt-6">
<Controller
name="settings.emailOwnerOnSubmission"
control={hookForm.control}
render={({ field: { value, onChange } }) => {
return (
<SettingsToggle
title={t("routing_forms_send_email_owner")}
description={t("routing_forms_send_email_owner_description")}
checked={value}
onCheckedChange={(val) => onChange(val)}
/>
);
}}
/>
<div className="mt-6">
<Controller
name="settings.emailOwnerOnSubmission"
control={hookForm.control}
render={({ field: { value, onChange } }) => {
return (
<SettingsToggle
title={t("routing_forms_send_email_owner")}
description={t("routing_forms_send_email_owner_description")}
checked={value}
onCheckedChange={(val) => onChange(val)}
/>
);
}}
/>
</div>
<div className="mt-6">
<Button color="secondary" onClick={() => setIsTestPreviewOpen(true)}>
{t("test_preview")}
</Button>
</div>
{!form._count?.responses && (
<Banner
className="mt-6"
variant="neutral"
title="No Responses yet"
description="Wait for some time for responses to be collected. You can go and submit the form yourself as well."
Icon={Icon.FiInfo}
onDismiss={() => console.log("dismissed")}
/>
)}
</div>
<div className="w-full rounded-md border border-gray-200 p-8">
<RoutingNavBar appUrl={appUrl} form={form} />
<Page hookForm={hookForm} form={form} appUrl={appUrl} />
</div>
{!form._count?.responses && (
<Banner
className="mt-6"
variant="neutral"
title="No Responses yet"
description="Wait for some time for responses to be collected. You can go and submit the form yourself as well."
Icon={Icon.FiInfo}
onDismiss={() => console.log("dismissed")}
/>
)}
</div>
<div className="w-full rounded-md border border-gray-200 p-8">
<RoutingNavBar appUrl={appUrl} form={form} />
<Page hookForm={hookForm} form={form} appUrl={appUrl} />
</div>
</div>
</ShellMain>
</FormActionsProvider>
</Form>
<Dialog open={isTestPreviewOpen} onOpenChange={setIsTestPreviewOpen}>
<DialogContent>
<DialogHeader title={t("test_routing_form")} subtitle={t("test_preview_description")} />
<div>
<form
onSubmit={(e) => {
e.preventDefault();
testRouting();
}}>
<div className="px-1">
{form && <FormInputFields form={form} response={response} setResponse={setResponse} />}
</div>
<div>
{decidedAction && (
<div className="mt-5 rounded-md bg-gray-100 p-3">
<div className="font-bold ">{t("route_to")}:</div>
<div className="mt-2">
{RoutingPages.map((page) => {
if (page.value === decidedAction.type) {
return <>{page.label}</>;
}
})}
:{" "}
{decidedAction.type === "customPageMessage" ? (
<span className="text-gray-700">{decidedAction.value}</span>
) : decidedAction.type === "externalRedirectUrl" ? (
<span className="text-gray-700 underline">
<a
target="_blank"
href={
decidedAction.value.includes("https://") ||
decidedAction.value.includes("http://")
? decidedAction.value
: `http://${decidedAction.value}`
}
rel="noreferrer">
{decidedAction.value}
</a>
</span>
) : (
<span className="text-gray-700 underline">
<a target="_blank" href={`/${decidedAction.value}`} rel="noreferrer">
{decidedAction.value}
</a>
</span>
)}
</div>
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button
color="secondary"
onClick={() => {
setIsTestPreviewOpen(false);
setDecidedAction(null);
setResponse({});
}}>
{t("close")}
</Button>
</DialogClose>
<Button type="submit">{t("Test Routing")}</Button>
</DialogFooter>
</form>
</div>
</ShellMain>
</FormActionsProvider>
</Form>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -104,7 +104,6 @@ const MultiSelectWidget = ({
return (
<Select
className="dark:border-darkgray-300 mb-2 block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 dark:bg-transparent dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500 sm:text-sm"
menuPosition="fixed"
onChange={(items) => {
setValue(items?.map((item) => item.value));
}}
@ -138,7 +137,6 @@ function SelectWidget({
return (
<Select
className="data-testid-select dark:border-darkgray-300 mb-2 block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 dark:bg-transparent dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500 sm:text-sm"
menuPosition="fixed"
onChange={(item) => {
if (!item) {
return;

View File

@ -128,6 +128,21 @@ type SerializableRoute = Pick<Route, "id" | "action"> & {
isFallback?: Route["isFallback"];
};
export const RoutingPages: { label: string; value: Route["action"]["type"] }[] = [
{
label: "Custom Page",
value: "customPageMessage",
},
{
label: "External Redirect",
value: "externalRedirectUrl",
},
{
label: "Event Redirect",
value: "eventTypeRedirectUrl",
},
];
const Route = ({
route,
routes,
@ -146,20 +161,7 @@ const Route = ({
moveDown?: { fn: () => void; check: () => boolean } | null;
}) => {
const index = routes.indexOf(route);
const RoutingPages: { label: string; value: Route["action"]["type"] }[] = [
{
label: "Custom Page",
value: "customPageMessage",
},
{
label: "External Redirect",
value: "externalRedirectUrl",
},
{
label: "Event Redirect",
value: "eventTypeRedirectUrl",
},
];
const { data: eventTypesByGroup } = trpc.useQuery(["viewer.eventTypes"]);
const eventOptions: { label: string; value: string }[] = [];

View File

@ -18,10 +18,10 @@ import showToast from "@calcom/ui/v2/core/notifications";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
import FormInputFields from "../../components/FormInputFields";
import { getSerializableForm } from "../../lib/getSerializableForm";
import { processRoute } from "../../lib/processRoute";
import { Response, Route } from "../../types/types";
import { getQueryBuilderConfig } from "../route-builder/[...appPages]";
function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getServerSideProps>) {
const [customPageMessage, setCustomPageMessage] = useState<Route["action"]["value"]>("");
@ -89,8 +89,6 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
const [response, setResponse] = useState<Response>({});
const queryBuilderConfig = getQueryBuilderConfig(form);
const handleOnSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onSubmit(response);
@ -124,57 +122,7 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
</p>
) : null}
</div>
{form.fields?.map((field) => {
const widget = queryBuilderConfig.widgets[field.type];
if (!("factory" in widget)) {
return null;
}
const Component = widget.factory;
const optionValues = field.selectText?.trim().split("\n");
const options = optionValues?.map((value) => {
const title = value;
return {
value,
title,
};
});
return (
<div key={field.id} className="mb-4 block flex-col sm:flex ">
<div className="min-w-48 mb-2 flex-grow">
<label
id="slug-label"
htmlFor="slug"
className="flex text-sm font-medium text-neutral-700 dark:text-white">
{field.label}
</label>
</div>
<div className="flex rounded-sm">
<Component
value={response[field.id]?.value}
// required property isn't accepted by query-builder types
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
/* @ts-ignore */
required={!!field.required}
listValues={options}
data-testid="field"
setValue={(value) => {
setResponse((response) => {
response = response || {};
return {
...response,
[field.id]: {
label: field.label,
value,
},
};
});
}}
/>
</div>
</div>
);
})}
<FormInputFields form={form} response={response} setResponse={setResponse} />
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
<Button
className="dark:bg-darkmodebrand dark:text-darkmodebrandcontrast dark:hover:border-darkmodebrandcontrast dark:border-transparent"

View File

@ -69,7 +69,7 @@ export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps
<DialogPrimitive.Content
{...props}
className={classNames(
"min-w-[360px] rounded bg-white text-left shadow-xl focus-visible:outline-none sm:w-full sm:align-middle",
"h-auto max-h-[inherit] min-w-[360px] rounded bg-white text-left shadow-xl focus-visible:outline-none sm:w-full sm:align-middle",
props.size == "xl"
? "p-0.5 sm:max-w-[98vw]"
: props.size == "lg"