chore: reorganize event type files (#16393)

* chore: reorganize event type files

* fixup! chore: reorganize event type files

* fixup! Merge branch 'main' into refactor-event-types-components-folder
This commit is contained in:
Morgan authored and GitHub committed 2024-08-30 10:41:37 +00:00
1 parent f3462776e8
commit 40fee7af51
43 files changed
+1048 -963

No files matched your search

@@ -9,6 +9,7 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import type { LocationObject } from "@calcom/core/location";
import { locationsResolver } from "@calcom/lib/event-types/utils/locationsResolver";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AppCategories } from "@calcom/prisma/enums";
import type { EventTypeMetaDataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
@@ -17,8 +18,6 @@ import { Button, Form, Icon } from "@calcom/ui";
import EventTypeAppSettingsWrapper from "@components/apps/installation/EventTypeAppSettingsWrapper";
import EventTypeConferencingAppSettings from "@components/apps/installation/EventTypeConferencingAppSettings";
import { locationsResolver } from "~/event-types/views/event-types-single-view";
export type TFormType = {
id: number;
metadata: z.infer<typeof EventTypeMetaDataSchema>;
@@ -3,8 +3,7 @@ import { useEffect, type FC } from "react";
import { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface";
import type { EventTypeAppsList } from "@calcom/app-store/utils";
import useAppsData from "@lib/hooks/useAppsData";
import useAppsData from "@calcom/lib/hooks/useAppsData";
import type { ConfigureStepCardProps } from "@components/apps/installation/ConfigureStepCard";
@@ -3,7 +3,11 @@ import { useMemo } from "react";
import { useFormContext } from "react-hook-form";
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
import type { TLocationOptions } from "@calcom/features/eventtypes/components/Locations";
import type { TEventTypeLocation } from "@calcom/features/eventtypes/components/Locations";
import Locations from "@calcom/features/eventtypes/components/Locations";
import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types";
import type { SingleValueLocationOption } from "@calcom/features/form/components/LocationSelect";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/client";
import { trpc } from "@calcom/trpc/react";
@@ -13,10 +17,6 @@ import { Skeleton, Label } from "@calcom/ui";
import { QueryCell } from "@lib/QueryCell";
import type { TFormType } from "@components/apps/installation/ConfigureStepCard";
import type { TLocationOptions } from "@components/eventtype/Locations";
import type { TEventTypeLocation } from "@components/eventtype/Locations";
import Locations from "@components/eventtype/Locations";
import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect";
const LocationsWrapper = ({
eventType,
@@ -15,6 +15,9 @@ import {
LocationType,
OrganizerDefaultConferencingAppType,
} from "@calcom/app-store/locations";
import CheckboxField from "@calcom/features/form/components/CheckboxField";
import type { LocationOption } from "@calcom/features/form/components/LocationSelect";
import LocationSelect from "@calcom/features/form/components/LocationSelect";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
@@ -22,10 +25,6 @@ import { Button, Icon, Input, Dialog, DialogContent, DialogFooter, Form, PhoneIn
import { QueryCell } from "@lib/QueryCell";
import CheckboxField from "@components/ui/form/CheckboxField";
import type { LocationOption } from "@components/ui/form/LocationSelect";
import LocationSelect from "@components/ui/form/LocationSelect";
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
interface ISetLocationDialog {
@@ -1 +0,0 @@
export { default } from "@calcom/features/ee/workflows/components/EventWorkflowsTab";
@@ -1,12 +1,11 @@
import React from "react";
import type { Props } from "react-select";
import Select from "@calcom/features/form/components/Select";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar } from "@calcom/ui";
import { Icon } from "@calcom/ui";
import Select from "@components/ui/form/Select";
type CheckedSelectOption = {
avatar: string;
label: string;
@@ -14,6 +14,9 @@ import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/emb
import { EventTypeDescription } from "@calcom/features/eventtypes/components";
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
import SkeletonLoader, {
InfiniteSkeletonLoader,
} from "@calcom/features/eventtypes/components/SkeletonLoader";
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
import Shell from "@calcom/features/shell/Shell";
@@ -66,8 +69,6 @@ import type { AppProps } from "@lib/app-providers";
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
import useMeQuery from "@lib/hooks/useMeQuery";
import SkeletonLoader, { InfiniteSkeletonLoader } from "@components/eventtype/SkeletonLoader";
type GetUserEventGroupsResponse = RouterOutputs["viewer"]["eventTypes"]["getUserEventGroups"];
type GetEventTypesFromGroupsResponse = RouterOutputs["viewer"]["eventTypes"]["getEventTypesFromGroup"];
@@ -1,835 +1,16 @@
"use client";
/* 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 { TFunction } from "next-i18next";
import dynamic from "next/dynamic";
// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router
import { useRouter } from "next/router";
import { useEffect, useMemo, useState, useRef } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { EventType } from "@calcom/features/eventtypes/components/EventType";
import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types";
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
import { getEventLocationType } from "@calcom/app-store/locations";
import { validateCustomEventName } from "@calcom/core/event";
import {
DEFAULT_PROMPT_VALUE,
DEFAULT_BEGIN_MESSAGE,
} from "@calcom/features/ee/cal-ai-phone/promptTemplates";
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import { sortHosts } from "@calcom/features/eventtypes/components/HostEditDialogs";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import { validateIntervalLimitOrder } from "@calcom/lib";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { HttpError } from "@calcom/lib/http-error";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import type { Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
/* eslint-disable @typescript-eslint/no-empty-function */
// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router
import { trpc } from "@calcom/trpc/react";
import { Form, showToast } from "@calcom/ui";
import type { AppProps } from "@lib/app-providers";
import { checkForEmptyAssignment } from "@lib/checkForEmptyAssignment";
import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayout";
import { type PageProps } from "~/event-types/views/event-types-single-view.getServerSideProps";
// These can't really be moved into calcom/ui due to the fact they use infered getserverside props typings;
const EventSetupTab = dynamic(() =>
import("@components/eventtype/EventSetupTab").then((mod) => mod.EventSetupTab)
);
const EventAvailabilityTab = dynamic(() =>
import("@components/eventtype/EventAvailabilityTab").then((mod) => mod.EventAvailabilityTab)
);
const EventTeamTab = dynamic(() =>
import("@components/eventtype/EventTeamTab").then((mod) => mod.EventTeamTab)
);
const EventLimitsTab = dynamic(() =>
import("@components/eventtype/EventLimitsTab").then((mod) => mod.EventLimitsTab)
);
const EventAdvancedTab = dynamic(() =>
import("@components/eventtype/EventAdvancedTab").then((mod) => mod.EventAdvancedTab)
);
const EventInstantTab = dynamic(() =>
import("@components/eventtype/EventInstantTab").then((mod) => mod.EventInstantTab)
);
const EventRecurringTab = dynamic(() =>
import("@components/eventtype/EventRecurringTab").then((mod) => mod.EventRecurringTab)
);
const EventAppsTab = dynamic(() =>
import("@components/eventtype/EventAppsTab").then((mod) => mod.EventAppsTab)
);
const EventWorkflowsTab = dynamic(() => import("@components/eventtype/EventWorkfowsTab"));
const EventWebhooksTab = dynamic(() =>
import("@components/eventtype/EventWebhooksTab").then((mod) => mod.EventWebhooksTab)
);
const EventAITab = dynamic(() => import("@components/eventtype/EventAITab").then((mod) => mod.EventAITab));
const ManagedEventTypeDialog = dynamic(() => import("@components/eventtype/ManagedEventDialog"));
const AssignmentWarningDialog = dynamic(() => import("@components/eventtype/AssignmentWarningDialog"));
export type Host = {
isFixed: boolean;
userId: number;
priority: number;
weight: number;
weightAdjustment: number;
};
export type CustomInputParsed = typeof customInputSchema._output;
const querySchema = z.object({
tabName: z
.enum([
"setup",
"availability",
"apps",
"limits",
"instant",
"recurring",
"team",
"advanced",
"workflows",
"webhooks",
"ai",
])
.optional()
.default("setup"),
});
export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"];
export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
export type EventTypeAssignedUsers = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["children"];
export type EventTypeHosts = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["hosts"];
export const locationsResolver = (t: TFunction) => {
return 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()
.superRefine((val, ctx) => {
if (val?.link) {
const link = val.link;
const eventLocationType = getEventLocationType(val.type);
if (
eventLocationType &&
!eventLocationType.default &&
eventLocationType.linkType === "static" &&
eventLocationType.urlRegExp
) {
const valid = z.string().regex(new RegExp(eventLocationType.urlRegExp)).safeParse(link).success;
if (!valid) {
const sampleUrl = eventLocationType.organizerInputPlaceholder;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [eventLocationType?.defaultValueVariable ?? "link"],
message: t("invalid_url_error_message", {
label: eventLocationType.label,
sampleUrl: sampleUrl ?? "https://cal.com",
}),
});
}
return;
}
const valid = z.string().url().optional().safeParse(link).success;
if (!valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [eventLocationType?.defaultValueVariable ?? "link"],
message: `Invalid URL`,
});
}
}
return;
})
)
.optional();
};
const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workflow[] }) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const telemetry = useTelemetry();
const {
data: { tabName },
} = useTypedQuery(querySchema);
const { data: eventTypeApps } = trpc.viewer.integrations.useQuery({
extendsFeature: "EventType",
teamId: props.eventType.team?.id || props.eventType.parent?.teamId,
onlyInstalled: true,
});
const { eventType, locationOptions, team, teamMembers, currentUserMembership, destinationCalendar } = props;
const [isOpenAssignmentWarnDialog, setIsOpenAssignmentWarnDialog] = useState<boolean>(false);
const [pendingRoute, setPendingRoute] = useState("");
const leaveWithoutAssigningHosts = useRef(false);
const isTeamEventTypeDeleted = useRef(false);
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
onSuccess: async () => {
const currentValues = formMethods.getValues();
currentValues.children = currentValues.children.map((child) => ({
...child,
created: true,
}));
currentValues.assignAllTeamMembers = currentValues.assignAllTeamMembers || false;
// Reset the form with these values as new default values to ensure the correct comparison for dirtyFields eval
formMethods.reset(currentValues);
showToast(t("event_type_updated_successfully", { eventTypeTitle: eventType.title }), "success");
},
async onSettled() {
await utils.viewer.eventTypes.get.invalidate();
},
onError: (err) => {
let message = "";
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
message = `${err.data.code}: ${t("error_event_type_unauthorized_update")}`;
}
if (err.data?.code === "PARSE_ERROR" || err.data?.code === "BAD_REQUEST") {
message = `${err.data.code}: ${t(err.message)}`;
}
if (err.data?.code === "INTERNAL_SERVER_ERROR") {
message = t("unexpected_error_try_again");
}
showToast(message ? t(message) : t(err.message), "error");
},
});
const router = useRouter();
const [periodDates] = useState<{ startDate: Date; endDate: Date }>({
startDate: new Date(eventType.periodStartDate || Date.now()),
endDate: new Date(eventType.periodEndDate || Date.now()),
});
const bookingFields: Prisma.JsonObject = {};
eventType.bookingFields.forEach(({ name }) => {
bookingFields[name] = name;
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const defaultValues: any = useMemo(() => {
return {
title: eventType.title,
id: eventType.id,
slug: eventType.slug,
afterEventBuffer: eventType.afterEventBuffer,
beforeEventBuffer: eventType.beforeEventBuffer,
eventName: eventType.eventName || "",
scheduleName: eventType.scheduleName,
periodDays: eventType.periodDays,
requiresBookerEmailVerification: eventType.requiresBookerEmailVerification,
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
seatsShowAttendees: eventType.seatsShowAttendees,
seatsShowAvailabilityCount: eventType.seatsShowAvailabilityCount,
lockTimeZoneToggleOnBookingPage: eventType.lockTimeZoneToggleOnBookingPage,
locations: eventType.locations || [],
destinationCalendar: eventType.destinationCalendar,
recurringEvent: eventType.recurringEvent || null,
isInstantEvent: eventType.isInstantEvent,
instantMeetingExpiryTimeOffsetInSeconds: eventType.instantMeetingExpiryTimeOffsetInSeconds,
description: eventType.description ?? undefined,
schedule: eventType.schedule || undefined,
bookingLimits: eventType.bookingLimits || undefined,
onlyShowFirstAvailableSlot: eventType.onlyShowFirstAvailableSlot || undefined,
durationLimits: eventType.durationLimits || undefined,
length: eventType.length,
hidden: eventType.hidden,
hashedLink: eventType.hashedLink?.link || undefined,
eventTypeColor: eventType.eventTypeColor || null,
periodDates: {
startDate: periodDates.startDate,
endDate: periodDates.endDate,
},
hideCalendarNotes: eventType.hideCalendarNotes,
offsetStart: eventType.offsetStart,
bookingFields: eventType.bookingFields,
periodType: eventType.periodType,
periodCountCalendarDays: eventType.periodCountCalendarDays ? true : false,
schedulingType: eventType.schedulingType,
requiresConfirmation: eventType.requiresConfirmation,
requiresConfirmationWillBlockSlot: eventType.requiresConfirmationWillBlockSlot,
slotInterval: eventType.slotInterval,
minimumBookingNotice: eventType.minimumBookingNotice,
metadata: eventType.metadata,
hosts: eventType.hosts.sort((a, b) => sortHosts(a, b, eventType.isRRWeightsEnabled)),
successRedirectUrl: eventType.successRedirectUrl || "",
forwardParamsSuccessRedirect: eventType.forwardParamsSuccessRedirect,
users: eventType.users,
useEventTypeDestinationCalendarEmail: eventType.useEventTypeDestinationCalendarEmail,
secondaryEmailId: eventType?.secondaryEmailId || -1,
children: eventType.children.map((ch) => ({
...ch,
created: true,
owner: {
...ch.owner,
eventTypeSlugs:
eventType.team?.members
.find((mem) => mem.user.id === ch.owner.id)
?.user.eventTypes.map((evTy) => evTy.slug)
.filter((slug) => slug !== eventType.slug) ?? [],
},
})),
seatsPerTimeSlotEnabled: eventType.seatsPerTimeSlot,
rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost,
assignAllTeamMembers: eventType.assignAllTeamMembers,
aiPhoneCallConfig: {
generalPrompt: eventType.aiPhoneCallConfig?.generalPrompt ?? DEFAULT_PROMPT_VALUE,
enabled: eventType.aiPhoneCallConfig?.enabled,
beginMessage: eventType.aiPhoneCallConfig?.beginMessage ?? DEFAULT_BEGIN_MESSAGE,
guestName: eventType.aiPhoneCallConfig?.guestName,
guestEmail: eventType.aiPhoneCallConfig?.guestEmail,
guestCompany: eventType.aiPhoneCallConfig?.guestCompany,
yourPhoneNumber: eventType.aiPhoneCallConfig?.yourPhoneNumber,
numberToCall: eventType.aiPhoneCallConfig?.numberToCall,
templateType: eventType.aiPhoneCallConfig?.templateType ?? "CUSTOM_TEMPLATE",
schedulerName: eventType.aiPhoneCallConfig?.schedulerName,
},
isRRWeightsEnabled: eventType.isRRWeightsEnabled,
};
}, [eventType, periodDates]);
const formMethods = useForm<FormValues>({
defaultValues,
resolver: zodResolver(
z
.object({
// Length if string, is converted to a number or it can be a number
// Make it optional because it's not submitted from all tabs of the page
eventName: z
.string()
.superRefine((val, ctx) => {
const validationResult = validateCustomEventName(val, bookingFields);
if (validationResult !== true) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("invalid_event_name_variables", { item: validationResult }),
});
}
})
.optional(),
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: locationsResolver(t),
})
// TODO: Add schema for other fields later.
.passthrough()
),
});
const {
formState: { isDirty: isFormDirty, dirtyFields },
} = formMethods;
const onDelete = () => {
isTeamEventTypeDeleted.current = true;
};
useEffect(() => {
const handleRouteChange = (url: string) => {
const paths = url.split("/");
// If the event-type is deleted, we can't show the empty assignment warning
if (isTeamEventTypeDeleted.current) return;
if (
!!team &&
!leaveWithoutAssigningHosts.current &&
(url === "/event-types" || paths[1] !== "event-types") &&
checkForEmptyAssignment({
assignedUsers: eventType.children,
hosts: eventType.hosts,
assignAllTeamMembers: eventType.assignAllTeamMembers,
isManagedEventType: eventType.schedulingType === SchedulingType.MANAGED,
})
) {
setIsOpenAssignmentWarnDialog(true);
setPendingRoute(url);
router.events.emit(
"routeChangeError",
new Error(`Aborted route change to ${url} because none was assigned to team event`)
);
throw "Aborted";
}
};
router.events.on("routeChangeStart", handleRouteChange);
return () => {
router.events.off("routeChangeStart", handleRouteChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router, eventType.hosts, eventType.children, eventType.assignAllTeamMembers]);
const appsMetadata = formMethods.getValues("metadata")?.apps;
const availability = formMethods.watch("availability");
let numberOfActiveApps = 0;
if (appsMetadata) {
numberOfActiveApps = Object.entries(appsMetadata).filter(
([appId, appData]) =>
eventTypeApps?.items.find((app) => app.slug === appId)?.isInstalled && appData.enabled
).length;
}
const permalink = `${WEBSITE_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
eventType.slug
}`;
const tabMap = {
setup: (
<EventSetupTab
eventType={eventType}
locationOptions={locationOptions}
team={team}
teamMembers={teamMembers}
destinationCalendar={destinationCalendar}
/>
),
availability: <EventAvailabilityTab eventType={eventType} isTeamEvent={!!team} />,
team: <EventTeamTab teamMembers={teamMembers} team={team} eventType={eventType} />,
limits: <EventLimitsTab eventType={eventType} />,
advanced: <EventAdvancedTab eventType={eventType} team={team} />,
instant: <EventInstantTab eventType={eventType} isTeamEvent={!!team} />,
recurring: <EventRecurringTab eventType={eventType} />,
apps: <EventAppsTab eventType={{ ...eventType, URL: permalink }} />,
workflows: props.allActiveWorkflows ? (
<EventWorkflowsTab eventType={eventType} workflows={props.allActiveWorkflows} />
) : (
<></>
),
webhooks: <EventWebhooksTab eventType={eventType} />,
ai: <EventAITab eventType={eventType} isTeamEvent={!!team} />,
} as const;
const isObject = <T,>(value: T): boolean => {
return value !== null && typeof value === "object" && !Array.isArray(value);
};
const isArray = <T,>(value: T): boolean => {
return Array.isArray(value);
};
const isFieldDirty = (fieldName: keyof FormValues) => {
// If the field itself is directly marked as dirty
if (dirtyFields[fieldName] === true) {
return true;
}
// Check if the field is an object or an array
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fieldValue: any = getNestedField(dirtyFields, fieldName);
if (isObject(fieldValue)) {
for (const key in fieldValue) {
if (fieldValue[key] === true) {
return true;
}
if (isObject(fieldValue[key]) || isArray(fieldValue[key])) {
const nestedFieldName = `${fieldName}.${key}` as keyof FormValues;
// Recursive call for nested objects or arrays
if (isFieldDirty(nestedFieldName)) {
return true;
}
}
}
}
if (isArray(fieldValue)) {
for (const element of fieldValue) {
// If element is an object, check each property of the object
if (isObject(element)) {
for (const key in element) {
if (element[key] === true) {
return true;
}
if (isObject(element[key]) || isArray(element[key])) {
const nestedFieldName = `${fieldName}.${key}` as keyof FormValues;
// Recursive call for nested objects or arrays within each element
if (isFieldDirty(nestedFieldName)) {
return true;
}
}
}
} else if (element === true) {
return true;
}
}
}
return false;
};
const getNestedField = (obj: typeof dirtyFields, path: string) => {
const keys = path.split(".");
let current = obj;
for (let i = 0; i < keys.length; i++) {
// @ts-expect-error /—— currentKey could be any deeply nested fields thanks to recursion
const currentKey = current[keys[i]];
if (currentKey === undefined) return undefined;
current = currentKey;
}
return current;
};
const getDirtyFields = (values: FormValues): Partial<FormValues> => {
if (!isFormDirty) {
return {};
}
const updatedFields: Partial<FormValues> = {};
Object.keys(dirtyFields).forEach((key) => {
const typedKey = key as keyof typeof dirtyFields;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
updatedFields[typedKey] = undefined;
const isDirty = isFieldDirty(typedKey);
if (isDirty) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
updatedFields[typedKey] = values[typedKey];
}
});
return updatedFields;
};
const handleSubmit = async (values: FormValues) => {
const { children } = values;
const dirtyValues = getDirtyFields(values);
const dirtyFieldExists = Object.keys(dirtyValues).length !== 0;
const {
periodDates,
periodCountCalendarDays,
beforeEventBuffer,
afterEventBuffer,
seatsPerTimeSlot,
seatsShowAttendees,
seatsShowAvailabilityCount,
bookingLimits,
onlyShowFirstAvailableSlot,
durationLimits,
recurringEvent,
eventTypeColor,
locations,
metadata,
customInputs,
assignAllTeamMembers,
// We don't need to send send these values to the backend
// eslint-disable-next-line @typescript-eslint/no-unused-vars
seatsPerTimeSlotEnabled,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
minimumBookingNoticeInDurationType,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
bookerLayouts,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
multipleDurationEnabled,
length,
...input
} = dirtyValues;
if (!Number(length)) throw new Error(t("event_setup_length_error"));
if (bookingLimits) {
const isValid = validateIntervalLimitOrder(bookingLimits);
if (!isValid) throw new Error(t("event_setup_booking_limits_error"));
}
if (durationLimits) {
const isValid = validateIntervalLimitOrder(durationLimits);
if (!isValid) throw new Error(t("event_setup_duration_limits_error"));
}
const layoutError = validateBookerLayouts(metadata?.bookerLayouts || null);
if (layoutError) throw new Error(t(layoutError));
if (metadata?.multipleDuration !== undefined) {
if (metadata?.multipleDuration.length < 1) {
throw new Error(t("event_setup_multiple_duration_error"));
} else {
// if length is unchanged, we skip this check
if (length !== undefined) {
if (!length && !metadata?.multipleDuration?.includes(length)) {
//This would work but it leaves the potential of this check being useless. Need to check against length and not eventType.length, but length can be undefined
throw new Error(t("event_setup_multiple_duration_default_error"));
}
}
}
}
// Prevent two payment apps to be enabled
// Ok to cast type here because this metadata will be updated as the event type metadata
if (checkForMultiplePaymentApps(metadata as z.infer<typeof EventTypeMetaDataSchema>))
throw new Error(t("event_setup_multiple_payment_apps_error"));
if (metadata?.apps?.stripe?.paymentOption === "HOLD" && seatsPerTimeSlot) {
throw new Error(t("seats_and_no_show_fee_error"));
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { availability, users, scheduleName, ...rest } = input;
const payload = {
...rest,
length,
locations,
recurringEvent,
periodStartDate: periodDates?.startDate,
periodEndDate: periodDates?.endDate,
periodCountCalendarDays,
id: eventType.id,
beforeEventBuffer,
afterEventBuffer,
bookingLimits,
onlyShowFirstAvailableSlot,
durationLimits,
eventTypeColor,
seatsPerTimeSlot,
seatsShowAttendees,
seatsShowAvailabilityCount,
metadata,
customInputs,
children,
assignAllTeamMembers,
};
// Filter out undefined values
const filteredPayload = Object.entries(payload).reduce((acc, [key, value]) => {
if (value !== undefined) {
// @ts-expect-error Element implicitly has any type
acc[key] = value;
}
return acc;
}, {});
if (dirtyFieldExists) {
updateMutation.mutate({ ...filteredPayload, id: eventType.id });
}
};
const [slugExistsChildrenDialogOpen, setSlugExistsChildrenDialogOpen] = useState<ChildrenEventType[]>([]);
const slug = formMethods.watch("slug") ?? eventType.slug;
// Optional prerender all tabs after 300 ms on mount
useEffect(() => {
const timeout = setTimeout(() => {
const Components = [
EventSetupTab,
EventAvailabilityTab,
EventTeamTab,
EventLimitsTab,
EventAdvancedTab,
EventInstantTab,
EventRecurringTab,
EventAppsTab,
EventWorkflowsTab,
EventWebhooksTab,
];
Components.forEach((C) => {
// @ts-expect-error Property 'render' does not exist on type 'ComponentClass
C.render.preload();
});
}, 300);
return () => {
clearTimeout(timeout);
};
}, []);
return (
<>
<EventTypeSingleLayout
enabledAppsNumber={numberOfActiveApps}
installedAppsNumber={eventTypeApps?.items.length || 0}
enabledWorkflowsNumber={props.allActiveWorkflows ? props.allActiveWorkflows.length : 0}
eventType={eventType}
activeWebhooksNumber={eventType.webhooks.filter((webhook) => webhook.active).length}
team={team}
availability={availability}
isUpdateMutationLoading={updateMutation.isPending}
formMethods={formMethods}
// disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
disableBorder={true}
currentUserMembership={currentUserMembership}
bookerUrl={eventType.bookerUrl}
isUserOrganizationAdmin={props.isUserOrganizationAdmin}
onDelete={onDelete}>
<Form
form={formMethods}
id="event-type-form"
handleSubmit={async (values) => {
const { children } = values;
const dirtyValues = getDirtyFields(values);
const dirtyFieldExists = Object.keys(dirtyValues).length !== 0;
const {
periodDates,
periodCountCalendarDays,
beforeEventBuffer,
afterEventBuffer,
seatsPerTimeSlot,
seatsShowAttendees,
seatsShowAvailabilityCount,
bookingLimits,
onlyShowFirstAvailableSlot,
durationLimits,
recurringEvent,
eventTypeColor,
locations,
metadata,
customInputs,
// We don't need to send send these values to the backend
// eslint-disable-next-line @typescript-eslint/no-unused-vars
seatsPerTimeSlotEnabled,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
multipleDurationEnabled,
length,
...input
} = dirtyValues;
if (length && !Number(length)) throw new Error(t("event_setup_length_error"));
if (bookingLimits) {
const isValid = validateIntervalLimitOrder(bookingLimits);
if (!isValid) throw new Error(t("event_setup_booking_limits_error"));
}
if (durationLimits) {
const isValid = validateIntervalLimitOrder(durationLimits);
if (!isValid) throw new Error(t("event_setup_duration_limits_error"));
}
const layoutError = validateBookerLayouts(metadata?.bookerLayouts || null);
if (layoutError) throw new Error(t(layoutError));
if (metadata?.multipleDuration !== undefined) {
if (metadata?.multipleDuration.length < 1) {
throw new Error(t("event_setup_multiple_duration_error"));
} else {
if (length !== undefined) {
if (!length && !metadata?.multipleDuration?.includes(length)) {
//This would work but it leaves the potential of this check being useless. Need to check against length and not eventType.length, but length can be undefined
throw new Error(t("event_setup_multiple_duration_default_error"));
}
}
}
}
// Prevent two payment apps to be enabled
// Ok to cast type here because this metadata will be updated as the event type metadata
if (checkForMultiplePaymentApps(metadata as z.infer<typeof EventTypeMetaDataSchema>))
throw new Error(t("event_setup_multiple_payment_apps_error"));
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { availability, users, scheduleName, ...rest } = input;
const payload = {
...rest,
children,
length,
locations,
recurringEvent,
periodStartDate: periodDates?.startDate,
periodEndDate: periodDates?.endDate,
periodCountCalendarDays,
id: eventType.id,
beforeEventBuffer,
afterEventBuffer,
bookingLimits,
onlyShowFirstAvailableSlot,
durationLimits,
eventTypeColor,
seatsPerTimeSlot,
seatsShowAttendees,
seatsShowAvailabilityCount,
metadata,
customInputs,
};
// Filter out undefined values
const filteredPayload = Object.entries(payload).reduce((acc, [key, value]) => {
if (value !== undefined) {
// @ts-expect-error Element implicitly has any type
acc[key] = value;
}
return acc;
}, {});
if (dirtyFieldExists) {
updateMutation.mutate({ ...filteredPayload, id: eventType.id, hashedLink: values.hashedLink });
}
}}>
<div ref={animationParentRef}>{tabMap[tabName]}</div>
</Form>
</EventTypeSingleLayout>
{slugExistsChildrenDialogOpen.length ? (
<ManagedEventTypeDialog
slugExistsChildrenDialogOpen={slugExistsChildrenDialogOpen}
isPending={formMethods.formState.isSubmitting}
onOpenChange={() => {
setSlugExistsChildrenDialogOpen([]);
}}
slug={slug}
onConfirm={(e: { preventDefault: () => void }) => {
e.preventDefault();
handleSubmit(formMethods.getValues());
telemetry.event(telemetryEventTypes.slugReplacementAction);
setSlugExistsChildrenDialogOpen([]);
}}
/>
) : null}
<AssignmentWarningDialog
isOpenAssignmentWarnDialog={isOpenAssignmentWarnDialog}
setIsOpenAssignmentWarnDialog={setIsOpenAssignmentWarnDialog}
pendingRoute={pendingRoute}
leaveWithoutAssigningHosts={leaveWithoutAssigningHosts}
id={eventType.id}
/>
</>
);
};
const EventTypePageWrapper: React.FC<PageProps> & {
PageWrapper?: AppProps["Component"]["PageWrapper"];
getLayout?: AppProps["Component"]["getLayout"];
@@ -855,7 +36,7 @@ const EventTypePageWrapper: React.FC<PageProps> & {
allActiveWorkflows: workflows,
};
return <EventTypePage {...propsData} />;
return <EventType {...propsData} />;
};
export default EventTypePageWrapper;
+1 -1
View File
@@ -3,6 +3,7 @@ import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
import { useCallback, useState } from "react";
import SkeletonLoader from "@calcom/features/availability/components/SkeletonLoader";
import { BulkEditDefaultForEventsModal } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
import Shell from "@calcom/features/shell/Shell";
@@ -19,7 +20,6 @@ import { EmptyScreen, showToast, ToggleGroup } from "@calcom/ui";
import { QueryCell } from "@lib/QueryCell";
import PageWrapper from "@components/PageWrapper";
import SkeletonLoader from "@components/availability/SkeletonLoader";
export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availability"]["list"]) {
const { t } = useLocale();
+2 -2
View File
@@ -4,12 +4,12 @@ import EventTypePageWrapper from "~/event-types/views/event-types-single-view";
import { getServerSideProps } from "~/event-types/views/event-types-single-view.getServerSideProps";
export type {
FormValues,
CustomInputParsed,
EventTypeSetup,
EventTypeSetupProps,
Host,
} from "~/event-types/views/event-types-single-view";
export type { FormValues } from "@calcom/features/eventtypes/lib/types";
} from "@calcom/features/eventtypes/lib/types";
EventTypePageWrapper.PageWrapper = PageWrapper;
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { checkForEmptyAssignment } from "../../lib/checkForEmptyAssignment";
import { checkForEmptyAssignment } from "@calcom/lib/event-types/utils/checkForEmptyAssignment";
describe("Tests to Check if Event Types have empty Assignment", () => {
it("should return true if managed event type has no assigned users", () => {
@@ -1,7 +1,6 @@
import classNames from "@calcom/lib/classNames";
import { Button, SkeletonText } from "@calcom/ui";
import classNames from "@lib/classNames";
function SkeletonLoader() {
return (
<ul className="divide-subtle border-subtle bg-default animate-pulse divide-y rounded-md border sm:mx-0 sm:overflow-hidden">
@@ -0,0 +1,759 @@
"use client";
/* eslint-disable @typescript-eslint/no-empty-function */
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { zodResolver } from "@hookform/resolvers/zod";
import dynamic from "next/dynamic";
// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router
import { useRouter } from "next/router";
import { useEffect, useMemo, useState, useRef } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
import { validateCustomEventName } from "@calcom/core/event";
import {
DEFAULT_PROMPT_VALUE,
DEFAULT_BEGIN_MESSAGE,
} from "@calcom/features/ee/cal-ai-phone/promptTemplates";
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import { sortHosts } from "@calcom/features/eventtypes/components/HostEditDialogs";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import { validateIntervalLimitOrder } from "@calcom/lib";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { checkForEmptyAssignment } from "@calcom/lib/event-types/utils/checkForEmptyAssignment";
import { locationsResolver } from "@calcom/lib/event-types/utils/locationsResolver";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { HttpError } from "@calcom/lib/http-error";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import type { Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Form, showToast } from "@calcom/ui";
import { EventTypeSingleLayout } from "./EventTypeLayout";
// These can't really be moved into calcom/ui due to the fact they use infered getserverside props typings;
const EventSetupTab = dynamic(() => import("./tabs/setup/EventSetupTab").then((mod) => mod.EventSetupTab));
const EventAvailabilityTab = dynamic(() =>
import("./tabs/availability/EventAvailabilityTab").then((mod) => mod.EventAvailabilityTab)
);
const EventTeamAssignmentTab = dynamic(() =>
import("./tabs/assignment/EventTeamAssignmentTab").then((mod) => mod.EventTeamAssignmentTab)
);
const EventLimitsTab = dynamic(() =>
import("./tabs/limits/EventLimitsTab").then((mod) => mod.EventLimitsTab)
);
const EventAdvancedTab = dynamic(() =>
import("./tabs/advanced/EventAdvancedTab").then((mod) => mod.EventAdvancedTab)
);
const EventInstantTab = dynamic(() =>
import("./tabs/instant/EventInstantTab").then((mod) => mod.EventInstantTab)
);
const EventRecurringTab = dynamic(() =>
import("./tabs/recurring/EventRecurringTab").then((mod) => mod.EventRecurringTab)
);
const EventAppsTab = dynamic(() => import("./tabs/apps/EventAppsTab").then((mod) => mod.EventAppsTab));
const EventWorkflowsTab = dynamic(() => import("./tabs/workflows/EventWorkfowsTab"));
const EventWebhooksTab = dynamic(() =>
import("./tabs/webhooks/EventWebhooksTab").then((mod) => mod.EventWebhooksTab)
);
const EventAITab = dynamic(() => import("./tabs/ai/EventAITab").then((mod) => mod.EventAITab));
const ManagedEventTypeDialog = dynamic(() => import("./dialogs/ManagedEventDialog"));
const AssignmentWarningDialog = dynamic(() => import("./dialogs/AssignmentWarningDialog"));
export type Host = {
isFixed: boolean;
userId: number;
priority: number;
weight: number;
weightAdjustment: number;
};
export type CustomInputParsed = typeof customInputSchema._output;
const querySchema = z.object({
tabName: z
.enum([
"setup",
"availability",
"apps",
"limits",
"instant",
"recurring",
"team",
"advanced",
"workflows",
"webhooks",
"ai",
])
.optional()
.default("setup"),
});
export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"];
export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
export type EventTypeAssignedUsers = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["children"];
export type EventTypeHosts = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["hosts"];
export const EventType = (props: EventTypeSetupProps & { allActiveWorkflows?: Workflow[] }) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const telemetry = useTelemetry();
const {
data: { tabName },
} = useTypedQuery(querySchema);
const { data: eventTypeApps } = trpc.viewer.integrations.useQuery({
extendsFeature: "EventType",
teamId: props.eventType.team?.id || props.eventType.parent?.teamId,
onlyInstalled: true,
});
const { eventType, locationOptions, team, teamMembers, currentUserMembership, destinationCalendar } = props;
const [isOpenAssignmentWarnDialog, setIsOpenAssignmentWarnDialog] = useState<boolean>(false);
const [pendingRoute, setPendingRoute] = useState("");
const leaveWithoutAssigningHosts = useRef(false);
const isTeamEventTypeDeleted = useRef(false);
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
onSuccess: async () => {
const currentValues = formMethods.getValues();
currentValues.children = currentValues.children.map((child) => ({
...child,
created: true,
}));
currentValues.assignAllTeamMembers = currentValues.assignAllTeamMembers || false;
// Reset the form with these values as new default values to ensure the correct comparison for dirtyFields eval
formMethods.reset(currentValues);
showToast(t("event_type_updated_successfully", { eventTypeTitle: eventType.title }), "success");
},
async onSettled() {
await utils.viewer.eventTypes.get.invalidate();
},
onError: (err) => {
let message = "";
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
message = `${err.data.code}: ${t("error_event_type_unauthorized_update")}`;
}
if (err.data?.code === "PARSE_ERROR" || err.data?.code === "BAD_REQUEST") {
message = `${err.data.code}: ${t(err.message)}`;
}
if (err.data?.code === "INTERNAL_SERVER_ERROR") {
message = t("unexpected_error_try_again");
}
showToast(message ? t(message) : t(err.message), "error");
},
});
const router = useRouter();
const [periodDates] = useState<{ startDate: Date; endDate: Date }>({
startDate: new Date(eventType.periodStartDate || Date.now()),
endDate: new Date(eventType.periodEndDate || Date.now()),
});
const bookingFields: Prisma.JsonObject = {};
eventType.bookingFields.forEach(({ name }) => {
bookingFields[name] = name;
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const defaultValues: any = useMemo(() => {
return {
title: eventType.title,
id: eventType.id,
slug: eventType.slug,
afterEventBuffer: eventType.afterEventBuffer,
beforeEventBuffer: eventType.beforeEventBuffer,
eventName: eventType.eventName || "",
scheduleName: eventType.scheduleName,
periodDays: eventType.periodDays,
requiresBookerEmailVerification: eventType.requiresBookerEmailVerification,
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
seatsShowAttendees: eventType.seatsShowAttendees,
seatsShowAvailabilityCount: eventType.seatsShowAvailabilityCount,
lockTimeZoneToggleOnBookingPage: eventType.lockTimeZoneToggleOnBookingPage,
locations: eventType.locations || [],
destinationCalendar: eventType.destinationCalendar,
recurringEvent: eventType.recurringEvent || null,
isInstantEvent: eventType.isInstantEvent,
instantMeetingExpiryTimeOffsetInSeconds: eventType.instantMeetingExpiryTimeOffsetInSeconds,
description: eventType.description ?? undefined,
schedule: eventType.schedule || undefined,
bookingLimits: eventType.bookingLimits || undefined,
onlyShowFirstAvailableSlot: eventType.onlyShowFirstAvailableSlot || undefined,
durationLimits: eventType.durationLimits || undefined,
length: eventType.length,
hidden: eventType.hidden,
hashedLink: eventType.hashedLink?.link || undefined,
eventTypeColor: eventType.eventTypeColor || null,
periodDates: {
startDate: periodDates.startDate,
endDate: periodDates.endDate,
},
hideCalendarNotes: eventType.hideCalendarNotes,
offsetStart: eventType.offsetStart,
bookingFields: eventType.bookingFields,
periodType: eventType.periodType,
periodCountCalendarDays: eventType.periodCountCalendarDays ? true : false,
schedulingType: eventType.schedulingType,
requiresConfirmation: eventType.requiresConfirmation,
requiresConfirmationWillBlockSlot: eventType.requiresConfirmationWillBlockSlot,
slotInterval: eventType.slotInterval,
minimumBookingNotice: eventType.minimumBookingNotice,
metadata: eventType.metadata,
hosts: eventType.hosts.sort((a, b) => sortHosts(a, b, eventType.isRRWeightsEnabled)),
successRedirectUrl: eventType.successRedirectUrl || "",
forwardParamsSuccessRedirect: eventType.forwardParamsSuccessRedirect,
users: eventType.users,
useEventTypeDestinationCalendarEmail: eventType.useEventTypeDestinationCalendarEmail,
secondaryEmailId: eventType?.secondaryEmailId || -1,
children: eventType.children.map((ch) => ({
...ch,
created: true,
owner: {
...ch.owner,
eventTypeSlugs:
eventType.team?.members
.find((mem) => mem.user.id === ch.owner.id)
?.user.eventTypes.map((evTy) => evTy.slug)
.filter((slug) => slug !== eventType.slug) ?? [],
},
})),
seatsPerTimeSlotEnabled: eventType.seatsPerTimeSlot,
rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost,
assignAllTeamMembers: eventType.assignAllTeamMembers,
aiPhoneCallConfig: {
generalPrompt: eventType.aiPhoneCallConfig?.generalPrompt ?? DEFAULT_PROMPT_VALUE,
enabled: eventType.aiPhoneCallConfig?.enabled,
beginMessage: eventType.aiPhoneCallConfig?.beginMessage ?? DEFAULT_BEGIN_MESSAGE,
guestName: eventType.aiPhoneCallConfig?.guestName,
guestEmail: eventType.aiPhoneCallConfig?.guestEmail,
guestCompany: eventType.aiPhoneCallConfig?.guestCompany,
yourPhoneNumber: eventType.aiPhoneCallConfig?.yourPhoneNumber,
numberToCall: eventType.aiPhoneCallConfig?.numberToCall,
templateType: eventType.aiPhoneCallConfig?.templateType ?? "CUSTOM_TEMPLATE",
schedulerName: eventType.aiPhoneCallConfig?.schedulerName,
},
isRRWeightsEnabled: eventType.isRRWeightsEnabled,
};
}, [eventType, periodDates]);
const formMethods = useForm<FormValues>({
defaultValues,
resolver: zodResolver(
z
.object({
// Length if string, is converted to a number or it can be a number
// Make it optional because it's not submitted from all tabs of the page
eventName: z
.string()
.superRefine((val, ctx) => {
const validationResult = validateCustomEventName(val, bookingFields);
if (validationResult !== true) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("invalid_event_name_variables", { item: validationResult }),
});
}
})
.optional(),
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: locationsResolver(t),
})
// TODO: Add schema for other fields later.
.passthrough()
),
});
const {
formState: { isDirty: isFormDirty, dirtyFields },
} = formMethods;
const onDelete = () => {
isTeamEventTypeDeleted.current = true;
};
useEffect(() => {
const handleRouteChange = (url: string) => {
const paths = url.split("/");
// If the event-type is deleted, we can't show the empty assignment warning
if (isTeamEventTypeDeleted.current) return;
if (
!!team &&
!leaveWithoutAssigningHosts.current &&
(url === "/event-types" || paths[1] !== "event-types") &&
checkForEmptyAssignment({
assignedUsers: eventType.children,
hosts: eventType.hosts,
assignAllTeamMembers: eventType.assignAllTeamMembers,
isManagedEventType: eventType.schedulingType === SchedulingType.MANAGED,
})
) {
setIsOpenAssignmentWarnDialog(true);
setPendingRoute(url);
router.events.emit(
"routeChangeError",
new Error(`Aborted route change to ${url} because none was assigned to team event`)
);
throw "Aborted";
}
};
router.events.on("routeChangeStart", handleRouteChange);
return () => {
router.events.off("routeChangeStart", handleRouteChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router, eventType.hosts, eventType.children, eventType.assignAllTeamMembers]);
const appsMetadata = formMethods.getValues("metadata")?.apps;
const availability = formMethods.watch("availability");
let numberOfActiveApps = 0;
if (appsMetadata) {
numberOfActiveApps = Object.entries(appsMetadata).filter(
([appId, appData]) =>
eventTypeApps?.items.find((app) => app.slug === appId)?.isInstalled && appData.enabled
).length;
}
const permalink = `${WEBSITE_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
eventType.slug
}`;
const tabMap = {
setup: (
<EventSetupTab
eventType={eventType}
locationOptions={locationOptions}
team={team}
teamMembers={teamMembers}
destinationCalendar={destinationCalendar}
/>
),
availability: <EventAvailabilityTab eventType={eventType} isTeamEvent={!!team} />,
team: <EventTeamAssignmentTab teamMembers={teamMembers} team={team} eventType={eventType} />,
limits: <EventLimitsTab eventType={eventType} />,
advanced: <EventAdvancedTab eventType={eventType} team={team} />,
instant: <EventInstantTab eventType={eventType} isTeamEvent={!!team} />,
recurring: <EventRecurringTab eventType={eventType} />,
apps: <EventAppsTab eventType={{ ...eventType, URL: permalink }} />,
workflows: props.allActiveWorkflows ? (
<EventWorkflowsTab eventType={eventType} workflows={props.allActiveWorkflows} />
) : (
<></>
),
webhooks: <EventWebhooksTab eventType={eventType} />,
ai: <EventAITab eventType={eventType} isTeamEvent={!!team} />,
} as const;
const isObject = <T,>(value: T): boolean => {
return value !== null && typeof value === "object" && !Array.isArray(value);
};
const isArray = <T,>(value: T): boolean => {
return Array.isArray(value);
};
const isFieldDirty = (fieldName: keyof FormValues) => {
// If the field itself is directly marked as dirty
if (dirtyFields[fieldName] === true) {
return true;
}
// Check if the field is an object or an array
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fieldValue: any = getNestedField(dirtyFields, fieldName);
if (isObject(fieldValue)) {
for (const key in fieldValue) {
if (fieldValue[key] === true) {
return true;
}
if (isObject(fieldValue[key]) || isArray(fieldValue[key])) {
const nestedFieldName = `${fieldName}.${key}` as keyof FormValues;
// Recursive call for nested objects or arrays
if (isFieldDirty(nestedFieldName)) {
return true;
}
}
}
}
if (isArray(fieldValue)) {
for (const element of fieldValue) {
// If element is an object, check each property of the object
if (isObject(element)) {
for (const key in element) {
if (element[key] === true) {
return true;
}
if (isObject(element[key]) || isArray(element[key])) {
const nestedFieldName = `${fieldName}.${key}` as keyof FormValues;
// Recursive call for nested objects or arrays within each element
if (isFieldDirty(nestedFieldName)) {
return true;
}
}
}
} else if (element === true) {
return true;
}
}
}
return false;
};
const getNestedField = (obj: typeof dirtyFields, path: string) => {
const keys = path.split(".");
let current = obj;
for (let i = 0; i < keys.length; i++) {
// @ts-expect-error /—— currentKey could be any deeply nested fields thanks to recursion
const currentKey = current[keys[i]];
if (currentKey === undefined) return undefined;
current = currentKey;
}
return current;
};
const getDirtyFields = (values: FormValues): Partial<FormValues> => {
if (!isFormDirty) {
return {};
}
const updatedFields: Partial<FormValues> = {};
Object.keys(dirtyFields).forEach((key) => {
const typedKey = key as keyof typeof dirtyFields;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
updatedFields[typedKey] = undefined;
const isDirty = isFieldDirty(typedKey);
if (isDirty) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
updatedFields[typedKey] = values[typedKey];
}
});
return updatedFields;
};
const handleSubmit = async (values: FormValues) => {
const { children } = values;
const dirtyValues = getDirtyFields(values);
const dirtyFieldExists = Object.keys(dirtyValues).length !== 0;
const {
periodDates,
periodCountCalendarDays,
beforeEventBuffer,
afterEventBuffer,
seatsPerTimeSlot,
seatsShowAttendees,
seatsShowAvailabilityCount,
bookingLimits,
onlyShowFirstAvailableSlot,
durationLimits,
recurringEvent,
eventTypeColor,
locations,
metadata,
customInputs,
assignAllTeamMembers,
// We don't need to send send these values to the backend
// eslint-disable-next-line @typescript-eslint/no-unused-vars
seatsPerTimeSlotEnabled,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
minimumBookingNoticeInDurationType,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
bookerLayouts,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
multipleDurationEnabled,
length,
...input
} = dirtyValues;
if (!Number(length)) throw new Error(t("event_setup_length_error"));
if (bookingLimits) {
const isValid = validateIntervalLimitOrder(bookingLimits);
if (!isValid) throw new Error(t("event_setup_booking_limits_error"));
}
if (durationLimits) {
const isValid = validateIntervalLimitOrder(durationLimits);
if (!isValid) throw new Error(t("event_setup_duration_limits_error"));
}
const layoutError = validateBookerLayouts(metadata?.bookerLayouts || null);
if (layoutError) throw new Error(t(layoutError));
if (metadata?.multipleDuration !== undefined) {
if (metadata?.multipleDuration.length < 1) {
throw new Error(t("event_setup_multiple_duration_error"));
} else {
// if length is unchanged, we skip this check
if (length !== undefined) {
if (!length && !metadata?.multipleDuration?.includes(length)) {
//This would work but it leaves the potential of this check being useless. Need to check against length and not eventType.length, but length can be undefined
throw new Error(t("event_setup_multiple_duration_default_error"));
}
}
}
}
// Prevent two payment apps to be enabled
// Ok to cast type here because this metadata will be updated as the event type metadata
if (checkForMultiplePaymentApps(metadata as z.infer<typeof EventTypeMetaDataSchema>))
throw new Error(t("event_setup_multiple_payment_apps_error"));
if (metadata?.apps?.stripe?.paymentOption === "HOLD" && seatsPerTimeSlot) {
throw new Error(t("seats_and_no_show_fee_error"));
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { availability, users, scheduleName, ...rest } = input;
const payload = {
...rest,
length,
locations,
recurringEvent,
periodStartDate: periodDates?.startDate,
periodEndDate: periodDates?.endDate,
periodCountCalendarDays,
id: eventType.id,
beforeEventBuffer,
afterEventBuffer,
bookingLimits,
onlyShowFirstAvailableSlot,
durationLimits,
eventTypeColor,
seatsPerTimeSlot,
seatsShowAttendees,
seatsShowAvailabilityCount,
metadata,
customInputs,
children,
assignAllTeamMembers,
};
// Filter out undefined values
const filteredPayload = Object.entries(payload).reduce((acc, [key, value]) => {
if (value !== undefined) {
// @ts-expect-error Element implicitly has any type
acc[key] = value;
}
return acc;
}, {});
if (dirtyFieldExists) {
updateMutation.mutate({ ...filteredPayload, id: eventType.id });
}
};
const [slugExistsChildrenDialogOpen, setSlugExistsChildrenDialogOpen] = useState<ChildrenEventType[]>([]);
const slug = formMethods.watch("slug") ?? eventType.slug;
// Optional prerender all tabs after 300 ms on mount
useEffect(() => {
const timeout = setTimeout(() => {
const Components = [
EventSetupTab,
EventAvailabilityTab,
EventTeamAssignmentTab,
EventLimitsTab,
EventAdvancedTab,
EventInstantTab,
EventRecurringTab,
EventAppsTab,
EventWorkflowsTab,
EventWebhooksTab,
];
Components.forEach((C) => {
// @ts-expect-error Property 'render' does not exist on type 'ComponentClass
C.render.preload();
});
}, 300);
return () => {
clearTimeout(timeout);
};
}, []);
return (
<>
<EventTypeSingleLayout
enabledAppsNumber={numberOfActiveApps}
installedAppsNumber={eventTypeApps?.items.length || 0}
enabledWorkflowsNumber={props.allActiveWorkflows ? props.allActiveWorkflows.length : 0}
eventType={eventType}
activeWebhooksNumber={eventType.webhooks.filter((webhook) => webhook.active).length}
team={team}
availability={availability}
isUpdateMutationLoading={updateMutation.isPending}
formMethods={formMethods}
// disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
disableBorder={true}
currentUserMembership={currentUserMembership}
bookerUrl={eventType.bookerUrl}
isUserOrganizationAdmin={props.isUserOrganizationAdmin}
onDelete={onDelete}>
<Form
form={formMethods}
id="event-type-form"
handleSubmit={async (values) => {
const { children } = values;
const dirtyValues = getDirtyFields(values);
const dirtyFieldExists = Object.keys(dirtyValues).length !== 0;
const {
periodDates,
periodCountCalendarDays,
beforeEventBuffer,
afterEventBuffer,
seatsPerTimeSlot,
seatsShowAttendees,
seatsShowAvailabilityCount,
bookingLimits,
onlyShowFirstAvailableSlot,
durationLimits,
recurringEvent,
eventTypeColor,
locations,
metadata,
customInputs,
// We don't need to send send these values to the backend
// eslint-disable-next-line @typescript-eslint/no-unused-vars
seatsPerTimeSlotEnabled,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
multipleDurationEnabled,
length,
...input
} = dirtyValues;
if (length && !Number(length)) throw new Error(t("event_setup_length_error"));
if (bookingLimits) {
const isValid = validateIntervalLimitOrder(bookingLimits);
if (!isValid) throw new Error(t("event_setup_booking_limits_error"));
}
if (durationLimits) {
const isValid = validateIntervalLimitOrder(durationLimits);
if (!isValid) throw new Error(t("event_setup_duration_limits_error"));
}
const layoutError = validateBookerLayouts(metadata?.bookerLayouts || null);
if (layoutError) throw new Error(t(layoutError));
if (metadata?.multipleDuration !== undefined) {
if (metadata?.multipleDuration.length < 1) {
throw new Error(t("event_setup_multiple_duration_error"));
} else {
if (length !== undefined) {
if (!length && !metadata?.multipleDuration?.includes(length)) {
//This would work but it leaves the potential of this check being useless. Need to check against length and not eventType.length, but length can be undefined
throw new Error(t("event_setup_multiple_duration_default_error"));
}
}
}
}
// Prevent two payment apps to be enabled
// Ok to cast type here because this metadata will be updated as the event type metadata
if (checkForMultiplePaymentApps(metadata as z.infer<typeof EventTypeMetaDataSchema>))
throw new Error(t("event_setup_multiple_payment_apps_error"));
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { availability, users, scheduleName, ...rest } = input;
const payload = {
...rest,
children,
length,
locations,
recurringEvent,
periodStartDate: periodDates?.startDate,
periodEndDate: periodDates?.endDate,
periodCountCalendarDays,
id: eventType.id,
beforeEventBuffer,
afterEventBuffer,
bookingLimits,
onlyShowFirstAvailableSlot,
durationLimits,
eventTypeColor,
seatsPerTimeSlot,
seatsShowAttendees,
seatsShowAvailabilityCount,
metadata,
customInputs,
};
// Filter out undefined values
const filteredPayload = Object.entries(payload).reduce((acc, [key, value]) => {
if (value !== undefined) {
// @ts-expect-error Element implicitly has any type
acc[key] = value;
}
return acc;
}, {});
if (dirtyFieldExists) {
updateMutation.mutate({ ...filteredPayload, id: eventType.id, hashedLink: values.hashedLink });
}
}}>
<div ref={animationParentRef}>{tabMap[tabName]}</div>
</Form>
</EventTypeSingleLayout>
{slugExistsChildrenDialogOpen.length ? (
<ManagedEventTypeDialog
slugExistsChildrenDialogOpen={slugExistsChildrenDialogOpen}
isPending={formMethods.formState.isSubmitting}
onOpenChange={() => {
setSlugExistsChildrenDialogOpen([]);
}}
slug={slug}
onConfirm={(e: { preventDefault: () => void }) => {
e.preventDefault();
handleSubmit(formMethods.getValues());
telemetry.event(telemetryEventTypes.slugReplacementAction);
setSlugExistsChildrenDialogOpen([]);
}}
/>
) : null}
<AssignmentWarningDialog
isOpenAssignmentWarnDialog={isOpenAssignmentWarnDialog}
setIsOpenAssignmentWarnDialog={setIsOpenAssignmentWarnDialog}
pendingRoute={pendingRoute}
leaveWithoutAssigningHosts={leaveWithoutAssigningHosts}
id={eventType.id}
/>
</>
);
};
@@ -1,26 +1,20 @@
import type { TFunction } from "next-i18next";
import { Trans } from "next-i18next";
import { useRouter } from "next/navigation";
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import { useMemo, useState, Suspense } from "react";
import type { UseFormReturn } from "react-hook-form";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
import type { FormValues, AvailabilityOption } from "@calcom/features/eventtypes/lib/types";
import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types";
import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { SchedulingType } from "@calcom/prisma/enums";
import { trpc, TRPCClientError } from "@calcom/trpc/react";
import type { DialogProps, VerticalTabItemProps } from "@calcom/ui";
import type { VerticalTabItemProps } from "@calcom/ui";
import {
Button,
ButtonGroup,
ConfirmationDialogContent,
Dialog,
DropdownMenuSeparator,
Dropdown,
DropdownMenuContent,
@@ -38,6 +32,8 @@ import {
VerticalTabs,
} from "@calcom/ui";
import { DeleteDialog } from "./dialogs/DeleteDialog";
type Props = {
children: React.ReactNode;
eventType: EventTypeSetupProps["eventType"];
@@ -113,66 +109,6 @@ function getNavigation({
] satisfies VerticalTabItemProps[];
}
function DeleteDialog({
isManagedEvent,
eventTypeId,
open,
onOpenChange,
onDelete,
}: {
isManagedEvent: string;
eventTypeId: number;
onDelete: () => void;
} & Pick<DialogProps, "open" | "onOpenChange">) {
const utils = trpc.useUtils();
const { t } = useLocale();
const router = useRouter();
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
onSuccess: async () => {
await utils.viewer.eventTypes.invalidate();
showToast(t("event_type_deleted_successfully"), "success");
onDelete();
router.push("/event-types");
onOpenChange?.(false);
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
onOpenChange?.(false);
} else if (err instanceof TRPCClientError) {
showToast(err.message, "error");
}
},
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<ConfirmationDialogContent
isPending={deleteMutation.isPending}
variety="danger"
title={t(`delete${isManagedEvent}_event_type`)}
confirmBtnText={t(`confirm_delete_event_type`)}
loadingText={t(`confirm_delete_event_type`)}
onConfirm={(e) => {
e.preventDefault();
deleteMutation.mutate({ id: eventTypeId });
}}>
<p className="mt-5">
<Trans
i18nKey={`delete${isManagedEvent}_event_type_description`}
components={{ li: <li />, ul: <ul className="ml-4 list-disc" /> }}>
<ul>
<li>Members assigned to this event type will also have their event types deleted.</li>
<li>Anyone who they&apos;ve shared their link with will no longer be able to book using it.</li>
</ul>
</Trans>
</p>
</ConfirmationDialogContent>
</Dialog>
);
}
function EventTypeSingleLayout({
children,
eventType,
@@ -2,22 +2,20 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { ErrorMessage } from "@hookform/error-message";
import { Trans } from "next-i18next";
import Link from "next/link";
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import { useEffect, useState } from "react";
import { Controller, useFieldArray } from "react-hook-form";
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
import type { EventLocationType } from "@calcom/app-store/locations";
import { getEventLocationType, MeetLocationType } from "@calcom/app-store/locations";
import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types";
import type { LocationFormValues, EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types";
import CheckboxField from "@calcom/features/form/components/CheckboxField";
import type { SingleValueLocationOption } from "@calcom/features/form/components/LocationSelect";
import LocationSelect from "@calcom/features/form/components/LocationSelect";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon, Input, PhoneInput, Button, showToast } from "@calcom/ui";
import CheckboxField from "@components/ui/form/CheckboxField";
import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect";
import LocationSelect from "@components/ui/form/LocationSelect";
export type TEventTypeLocation = Pick<EventTypeSetupProps["eventType"], "locations">;
export type TLocationOptions = Pick<EventTypeSetupProps, "locationOptions">["locationOptions"];
export type TDestinationCalendar = { integration: string } | null;
@@ -0,0 +1,68 @@
import { Trans } from "next-i18next";
import { useRouter } from "next/navigation";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { trpc, TRPCClientError } from "@calcom/trpc/react";
import type { DialogProps } from "@calcom/ui";
import { ConfirmationDialogContent, Dialog, showToast } from "@calcom/ui";
export function DeleteDialog({
isManagedEvent,
eventTypeId,
open,
onOpenChange,
onDelete,
}: {
isManagedEvent: string;
eventTypeId: number;
onDelete: () => void;
} & Pick<DialogProps, "open" | "onOpenChange">) {
const utils = trpc.useUtils();
const { t } = useLocale();
const router = useRouter();
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
onSuccess: async () => {
await utils.viewer.eventTypes.invalidate();
showToast(t("event_type_deleted_successfully"), "success");
onDelete();
router.push("/event-types");
onOpenChange?.(false);
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
onOpenChange?.(false);
} else if (err instanceof TRPCClientError) {
showToast(err.message, "error");
}
},
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<ConfirmationDialogContent
isPending={deleteMutation.isPending}
variety="danger"
title={t(`delete${isManagedEvent}_event_type`)}
confirmBtnText={t(`confirm_delete_event_type`)}
loadingText={t(`confirm_delete_event_type`)}
onConfirm={(e) => {
e.preventDefault();
deleteMutation.mutate({ id: eventTypeId });
}}>
<p className="mt-5">
<Trans
i18nKey={`delete${isManagedEvent}_event_type_description`}
components={{ li: <li />, ul: <ul className="ml-4 list-disc" /> }}>
<ul>
<li>Members assigned to this event type will also have their event types deleted.</li>
<li>Anyone who they&apos;ve shared their link with will no longer be able to book using it.</li>
</ul>
</Trans>
</p>
</ConfirmationDialogContent>
</Dialog>
);
}
@@ -0,0 +1,82 @@
import type { TFunction } from "next-i18next";
import { Trans } from "next-i18next";
import { useState } from "react";
import {
SettingsToggle,
Dialog,
DialogContent,
DialogFooter,
InputField,
DialogClose,
Button,
} from "@calcom/ui";
interface DisableEmailsSettingProps {
checked: boolean;
onCheckedChange: (e: boolean) => void;
recipient: "attendees" | "hosts";
t: TFunction;
}
export const DisableAllEmailsSetting = ({
checked,
onCheckedChange,
recipient,
t,
}: DisableEmailsSettingProps) => {
const [dialogOpen, setDialogOpen] = useState(false);
const [confirmText, setConfirmText] = useState("");
const title =
recipient === "attendees" ? t("disable_all_emails_to_attendees") : t("disable_all_emails_to_hosts");
return (
<div>
<Dialog open={dialogOpen} onOpenChange={(e) => setDialogOpen(e)}>
<DialogContent title={title} Icon="circle-alert">
<p className="text-default text-sm">
<Trans i18nKey="disable_attendees_emails_description">
This will disable all emails to {{ recipient }}. This includes booking confirmations, requests,
reschedules and reschedule requests, cancellation emails, and any other emails related to
booking updates.
<br />
<br />
It is your responsibility to ensure that your {{ recipient }} are aware of any bookings and
changes to their bookings.
</Trans>
</p>
<p className="text-default mb-1 mt-2 text-sm">{t("type_confirm_to_continue")}</p>
<InputField
value={confirmText}
onChange={(e) => {
setConfirmText(e.target.value);
}}
/>
<DialogFooter>
<DialogClose />
<Button
disabled={confirmText !== "confirm"}
onClick={(e) => {
onCheckedChange(true);
setDialogOpen(false);
}}>
{t("disable_email")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={title}
description={t("disable_all_emails_description")}
checked={!!checked}
onCheckedChange={() => {
checked ? onCheckedChange(!checked) : setDialogOpen(true);
}}
/>
</div>
);
};
@@ -1,5 +1,4 @@
import dynamic from "next/dynamic";
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import { useEffect, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { z } from "zod";
@@ -13,7 +12,7 @@ import {
allowDisablingAttendeeConfirmationEmails,
allowDisablingHostConfirmationEmails,
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import type { FormValues, EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types";
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
import type { fieldSchema } from "@calcom/features/form-builder/schema";
import type { EditableSchema } from "@calcom/features/form-builder/schema";
@@ -44,12 +43,12 @@ import {
ColorPicker,
} from "@calcom/ui";
import { DisableAllEmailsSetting } from "./DisableAllEmailsSetting";
import RequiresConfirmationController from "./RequiresConfirmationController";
import { DisableAllEmailsSetting } from "./settings/DisableAllEmailsSetting";
type BookingField = z.infer<typeof fieldSchema>;
const CustomEventTypeModal = dynamic(() => import("@components/eventtype/CustomEventTypeModal"));
const CustomEventTypeModal = dynamic(() => import("./CustomEventTypeModal"));
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps, "eventType" | "team">) => {
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
@@ -1,13 +1,13 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import type { UnitTypeLongPlural } from "dayjs";
import { Trans } from "next-i18next";
import type { EventTypeSetup } from "pages/event-types/[type]";
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type z from "zod";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import type { EventTypeSetup } from "@calcom/features/eventtypes/lib/types";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -1,6 +1,5 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import { useSession } from "next-auth/react";
import type { EventTypeSetup } from "pages/event-types/[type]";
import React, { useState } from "react";
import { useFormContext, Controller } from "react-hook-form";
import { z } from "zod";
@@ -9,7 +8,7 @@ import { getTemplateFieldsSchema } from "@calcom/features/ee/cal-ai-phone/getTem
import { TEMPLATES_FIELDS } from "@calcom/features/ee/cal-ai-phone/template-fields-map";
import type { TemplateType } from "@calcom/features/ee/cal-ai-phone/zod-utils";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import type { EventTypeSetup, FormValues } from "@calcom/features/eventtypes/lib/types";
import { ComponentForField } from "@calcom/features/form-builder/FormBuilderField";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -1,4 +1,4 @@
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types";
import AIEventController from "./AIEventController";
@@ -1,19 +1,17 @@
import { Trans } from "next-i18next";
import Link from "next/link";
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import { useFormContext } from "react-hook-form";
import { EventTypeAppCard } from "@calcom/app-store/_components/EventTypeAppCardInterface";
import type { EventTypeAppCardComponentProps } from "@calcom/app-store/types";
import type { EventTypeAppsList } from "@calcom/app-store/utils";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import type { FormValues, EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types";
import useAppsData from "@calcom/lib/hooks/useAppsData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, EmptyScreen } from "@calcom/ui";
import useAppsData from "@lib/hooks/useAppsData";
export type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
EventTypeAppCardComponentProps["eventType"];
@@ -1,6 +1,5 @@
import { Trans } from "next-i18next";
import Link from "next/link";
import type { EventTypeSetupProps, Host } from "pages/event-types/[type]";
import { useEffect, useRef, useState } from "react";
import type { ComponentProps, Dispatch, SetStateAction } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
@@ -12,7 +11,12 @@ import AddMembersWithSwitch, {
import AssignAllTeamMembers from "@calcom/features/eventtypes/components/AssignAllTeamMembers";
import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import { sortHosts, weightDescription } from "@calcom/features/eventtypes/components/HostEditDialogs";
import type { FormValues, TeamMember } from "@calcom/features/eventtypes/lib/types";
import type {
FormValues,
TeamMember,
EventTypeSetupProps,
Host,
} from "@calcom/features/eventtypes/lib/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/enums";
import { Label, Select, SettingsToggle } from "@calcom/ui";
@@ -388,7 +392,7 @@ const Hosts = ({
);
};
export const EventTeamTab = ({
export const EventTeamAssignmentTab = ({
team,
teamMembers,
eventType,
@@ -1,12 +1,12 @@
import type { EventTypeSetup } from "pages/event-types/[type]";
import { useState, memo, useEffect } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { OptionProps, SingleValueProps } from "react-select";
import { components } from "react-select";
import dayjs from "@calcom/dayjs";
import { SelectSkeletonLoader } from "@calcom/features/availability/components/SkeletonLoader";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import type { AvailabilityOption, FormValues } from "@calcom/features/eventtypes/lib/types";
import type { AvailabilityOption, FormValues, EventTypeSetup } from "@calcom/features/eventtypes/lib/types";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { weekdayNames } from "@calcom/lib/weekday";
@@ -16,8 +16,6 @@ import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Badge, Button, Icon, Select, SettingsToggle, SkeletonText } from "@calcom/ui";
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
const { label, isDefault, isManaged = false } = props.data;
const { t } = useLocale();
@@ -1,5 +1,4 @@
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import InstantEventController from "./InstantEventController";
@@ -1,12 +1,11 @@
import type { Webhook } from "@prisma/client";
import { useSession } from "next-auth/react";
import type { EventTypeSetup } from "pages/event-types/[type]";
import { useState } from "react";
import { useFormContext, Controller } from "react-hook-form";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import type { EventTypeSetup, FormValues } from "@calcom/features/eventtypes/lib/types";
import { WebhookForm } from "@calcom/features/webhooks/components";
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
@@ -1,6 +1,5 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as RadioGroup from "@radix-ui/react-radio-group";
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import type { Key } from "react";
import React, { useEffect, useState } from "react";
import type { UseFormRegisterReturn, UseFormReturn } from "react-hook-form";
@@ -9,7 +8,8 @@ import type { SingleValue } from "react-select";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { getDefinedBufferTimes } from "@calcom/features/eventtypes/lib/getDefinedBufferTimes";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import type { FormValues, EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types";
import CheckboxField from "@calcom/features/form/components/CheckboxField";
import { classNames } from "@calcom/lib";
import { ROLLING_WINDOW_PERIOD_MAX_DAYS_TO_CHECK } from "@calcom/lib/constants";
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
@@ -21,8 +21,6 @@ import { PeriodType } from "@calcom/prisma/enums";
import type { IntervalLimit } from "@calcom/types/Calendar";
import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui";
import CheckboxField from "@components/ui/form/CheckboxField";
type IPeriodType = (typeof PeriodType)[keyof typeof PeriodType];
/**
@@ -1,5 +1,4 @@
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import RecurringEventController from "./RecurringEventController";
@@ -1,8 +1,8 @@
import type { EventTypeSetup } from "pages/event-types/[type]";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import type { EventTypeSetup } from "@calcom/features/eventtypes/lib/types";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -1,4 +1,3 @@
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
@@ -6,6 +5,8 @@ import type { MultiValue } from "react-select";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import Locations from "@calcom/features/eventtypes/components/Locations";
import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types";
import type { FormValues, LocationFormValues } from "@calcom/features/eventtypes/lib/types";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -14,8 +15,6 @@ import { slugify } from "@calcom/lib/slugify";
import turndown from "@calcom/lib/turndownService";
import { Label, Select, SettingsToggle, Skeleton, TextField, Editor } from "@calcom/ui";
import Locations from "@components/eventtype/Locations";
export const EventSetupTab = (
props: Pick<
EventTypeSetupProps,
@@ -1,12 +1,11 @@
import type { Webhook } from "@prisma/client";
import { Trans } from "next-i18next";
import Link from "next/link";
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import type { FormValues, EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types";
import { WebhookForm } from "@calcom/features/webhooks/components";
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
@@ -4,7 +4,11 @@ import { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import { Trans } from "react-i18next";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import SkeletonLoader from "@calcom/features/ee/workflows/components/SkeletonLoaderEventWorkflowsTab";
import type { WorkflowType } from "@calcom/features/ee/workflows/components/WorkflowListPage";
import { getActionIcon } from "@calcom/features/ee/workflows/lib/getActionIcon";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -14,11 +18,6 @@ import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, EmptyScreen, Icon, showToast, Switch, Tooltip } from "@calcom/ui";
import LicenseRequired from "../../common/components/LicenseRequired";
import { getActionIcon } from "../lib/getActionIcon";
import SkeletonLoader from "./SkeletonLoaderEventWorkflowsTab";
import type { WorkflowType } from "./WorkflowListPage";
type PartialWorkflowType = Pick<WorkflowType, "name" | "activeOn" | "isOrg" | "steps" | "id" | "readOnly">;
type ItemProps = {
@@ -133,3 +133,6 @@ export type FormValues = {
};
export type LocationFormValues = Pick<FormValues, "id" | "locations" | "bookingFields" | "seatsPerTimeSlot">;
export type EventTypeAssignedUsers = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["children"];
export type EventTypeHosts = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["hosts"];
@@ -1,4 +1,7 @@
import type { EventTypeAssignedUsers, EventTypeHosts } from "~/event-types/views/event-types-single-view";
import type {
EventTypeAssignedUsers,
EventTypeHosts,
} from "@calcom/features/eventtypes/components/EventType";
// This function checks if EventType requires assignment.
// returns true: if EventType requires assignment but there is no assignment yet done by the user.
@@ -0,0 +1,70 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { isValidPhoneNumber } from "libphonenumber-js";
import type { TFunction } from "next-i18next";
// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router
import { z } from "zod";
import { getEventLocationType } from "@calcom/app-store/locations";
export const locationsResolver = (t: TFunction) => {
return 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()
.superRefine((val, ctx) => {
if (val?.link) {
const link = val.link;
const eventLocationType = getEventLocationType(val.type);
if (
eventLocationType &&
!eventLocationType.default &&
eventLocationType.linkType === "static" &&
eventLocationType.urlRegExp
) {
const valid = z.string().regex(new RegExp(eventLocationType.urlRegExp)).safeParse(link).success;
if (!valid) {
const sampleUrl = eventLocationType.organizerInputPlaceholder;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [eventLocationType?.defaultValueVariable ?? "link"],
message: t("invalid_url_error_message", {
label: eventLocationType.label,
sampleUrl: sampleUrl ?? "https://cal.com",
}),
});
}
return;
}
const valid = z.string().url().optional().safeParse(link).success;
if (!valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [eventLocationType?.defaultValueVariable ?? "link"],
message: `Invalid URL`,
});
}
}
return;
})
)
.optional();
};
@@ -1,8 +1,8 @@
import type { FormValues } from "pages/event-types/[type]";
import { useFormContext } from "react-hook-form";
import type { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
import type { EventTypeAppsList } from "@calcom/app-store/utils";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
const useAppsData = () => {
const formMethods = useFormContext<FormValues>();