Skip to content

Commit a7cb717

Browse files
dhairyashiileunjae-leevolnei
authored
fix: hide duplicate phone field when attendee phone location selected (calcom#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>
1 parent 3e7a848 commit a7cb717

4 files changed

Lines changed: 250 additions & 8 deletions

File tree

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import type { Page } from "@playwright/test";
2+
import { expect } from "@playwright/test";
3+
import type { createUsersFixture } from "playwright/fixtures/users";
4+
5+
import { test } from "./lib/fixtures";
6+
import { gotoBookingPage, saveEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
7+
8+
const normalizePhone = (s: string) => s.replace(/[^+\d]/g, "");
9+
10+
test.describe.configure({ mode: "serial" });
11+
12+
test.describe("Phone Location Auto-fill Feature", () => {
13+
test("should auto-fill untouched phone fields when phone location is selected", async ({ page, users }) => {
14+
await createUserWithPhoneFields({ users, page });
15+
16+
await gotoBookingPage(page);
17+
await selectFirstAvailableTimeSlotNextMonth(page);
18+
19+
// Verify custom phone fields start empty or country prefix (e.g., "+1")
20+
const v1 = await page.locator('[name="phone-1"]').inputValue();
21+
const v2 = await page.locator('[name="phone-2"]').inputValue();
22+
expect(v1 === "" || /^\+\d{1,3}$/.test(v1)).toBeTruthy();
23+
expect(v2 === "" || /^\+\d{1,3}$/.test(v2)).toBeTruthy();
24+
25+
// Select phone location and enter phone number
26+
const phoneNumber = "+14155551234";
27+
await selectPhoneLocation(page);
28+
await fillPhoneLocationInput(page, phoneNumber);
29+
30+
// Verify both custom phone fields are auto-filled (normalized)
31+
await expect
32+
.poll(async () => normalizePhone(await page.locator('[name="phone-1"]').inputValue()))
33+
.toBe(normalizePhone(phoneNumber));
34+
await expect
35+
.poll(async () => normalizePhone(await page.locator('[name="phone-2"]').inputValue()))
36+
.toBe(normalizePhone(phoneNumber));
37+
38+
// Skip booking confirmation to keep this test fast and focused on autofill behavior
39+
});
40+
41+
test("should NOT sync changes from custom phone fields back to location or other fields", async ({ page, users }) => {
42+
await createUserWithPhoneFields({ users, page });
43+
44+
await gotoBookingPage(page);
45+
await selectFirstAvailableTimeSlotNextMonth(page);
46+
47+
// Select phone location and enter phone number
48+
const locationPhoneNumber = "+14155551234";
49+
await selectPhoneLocation(page);
50+
await fillPhoneLocationInput(page, locationPhoneNumber);
51+
52+
// Verify both custom phone fields are auto-filled
53+
await expect
54+
.poll(async () => normalizePhone(await page.locator('[name="phone-1"]').inputValue()))
55+
.toBe(normalizePhone(locationPhoneNumber));
56+
await expect
57+
.poll(async () => normalizePhone(await page.locator('[name="phone-2"]').inputValue()))
58+
.toBe(normalizePhone(locationPhoneNumber));
59+
60+
// Now manually change phone-2 to a different number
61+
const differentPhoneNumber = "+14155559999";
62+
const phone2Input = page.locator('[name="phone-2"]');
63+
await phone2Input.clear();
64+
await phone2Input.fill(differentPhoneNumber);
65+
await phone2Input.blur(); // Trigger blur event
66+
67+
// Verify phone-1 is still the original location phone (NOT changed to phone-2's value)
68+
const phone1Value = await page.locator('[name="phone-1"]').inputValue();
69+
expect(normalizePhone(phone1Value)).toBe(normalizePhone(locationPhoneNumber));
70+
71+
// Verify location field is still the original value (NOT changed to phone-2's value)
72+
const locationValue = await page.locator(`[data-fob-field-name="location"] input`).inputValue();
73+
expect(normalizePhone(locationValue)).toBe(normalizePhone(locationPhoneNumber));
74+
75+
// Verify phone-2 has the new value
76+
const phone2Value = await page.locator('[name="phone-2"]').inputValue();
77+
expect(normalizePhone(phone2Value)).toBe(normalizePhone(differentPhoneNumber));
78+
});
79+
});
80+
81+
// Helper Functions
82+
83+
async function createUserWithPhoneFields({
84+
users,
85+
page,
86+
}: {
87+
users: ReturnType<typeof createUsersFixture>;
88+
page: Page;
89+
}) {
90+
try {
91+
const user = await users.create();
92+
await user.apiLogin();
93+
await page.goto("/event-types");
94+
95+
// Go to first event type
96+
const $eventTypes = page.locator("[data-testid=event-types] > li a");
97+
await $eventTypes.first().click();
98+
99+
// Enable Attendee Phone Number location
100+
await selectAttendeePhoneNumber(page);
101+
102+
// Add two custom phone fields
103+
await page.getByTestId("vertical-tab-event_advanced_tab_title").click();
104+
105+
await addPhoneQuestion(page, "phone-1", "Phone Number 1", true);
106+
await addPhoneQuestion(page, "phone-2", "Phone Number 2", true);
107+
108+
// Save once at the end
109+
await saveEventType(page);
110+
111+
return user;
112+
} catch (error) {
113+
console.error("Failed to create user with phone fields:", error);
114+
throw error;
115+
}
116+
}
117+
118+
async function addPhoneQuestion(page: Page, name: string, label: string, required: boolean) {
119+
await page.click('[data-testid="add-field"]');
120+
// Wait for modal to open by ensuring field-type control is present
121+
await page.waitForSelector("[id=test-field-type]");
122+
123+
// Select Phone type
124+
await page.locator("[id=test-field-type]").click();
125+
await page.waitForSelector('[data-testid="select-option-phone"]');
126+
await page.locator('[data-testid="select-option-phone"]').click();
127+
128+
// Fill name
129+
await page.fill('[name="name"]', name);
130+
131+
// Fill label
132+
await page.fill('[name="label"]', label);
133+
134+
// Set required if needed
135+
if (required) {
136+
// Try to find and check the required checkbox, but don't fail if it doesn't exist
137+
try {
138+
await page.waitForSelector('input[name="required"]', { timeout: 500 });
139+
const requiredCheckbox = page.locator('input[name="required"]').first();
140+
await requiredCheckbox.check();
141+
} catch {
142+
// Checkbox not found or not needed
143+
}
144+
}
145+
146+
// Click save button for the field
147+
await page.click('[data-testid="field-add-save"]');
148+
// Wait for the modal to close
149+
await page.locator('[data-testid="field-add-save"]').waitFor({ state: "detached" });
150+
}
151+
152+
async function selectAttendeePhoneNumber(page: Page) {
153+
await page.getByTestId("location-select").click();
154+
await page.getByTestId("location-select-item-phone").click();
155+
}
156+
157+
async function selectPhoneLocation(page: Page) {
158+
// When "Attendee Phone Number" is the location, the booking form
159+
// shows a phone input directly - no radio button selection needed.
160+
// Just wait for location field to be ready
161+
await page.waitForSelector('[data-fob-field-name="location"]');
162+
}
163+
164+
async function fillPhoneLocationInput(page: Page, phoneNumber: string) {
165+
// The location field has a phone input when "Attendee Phone Number" is selected
166+
await page.waitForSelector(`[data-fob-field-name="location"] input`);
167+
const locationInput = page.locator(`[data-fob-field-name="location"] input`);
168+
169+
// Ensure the field is empty first
170+
await locationInput.clear();
171+
// Wait for mask/prefix to settle (empty string or just country prefix)
172+
await expect.poll(async () => locationInput.inputValue()).toMatch(/^(?:|\+\d{1,3})$/);
173+
174+
// If the mask auto-inserts a country prefix, avoid duplicating it
175+
const prefill = await locationInput.inputValue();
176+
let toType = phoneNumber;
177+
if (/^\+\d{1,3}$/.test(prefill) && phoneNumber.startsWith(prefill)) {
178+
toType = phoneNumber.slice(prefill.length);
179+
}
180+
181+
// Type the phone number with a small delay to play nicely with masking
182+
await locationInput.pressSequentially(toType, { delay: 20 });
183+
184+
// Trigger blur to ensure the auto-fill effect runs
185+
await page.locator('[name="name"]').click();
186+
}
187+
188+
// removed local gotoBookingPage/selectFirstAvailableTimeSlot/saveEventType in favor of shared helpers

apps/web/playwright/event-types.e2e.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,9 @@ test.describe("Event Types tests", () => {
212212
await bookTimeSlot(page);
213213

214214
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
215-
await expect(page.locator("text=+19199999999")).toBeVisible();
215+
await expect(page.locator("text=+19199999999")).toHaveCount(2);
216+
await expect(page.locator("text=+19199999999").first()).toBeVisible();
217+
await expect(page.locator("text=+19199999999").nth(1)).toBeVisible();
216218
});
217219

218220
test("Can add Organzer Phone Number location and book with it", async ({ page }) => {
@@ -274,7 +276,6 @@ test.describe("Event Types tests", () => {
274276
});
275277

276278
// TODO: This test is extremely flaky and has been failing a lot, blocking many PRs. Fix this.
277-
// eslint-disable-next-line playwright/no-skipped-test
278279
test.skip("Can remove location from multiple locations that are saved", async ({ page }) => {
279280
await gotoFirstEventType(page);
280281

@@ -419,7 +420,6 @@ test.describe("Event Types tests", () => {
419420
});
420421
test("should enable timezone lock in event advanced settings and verify disabled timezone selector on booking page", async ({
421422
page,
422-
users,
423423
}) => {
424424
await gotoFirstEventType(page);
425425
await expect(page.locator("[data-testid=event-title]")).toBeVisible();

packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import { useMemo, useRef } from "react";
12
import { useFormContext } from "react-hook-form";
3+
import { z } from "zod";
24

35
import type { LocationObject } from "@calcom/app-store/locations";
46
import { getOrganizerInputLocationTypes } from "@calcom/app-store/locations";
7+
import { DefaultEventLocationTypeEnum } from "@calcom/app-store/locations";
58
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
69
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
710
import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
@@ -13,7 +16,15 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
1316
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
1417
import type { RouterOutputs } from "@calcom/trpc/react";
1518

19+
type TouchedFields = {
20+
responses?: Record<string, boolean>;
21+
};
22+
1623
type Fields = NonNullable<RouterOutputs["viewer"]["public"]["event"]>["bookingFields"];
24+
const PhoneLocationSchema = z.object({
25+
value: z.literal(DefaultEventLocationTypeEnum.Phone),
26+
optionValue: z.string().optional(),
27+
});
1728
export const BookingFields = ({
1829
fields,
1930
locations,
@@ -32,11 +43,45 @@ export const BookingFields = ({
3243
paymentCurrency?: string;
3344
}) => {
3445
const { t, i18n } = useLocale();
35-
const { watch, setValue } = useFormContext();
46+
const { watch, setValue, formState } = useFormContext();
3647
const locationResponse = watch("responses.location");
3748
const currentView = rescheduleUid ? "reschedule" : "";
3849
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
3950

51+
// Identify all phone fields (except location field)
52+
const otherPhoneFieldNames = useMemo(
53+
() => fields.filter((f) => f.type === "phone" && f.name !== SystemField.Enum.location).map((f) => f.name),
54+
[fields]
55+
);
56+
57+
// Track last synced value to avoid redundant updates
58+
const lastSyncedPhoneRef = useRef<string | null>(null);
59+
60+
// Event-driven sync function
61+
const syncPhoneFields = (locationValue: unknown) => {
62+
const parsed = PhoneLocationSchema.safeParse(locationValue);
63+
if (!parsed.success) return;
64+
const { optionValue } = parsed.data;
65+
const phone = (optionValue ?? "").trim();
66+
67+
// Skip if empty or same as last sync (avoid redundant updates during typing)
68+
if (!phone || phone === lastSyncedPhoneRef.current) return;
69+
70+
// Copy phone to other phone fields (only if user hasn't manually touched them)
71+
otherPhoneFieldNames.forEach((name) => {
72+
const targetTouched = !!(formState.touchedFields as TouchedFields)?.responses?.[name];
73+
74+
if (!targetTouched) {
75+
setValue(`responses.${name}`, phone, {
76+
shouldDirty: false,
77+
shouldValidate: false,
78+
});
79+
}
80+
});
81+
82+
lastSyncedPhoneRef.current = phone;
83+
};
84+
4085
const getPriceFormattedLabel = (label: string, price: number) =>
4186
`${label} (${Intl.NumberFormat(i18n.language, {
4287
style: "currency",
@@ -205,6 +250,11 @@ export const BookingFields = ({
205250
field={{ ...fieldWithPrice, hidden }}
206251
readOnly={readOnly}
207252
key={index}
253+
{...(field.name === SystemField.Enum.location && {
254+
onValueChange: ({ value }) => {
255+
syncPhoneFields(value);
256+
},
257+
})}
208258
/>
209259
);
210260
})}

packages/features/form-builder/FormBuilderField.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ const renderLabel = (field: Partial<RhfFormField>) => {
2525
if (field.labelAsSafeHtml) {
2626
return (
2727
<span
28-
// eslint-disable-next-line react/no-danger
2928
dangerouslySetInnerHTML={{ __html: markdownToSafeHTML(field.labelAsSafeHtml) }}
3029
/>
3130
);
@@ -66,10 +65,12 @@ export const FormBuilderField = ({
6665
field,
6766
readOnly,
6867
className,
68+
onValueChange,
6969
}: {
7070
field: RhfFormFields[number];
7171
readOnly: boolean;
7272
className: string;
73+
onValueChange?: (args: { name: string; value: unknown; prevValue: unknown }) => void;
7374
}) => {
7475
const { t } = useLocale();
7576
const { control, formState } = useFormContext();
@@ -87,15 +88,18 @@ export const FormBuilderField = ({
8788
// Make it a variable
8889
name={`responses.${field.name}`}
8990
render={({ field: { value, onChange }, fieldState: { error } }) => {
91+
const setAndNotify = (val: unknown) => {
92+
onChange(val);
93+
onValueChange?.({ name: field.name, value: val, prevValue: value });
94+
};
95+
9096
return (
9197
<div>
9298
<ComponentForField
9399
field={{ ...field, label, placeholder, hidden }}
94100
value={value}
95101
readOnly={readOnly || shouldBeDisabled}
96-
setValue={(val: unknown) => {
97-
onChange(val);
98-
}}
102+
setValue={setAndNotify}
99103
noLabel={noLabel}
100104
translatedDefaultLabel={translatedDefaultLabel}
101105
/>

0 commit comments

Comments
 (0)