From 9250b91bb0d5a66ccf2cf42311ac9999c79f6a84 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Tue, 24 Oct 2023 17:59:54 +0530 Subject: [PATCH] feat: remove location modal in event setup (#11796) Co-authored-by: Peer Richelsen --- .../components/eventtype/EventSetupTab.tsx | 503 ++++++++++-------- apps/web/components/ui/form/CheckboxField.tsx | 2 +- apps/web/pages/event-types/[type]/index.tsx | 23 + apps/web/playwright/event-types.e2e.ts | 126 ++++- apps/web/public/static/locales/en/common.json | 1 + 5 files changed, 408 insertions(+), 247 deletions(-) diff --git a/apps/web/components/eventtype/EventSetupTab.tsx b/apps/web/components/eventtype/EventSetupTab.tsx index a5a386a2d3..754f060868 100644 --- a/apps/web/components/eventtype/EventSetupTab.tsx +++ b/apps/web/components/eventtype/EventSetupTab.tsx @@ -1,27 +1,22 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { isValidPhoneNumber } from "libphonenumber-js"; +import { ErrorMessage } from "@hookform/error-message"; import { Trans } from "next-i18next"; import Link from "next/link"; import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]"; import { useEffect, useState } from "react"; -import { Controller, useForm, useFormContext } from "react-hook-form"; +import { Controller, useFormContext, useFieldArray } from "react-hook-form"; import type { MultiValue } from "react-select"; -import { z } from "zod"; import type { EventLocationType } from "@calcom/app-store/locations"; -import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations"; +import { getEventLocationType, LocationType, MeetLocationType } from "@calcom/app-store/locations"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; -import { classNames } from "@calcom/lib"; import { CAL_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import invertLogoOnDark from "@calcom/lib/invertLogoOnDark"; import { md } from "@calcom/lib/markdownIt"; import { slugify } from "@calcom/lib/slugify"; import turndown from "@calcom/lib/turndownService"; import { - Button, Label, Select, SettingsToggle, @@ -30,11 +25,16 @@ import { Editor, SkeletonContainer, SkeletonText, + Input, + PhoneInput, + Button, + showToast, } from "@calcom/ui"; -import { Edit2, Check, X, Plus } from "@calcom/ui/components/icon"; +import { Plus, X, Check } from "@calcom/ui/components/icon"; +import { CornerDownRight } from "@calcom/ui/components/icon"; -import { EditLocationDialog } from "@components/dialog/EditLocationDialog"; -import type { SingleValueLocationOption, LocationOption } from "@components/ui/form/LocationSelect"; +import CheckboxField from "@components/ui/form/CheckboxField"; +import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect"; import LocationSelect from "@components/ui/form/LocationSelect"; const getLocationFromType = ( @@ -114,9 +114,6 @@ export const EventSetupTab = ( const { t } = useLocale(); const formMethods = useFormContext(); const { eventType, team, destinationCalendar } = props; - const [showLocationModal, setShowLocationModal] = useState(false); - const [editingLocationType, setEditingLocationType] = useState(""); - const [selectedLocation, setSelectedLocation] = useState(undefined); const [multipleDuration, setMultipleDuration] = useState(eventType.metadata?.multipleDuration); const orgBranding = useOrgBranding(); const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled"); @@ -150,83 +147,6 @@ export const EventSetupTab = ( selectedMultipleDuration.find((opt) => opt.value === eventType.length) ?? null ); - const openLocationModal = (type: EventLocationType["type"], address = "") => { - const option = getLocationFromType(type, locationOptions); - if (option && option.value === LocationType.InPerson) { - const inPersonOption = { - ...option, - address, - }; - setSelectedLocation(inPersonOption); - } else { - setSelectedLocation(option); - } - setShowLocationModal(true); - }; - - const removeLocation = (selectedLocation: (typeof eventType.locations)[number]) => { - formMethods.setValue( - "locations", - formMethods.getValues("locations").filter((location) => { - if (location.type === LocationType.InPerson) { - return location.address !== selectedLocation.address; - } - return location.type !== selectedLocation.type; - }), - { shouldValidate: true } - ); - }; - - const saveLocation = (newLocationType: EventLocationType["type"], details = {}) => { - const locationType = editingLocationType !== "" ? editingLocationType : newLocationType; - const existingIdx = formMethods.getValues("locations").findIndex((loc) => locationType === loc.type); - if (existingIdx !== -1) { - const copy = formMethods.getValues("locations"); - if (editingLocationType !== "") { - copy[existingIdx] = { - ...details, - type: newLocationType, - }; - } - - formMethods.setValue("locations", [ - ...copy, - ...(newLocationType === LocationType.InPerson && editingLocationType === "" - ? [{ ...details, type: newLocationType }] - : []), - ]); - } else { - formMethods.setValue( - "locations", - formMethods.getValues("locations").concat({ type: newLocationType, ...details }) - ); - } - - setEditingLocationType(""); - setShowLocationModal(false); - }; - - const locationFormSchema = z.object({ - locationType: z.string(), - locationAddress: z.string().optional(), - displayLocationPublicly: z.boolean().optional(), - locationPhoneNumber: z - .string() - .refine((val) => isValidPhoneNumber(val)) - .optional(), - locationLink: z.string().url().optional(), // URL validates as new URL() - which requires HTTPS:// In the input field - }); - - const locationFormMethods = useForm<{ - locationType: EventLocationType["type"]; - locationPhoneNumber?: string; - locationAddress?: string; // TODO: We should validate address or fetch the address from googles api to see if its valid? - locationLink?: string; // Currently this only accepts links that are HTTPS:// - displayLocationPublicly?: boolean; - }>({ - resolver: zodResolver(locationFormSchema), - }); - const { isChildrenManagedEventType, isManagedEventType, shouldLockIndicator, shouldLockDisableProps } = useLockedFieldsManager( eventType, @@ -236,6 +156,15 @@ export const EventSetupTab = ( const Locations = () => { const { t } = useLocale(); + const { + fields: locationFields, + append, + remove, + update: updateLocationField, + } = useFieldArray({ + control: formMethods.control, + name: "locations", + }); const [animationRef] = useAutoAnimate(); @@ -254,131 +183,266 @@ export const EventSetupTab = ( const { locationDetails, locationAvailable } = getLocationInfo(props); + const LocationInput = (props: { + eventLocationType: EventLocationType; + defaultValue?: string; + index: number; + }) => { + const { eventLocationType, index, ...remainingProps } = props; + + if (eventLocationType?.organizerInputType === "text") { + const { defaultValue, ...rest } = remainingProps; + + return ( + { + return ( + <> + + + + ); + }} + /> + ); + } else if (eventLocationType?.organizerInputType === "phone") { + const { defaultValue, ...rest } = remainingProps; + + return ( + { + return ( + <> + + + + ); + }} + /> + ); + } + return null; + }; + + const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false); + const [selectedNewOption, setSelectedNewOption] = useState(null); + return (
- {validLocations.length === 0 && ( -
- { - if (e?.value) { - const newLocationType = e.value; - const eventLocationType = getEventLocationType(newLocationType); - if (!eventLocationType) { - return; - } - locationFormMethods.setValue("locationType", newLocationType); - if (eventLocationType.organizerInputType) { - openLocationModal(newLocationType); - } else { - saveLocation(newLocationType); - } +
    + {locationFields.map((field, index) => { + const eventLocationType = getEventLocationType(field.type); + const defaultLocation = formMethods + .getValues("locations") + ?.find((location: { type: EventLocationType["type"]; address?: string }) => { + if (location.type === LocationType.InPerson) { + return location.type === eventLocationType?.type && location.address === field?.address; + } else { + return location.type === eventLocationType?.type; } - }} - /> -
- )} - {validLocations.length > 0 && ( -
    - {validLocations.map((location, index) => { - const eventLocationType = getEventLocationType(location.type); - if (!eventLocationType) { - return null; - } + }); - const eventLabel = - location[eventLocationType.defaultValueVariable] || t(eventLocationType.label); - return ( -
  • -
    -
    - {`${eventLocationType.label} - {`${eventLabel} ${ - location.teamName ? `(${location.teamName})` : "" - }`} + const option = getLocationFromType(field.type, locationOptions); + + return ( +
  • +
    + { + if (e?.value) { + const newLocationType = e.value; + const eventLocationType = getEventLocationType(newLocationType); + if (!eventLocationType) { + return; + } + const canAddLocation = + eventLocationType.organizerInputType || + !validLocations.find((location) => location.type === newLocationType); + + if (canAddLocation) { + updateLocationField(index, { type: newLocationType }); + } else { + updateLocationField(index, { type: field.type }); + showToast(t("location_already_exists"), "warning"); + } + } + }} + /> + +
    + + {eventLocationType?.organizerInputType && ( +
    +
    +
    + +
    +
    + +
    +
    +
    + { + const fieldValues = formMethods.getValues().locations[index]; + updateLocationField(index, { + ...fieldValues, + displayLocationPublicly: e.target.checked, + }); }} - aria-label={t("edit")} - className="hover:text-emphasis text-subtle mr-1 p-1"> - - - + informationIconText={t("display_location_info_badge")} + />
    -
  • - ); - })} - {validLocations.some( - (location) => - location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar" - ) && ( -
    - - -

    - The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work. - Change it{" "} - - here. - {" "} -

    -
    -
    - )} - {isChildrenManagedEventType && !locationAvailable && locationDetails && ( -

    - {t("app_not_connected", { appName: locationDetails.name })}{" "} - - {t("connect_now")} - -

    - )} - {validLocations.length > 0 && !isManagedEventType && !isChildrenManagedEventType && ( -
  • - + )}
  • - )} -
- )} + ); + })} + {(validLocations.length === 0 || showEmptyLocationSelect) && ( +
+ { + if (e?.value) { + const newLocationType = e.value; + const eventLocationType = getEventLocationType(newLocationType); + if (!eventLocationType) { + return; + } + + const canAppendLocation = + eventLocationType.organizerInputType || + !validLocations.find((location) => location.type === newLocationType); + + if (canAppendLocation) { + append({ type: newLocationType }); + setSelectedNewOption(e); + } else { + showToast(t("location_already_exists"), "warning"); + setSelectedNewOption(null); + } + } + }} + /> +
+ )} + {validLocations.some( + (location) => + location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar" + ) && ( +
+
+ +
+ +

+ The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work. + Change it{" "} + + here. + {" "} +

+
+
+ )} + {isChildrenManagedEventType && !locationAvailable && locationDetails && ( +

+ {t("app_not_connected", { appName: locationDetails.name })}{" "} + + {t("connect_now")} + +

+ )} + {validLocations.length > 0 && !isManagedEventType && !isChildrenManagedEventType && ( +
  • + +
  • + )} + +

    + + Can't find the right video app? Visit our + + App Store + + . + +

    ); }; @@ -542,33 +606,6 @@ export const EventSetupTab = ( /> - - {/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */} - ); diff --git a/apps/web/components/ui/form/CheckboxField.tsx b/apps/web/components/ui/form/CheckboxField.tsx index 222cbd7731..8298fbb5b5 100644 --- a/apps/web/components/ui/form/CheckboxField.tsx +++ b/apps/web/components/ui/form/CheckboxField.tsx @@ -52,7 +52,7 @@ const CheckboxField = forwardRef( className="text-primary-600 focus:ring-primary-500 border-default bg-default h-4 w-4 rounded" /> - {description} + {description} )} {informationIconText && } diff --git a/apps/web/pages/event-types/[type]/index.tsx b/apps/web/pages/event-types/[type]/index.tsx index 1c0df98e0a..3c944f399e 100644 --- a/apps/web/pages/event-types/[type]/index.tsx +++ b/apps/web/pages/event-types/[type]/index.tsx @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-empty-function */ import { useAutoAnimate } from "@formkit/auto-animate/react"; import { zodResolver } from "@hookform/resolvers/zod"; +import { isValidPhoneNumber } from "libphonenumber-js"; import type { GetServerSidePropsContext } from "next"; import dynamic from "next/dynamic"; import { useEffect, useMemo, useState } from "react"; @@ -299,6 +300,28 @@ const EventTypePage = (props: EventTypeSetupProps) => { length: z.union([z.string().transform((val) => +val), z.number()]).optional(), offsetStart: z.union([z.string().transform((val) => +val), z.number()]).optional(), bookingFields: eventTypeBookingFields, + locations: z + .array( + z + .object({ + type: z.string(), + address: z.string().optional(), + link: z.string().url().optional(), + phone: z + .string() + .refine((val) => isValidPhoneNumber(val)) + .optional(), + hostPhoneNumber: z + .string() + .refine((val) => isValidPhoneNumber(val)) + .optional(), + displayLocationPublicly: z.boolean().optional(), + credentialId: z.number().optional(), + teamName: z.string().optional(), + }) + .passthrough() + ) + .optional(), }) // TODO: Add schema for other fields later. .passthrough() diff --git a/apps/web/playwright/event-types.e2e.ts b/apps/web/playwright/event-types.e2e.ts index a5af946dda..70e7e18b88 100644 --- a/apps/web/playwright/event-types.e2e.ts +++ b/apps/web/playwright/event-types.e2e.ts @@ -115,23 +115,13 @@ test.describe("Event Types tests", () => { const locationData = ["location 1", "location 2", "location 3"]; - const fillLocation = async (inputText: string) => { - await page.locator("#location-select").click(); - await page.locator("text=In Person (Organizer Address)").click(); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(1000); - await page.locator('input[name="locationAddress"]').fill(inputText); - await page.locator("[data-testid=display-location]").check(); - await page.locator("[data-testid=update-location]").click(); - }; - - await fillLocation(locationData[0]); + await fillLocation(page, locationData[0], 0); await page.locator("[data-testid=add-location]").click(); - await fillLocation(locationData[1]); + await fillLocation(page, locationData[1], 1); await page.locator("[data-testid=add-location]").click(); - await fillLocation(locationData[2]); + await fillLocation(page, locationData[2], 2); await page.locator("[data-testid=update-eventtype]").click(); @@ -177,6 +167,93 @@ test.describe("Event Types tests", () => { await expect(page.locator("[data-testid=success-page]")).toBeVisible(); await expect(page.locator("text=+19199999999")).toBeVisible(); }); + + test("Can add Organzer Phone Number location and book with it", async ({ page }) => { + await gotoFirstEventType(page); + + await page.locator("#location-select").click(); + await page.locator(`text="Organizer Phone Number"`).click(); + const locationInputName = "locations[0].hostPhoneNumber"; + await page.locator(`input[name="${locationInputName}"]`).waitFor(); + await page.locator(`input[name="${locationInputName}"]`).fill("9199999999"); + + await saveEventType(page); + await gotoBookingPage(page); + await selectFirstAvailableTimeSlotNextMonth(page); + + await bookTimeSlot(page); + + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + await expect(page.locator("text=+19199999999")).toBeVisible(); + }); + + test("Can add Cal video location and book with it", async ({ page }) => { + await gotoFirstEventType(page); + + await page.locator("#location-select").click(); + await page.locator(`text="Cal Video (Global)"`).click(); + + await saveEventType(page); + await page.getByTestId("toast-success").waitFor(); + await gotoBookingPage(page); + await selectFirstAvailableTimeSlotNextMonth(page); + + await bookTimeSlot(page); + + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + await expect(page.locator("[data-testid=where] ")).toContainText("Cal Video"); + }); + + test("Can add Link Meeting as location and book with it", async ({ page }) => { + await gotoFirstEventType(page); + + await page.locator("#location-select").click(); + await page.locator(`text="Link meeting"`).click(); + + const locationInputName = `locations[0].link`; + + const testUrl = "https://cal.ai/"; + await page.locator(`input[name="${locationInputName}"]`).fill(testUrl); + + await saveEventType(page); + await page.getByTestId("toast-success").waitFor(); + await gotoBookingPage(page); + await selectFirstAvailableTimeSlotNextMonth(page); + + await bookTimeSlot(page); + + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + const linkElement = await page.locator("[data-testid=where] > a"); + expect(await linkElement.getAttribute("href")).toBe(testUrl); + }); + + test("Can remove location from multiple locations that are saved", async ({ page }) => { + await gotoFirstEventType(page); + + // Add Attendee Phone Number location + await selectAttendeePhoneNumber(page); + + // Add Cal Video location + await addAnotherLocation(page, "Cal Video (Global)"); + + await saveEventType(page); + await page.waitForLoadState("networkidle"); + + // Remove Attendee Phone Number Location + const removeButtomId = "delete-locations.0.type"; + await page.getByTestId(removeButtomId).click(); + + await saveEventType(page); + await page.waitForLoadState("networkidle"); + + await gotoBookingPage(page); + await selectFirstAvailableTimeSlotNextMonth(page); + + await bookTimeSlot(page); + + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + await expect(page.locator("[data-testid=where]")).toHaveText(/Cal Video/); + }); }); }); }); @@ -205,3 +282,26 @@ async function gotoBookingPage(page: Page) { await page.goto(previewLink ?? ""); } + +/** + * Adds n+1 location to the event type + */ +async function addAnotherLocation(page: Page, locationOptionText: string) { + await page.locator("[data-testid=add-location]").click(); + // When adding another location, the dropdown opens automatically. So, we don't need to open it here. + // + await page.locator(`text="${locationOptionText}"`).click(); +} + +const fillLocation = async (page: Page, inputText: string, index: number) => { + // Except the first location, dropdown automatically opens when adding another location + if (index == 0) { + await page.locator("#location-select").last().click(); + } + await page.locator("text=In Person (Organizer Address)").last().click(); + + const locationInputName = `locations[${index}].address`; + await page.locator(`input[name="${locationInputName}"]`).waitFor(); + await page.locator(`input[name="locations[${index}].address"]`).fill(inputText); + await page.locator("[data-testid=display-location]").last().check(); +}; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 7a1b81c44c..7a78643903 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1605,6 +1605,7 @@ "options": "Options", "enter_option": "Enter Option {{index}}", "add_an_option": "Add an option", + "location_already_exists": "This Location already exists. Please select a new location", "radio": "Radio", "google_meet_warning": "In order to use Google Meet you must set your destination calendar to a Google Calendar", "individual": "Individual",