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
parent
29c4efe4a8
commit
31875f7535
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 }[] = [];
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue