fix: hide duplicate phone field when attendee phone location selected (#23118)

* fix: hide duplicate phone field when attendee phone location selected

* Instead of hiding use autofill the value to all other phone fields

* add e2e test

* add not sync test and use changeHandler instead of useEffect

* address cubics comments

* adding the phone check back

* use zod schema instead of this type casting

* use Enum instead of hardcoded string for phone

* Fix e2e test

* Fix e2e tests

* Delete .retracify.html

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
Co-authored-by: Volnei Munhoz <volnei@cal.com>
This commit is contained in:
Dhairyashil Shinde
2025-11-12 13:54:36 +00:00
committed by GitHub
co-authored by Eunjae Lee Volnei Munhoz Volnei Munhoz
parent 3e7a848769
commit a7cb71726a
4 changed files with 250 additions and 8 deletions
@@ -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<typeof createUsersFixture>;
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
+3 -3
View File
@@ -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();
@@ -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<string, boolean>;
};
type Fields = NonNullable<RouterOutputs["viewer"]["public"]["event"]>["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<string | null>(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);
},
})}
/>
);
})}
@@ -25,7 +25,6 @@ const renderLabel = (field: Partial<RhfFormField>) => {
if (field.labelAsSafeHtml) {
return (
<span
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: markdownToSafeHTML(field.labelAsSafeHtml) }}
/>
);
@@ -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 (
<div>
<ComponentForField
field={{ ...field, label, placeholder, hidden }}
value={value}
readOnly={readOnly || shouldBeDisabled}
setValue={(val: unknown) => {
onChange(val);
}}
setValue={setAndNotify}
noLabel={noLabel}
translatedDefaultLabel={translatedDefaultLabel}
/>