Improvements: Prefill Routing Forms and connect prefilling with Booking Form (#8780)
* Support prefilling routing form and prefilling Booking form through routing form * Use Option Value as is instead of lowercasing * Fix prefill validation issue * Add prefill tests * Fix Routing Form tests * Small fix
This commit is contained in:
parent
81655f9988
commit
b8b6c48d7d
|
@ -1815,6 +1815,7 @@
|
||||||
"open_dialog_with_element_click": "Open your Cal dialog when someone clicks an element.",
|
"open_dialog_with_element_click": "Open your Cal dialog when someone clicks an element.",
|
||||||
"need_help_embedding": "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options.",
|
"need_help_embedding": "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options.",
|
||||||
"book_my_cal": "Book my Cal",
|
"book_my_cal": "Book my Cal",
|
||||||
|
"form_updated_successfully":"Form updated successfully.",
|
||||||
"email_not_cal_member_cta": "Join your team",
|
"email_not_cal_member_cta": "Join your team",
|
||||||
"disable_attendees_confirmation_emails": "Disable default confirmation emails for attendees",
|
"disable_attendees_confirmation_emails": "Disable default confirmation emails for attendees",
|
||||||
"disable_attendees_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the attendees when the event is booked.",
|
"disable_attendees_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the attendees when the event is booked.",
|
||||||
|
|
|
@ -276,6 +276,9 @@ export function FormActionsProvider({ appUrl, children }: { appUrl: string; chil
|
||||||
}
|
}
|
||||||
return { previousValue };
|
return { previousValue };
|
||||||
},
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
showToast(t("form_updated_successfully"), "success");
|
||||||
|
},
|
||||||
onSettled: (routingForm) => {
|
onSettled: (routingForm) => {
|
||||||
utils.viewer.appRoutingForms.forms.invalidate();
|
utils.viewer.appRoutingForms.forms.invalidate();
|
||||||
if (routingForm) {
|
if (routingForm) {
|
||||||
|
@ -463,7 +466,7 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
|
||||||
const Component = as || Button;
|
const Component = as || Button;
|
||||||
if (!dropdown) {
|
if (!dropdown) {
|
||||||
return (
|
return (
|
||||||
<Component ref={forwardedRef} {...actionProps}>
|
<Component data-testid={`form-action-${actionName}`} ref={forwardedRef} {...actionProps}>
|
||||||
{children}
|
{children}
|
||||||
</Component>
|
</Component>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import type { App_RoutingForms_Form } from "@prisma/client";
|
import type { App_RoutingForms_Form } from "@prisma/client";
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
|
import getFieldIdentifier from "../lib/getFieldIdentifier";
|
||||||
import { getQueryBuilderConfig } from "../lib/getQueryBuilderConfig";
|
import { getQueryBuilderConfig } from "../lib/getQueryBuilderConfig";
|
||||||
import isRouterLinkedField from "../lib/isRouterLinkedField";
|
import isRouterLinkedField from "../lib/isRouterLinkedField";
|
||||||
import type { SerializableForm, Response } from "../types/types";
|
import type { SerializableForm, Response } from "../types/types";
|
||||||
|
@ -52,7 +53,7 @@ export default function FormInputFields(props: Props) {
|
||||||
/* @ts-ignore */
|
/* @ts-ignore */
|
||||||
required={!!field.required}
|
required={!!field.required}
|
||||||
listValues={options}
|
listValues={options}
|
||||||
data-testid="form-field"
|
data-testid={`form-field-${getFieldIdentifier(field)}`}
|
||||||
setValue={(value) => {
|
setValue={(value) => {
|
||||||
setResponse((response) => {
|
setResponse((response) => {
|
||||||
response = response || {};
|
response = response || {};
|
||||||
|
|
|
@ -251,7 +251,7 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
|
||||||
|
|
||||||
const mutation = trpc.viewer.appRoutingForms.formMutation.useMutation({
|
const mutation = trpc.viewer.appRoutingForms.formMutation.useMutation({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
showToast("Form updated successfully.", "success");
|
showToast(t("form_updated_successfully"), "success");
|
||||||
},
|
},
|
||||||
onError(e) {
|
onError(e) {
|
||||||
if (e.message) {
|
if (e.message) {
|
||||||
|
|
|
@ -157,7 +157,7 @@ const MultiSelectWidget = ({
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultValue = selectItems.filter((item) => value?.includes(item.value));
|
const optionsFromList = selectItems.filter((item) => value?.includes(item.value));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
|
@ -165,7 +165,7 @@ const MultiSelectWidget = ({
|
||||||
onChange={(items) => {
|
onChange={(items) => {
|
||||||
setValue(items?.map((item) => item.value));
|
setValue(items?.map((item) => item.value));
|
||||||
}}
|
}}
|
||||||
defaultValue={defaultValue}
|
value={optionsFromList}
|
||||||
isMulti={true}
|
isMulti={true}
|
||||||
isDisabled={remainingProps.readOnly}
|
isDisabled={remainingProps.readOnly}
|
||||||
options={selectItems}
|
options={selectItems}
|
||||||
|
@ -184,7 +184,7 @@ function SelectWidget({ listValues, setValue, value, ...remainingProps }: Select
|
||||||
value: item.value,
|
value: item.value,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const defaultValue = selectItems.find((item) => item.value === value);
|
const optionFromList = selectItems.find((item) => item.value === value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
|
@ -196,7 +196,7 @@ function SelectWidget({ listValues, setValue, value, ...remainingProps }: Select
|
||||||
setValue(item.value);
|
setValue(item.value);
|
||||||
}}
|
}}
|
||||||
isDisabled={remainingProps.readOnly}
|
isDisabled={remainingProps.readOnly}
|
||||||
defaultValue={defaultValue}
|
value={optionFromList}
|
||||||
options={selectItems}
|
options={selectItems}
|
||||||
{...remainingProps}
|
{...remainingProps}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -134,7 +134,7 @@ function Field({
|
||||||
<TextField
|
<TextField
|
||||||
disabled={!!router}
|
disabled={!!router}
|
||||||
label="Identifier"
|
label="Identifier"
|
||||||
name="identifier"
|
name={`${hookFieldNamespace}.identifier`}
|
||||||
required
|
required
|
||||||
placeholder={t("identifies_name_field")}
|
placeholder={t("identifies_name_field")}
|
||||||
value={identifier}
|
value={identifier}
|
||||||
|
|
|
@ -175,7 +175,7 @@ const Route = ({
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
isDisabled={disabled}
|
isDisabled={disabled}
|
||||||
className="block w-full flex-grow px-2"
|
className="data-testid-select-routing-action block w-full flex-grow px-2"
|
||||||
required
|
required
|
||||||
value={RoutingPages.find((page) => page.value === route.action?.type)}
|
value={RoutingPages.find((page) => page.value === route.action?.type)}
|
||||||
onChange={(item) => {
|
onChange={(item) => {
|
||||||
|
|
|
@ -16,10 +16,12 @@ import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||||
import { Button, showToast, useCalcomTheme } from "@calcom/ui";
|
import { Button, showToast, useCalcomTheme } from "@calcom/ui";
|
||||||
|
|
||||||
import FormInputFields from "../../components/FormInputFields";
|
import FormInputFields from "../../components/FormInputFields";
|
||||||
|
import getFieldIdentifier from "../../lib/getFieldIdentifier";
|
||||||
import { getSerializableForm } from "../../lib/getSerializableForm";
|
import { getSerializableForm } from "../../lib/getSerializableForm";
|
||||||
import { processRoute } from "../../lib/processRoute";
|
import { processRoute } from "../../lib/processRoute";
|
||||||
import type { Response, Route } from "../../types/types";
|
import type { Response, Route } from "../../types/types";
|
||||||
|
|
||||||
|
type Props = inferSSRProps<typeof getServerSideProps>;
|
||||||
const useBrandColors = ({
|
const useBrandColors = ({
|
||||||
brandColor,
|
brandColor,
|
||||||
darkBrandColor,
|
darkBrandColor,
|
||||||
|
@ -34,7 +36,7 @@ const useBrandColors = ({
|
||||||
useCalcomTheme(brandTheme);
|
useCalcomTheme(brandTheme);
|
||||||
};
|
};
|
||||||
|
|
||||||
function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getServerSideProps>) {
|
function RoutingForm({ form, profile, ...restProps }: Props) {
|
||||||
const [customPageMessage, setCustomPageMessage] = useState<Route["action"]["value"]>("");
|
const [customPageMessage, setCustomPageMessage] = useState<Route["action"]["value"]>("");
|
||||||
const formFillerIdRef = useRef(uuidv4());
|
const formFillerIdRef = useRef(uuidv4());
|
||||||
const isEmbed = useIsEmbed(restProps.isEmbed);
|
const isEmbed = useIsEmbed(restProps.isEmbed);
|
||||||
|
@ -43,12 +45,15 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
|
||||||
brandColor: profile.brandColor,
|
brandColor: profile.brandColor,
|
||||||
darkBrandColor: profile.darkBrandColor,
|
darkBrandColor: profile.darkBrandColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [response, setResponse] = usePrefilledResponse(form);
|
||||||
|
|
||||||
// TODO: We might want to prevent spam from a single user by having same formFillerId across pageviews
|
// TODO: We might want to prevent spam from a single user by having same formFillerId across pageviews
|
||||||
// But technically, a user can fill form multiple times due to any number of reasons and we currently can't differentiate b/w that.
|
// But technically, a user can fill form multiple times due to any number of reasons and we currently can't differentiate b/w that.
|
||||||
// - like a network error
|
// - like a network error
|
||||||
// - or he abandoned booking flow in between
|
// - or he abandoned booking flow in between
|
||||||
const formFillerId = formFillerIdRef.current;
|
const formFillerId = formFillerIdRef.current;
|
||||||
const decidedActionRef = useRef<Route["action"]>();
|
const decidedActionWithFormResponseRef = useRef<{ action: Route["action"]; response: Response }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const onSubmit = (response: Response) => {
|
const onSubmit = (response: Response) => {
|
||||||
|
@ -65,7 +70,10 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
|
||||||
formFillerId,
|
formFillerId,
|
||||||
response: response,
|
response: response,
|
||||||
});
|
});
|
||||||
decidedActionRef.current = decidedAction;
|
decidedActionWithFormResponseRef.current = {
|
||||||
|
action: decidedAction,
|
||||||
|
response,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -75,19 +83,26 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
|
||||||
|
|
||||||
const responseMutation = trpc.viewer.appRoutingForms.public.response.useMutation({
|
const responseMutation = trpc.viewer.appRoutingForms.public.response.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
const decidedAction = decidedActionRef.current;
|
const decidedActionWithFormResponse = decidedActionWithFormResponseRef.current;
|
||||||
if (!decidedAction) {
|
if (!decidedActionWithFormResponse) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const fields = form.fields;
|
||||||
|
if (!fields) {
|
||||||
|
throw new Error("Routing Form fields must exist here");
|
||||||
|
}
|
||||||
|
const allURLSearchParams = getUrlSearchParamsToForward(decidedActionWithFormResponse.response, fields);
|
||||||
|
const decidedAction = decidedActionWithFormResponse.action;
|
||||||
|
|
||||||
//TODO: Maybe take action after successful mutation
|
//TODO: Maybe take action after successful mutation
|
||||||
if (decidedAction.type === "customPageMessage") {
|
if (decidedAction.type === "customPageMessage") {
|
||||||
setCustomPageMessage(decidedAction.value);
|
setCustomPageMessage(decidedAction.value);
|
||||||
} else if (decidedAction.type === "eventTypeRedirectUrl") {
|
} else if (decidedAction.type === "eventTypeRedirectUrl") {
|
||||||
router.push(`/${decidedAction.value}`);
|
router.push(`/${decidedAction.value}?${allURLSearchParams}`);
|
||||||
} else if (decidedAction.type === "externalRedirectUrl") {
|
} else if (decidedAction.type === "externalRedirectUrl") {
|
||||||
window.parent.location.href = decidedAction.value;
|
window.parent.location.href = `${decidedAction.value}?${allURLSearchParams}`;
|
||||||
}
|
}
|
||||||
|
// We don't want to show this message as it doesn't look good in Embed.
|
||||||
// showToast("Form submitted successfully! Redirecting now ...", "success");
|
// showToast("Form submitted successfully! Redirecting now ...", "success");
|
||||||
},
|
},
|
||||||
onError: (e) => {
|
onError: (e) => {
|
||||||
|
@ -97,12 +112,11 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
|
||||||
if (e?.data?.code === "CONFLICT") {
|
if (e?.data?.code === "CONFLICT") {
|
||||||
return void showToast("Form already submitted", "error");
|
return void showToast("Form already submitted", "error");
|
||||||
}
|
}
|
||||||
|
// We don't want to show this error as it doesn't look good in Embed.
|
||||||
// showToast("Something went wrong", "error");
|
// showToast("Something went wrong", "error");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [response, setResponse] = useState<Response>({});
|
|
||||||
|
|
||||||
const handleOnSubmit = (e: FormEvent<HTMLFormElement>) => {
|
const handleOnSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSubmit(response);
|
onSubmit(response);
|
||||||
|
@ -161,6 +175,53 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUrlSearchParamsToForward(response: Response, fields: NonNullable<Props["form"]["fields"]>) {
|
||||||
|
type Params = Record<string, string | string[]>;
|
||||||
|
const paramsFromResponse: Params = {};
|
||||||
|
const paramsFromCurrentUrl: Params = {};
|
||||||
|
|
||||||
|
// Build query params from response
|
||||||
|
Object.entries(response).forEach(([key, fieldResponse]) => {
|
||||||
|
const foundField = fields.find((f) => f.id === key);
|
||||||
|
if (!foundField) {
|
||||||
|
// If for some reason, the field isn't there, let's just
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
paramsFromResponse[getFieldIdentifier(foundField) as keyof typeof paramsFromResponse] =
|
||||||
|
fieldResponse.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build query params from current URL. It excludes route params
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
for (const [name, value] of new URLSearchParams(window.location.search).entries()) {
|
||||||
|
const target = paramsFromCurrentUrl[name];
|
||||||
|
if (target instanceof Array) {
|
||||||
|
target.push(value);
|
||||||
|
} else {
|
||||||
|
paramsFromCurrentUrl[name] = [value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allQueryParams: Params = {
|
||||||
|
...paramsFromCurrentUrl,
|
||||||
|
// In case of conflict b/w paramsFromResponse and paramsFromCurrentUrl, paramsFromResponse should win as the booker probably improved upon the prefilled value.
|
||||||
|
...paramsFromResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
const allQueryURLSearchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
// Make serializable URLSearchParams instance
|
||||||
|
Object.entries(allQueryParams).forEach(([param, value]) => {
|
||||||
|
const valueArray = value instanceof Array ? value : [value];
|
||||||
|
valueArray.forEach((v) => {
|
||||||
|
allQueryURLSearchParams.append(param, v);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return allQueryURLSearchParams;
|
||||||
|
}
|
||||||
|
|
||||||
export default function RoutingLink(props: inferSSRProps<typeof getServerSideProps>) {
|
export default function RoutingLink(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
return <RoutingForm {...props} />;
|
return <RoutingForm {...props} />;
|
||||||
}
|
}
|
||||||
|
@ -220,3 +281,19 @@ export const getServerSideProps = async function getServerSideProps(
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const usePrefilledResponse = (form: Props["form"]) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const prefillResponse: Response = {};
|
||||||
|
|
||||||
|
// Prefill the form from query params
|
||||||
|
form.fields?.forEach((field) => {
|
||||||
|
prefillResponse[field.id] = {
|
||||||
|
value: router.query[getFieldIdentifier(field)] || "",
|
||||||
|
label: field.label,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const [response, setResponse] = useState<Response>(prefillResponse);
|
||||||
|
return [response, setResponse] as const;
|
||||||
|
};
|
||||||
|
|
|
@ -16,21 +16,19 @@ test.describe("Routing Forms", () => {
|
||||||
|
|
||||||
const formId = await addForm(page);
|
const formId = await addForm(page);
|
||||||
|
|
||||||
await page.click('[href="/apps/routing-forms/forms"]');
|
|
||||||
// TODO: Workaround for bug in https://github.com/calcom/cal.com/issues/3410
|
|
||||||
await page.click('[href="/apps/routing-forms/forms"]');
|
await page.click('[href="/apps/routing-forms/forms"]');
|
||||||
|
|
||||||
await page.waitForSelector('[data-testid="routing-forms-list"]');
|
await page.waitForSelector('[data-testid="routing-forms-list"]');
|
||||||
// Ensure that it's visible in forms list
|
// Ensure that it's visible in forms list
|
||||||
expect(await page.locator('[data-testid="routing-forms-list"] > li').count()).toBe(1);
|
expect(await page.locator('[data-testid="routing-forms-list"] > li').count()).toBe(1);
|
||||||
|
|
||||||
await gotoRoutingLink(page, formId);
|
await gotoRoutingLink({ page, formId });
|
||||||
await page.isVisible("text=Test Form Name");
|
await expect(page.locator("text=Test Form Name")).toBeVisible();
|
||||||
|
|
||||||
await page.goto(`apps/routing-forms/route-builder/${formId}`);
|
await page.goto(`apps/routing-forms/route-builder/${formId}`);
|
||||||
await page.click('[data-testid="toggle-form"] [value="on"]');
|
await disableForm(page);
|
||||||
await gotoRoutingLink(page, formId);
|
await gotoRoutingLink({ page, formId });
|
||||||
await page.isVisible("text=ERROR 404");
|
await expect(page.locator("text=ERROR 404")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should be able to edit the form", async ({ page }) => {
|
test("should be able to edit the form", async ({ page }) => {
|
||||||
|
@ -41,16 +39,18 @@ test.describe("Routing Forms", () => {
|
||||||
|
|
||||||
const createdFields: Record<number, { label: string; typeIndex: number }> = {};
|
const createdFields: Record<number, { label: string; typeIndex: number }> = {};
|
||||||
|
|
||||||
const { types } = await addMultipleFieldsAndSaveForm(formId, page, { description, label });
|
const { fieldTypesList: types, fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
|
||||||
|
description,
|
||||||
await page.reload();
|
label,
|
||||||
|
});
|
||||||
|
|
||||||
expect(await page.inputValue(`[data-testid="description"]`)).toBe(description);
|
expect(await page.inputValue(`[data-testid="description"]`)).toBe(description);
|
||||||
expect(await page.locator('[data-testid="field"]').count()).toBe(types.length);
|
expect(await page.locator('[data-testid="field"]').count()).toBe(types.length);
|
||||||
|
|
||||||
types.forEach((item, index) => {
|
fields.forEach((item, index) => {
|
||||||
createdFields[index] = { label: `Test Label ${index + 1}`, typeIndex: index };
|
createdFields[index] = { label: item.label, typeIndex: index };
|
||||||
});
|
});
|
||||||
|
|
||||||
await expectCurrentFormToHaveFields(page, createdFields, types);
|
await expectCurrentFormToHaveFields(page, createdFields, types);
|
||||||
|
|
||||||
await page.click('[href*="/apps/routing-forms/route-builder/"]');
|
await page.click('[href*="/apps/routing-forms/route-builder/"]');
|
||||||
|
@ -63,9 +63,7 @@ test.describe("Routing Forms", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("F1<-F2 Relationship", () => {
|
test.describe("F1<-F2 Relationship", () => {
|
||||||
// TODO: Fix this test, it is very flaky
|
test("Create relationship by adding F1 as route.Editing F1 should update F2", async ({ page }) => {
|
||||||
// prettier-ignore
|
|
||||||
test.fixme("Create relationship by adding F1 as route.Editing F1 should update F2", async ({ page }) => {
|
|
||||||
const form1Id = await addForm(page, { name: "F1" });
|
const form1Id = await addForm(page, { name: "F1" });
|
||||||
const form2Id = await addForm(page, { name: "F2" });
|
const form2Id = await addForm(page, { name: "F2" });
|
||||||
|
|
||||||
|
@ -118,6 +116,63 @@ test.describe("Routing Forms", () => {
|
||||||
todo("Create relationship by using duplicate with live connect");
|
todo("Create relationship by using duplicate with live connect");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should be able to submit a prefilled form with all types of fields", async ({ page }) => {
|
||||||
|
const formId = await addForm(page);
|
||||||
|
await page.click('[href*="/apps/routing-forms/route-builder/"]');
|
||||||
|
await selectNewRoute(page);
|
||||||
|
await selectOption({
|
||||||
|
selector: {
|
||||||
|
selector: ".data-testid-select-routing-action",
|
||||||
|
nth: 0,
|
||||||
|
},
|
||||||
|
option: 2,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
await page.fill("[name=externalRedirectUrl]", "https://www.google.com");
|
||||||
|
await saveCurrentForm(page);
|
||||||
|
|
||||||
|
const { fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
|
||||||
|
description: "Description",
|
||||||
|
label: "Test Field",
|
||||||
|
});
|
||||||
|
const queryString =
|
||||||
|
"firstField=456&Test Field Number=456&Test Field Select=456&Test Field MultiSelect=456&Test Field MultiSelect=789&Test Field Phone=456&Test Field Email=456@example.com";
|
||||||
|
|
||||||
|
await gotoRoutingLink({ page, queryString });
|
||||||
|
|
||||||
|
await page.fill('[data-testid="form-field-Test Field Long Text"]', "manual-fill");
|
||||||
|
|
||||||
|
expect(await page.locator(`[data-testid="form-field-firstField"]`).inputValue()).toBe("456");
|
||||||
|
expect(await page.locator(`[data-testid="form-field-Test Field Number"]`).inputValue()).toBe("456");
|
||||||
|
|
||||||
|
// TODO: Verify select and multiselect has prefilled values.
|
||||||
|
// expect(await page.locator(`[data-testid="form-field-Test Field Select"]`).inputValue()).toBe("456");
|
||||||
|
// expect(await page.locator(`[data-testid="form-field-Test Field MultiSelect"]`).inputValue()).toBe("456");
|
||||||
|
|
||||||
|
expect(await page.locator(`[data-testid="form-field-Test Field Phone"]`).inputValue()).toBe("456");
|
||||||
|
expect(await page.locator(`[data-testid="form-field-Test Field Email"]`).inputValue()).toBe(
|
||||||
|
"456@example.com"
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForURL((url) => {
|
||||||
|
return url.hostname.includes("google.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = new URL(page.url());
|
||||||
|
|
||||||
|
// Coming from the response filled by booker
|
||||||
|
expect(url.searchParams.get("firstField")).toBe("456");
|
||||||
|
|
||||||
|
// All other params come from prefill URL
|
||||||
|
expect(url.searchParams.get("Test Field Number")).toBe("456");
|
||||||
|
expect(url.searchParams.get("Test Field Long Text")).toBe("manual-fill");
|
||||||
|
expect(url.searchParams.get("Test Field Select")).toBe("456");
|
||||||
|
expect(url.searchParams.getAll("Test Field MultiSelect")).toMatchObject(["456", "789"]);
|
||||||
|
expect(url.searchParams.get("Test Field Phone")).toBe("456");
|
||||||
|
expect(url.searchParams.get("Test Field Email")).toBe("456@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
// TODO: How to install the app just once?
|
// TODO: How to install the app just once?
|
||||||
test.beforeEach(async ({ page, users }) => {
|
test.beforeEach(async ({ page, users }) => {
|
||||||
const user = await users.create(
|
const user = await users.create(
|
||||||
|
@ -266,16 +321,16 @@ test.describe("Routing Forms", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto(`/router?form=${routingForm.id}&Test field=custom-page`);
|
await page.goto(`/router?form=${routingForm.id}&Test field=custom-page`);
|
||||||
await page.isVisible("text=Custom Page Result");
|
await expect(page.locator("text=Custom Page Result")).toBeVisible();
|
||||||
|
|
||||||
await page.goto(`/router?form=${routingForm.id}&Test field=doesntmatter&multi=Option-2`);
|
await page.goto(`/router?form=${routingForm.id}&Test field=doesntmatter&multi=Option-2`);
|
||||||
await page.isVisible("text=Multiselect chosen");
|
await expect(page.locator("text=Multiselect chosen")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Routing Link should validate fields", async ({ page, users }) => {
|
test("Routing Link should validate fields", async ({ page, users }) => {
|
||||||
const user = await createUserAndLoginAndInstallApp({ users, page });
|
const user = await createUserAndLoginAndInstallApp({ users, page });
|
||||||
const routingForm = user.routingForms[0];
|
const routingForm = user.routingForms[0];
|
||||||
await gotoRoutingLink(page, routingForm.id);
|
await gotoRoutingLink({ page, formId: routingForm.id });
|
||||||
page.click('button[type="submit"]');
|
page.click('button[type="submit"]');
|
||||||
const firstInputMissingValue = await page.evaluate(() => {
|
const firstInputMissingValue = await page.evaluate(() => {
|
||||||
return document.querySelectorAll("input")[0].validity.valueMissing;
|
return document.querySelectorAll("input")[0].validity.valueMissing;
|
||||||
|
@ -291,40 +346,45 @@ test.describe("Routing Forms", () => {
|
||||||
await page.click('[data-testid="test-preview"]');
|
await page.click('[data-testid="test-preview"]');
|
||||||
|
|
||||||
// //event redirect
|
// //event redirect
|
||||||
await page.fill('[data-testid="form-field"]', "event-routing");
|
await page.fill('[data-testid="form-field-Test field"]', "event-routing");
|
||||||
await page.click('[data-testid="test-routing"]');
|
await page.click('[data-testid="test-routing"]');
|
||||||
let routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
let routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
||||||
let route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
let route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||||
await expect(routingType).toBe("Event Redirect");
|
expect(routingType).toBe("Event Redirect");
|
||||||
await expect(route).toBe("pro/30min");
|
expect(route).toBe("pro/30min");
|
||||||
|
|
||||||
//custom page
|
//custom page
|
||||||
await page.fill('[data-testid="form-field"]', "custom-page");
|
await page.fill('[data-testid="form-field-Test field"]', "custom-page");
|
||||||
await page.click('[data-testid="test-routing"]');
|
await page.click('[data-testid="test-routing"]');
|
||||||
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
||||||
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||||
await expect(routingType).toBe("Custom Page");
|
expect(routingType).toBe("Custom Page");
|
||||||
await expect(route).toBe("Custom Page Result");
|
expect(route).toBe("Custom Page Result");
|
||||||
|
|
||||||
//external redirect
|
//external redirect
|
||||||
await page.fill('[data-testid="form-field"]', "external-redirect");
|
await page.fill('[data-testid="form-field-Test field"]', "external-redirect");
|
||||||
await page.click('[data-testid="test-routing"]');
|
await page.click('[data-testid="test-routing"]');
|
||||||
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
||||||
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||||
await expect(routingType).toBe("External Redirect");
|
expect(routingType).toBe("External Redirect");
|
||||||
await expect(route).toBe("https://google.com");
|
expect(route).toBe("https://google.com");
|
||||||
|
|
||||||
//fallback route
|
//fallback route
|
||||||
await page.fill('[data-testid="form-field"]', "fallback");
|
await page.fill('[data-testid="form-field-Test field"]', "fallback");
|
||||||
await page.click('[data-testid="test-routing"]');
|
await page.click('[data-testid="test-routing"]');
|
||||||
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
||||||
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||||
await expect(routingType).toBe("Custom Page");
|
expect(routingType).toBe("Custom Page");
|
||||||
await expect(route).toBe("Fallback Message");
|
expect(route).toBe("Fallback Message");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function disableForm(page: Page) {
|
||||||
|
await page.click('[data-testid="toggle-form"] [value="on"]');
|
||||||
|
await page.waitForSelector(".data-testid-toast-success");
|
||||||
|
}
|
||||||
|
|
||||||
async function expectCurrentFormToHaveFields(
|
async function expectCurrentFormToHaveFields(
|
||||||
page: Page,
|
page: Page,
|
||||||
fields: {
|
fields: {
|
||||||
|
@ -341,24 +401,24 @@ async function expectCurrentFormToHaveFields(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fillSeededForm(page: Page, routingFormId: string) {
|
async function fillSeededForm(page: Page, routingFormId: string) {
|
||||||
await gotoRoutingLink(page, routingFormId);
|
await gotoRoutingLink({ page, formId: routingFormId });
|
||||||
await page.fill('[data-testid="form-field"]', "event-routing");
|
await page.fill('[data-testid="form-field-Test field"]', "event-routing");
|
||||||
page.click('button[type="submit"]');
|
page.click('button[type="submit"]');
|
||||||
await page.waitForURL((url) => {
|
await page.waitForURL((url) => {
|
||||||
return url.pathname.endsWith("/pro/30min");
|
return url.pathname.endsWith("/pro/30min");
|
||||||
});
|
});
|
||||||
|
|
||||||
await gotoRoutingLink(page, routingFormId);
|
await gotoRoutingLink({ page, formId: routingFormId });
|
||||||
await page.fill('[data-testid="form-field"]', "external-redirect");
|
await page.fill('[data-testid="form-field-Test field"]', "external-redirect");
|
||||||
page.click('button[type="submit"]');
|
page.click('button[type="submit"]');
|
||||||
await page.waitForURL((url) => {
|
await page.waitForURL((url) => {
|
||||||
return url.hostname.includes("google.com");
|
return url.hostname.includes("google.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
await gotoRoutingLink(page, routingFormId);
|
await gotoRoutingLink({ page, formId: routingFormId });
|
||||||
await page.fill('[data-testid="form-field"]', "custom-page");
|
await page.fill('[data-testid="form-field-Test field"]', "custom-page");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await page.isVisible("text=Custom Page Result");
|
await expect(page.locator("text=Custom Page Result")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
|
export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
|
||||||
|
@ -375,7 +435,7 @@ export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
|
||||||
return formId;
|
return formId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addMultipleFieldsAndSaveForm(
|
async function addAllTypesOfFieldsAndSaveForm(
|
||||||
formId: string,
|
formId: string,
|
||||||
page: Page,
|
page: Page,
|
||||||
form: { description: string; label: string }
|
form: { description: string; label: string }
|
||||||
|
@ -384,33 +444,51 @@ async function addMultipleFieldsAndSaveForm(
|
||||||
await page.click('[data-testid="add-field"]');
|
await page.click('[data-testid="add-field"]');
|
||||||
await page.fill('[data-testid="description"]', form.description);
|
await page.fill('[data-testid="description"]', form.description);
|
||||||
|
|
||||||
const { optionsInUi: types } = await verifySelectOptions(
|
const { optionsInUi: fieldTypesList } = await verifySelectOptions(
|
||||||
{ selector: ".data-testid-field-type", nth: 0 },
|
{ selector: ".data-testid-field-type", nth: 0 },
|
||||||
["Email", "Long Text", "MultiSelect", "Number", "Phone", "Select", "Short Text"],
|
["Email", "Long Text", "MultiSelect", "Number", "Phone", "Select", "Short Text"],
|
||||||
page
|
page
|
||||||
);
|
);
|
||||||
await page.fill(`[name="fields.0.label"]`, `${form.label} 1`);
|
|
||||||
|
|
||||||
await page.click('[data-testid="add-field"]');
|
const fields = [];
|
||||||
|
for (let index = 0; index < fieldTypesList.length; index++) {
|
||||||
|
const fieldTypeLabel = fieldTypesList[index];
|
||||||
|
const nth = index;
|
||||||
|
const label = `${form.label} ${fieldTypeLabel}`;
|
||||||
|
let identifier = "";
|
||||||
|
|
||||||
const withoutFirstValue = [...types].filter((val) => val !== "Short Text");
|
if (index !== 0) {
|
||||||
|
identifier = label;
|
||||||
|
// Click on the field type dropdown.
|
||||||
|
await page.locator(".data-testid-field-type").nth(nth).click();
|
||||||
|
// Click on the dropdown option.
|
||||||
|
await page.locator(`[data-testid="select-option-${fieldTypeLabel}"]`).click();
|
||||||
|
} else {
|
||||||
|
// Set the identifier manually for the first field to test out a case when identifier isn't computed from label automatically
|
||||||
|
// First field type is by default selected. So, no need to choose from dropdown
|
||||||
|
identifier = "firstField";
|
||||||
|
}
|
||||||
|
|
||||||
for (let index = 0; index < withoutFirstValue.length; index++) {
|
if (fieldTypeLabel === "MultiSelect" || fieldTypeLabel === "Select") {
|
||||||
const fieldName = withoutFirstValue[index];
|
await page.fill(`[name="fields.${nth}.selectText"]`, "123\n456\n789");
|
||||||
const nth = index + 1;
|
}
|
||||||
const label = `${form.label} ${index + 2}`;
|
|
||||||
|
|
||||||
await page.locator(".data-testid-field-type").nth(nth).click();
|
|
||||||
await page.locator(`[data-testid="select-option-${fieldName}"]`).click();
|
|
||||||
await page.fill(`[name="fields.${nth}.label"]`, label);
|
await page.fill(`[name="fields.${nth}.label"]`, label);
|
||||||
if (index !== withoutFirstValue.length - 1) {
|
|
||||||
|
if (identifier !== label) {
|
||||||
|
await page.fill(`[name="fields.${nth}.identifier"]`, identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index !== fieldTypesList.length - 1) {
|
||||||
await page.click('[data-testid="add-field"]');
|
await page.click('[data-testid="add-field"]');
|
||||||
}
|
}
|
||||||
|
fields.push({ identifier: identifier, label, type: fieldTypeLabel });
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveCurrentForm(page);
|
await saveCurrentForm(page);
|
||||||
return {
|
return {
|
||||||
types,
|
fieldTypesList,
|
||||||
|
fields,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -461,6 +539,9 @@ async function selectOption({
|
||||||
}: {
|
}: {
|
||||||
page: Page;
|
page: Page;
|
||||||
selector: { selector: string; nth: number };
|
selector: { selector: string; nth: number };
|
||||||
|
/**
|
||||||
|
* Index of option to select. Starts from 1
|
||||||
|
*/
|
||||||
option: number;
|
option: number;
|
||||||
}) {
|
}) {
|
||||||
const locatorForSelect = page.locator(selector.selector).nth(selector.nth);
|
const locatorForSelect = page.locator(selector.selector).nth(selector.nth);
|
||||||
|
@ -513,8 +594,29 @@ async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function gotoRoutingLink(page: Page, formId: string) {
|
async function gotoRoutingLink({
|
||||||
await page.goto(`/forms/${formId}`);
|
page,
|
||||||
|
formId,
|
||||||
|
queryString = "",
|
||||||
|
}: {
|
||||||
|
page: Page;
|
||||||
|
formId?: string;
|
||||||
|
queryString?: string;
|
||||||
|
}) {
|
||||||
|
let previewLink = null;
|
||||||
|
if (!formId) {
|
||||||
|
// Instead of clicking on the preview link, we are going to the preview link directly because the earlier opens a new tab which is a bit difficult to manage with Playwright
|
||||||
|
const href = await page.locator('[data-testid="form-action-preview"]').getAttribute("href");
|
||||||
|
if (!href) {
|
||||||
|
throw new Error("Preview link not found");
|
||||||
|
}
|
||||||
|
previewLink = href;
|
||||||
|
} else {
|
||||||
|
previewLink = `/forms/${formId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(`${previewLink}${queryString ? `?${queryString}` : ""}`);
|
||||||
|
|
||||||
// HACK: There seems to be some issue with the inputs to the form getting reset if we don't wait.
|
// 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));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
}
|
}
|
||||||
|
|
|
@ -201,7 +201,7 @@ export const FormBuilder = function FormBuilder({
|
||||||
// It has the same drawback that if the label is changed, the value of the option will change. It is not a big deal for now.
|
// It has the same drawback that if the label is changed, the value of the option will change. It is not a big deal for now.
|
||||||
value.splice(index, 1, {
|
value.splice(index, 1, {
|
||||||
label: e.target.value,
|
label: e.target.value,
|
||||||
value: e.target.value.toLowerCase().trim(),
|
value: e.target.value.trim(),
|
||||||
});
|
});
|
||||||
onChange(value);
|
onChange(value);
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -53,7 +53,9 @@ if (IS_EMBED_REACT_TEST) {
|
||||||
const config: PlaywrightTestConfig = {
|
const config: PlaywrightTestConfig = {
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: os.cpus().length,
|
// While debugging it should be focussed mode
|
||||||
|
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||||
|
workers: process.env.PWDEBUG ? 1 : os.cpus().length,
|
||||||
timeout: DEFAULT_TEST_TIMEOUT,
|
timeout: DEFAULT_TEST_TIMEOUT,
|
||||||
maxFailures: headless ? 10 : undefined,
|
maxFailures: headless ? 10 : undefined,
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
|
|
Loading…
Reference in New Issue
Block a user