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 fixpull/8986/head
parent
81655f9988
commit
b8b6c48d7d
|
@ -1815,6 +1815,7 @@
|
|||
"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.",
|
||||
"book_my_cal": "Book my Cal",
|
||||
"form_updated_successfully":"Form updated successfully.",
|
||||
"email_not_cal_member_cta": "Join your team",
|
||||
"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.",
|
||||
|
|
|
@ -276,6 +276,9 @@ export function FormActionsProvider({ appUrl, children }: { appUrl: string; chil
|
|||
}
|
||||
return { previousValue };
|
||||
},
|
||||
onSuccess: () => {
|
||||
showToast(t("form_updated_successfully"), "success");
|
||||
},
|
||||
onSettled: (routingForm) => {
|
||||
utils.viewer.appRoutingForms.forms.invalidate();
|
||||
if (routingForm) {
|
||||
|
@ -463,7 +466,7 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
|
|||
const Component = as || Button;
|
||||
if (!dropdown) {
|
||||
return (
|
||||
<Component ref={forwardedRef} {...actionProps}>
|
||||
<Component data-testid={`form-action-${actionName}`} ref={forwardedRef} {...actionProps}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import type { App_RoutingForms_Form } from "@prisma/client";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import getFieldIdentifier from "../lib/getFieldIdentifier";
|
||||
import { getQueryBuilderConfig } from "../lib/getQueryBuilderConfig";
|
||||
import isRouterLinkedField from "../lib/isRouterLinkedField";
|
||||
import type { SerializableForm, Response } from "../types/types";
|
||||
|
@ -52,7 +53,7 @@ export default function FormInputFields(props: Props) {
|
|||
/* @ts-ignore */
|
||||
required={!!field.required}
|
||||
listValues={options}
|
||||
data-testid="form-field"
|
||||
data-testid={`form-field-${getFieldIdentifier(field)}`}
|
||||
setValue={(value) => {
|
||||
setResponse((response) => {
|
||||
response = response || {};
|
||||
|
|
|
@ -251,7 +251,7 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
|
|||
|
||||
const mutation = trpc.viewer.appRoutingForms.formMutation.useMutation({
|
||||
onSuccess() {
|
||||
showToast("Form updated successfully.", "success");
|
||||
showToast(t("form_updated_successfully"), "success");
|
||||
},
|
||||
onError(e) {
|
||||
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 (
|
||||
<Select
|
||||
|
@ -165,7 +165,7 @@ const MultiSelectWidget = ({
|
|||
onChange={(items) => {
|
||||
setValue(items?.map((item) => item.value));
|
||||
}}
|
||||
defaultValue={defaultValue}
|
||||
value={optionsFromList}
|
||||
isMulti={true}
|
||||
isDisabled={remainingProps.readOnly}
|
||||
options={selectItems}
|
||||
|
@ -184,7 +184,7 @@ function SelectWidget({ listValues, setValue, value, ...remainingProps }: Select
|
|||
value: item.value,
|
||||
};
|
||||
});
|
||||
const defaultValue = selectItems.find((item) => item.value === value);
|
||||
const optionFromList = selectItems.find((item) => item.value === value);
|
||||
|
||||
return (
|
||||
<Select
|
||||
|
@ -196,7 +196,7 @@ function SelectWidget({ listValues, setValue, value, ...remainingProps }: Select
|
|||
setValue(item.value);
|
||||
}}
|
||||
isDisabled={remainingProps.readOnly}
|
||||
defaultValue={defaultValue}
|
||||
value={optionFromList}
|
||||
options={selectItems}
|
||||
{...remainingProps}
|
||||
/>
|
||||
|
|
|
@ -134,7 +134,7 @@ function Field({
|
|||
<TextField
|
||||
disabled={!!router}
|
||||
label="Identifier"
|
||||
name="identifier"
|
||||
name={`${hookFieldNamespace}.identifier`}
|
||||
required
|
||||
placeholder={t("identifies_name_field")}
|
||||
value={identifier}
|
||||
|
|
|
@ -175,7 +175,7 @@ const Route = ({
|
|||
</div>
|
||||
<Select
|
||||
isDisabled={disabled}
|
||||
className="block w-full flex-grow px-2"
|
||||
className="data-testid-select-routing-action block w-full flex-grow px-2"
|
||||
required
|
||||
value={RoutingPages.find((page) => page.value === route.action?.type)}
|
||||
onChange={(item) => {
|
||||
|
|
|
@ -16,10 +16,12 @@ import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
|||
import { Button, showToast, useCalcomTheme } from "@calcom/ui";
|
||||
|
||||
import FormInputFields from "../../components/FormInputFields";
|
||||
import getFieldIdentifier from "../../lib/getFieldIdentifier";
|
||||
import { getSerializableForm } from "../../lib/getSerializableForm";
|
||||
import { processRoute } from "../../lib/processRoute";
|
||||
import type { Response, Route } from "../../types/types";
|
||||
|
||||
type Props = inferSSRProps<typeof getServerSideProps>;
|
||||
const useBrandColors = ({
|
||||
brandColor,
|
||||
darkBrandColor,
|
||||
|
@ -34,7 +36,7 @@ const useBrandColors = ({
|
|||
useCalcomTheme(brandTheme);
|
||||
};
|
||||
|
||||
function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getServerSideProps>) {
|
||||
function RoutingForm({ form, profile, ...restProps }: Props) {
|
||||
const [customPageMessage, setCustomPageMessage] = useState<Route["action"]["value"]>("");
|
||||
const formFillerIdRef = useRef(uuidv4());
|
||||
const isEmbed = useIsEmbed(restProps.isEmbed);
|
||||
|
@ -43,12 +45,15 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
|
|||
brandColor: profile.brandColor,
|
||||
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
|
||||
// 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
|
||||
// - or he abandoned booking flow in between
|
||||
const formFillerId = formFillerIdRef.current;
|
||||
const decidedActionRef = useRef<Route["action"]>();
|
||||
const decidedActionWithFormResponseRef = useRef<{ action: Route["action"]; response: Response }>();
|
||||
const router = useRouter();
|
||||
|
||||
const onSubmit = (response: Response) => {
|
||||
|
@ -65,7 +70,10 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
|
|||
formFillerId,
|
||||
response: response,
|
||||
});
|
||||
decidedActionRef.current = decidedAction;
|
||||
decidedActionWithFormResponseRef.current = {
|
||||
action: decidedAction,
|
||||
response,
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -75,19 +83,26 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
|
|||
|
||||
const responseMutation = trpc.viewer.appRoutingForms.public.response.useMutation({
|
||||
onSuccess: () => {
|
||||
const decidedAction = decidedActionRef.current;
|
||||
if (!decidedAction) {
|
||||
const decidedActionWithFormResponse = decidedActionWithFormResponseRef.current;
|
||||
if (!decidedActionWithFormResponse) {
|
||||
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
|
||||
if (decidedAction.type === "customPageMessage") {
|
||||
setCustomPageMessage(decidedAction.value);
|
||||
} else if (decidedAction.type === "eventTypeRedirectUrl") {
|
||||
router.push(`/${decidedAction.value}`);
|
||||
router.push(`/${decidedAction.value}?${allURLSearchParams}`);
|
||||
} 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");
|
||||
},
|
||||
onError: (e) => {
|
||||
|
@ -97,12 +112,11 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
|
|||
if (e?.data?.code === "CONFLICT") {
|
||||
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");
|
||||
},
|
||||
});
|
||||
|
||||
const [response, setResponse] = useState<Response>({});
|
||||
|
||||
const handleOnSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
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>) {
|
||||
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);
|
||||
|
||||
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.waitForSelector('[data-testid="routing-forms-list"]');
|
||||
// Ensure that it's visible in forms list
|
||||
expect(await page.locator('[data-testid="routing-forms-list"] > li').count()).toBe(1);
|
||||
|
||||
await gotoRoutingLink(page, formId);
|
||||
await page.isVisible("text=Test Form Name");
|
||||
await gotoRoutingLink({ page, formId });
|
||||
await expect(page.locator("text=Test Form Name")).toBeVisible();
|
||||
|
||||
await page.goto(`apps/routing-forms/route-builder/${formId}`);
|
||||
await page.click('[data-testid="toggle-form"] [value="on"]');
|
||||
await gotoRoutingLink(page, formId);
|
||||
await page.isVisible("text=ERROR 404");
|
||||
await disableForm(page);
|
||||
await gotoRoutingLink({ page, formId });
|
||||
await expect(page.locator("text=ERROR 404")).toBeVisible();
|
||||
});
|
||||
|
||||
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 { types } = await addMultipleFieldsAndSaveForm(formId, page, { description, label });
|
||||
|
||||
await page.reload();
|
||||
const { fieldTypesList: types, fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
|
||||
description,
|
||||
label,
|
||||
});
|
||||
|
||||
expect(await page.inputValue(`[data-testid="description"]`)).toBe(description);
|
||||
expect(await page.locator('[data-testid="field"]').count()).toBe(types.length);
|
||||
|
||||
types.forEach((item, index) => {
|
||||
createdFields[index] = { label: `Test Label ${index + 1}`, typeIndex: index };
|
||||
fields.forEach((item, index) => {
|
||||
createdFields[index] = { label: item.label, typeIndex: index };
|
||||
});
|
||||
|
||||
await expectCurrentFormToHaveFields(page, createdFields, types);
|
||||
|
||||
await page.click('[href*="/apps/routing-forms/route-builder/"]');
|
||||
|
@ -63,9 +63,7 @@ test.describe("Routing Forms", () => {
|
|||
});
|
||||
|
||||
test.describe("F1<-F2 Relationship", () => {
|
||||
// TODO: Fix this test, it is very flaky
|
||||
// prettier-ignore
|
||||
test.fixme("Create relationship by adding F1 as route.Editing F1 should update F2", async ({ page }) => {
|
||||
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" });
|
||||
|
||||
|
@ -118,6 +116,63 @@ test.describe("Routing Forms", () => {
|
|||
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?
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
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.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.isVisible("text=Multiselect chosen");
|
||||
await expect(page.locator("text=Multiselect chosen")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Routing Link should validate fields", async ({ page, users }) => {
|
||||
const user = await createUserAndLoginAndInstallApp({ users, page });
|
||||
const routingForm = user.routingForms[0];
|
||||
await gotoRoutingLink(page, routingForm.id);
|
||||
await gotoRoutingLink({ page, formId: routingForm.id });
|
||||
page.click('button[type="submit"]');
|
||||
const firstInputMissingValue = await page.evaluate(() => {
|
||||
return document.querySelectorAll("input")[0].validity.valueMissing;
|
||||
|
@ -291,40 +346,45 @@ test.describe("Routing Forms", () => {
|
|||
await page.click('[data-testid="test-preview"]');
|
||||
|
||||
// //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"]');
|
||||
let routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
||||
let route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||
await expect(routingType).toBe("Event Redirect");
|
||||
await expect(route).toBe("pro/30min");
|
||||
expect(routingType).toBe("Event Redirect");
|
||||
expect(route).toBe("pro/30min");
|
||||
|
||||
//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"]');
|
||||
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
||||
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||
await expect(routingType).toBe("Custom Page");
|
||||
await expect(route).toBe("Custom Page Result");
|
||||
expect(routingType).toBe("Custom Page");
|
||||
expect(route).toBe("Custom Page Result");
|
||||
|
||||
//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"]');
|
||||
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
||||
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||
await expect(routingType).toBe("External Redirect");
|
||||
await expect(route).toBe("https://google.com");
|
||||
expect(routingType).toBe("External Redirect");
|
||||
expect(route).toBe("https://google.com");
|
||||
|
||||
//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"]');
|
||||
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
|
||||
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||
await expect(routingType).toBe("Custom Page");
|
||||
await expect(route).toBe("Fallback Message");
|
||||
expect(routingType).toBe("Custom Page");
|
||||
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(
|
||||
page: Page,
|
||||
fields: {
|
||||
|
@ -341,24 +401,24 @@ async function expectCurrentFormToHaveFields(
|
|||
}
|
||||
|
||||
async function fillSeededForm(page: Page, routingFormId: string) {
|
||||
await gotoRoutingLink(page, routingFormId);
|
||||
await page.fill('[data-testid="form-field"]', "event-routing");
|
||||
await gotoRoutingLink({ page, formId: routingFormId });
|
||||
await page.fill('[data-testid="form-field-Test field"]', "event-routing");
|
||||
page.click('button[type="submit"]');
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.endsWith("/pro/30min");
|
||||
});
|
||||
|
||||
await gotoRoutingLink(page, routingFormId);
|
||||
await page.fill('[data-testid="form-field"]', "external-redirect");
|
||||
await gotoRoutingLink({ page, formId: routingFormId });
|
||||
await page.fill('[data-testid="form-field-Test field"]', "external-redirect");
|
||||
page.click('button[type="submit"]');
|
||||
await page.waitForURL((url) => {
|
||||
return url.hostname.includes("google.com");
|
||||
});
|
||||
|
||||
await gotoRoutingLink(page, routingFormId);
|
||||
await page.fill('[data-testid="form-field"]', "custom-page");
|
||||
await gotoRoutingLink({ page, formId: routingFormId });
|
||||
await page.fill('[data-testid="form-field-Test field"]', "custom-page");
|
||||
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" } = {}) {
|
||||
|
@ -375,7 +435,7 @@ export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
|
|||
return formId;
|
||||
}
|
||||
|
||||
async function addMultipleFieldsAndSaveForm(
|
||||
async function addAllTypesOfFieldsAndSaveForm(
|
||||
formId: string,
|
||||
page: Page,
|
||||
form: { description: string; label: string }
|
||||
|
@ -384,33 +444,51 @@ async function addMultipleFieldsAndSaveForm(
|
|||
await page.click('[data-testid="add-field"]');
|
||||
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 },
|
||||
["Email", "Long Text", "MultiSelect", "Number", "Phone", "Select", "Short Text"],
|
||||
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++) {
|
||||
const fieldName = withoutFirstValue[index];
|
||||
const nth = index + 1;
|
||||
const label = `${form.label} ${index + 2}`;
|
||||
if (fieldTypeLabel === "MultiSelect" || fieldTypeLabel === "Select") {
|
||||
await page.fill(`[name="fields.${nth}.selectText"]`, "123\n456\n789");
|
||||
}
|
||||
|
||||
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);
|
||||
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"]');
|
||||
}
|
||||
fields.push({ identifier: identifier, label, type: fieldTypeLabel });
|
||||
}
|
||||
|
||||
await saveCurrentForm(page);
|
||||
return {
|
||||
types,
|
||||
fieldTypesList,
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -461,6 +539,9 @@ async function selectOption({
|
|||
}: {
|
||||
page: Page;
|
||||
selector: { selector: string; nth: number };
|
||||
/**
|
||||
* Index of option to select. Starts from 1
|
||||
*/
|
||||
option: number;
|
||||
}) {
|
||||
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) {
|
||||
await page.goto(`/forms/${formId}`);
|
||||
async function gotoRoutingLink({
|
||||
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.
|
||||
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.
|
||||
value.splice(index, 1, {
|
||||
label: e.target.value,
|
||||
value: e.target.value.toLowerCase().trim(),
|
||||
value: e.target.value.trim(),
|
||||
});
|
||||
onChange(value);
|
||||
}}
|
||||
|
|
|
@ -53,7 +53,9 @@ if (IS_EMBED_REACT_TEST) {
|
|||
const config: PlaywrightTestConfig = {
|
||||
forbidOnly: !!process.env.CI,
|
||||
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,
|
||||
maxFailures: headless ? 10 : undefined,
|
||||
fullyParallel: true,
|
||||
|
|
Loading…
Reference in New Issue