diff --git a/apps/web/playwright/booking-phone-autofill.e2e.ts b/apps/web/playwright/booking-phone-autofill.e2e.ts new file mode 100644 index 0000000000..7b7c37020c --- /dev/null +++ b/apps/web/playwright/booking-phone-autofill.e2e.ts @@ -0,0 +1,188 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import type { createUsersFixture } from "playwright/fixtures/users"; + +import { test } from "./lib/fixtures"; +import { gotoBookingPage, saveEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils"; + +const normalizePhone = (s: string) => s.replace(/[^+\d]/g, ""); + +test.describe.configure({ mode: "serial" }); + +test.describe("Phone Location Auto-fill Feature", () => { + test("should auto-fill untouched phone fields when phone location is selected", async ({ page, users }) => { + await createUserWithPhoneFields({ users, page }); + + await gotoBookingPage(page); + await selectFirstAvailableTimeSlotNextMonth(page); + + // Verify custom phone fields start empty or country prefix (e.g., "+1") + const v1 = await page.locator('[name="phone-1"]').inputValue(); + const v2 = await page.locator('[name="phone-2"]').inputValue(); + expect(v1 === "" || /^\+\d{1,3}$/.test(v1)).toBeTruthy(); + expect(v2 === "" || /^\+\d{1,3}$/.test(v2)).toBeTruthy(); + + // Select phone location and enter phone number + const phoneNumber = "+14155551234"; + await selectPhoneLocation(page); + await fillPhoneLocationInput(page, phoneNumber); + + // Verify both custom phone fields are auto-filled (normalized) + await expect + .poll(async () => normalizePhone(await page.locator('[name="phone-1"]').inputValue())) + .toBe(normalizePhone(phoneNumber)); + await expect + .poll(async () => normalizePhone(await page.locator('[name="phone-2"]').inputValue())) + .toBe(normalizePhone(phoneNumber)); + + // Skip booking confirmation to keep this test fast and focused on autofill behavior + }); + + test("should NOT sync changes from custom phone fields back to location or other fields", async ({ page, users }) => { + await createUserWithPhoneFields({ users, page }); + + await gotoBookingPage(page); + await selectFirstAvailableTimeSlotNextMonth(page); + + // Select phone location and enter phone number + const locationPhoneNumber = "+14155551234"; + await selectPhoneLocation(page); + await fillPhoneLocationInput(page, locationPhoneNumber); + + // Verify both custom phone fields are auto-filled + await expect + .poll(async () => normalizePhone(await page.locator('[name="phone-1"]').inputValue())) + .toBe(normalizePhone(locationPhoneNumber)); + await expect + .poll(async () => normalizePhone(await page.locator('[name="phone-2"]').inputValue())) + .toBe(normalizePhone(locationPhoneNumber)); + + // Now manually change phone-2 to a different number + const differentPhoneNumber = "+14155559999"; + const phone2Input = page.locator('[name="phone-2"]'); + await phone2Input.clear(); + await phone2Input.fill(differentPhoneNumber); + await phone2Input.blur(); // Trigger blur event + + // Verify phone-1 is still the original location phone (NOT changed to phone-2's value) + const phone1Value = await page.locator('[name="phone-1"]').inputValue(); + expect(normalizePhone(phone1Value)).toBe(normalizePhone(locationPhoneNumber)); + + // Verify location field is still the original value (NOT changed to phone-2's value) + const locationValue = await page.locator(`[data-fob-field-name="location"] input`).inputValue(); + expect(normalizePhone(locationValue)).toBe(normalizePhone(locationPhoneNumber)); + + // Verify phone-2 has the new value + const phone2Value = await page.locator('[name="phone-2"]').inputValue(); + expect(normalizePhone(phone2Value)).toBe(normalizePhone(differentPhoneNumber)); + }); +}); + +// Helper Functions + +async function createUserWithPhoneFields({ + users, + page, +}: { + users: ReturnType; + page: Page; +}) { + try { + const user = await users.create(); + await user.apiLogin(); + await page.goto("/event-types"); + + // Go to first event type + const $eventTypes = page.locator("[data-testid=event-types] > li a"); + await $eventTypes.first().click(); + + // Enable Attendee Phone Number location + await selectAttendeePhoneNumber(page); + + // Add two custom phone fields + await page.getByTestId("vertical-tab-event_advanced_tab_title").click(); + + await addPhoneQuestion(page, "phone-1", "Phone Number 1", true); + await addPhoneQuestion(page, "phone-2", "Phone Number 2", true); + + // Save once at the end + await saveEventType(page); + + return user; + } catch (error) { + console.error("Failed to create user with phone fields:", error); + throw error; + } +} + +async function addPhoneQuestion(page: Page, name: string, label: string, required: boolean) { + await page.click('[data-testid="add-field"]'); + // Wait for modal to open by ensuring field-type control is present + await page.waitForSelector("[id=test-field-type]"); + + // Select Phone type + await page.locator("[id=test-field-type]").click(); + await page.waitForSelector('[data-testid="select-option-phone"]'); + await page.locator('[data-testid="select-option-phone"]').click(); + + // Fill name + await page.fill('[name="name"]', name); + + // Fill label + await page.fill('[name="label"]', label); + + // Set required if needed + if (required) { + // Try to find and check the required checkbox, but don't fail if it doesn't exist + try { + await page.waitForSelector('input[name="required"]', { timeout: 500 }); + const requiredCheckbox = page.locator('input[name="required"]').first(); + await requiredCheckbox.check(); + } catch { + // Checkbox not found or not needed + } + } + + // Click save button for the field + await page.click('[data-testid="field-add-save"]'); + // Wait for the modal to close + await page.locator('[data-testid="field-add-save"]').waitFor({ state: "detached" }); +} + +async function selectAttendeePhoneNumber(page: Page) { + await page.getByTestId("location-select").click(); + await page.getByTestId("location-select-item-phone").click(); +} + +async function selectPhoneLocation(page: Page) { + // When "Attendee Phone Number" is the location, the booking form + // shows a phone input directly - no radio button selection needed. + // Just wait for location field to be ready + await page.waitForSelector('[data-fob-field-name="location"]'); +} + +async function fillPhoneLocationInput(page: Page, phoneNumber: string) { + // The location field has a phone input when "Attendee Phone Number" is selected + await page.waitForSelector(`[data-fob-field-name="location"] input`); + const locationInput = page.locator(`[data-fob-field-name="location"] input`); + + // Ensure the field is empty first + await locationInput.clear(); + // Wait for mask/prefix to settle (empty string or just country prefix) + await expect.poll(async () => locationInput.inputValue()).toMatch(/^(?:|\+\d{1,3})$/); + + // If the mask auto-inserts a country prefix, avoid duplicating it + const prefill = await locationInput.inputValue(); + let toType = phoneNumber; + if (/^\+\d{1,3}$/.test(prefill) && phoneNumber.startsWith(prefill)) { + toType = phoneNumber.slice(prefill.length); + } + + // Type the phone number with a small delay to play nicely with masking + await locationInput.pressSequentially(toType, { delay: 20 }); + + // Trigger blur to ensure the auto-fill effect runs + await page.locator('[name="name"]').click(); +} + +// removed local gotoBookingPage/selectFirstAvailableTimeSlot/saveEventType in favor of shared helpers diff --git a/apps/web/playwright/event-types.e2e.ts b/apps/web/playwright/event-types.e2e.ts index ba68567a5c..00afe1f4dc 100644 --- a/apps/web/playwright/event-types.e2e.ts +++ b/apps/web/playwright/event-types.e2e.ts @@ -212,7 +212,9 @@ test.describe("Event Types tests", () => { await bookTimeSlot(page); await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - await expect(page.locator("text=+19199999999")).toBeVisible(); + await expect(page.locator("text=+19199999999")).toHaveCount(2); + await expect(page.locator("text=+19199999999").first()).toBeVisible(); + await expect(page.locator("text=+19199999999").nth(1)).toBeVisible(); }); test("Can add Organzer Phone Number location and book with it", async ({ page }) => { @@ -274,7 +276,6 @@ test.describe("Event Types tests", () => { }); // TODO: This test is extremely flaky and has been failing a lot, blocking many PRs. Fix this. - // eslint-disable-next-line playwright/no-skipped-test test.skip("Can remove location from multiple locations that are saved", async ({ page }) => { await gotoFirstEventType(page); @@ -419,7 +420,6 @@ test.describe("Event Types tests", () => { }); test("should enable timezone lock in event advanced settings and verify disabled timezone selector on booking page", async ({ page, - users, }) => { await gotoFirstEventType(page); await expect(page.locator("[data-testid=event-title]")).toBeVisible(); diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx index 3b7195ef78..a64b3b7718 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx @@ -1,7 +1,10 @@ +import { useMemo, useRef } from "react"; import { useFormContext } from "react-hook-form"; +import { z } from "zod"; import type { LocationObject } from "@calcom/app-store/locations"; import { getOrganizerInputLocationTypes } from "@calcom/app-store/locations"; +import { DefaultEventLocationTypeEnum } from "@calcom/app-store/locations"; import { useBookerStore } from "@calcom/features/bookings/Booker/store"; import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect"; @@ -13,7 +16,15 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; import type { RouterOutputs } from "@calcom/trpc/react"; +type TouchedFields = { + responses?: Record; +}; + type Fields = NonNullable["bookingFields"]; +const PhoneLocationSchema = z.object({ + value: z.literal(DefaultEventLocationTypeEnum.Phone), + optionValue: z.string().optional(), +}); export const BookingFields = ({ fields, locations, @@ -32,11 +43,45 @@ export const BookingFields = ({ paymentCurrency?: string; }) => { const { t, i18n } = useLocale(); - const { watch, setValue } = useFormContext(); + const { watch, setValue, formState } = useFormContext(); const locationResponse = watch("responses.location"); const currentView = rescheduleUid ? "reschedule" : ""; const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting); + // Identify all phone fields (except location field) + const otherPhoneFieldNames = useMemo( + () => fields.filter((f) => f.type === "phone" && f.name !== SystemField.Enum.location).map((f) => f.name), + [fields] + ); + + // Track last synced value to avoid redundant updates + const lastSyncedPhoneRef = useRef(null); + + // Event-driven sync function + const syncPhoneFields = (locationValue: unknown) => { + const parsed = PhoneLocationSchema.safeParse(locationValue); + if (!parsed.success) return; + const { optionValue } = parsed.data; + const phone = (optionValue ?? "").trim(); + + // Skip if empty or same as last sync (avoid redundant updates during typing) + if (!phone || phone === lastSyncedPhoneRef.current) return; + + // Copy phone to other phone fields (only if user hasn't manually touched them) + otherPhoneFieldNames.forEach((name) => { + const targetTouched = !!(formState.touchedFields as TouchedFields)?.responses?.[name]; + + if (!targetTouched) { + setValue(`responses.${name}`, phone, { + shouldDirty: false, + shouldValidate: false, + }); + } + }); + + lastSyncedPhoneRef.current = phone; + }; + const getPriceFormattedLabel = (label: string, price: number) => `${label} (${Intl.NumberFormat(i18n.language, { style: "currency", @@ -205,6 +250,11 @@ export const BookingFields = ({ field={{ ...fieldWithPrice, hidden }} readOnly={readOnly} key={index} + {...(field.name === SystemField.Enum.location && { + onValueChange: ({ value }) => { + syncPhoneFields(value); + }, + })} /> ); })} diff --git a/packages/features/form-builder/FormBuilderField.tsx b/packages/features/form-builder/FormBuilderField.tsx index 88db03a320..0558246b89 100644 --- a/packages/features/form-builder/FormBuilderField.tsx +++ b/packages/features/form-builder/FormBuilderField.tsx @@ -25,7 +25,6 @@ const renderLabel = (field: Partial) => { if (field.labelAsSafeHtml) { return ( ); @@ -66,10 +65,12 @@ export const FormBuilderField = ({ field, readOnly, className, + onValueChange, }: { field: RhfFormFields[number]; readOnly: boolean; className: string; + onValueChange?: (args: { name: string; value: unknown; prevValue: unknown }) => void; }) => { const { t } = useLocale(); const { control, formState } = useFormContext(); @@ -87,15 +88,18 @@ export const FormBuilderField = ({ // Make it a variable name={`responses.${field.name}`} render={({ field: { value, onChange }, fieldState: { error } }) => { + const setAndNotify = (val: unknown) => { + onChange(val); + onValueChange?.({ name: field.name, value: val, prevValue: value }); + }; + return (