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:
co-authored by
Eunjae Lee
Volnei Munhoz
Volnei Munhoz
parent
3e7a848769
commit
a7cb71726a
@@ -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
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user