feat: remove location modal in event setup (#11796)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>teste2e-createManagedBooking^2
parent
b934c74c30
commit
9250b91bb0
|
@ -1,27 +1,22 @@
|
||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { ErrorMessage } from "@hookform/error-message";
|
||||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
|
||||||
import { Trans } from "next-i18next";
|
import { Trans } from "next-i18next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||||
import { useEffect, useState } from "react";
|
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 type { MultiValue } from "react-select";
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
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 useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||||
import { classNames } from "@calcom/lib";
|
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import invertLogoOnDark from "@calcom/lib/invertLogoOnDark";
|
|
||||||
import { md } from "@calcom/lib/markdownIt";
|
import { md } from "@calcom/lib/markdownIt";
|
||||||
import { slugify } from "@calcom/lib/slugify";
|
import { slugify } from "@calcom/lib/slugify";
|
||||||
import turndown from "@calcom/lib/turndownService";
|
import turndown from "@calcom/lib/turndownService";
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
Label,
|
Label,
|
||||||
Select,
|
Select,
|
||||||
SettingsToggle,
|
SettingsToggle,
|
||||||
|
@ -30,11 +25,16 @@ import {
|
||||||
Editor,
|
Editor,
|
||||||
SkeletonContainer,
|
SkeletonContainer,
|
||||||
SkeletonText,
|
SkeletonText,
|
||||||
|
Input,
|
||||||
|
PhoneInput,
|
||||||
|
Button,
|
||||||
|
showToast,
|
||||||
} from "@calcom/ui";
|
} 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 CheckboxField from "@components/ui/form/CheckboxField";
|
||||||
import type { SingleValueLocationOption, LocationOption } from "@components/ui/form/LocationSelect";
|
import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect";
|
||||||
import LocationSelect from "@components/ui/form/LocationSelect";
|
import LocationSelect from "@components/ui/form/LocationSelect";
|
||||||
|
|
||||||
const getLocationFromType = (
|
const getLocationFromType = (
|
||||||
|
@ -114,9 +114,6 @@ export const EventSetupTab = (
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const formMethods = useFormContext<FormValues>();
|
const formMethods = useFormContext<FormValues>();
|
||||||
const { eventType, team, destinationCalendar } = props;
|
const { eventType, team, destinationCalendar } = props;
|
||||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
|
||||||
const [editingLocationType, setEditingLocationType] = useState<string>("");
|
|
||||||
const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined);
|
|
||||||
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata?.multipleDuration);
|
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata?.multipleDuration);
|
||||||
const orgBranding = useOrgBranding();
|
const orgBranding = useOrgBranding();
|
||||||
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||||
|
@ -150,83 +147,6 @@ export const EventSetupTab = (
|
||||||
selectedMultipleDuration.find((opt) => opt.value === eventType.length) ?? null
|
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 } =
|
const { isChildrenManagedEventType, isManagedEventType, shouldLockIndicator, shouldLockDisableProps } =
|
||||||
useLockedFieldsManager(
|
useLockedFieldsManager(
|
||||||
eventType,
|
eventType,
|
||||||
|
@ -236,6 +156,15 @@ export const EventSetupTab = (
|
||||||
|
|
||||||
const Locations = () => {
|
const Locations = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
const {
|
||||||
|
fields: locationFields,
|
||||||
|
append,
|
||||||
|
remove,
|
||||||
|
update: updateLocationField,
|
||||||
|
} = useFieldArray({
|
||||||
|
control: formMethods.control,
|
||||||
|
name: "locations",
|
||||||
|
});
|
||||||
|
|
||||||
const [animationRef] = useAutoAnimate<HTMLUListElement>();
|
const [animationRef] = useAutoAnimate<HTMLUListElement>();
|
||||||
|
|
||||||
|
@ -254,14 +183,188 @@ export const EventSetupTab = (
|
||||||
|
|
||||||
const { locationDetails, locationAvailable } = getLocationInfo(props);
|
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 (
|
||||||
|
<Controller
|
||||||
|
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||||
|
control={formMethods.control}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
render={({ field: { onChange, value } }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
onChange={onChange}
|
||||||
|
value={value}
|
||||||
|
className="my-0"
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
<ErrorMessage
|
||||||
|
errors={formMethods.formState.errors.locations?.[index]}
|
||||||
|
name={eventLocationType.defaultValueVariable}
|
||||||
|
className="text-error my-1 text-sm"
|
||||||
|
as="div"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (eventLocationType?.organizerInputType === "phone") {
|
||||||
|
const { defaultValue, ...rest } = remainingProps;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||||
|
control={formMethods.control}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
render={({ field: { onChange, value } }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PhoneInput
|
||||||
|
required
|
||||||
|
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
<ErrorMessage
|
||||||
|
errors={formMethods.formState.errors.locations?.[index]}
|
||||||
|
name={eventLocationType.defaultValueVariable}
|
||||||
|
className="text-error my-1 text-sm"
|
||||||
|
as="div"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false);
|
||||||
|
const [selectedNewOption, setSelectedNewOption] = useState<SingleValueLocationOption | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{validLocations.length === 0 && (
|
<ul ref={animationRef} className="space-y-2">
|
||||||
<div className="flex">
|
{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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const option = getLocationFromType(field.type, locationOptions);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={field.id}>
|
||||||
|
<div className="flex w-full items-center">
|
||||||
<LocationSelect
|
<LocationSelect
|
||||||
|
name={`locations[${index}].type`}
|
||||||
placeholder={t("select")}
|
placeholder={t("select")}
|
||||||
options={locationOptions}
|
options={locationOptions}
|
||||||
isDisabled={shouldLockDisableProps("locations").disabled}
|
isDisabled={shouldLockDisableProps("locations").disabled}
|
||||||
|
defaultValue={option}
|
||||||
|
isSearchable={false}
|
||||||
|
className="block min-w-0 flex-1 rounded-sm text-sm"
|
||||||
|
menuPlacement="auto"
|
||||||
|
onChange={(e: SingleValueLocationOption) => {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
data-testid={`delete-locations.${index}.type`}
|
||||||
|
className="min-h-9 block h-9 px-2"
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
aria-label={t("remove")}>
|
||||||
|
<div className="h-4 w-4">
|
||||||
|
<X className="border-l-1 hover:text-emphasis text-subtle h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{eventLocationType?.organizerInputType && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<CornerDownRight className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<LocationInput
|
||||||
|
defaultValue={
|
||||||
|
defaultLocation
|
||||||
|
? defaultLocation[eventLocationType.defaultValueVariable]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
eventLocationType={eventLocationType}
|
||||||
|
index={index}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-6">
|
||||||
|
<CheckboxField
|
||||||
|
data-testid="display-location"
|
||||||
|
defaultChecked={defaultLocation?.displayLocationPublicly}
|
||||||
|
description={t("display_location_label")}
|
||||||
|
onChange={(e) => {
|
||||||
|
const fieldValues = formMethods.getValues().locations[index];
|
||||||
|
updateLocationField(index, {
|
||||||
|
...fieldValues,
|
||||||
|
displayLocationPublicly: e.target.checked,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
informationIconText={t("display_location_info_badge")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(validLocations.length === 0 || showEmptyLocationSelect) && (
|
||||||
|
<div className="flex">
|
||||||
|
<LocationSelect
|
||||||
|
defaultMenuIsOpen={showEmptyLocationSelect}
|
||||||
|
autoFocus
|
||||||
|
placeholder={t("select")}
|
||||||
|
options={locationOptions}
|
||||||
|
value={selectedNewOption}
|
||||||
|
isDisabled={shouldLockDisableProps("locations").disabled}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||||
|
@ -273,78 +376,31 @@ export const EventSetupTab = (
|
||||||
if (!eventLocationType) {
|
if (!eventLocationType) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
locationFormMethods.setValue("locationType", newLocationType);
|
|
||||||
if (eventLocationType.organizerInputType) {
|
|
||||||
openLocationModal(newLocationType);
|
|
||||||
} else {
|
|
||||||
saveLocation(newLocationType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{validLocations.length > 0 && (
|
|
||||||
<ul ref={animationRef}>
|
|
||||||
{validLocations.map((location, index) => {
|
|
||||||
const eventLocationType = getEventLocationType(location.type);
|
|
||||||
if (!eventLocationType) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventLabel =
|
const canAppendLocation =
|
||||||
location[eventLocationType.defaultValueVariable] || t(eventLocationType.label);
|
eventLocationType.organizerInputType ||
|
||||||
return (
|
!validLocations.find((location) => location.type === newLocationType);
|
||||||
<li
|
|
||||||
key={`${location.type}${index}`}
|
if (canAppendLocation) {
|
||||||
className="border-default text-default mb-2 h-9 rounded-md border px-2 py-1.5 hover:cursor-pointer">
|
append({ type: newLocationType });
|
||||||
<div className="flex items-center justify-between">
|
setSelectedNewOption(e);
|
||||||
<div className="flex items-center">
|
|
||||||
<img
|
|
||||||
src={eventLocationType.iconUrl}
|
|
||||||
className={classNames(
|
|
||||||
"h-4 w-4",
|
|
||||||
classNames(invertLogoOnDark(eventLocationType.iconUrl))
|
|
||||||
)}
|
|
||||||
alt={`${eventLocationType.label} logo`}
|
|
||||||
/>
|
|
||||||
<span className="ms-1 line-clamp-1 text-sm">{`${eventLabel} ${
|
|
||||||
location.teamName ? `(${location.teamName})` : ""
|
|
||||||
}`}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
locationFormMethods.setValue("locationType", location.type);
|
|
||||||
locationFormMethods.unregister("locationLink");
|
|
||||||
if (location.type === LocationType.InPerson) {
|
|
||||||
locationFormMethods.setValue("locationAddress", location.address);
|
|
||||||
} else {
|
} else {
|
||||||
locationFormMethods.unregister("locationAddress");
|
showToast(t("location_already_exists"), "warning");
|
||||||
|
setSelectedNewOption(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
locationFormMethods.unregister("locationPhoneNumber");
|
|
||||||
setEditingLocationType(location.type);
|
|
||||||
openLocationModal(location.type, location.address);
|
|
||||||
}}
|
}}
|
||||||
aria-label={t("edit")}
|
/>
|
||||||
className="hover:text-emphasis text-subtle mr-1 p-1">
|
|
||||||
<Edit2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => removeLocation(location)} aria-label={t("remove")}>
|
|
||||||
<X className="border-l-1 hover:text-emphasis text-subtle h-6 w-6 pl-1 " />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{validLocations.some(
|
{validLocations.some(
|
||||||
(location) =>
|
(location) =>
|
||||||
location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar"
|
location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar"
|
||||||
) && (
|
) && (
|
||||||
<div className="text-default flex text-sm">
|
<div className="text-default flex items-center text-sm">
|
||||||
<Check className="mr-1.5 mt-0.5 h-2 w-2.5" />
|
<div className="mr-1.5 h-3 w-3">
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
<Trans i18nKey="event_type_requres_google_cal">
|
<Trans i18nKey="event_type_requres_google_cal">
|
||||||
<p>
|
<p>
|
||||||
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
|
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
|
||||||
|
@ -372,13 +428,21 @@ export const EventSetupTab = (
|
||||||
data-testid="add-location"
|
data-testid="add-location"
|
||||||
StartIcon={Plus}
|
StartIcon={Plus}
|
||||||
color="minimal"
|
color="minimal"
|
||||||
onClick={() => setShowLocationModal(true)}>
|
onClick={() => setShowEmptyLocationSelect(true)}>
|
||||||
{t("add_location")}
|
{t("add_location")}
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
<p className="text-default mt-2 text-sm">
|
||||||
|
<Trans i18nKey="cant_find_the_right_video_app_visit_our_app_store">
|
||||||
|
Can't find the right video app? Visit our
|
||||||
|
<Link className="cursor-pointer text-blue-500 underline" href="/apps/categories/video">
|
||||||
|
App Store
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -542,33 +606,6 @@ export const EventSetupTab = (
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
|
|
||||||
<EditLocationDialog
|
|
||||||
isOpenDialog={showLocationModal}
|
|
||||||
setShowLocationModal={setShowLocationModal}
|
|
||||||
saveLocation={saveLocation}
|
|
||||||
defaultValues={formMethods.getValues("locations")}
|
|
||||||
selection={
|
|
||||||
selectedLocation
|
|
||||||
? selectedLocation.address
|
|
||||||
? {
|
|
||||||
value: selectedLocation.value,
|
|
||||||
label: t(selectedLocation.label),
|
|
||||||
icon: selectedLocation.icon,
|
|
||||||
address: selectedLocation.address,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
value: selectedLocation.value,
|
|
||||||
label: t(selectedLocation.label),
|
|
||||||
icon: selectedLocation.icon,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
setSelectedLocation={setSelectedLocation}
|
|
||||||
setEditingLocationType={setEditingLocationType}
|
|
||||||
teamId={eventType.team?.id}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -52,7 +52,7 @@ const CheckboxField = forwardRef<HTMLInputElement, Props>(
|
||||||
className="text-primary-600 focus:ring-primary-500 border-default bg-default h-4 w-4 rounded"
|
className="text-primary-600 focus:ring-primary-500 border-default bg-default h-4 w-4 rounded"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="ms-3 text-sm">{description}</span>
|
<span className="ms-2 text-sm">{description}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{informationIconText && <InfoBadge content={informationIconText} />}
|
{informationIconText && <InfoBadge content={informationIconText} />}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
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(),
|
length: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
||||||
offsetStart: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
offsetStart: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
||||||
bookingFields: eventTypeBookingFields,
|
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.
|
// TODO: Add schema for other fields later.
|
||||||
.passthrough()
|
.passthrough()
|
||||||
|
|
|
@ -115,23 +115,13 @@ test.describe("Event Types tests", () => {
|
||||||
|
|
||||||
const locationData = ["location 1", "location 2", "location 3"];
|
const locationData = ["location 1", "location 2", "location 3"];
|
||||||
|
|
||||||
const fillLocation = async (inputText: string) => {
|
await fillLocation(page, locationData[0], 0);
|
||||||
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 page.locator("[data-testid=add-location]").click();
|
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 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();
|
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("[data-testid=success-page]")).toBeVisible();
|
||||||
await expect(page.locator("text=+19199999999")).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 ?? "");
|
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();
|
||||||
|
};
|
||||||
|
|
|
@ -1605,6 +1605,7 @@
|
||||||
"options": "Options",
|
"options": "Options",
|
||||||
"enter_option": "Enter Option {{index}}",
|
"enter_option": "Enter Option {{index}}",
|
||||||
"add_an_option": "Add an option",
|
"add_an_option": "Add an option",
|
||||||
|
"location_already_exists": "This Location already exists. Please select a new location",
|
||||||
"radio": "Radio",
|
"radio": "Radio",
|
||||||
"google_meet_warning": "In order to use Google Meet you must set your destination calendar to a Google Calendar",
|
"google_meet_warning": "In order to use Google Meet you must set your destination calendar to a Google Calendar",
|
||||||
"individual": "Individual",
|
"individual": "Individual",
|
||||||
|
|
Loading…
Reference in New Issue