chore: EventTypeWebWrapper base pr (#16550)

* chore: EventTypeWebWrapper base pr

* fixup! chore: EventTypeWebWrapper base pr

* fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

* wip

* fix: error

* Event type atom wrapper handles app dir

* fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

* fixup! fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

* remove console log

* review comments

* fixup! review comments

* fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
This commit is contained in:
Morgan
2024-09-18 12:26:04 +03:00
committed by GitHub
co-authored by Somay Chauhan
parent 3a5325f2a8
commit 2b43d9a716
9 changed files with 831 additions and 1466 deletions
@@ -4,12 +4,12 @@ import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { EventType } from "@calcom/atoms/monorepo";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/event-types/[type]/getServerSideProps";
import type { PageProps as EventTypePageProps } from "@lib/event-types/[type]/getServerSideProps";
import EventTypePageWrapper from "~/event-types/views/event-types-single-view";
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const { eventType } = await getData(legacyCtx);
@@ -21,5 +21,5 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
};
const getData = withAppDirSsr(getServerSideProps);
const Page = (props: EventTypePageProps) => <EventTypePageWrapper {...props} isAppDir={true} />;
const Page = ({ type, ...rest }: EventTypePageProps) => <EventType {...rest} id={type} isAppDir={true} />;
export default WithLayout({ getLayout: null, getData, Page })<"P">;
@@ -1,38 +1,11 @@
"use client";
import { EventType } from "@calcom/features/eventtypes/components/EventType";
import { EventTypeAppDir } from "@calcom/features/eventtypes/components/EventTypeAppDir";
import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types";
/* eslint-disable @typescript-eslint/no-empty-function */
// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router
import { trpc } from "@calcom/trpc/react";
import { EventType } from "@calcom/atoms/monorepo";
import type { PageProps } from "@lib/event-types/[type]/getServerSideProps";
const EventTypePageWrapper = (props: PageProps & { isAppDir?: boolean }) => {
const { data } = trpc.viewer.eventTypes.get.useQuery({ id: props.type });
if (!data) return null;
const eventType = data.eventType;
const { data: workflows } = trpc.viewer.workflows.getAllActiveWorkflows.useQuery({
eventType: {
id: props.type,
teamId: eventType.teamId,
userId: eventType.userId,
parent: eventType.parent,
metadata: eventType.metadata,
},
});
const propsData = {
...(data as EventTypeSetupProps),
allActiveWorkflows: workflows,
};
return props.isAppDir ? <EventTypeAppDir {...propsData} /> : <EventType {...propsData} />;
const EventTypePageWrapper = ({ type, ...rest }: PageProps) => {
return <EventType {...rest} id={type} isAppDir={false} />;
};
export default EventTypePageWrapper;
@@ -2,84 +2,25 @@
/* 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";
import type { UseFormReturn } from "react-hook-form";
// 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 type {
TabMap,
EventTypeSetupProps,
FormValues,
EventTypeApps,
} from "@calcom/features/eventtypes/lib/types";
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 { customInputSchema } 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 { Form } 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;
@@ -109,236 +50,31 @@ const querySchema = z.object({
.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();
export const EventType = (
props: EventTypeSetupProps & {
allActiveWorkflows?: Workflow[];
tabMap: TabMap;
onDelete: () => void;
onConflict: (eventTypes: ChildrenEventType[]) => void;
children?: React.ReactNode;
handleSubmit: (values: FormValues) => void;
formMethods: UseFormReturn<FormValues>;
eventTypeApps?: EventTypeApps;
isUpdating: boolean;
}
) => {
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 { formMethods, eventTypeApps } = props;
const { eventType, team, currentUserMembership, tabMap, isUpdating } = props;
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();
await utils.viewer.eventTypes.getByViewer.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,
instantMeetingSchedule: eventType.instantMeetingSchedule || 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");
@@ -351,265 +87,8 @@ export const EventType = (props: EventTypeSetupProps & { allActiveWorkflows?: Wo
).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 (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 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
@@ -620,147 +99,19 @@ export const EventType = (props: EventTypeSetupProps & { allActiveWorkflows?: Wo
activeWebhooksNumber={eventType.webhooks.filter((webhook) => webhook.active).length}
team={team}
availability={availability}
isUpdateMutationLoading={updateMutation.isPending}
isUpdateMutationLoading={isUpdating}
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 conflicts = children.filter((child) => child.owner.eventTypeSlugs.includes(values.slug));
if (conflicts.length > 0) {
setSlugExistsChildrenDialogOpen(conflicts);
return;
}
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 });
}
}}>
onDelete={props.onDelete}>
<Form form={formMethods} id="event-type-form" handleSubmit={props.handleSubmit}>
<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}
/>
{props.children}
</>
);
};
@@ -1,755 +0,0 @@
"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";
import { usePathname } from "next/navigation";
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 EventTypeAppDir = (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 pathname = usePathname();
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,
instantMeetingSchedule: eventType.instantMeetingSchedule || 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);
// The part where it is different from the original code in `EventType` component STARTS HERE
throw new Error(`Aborted route change to ${url} because none was assigned to team event`);
}
};
handleRouteChange(pathname || "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname, eventType.hosts, eventType.children, eventType.assignAllTeamMembers]);
// The part where it is different from the original code in `EventType` component ENDS HERE
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}
/>
</>
);
};
+16 -1
View File
@@ -7,7 +7,7 @@ import type { BookerLayoutSettings, EventTypeMetaDataSchema } from "@calcom/pris
import type { customInputSchema } from "@calcom/prisma/zod-utils";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { eventTypeColor } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { RouterOutputs, RouterInputs } from "@calcom/trpc/react";
import type { IntervalLimit, RecurringEvent } from "@calcom/types/Calendar";
export type CustomInputParsed = typeof customInputSchema._output;
@@ -20,6 +20,7 @@ export type AvailabilityOption = {
};
export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"];
export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
export type EventTypeApps = RouterOutputs["viewer"]["integrations"];
export type Host = {
isFixed: boolean;
userId: number;
@@ -137,3 +138,17 @@ export type LocationFormValues = Pick<FormValues, "id" | "locations" | "bookingF
export type EventTypeAssignedUsers = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["children"];
export type EventTypeHosts = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["hosts"];
export type EventTypeUpdateInput = RouterInputs["viewer"]["eventTypes"]["update"];
export type TabMap = {
advanced: React.ReactNode;
ai?: React.ReactNode;
apps?: React.ReactNode;
availability: React.ReactNode;
instant?: React.ReactNode;
limits: React.ReactNode;
recurring: React.ReactNode;
setup: React.ReactNode;
team?: React.ReactNode;
webhooks?: React.ReactNode;
workflows?: React.ReactNode;
};
@@ -0,0 +1,369 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMemo, useState } 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 { TemplateType } from "@calcom/features/ee/cal-ai-phone/zod-utils";
import { sortHosts } from "@calcom/features/eventtypes/components/HostEditDialogs";
import type {
FormValues,
EventTypeSetupProps,
EventTypeUpdateInput,
} from "@calcom/features/eventtypes/lib/types";
import { validateIntervalLimitOrder } from "@calcom/lib";
import { locationsResolver } from "@calcom/lib/event-types/utils/locationsResolver";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { eventTypeBookingFields as eventTypeBookingFieldsSchema } from "@calcom/prisma/zod-utils";
type Fields = z.infer<typeof eventTypeBookingFieldsSchema>;
export const useEventTypeForm = ({
eventType,
onSubmit,
}: {
eventType: EventTypeSetupProps["eventType"];
onSubmit: (data: EventTypeUpdateInput) => void;
}) => {
const { t } = useLocale();
const bookingFields: Record<string, Fields[number]["name"]> = {};
const [periodDates] = useState<{ startDate: Date; endDate: Date }>({
startDate: new Date(eventType.periodStartDate || Date.now()),
endDate: new Date(eventType.periodEndDate || Date.now()),
});
eventType.bookingFields.forEach(({ name }: { name: string }) => {
bookingFields[name] = name;
});
// this is a nightmare to type, will do in follow up PR
// 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,
instantMeetingSchedule: eventType.instantMeetingSchedule || 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 form = 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: eventTypeBookingFieldsSchema,
locations: locationsResolver(t),
})
// TODO: Add schema for other fields later.
.passthrough()
),
});
const {
formState: { isDirty: isFormDirty, dirtyFields },
} = form;
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 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 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 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 (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 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,
aiPhoneCallConfig: rest.aiPhoneCallConfig
? { ...rest.aiPhoneCallConfig, templateType: rest.aiPhoneCallConfig.templateType as TemplateType }
: undefined,
} satisfies EventTypeUpdateInput;
// 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;
}, {}) as EventTypeUpdateInput;
if (dirtyFieldExists) {
onSubmit({ ...filteredPayload, id: eventType.id });
}
};
return { form, handleSubmit };
};
@@ -0,0 +1,62 @@
// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router
import { useEffect } from "react";
import type {
EventTypeAssignedUsers,
EventTypeHosts,
} from "@calcom/features/eventtypes/components/EventType";
import { checkForEmptyAssignment } from "@calcom/lib/event-types/utils/checkForEmptyAssignment";
export const useHandleRouteChange = ({
isTeamEventTypeDeleted,
isleavingWithoutAssigningHosts,
isTeamEventType,
assignedUsers,
hosts,
assignAllTeamMembers,
isManagedEventType,
onError,
onStart,
onEnd,
watchTrigger,
}: {
isTeamEventTypeDeleted: boolean;
isleavingWithoutAssigningHosts: boolean;
isTeamEventType: boolean;
assignedUsers: EventTypeAssignedUsers;
hosts: EventTypeHosts;
assignAllTeamMembers: boolean;
isManagedEventType: boolean;
watchTrigger: unknown;
onError?: (url: string) => void;
onStart?: (handleRouteChange: (url: string) => void) => void;
onEnd?: (handleRouteChange: (url: string) => void) => void;
}) => {
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) return;
if (
!!isTeamEventType &&
!isleavingWithoutAssigningHosts &&
(url === "/event-types" || paths[1] !== "event-types") &&
checkForEmptyAssignment({
assignedUsers,
hosts,
assignAllTeamMembers,
isManagedEventType,
})
) {
onError?.(url);
}
};
onStart?.(handleRouteChange);
return () => {
onEnd?.(handleRouteChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [watchTrigger, hosts, assignedUsers, assignAllTeamMembers, onError, onStart, onEnd]);
};
@@ -0,0 +1,349 @@
"use client";
import dynamic from "next/dynamic";
import { usePathname } from "next/navigation";
// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router
import { useRouter as usePageRouter } from "next/router";
// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router
import type { NextRouter as NextPageRouter } from "next/router";
import React, { useEffect, useRef, useState } from "react";
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import { EventType as EventTypeComponent } from "@calcom/features/eventtypes/components/EventType";
import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { SchedulingType } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui";
import { useEventTypeForm } from "../hooks/useEventTypeForm";
import { useHandleRouteChange } from "../hooks/useHandleRouteChange";
const ManagedEventTypeDialog = dynamic(
() => import("@calcom/features/eventtypes/components/dialogs/ManagedEventDialog")
);
const AssignmentWarningDialog = dynamic(
() => import("@calcom/features/eventtypes/components/dialogs/AssignmentWarningDialog")
);
const EventSetupTab = dynamic(() =>
// import web wrapper when it's ready
import("@calcom/features/eventtypes/components/tabs/setup/EventSetupTab").then((mod) => mod.EventSetupTab)
);
const EventAvailabilityTab = dynamic(() =>
// import web wrapper when it's ready
import("@calcom/features/eventtypes/components/tabs/availability/EventAvailabilityTab").then(
(mod) => mod.EventAvailabilityTab
)
);
const EventTeamAssignmentTab = dynamic(() =>
import("@calcom/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab").then(
(mod) => mod.EventTeamAssignmentTab
)
);
const EventLimitsTab = dynamic(() =>
// import web wrapper when it's ready
import("@calcom/features/eventtypes/components/tabs/limits/EventLimitsTab").then(
(mod) => mod.EventLimitsTab
)
);
const EventAdvancedTab = dynamic(() =>
// import web wrapper when it's ready
import("@calcom/features/eventtypes/components/tabs/advanced/EventAdvancedTab").then(
(mod) => mod.EventAdvancedTab
)
);
const EventInstantTab = dynamic(() =>
import("@calcom/features/eventtypes/components/tabs/instant/EventInstantTab").then(
(mod) => mod.EventInstantTab
)
);
const EventRecurringTab = dynamic(() =>
// import web wrapper when it's ready
import("@calcom/features/eventtypes/components/tabs/recurring/EventRecurringTab").then(
(mod) => mod.EventRecurringTab
)
);
const EventAppsTab = dynamic(() =>
import("@calcom/features/eventtypes/components/tabs/apps/EventAppsTab").then((mod) => mod.EventAppsTab)
);
const EventWorkflowsTab = dynamic(
() => import("@calcom/features/eventtypes/components/tabs/workflows/EventWorkfowsTab")
);
const EventWebhooksTab = dynamic(() =>
import("@calcom/features/eventtypes/components/tabs/webhooks/EventWebhooksTab").then(
(mod) => mod.EventWebhooksTab
)
);
const EventAITab = dynamic(() =>
import("@calcom/features/eventtypes/components/tabs/ai/EventAITab").then((mod) => mod.EventAITab)
);
export type EventTypeWebWrapperProps = {
id: number;
isAppDir?: boolean;
};
// discriminative factor: isAppDir
type EventTypeAppComponentProp = {
id: number;
isAppDir: true;
pathname: string;
pageRouter: null;
};
// discriminative factor: isAppDir
type EventTypePageComponentProp = {
id: number;
isAppDir: false;
pageRouter: NextPageRouter;
pathname: null;
};
type EventTypeAppPageComponentProp = EventTypeAppComponentProp | EventTypePageComponentProp;
export const EventTypeWebWrapper = ({ id, isAppDir }: EventTypeWebWrapperProps & { isAppDir?: boolean }) => {
const { data: eventTypeQueryData } = trpc.viewer.eventTypes.get.useQuery({ id });
if (!eventTypeQueryData) return null;
return isAppDir ? (
<EventTypeAppWrapper {...eventTypeQueryData} id={id} />
) : (
<EventTypePageWrapper {...eventTypeQueryData} id={id} />
);
};
const EventTypePageWrapper = ({ id, ...rest }: EventTypeSetupProps & { id: number }) => {
const router = usePageRouter();
return <EventTypeWeb {...rest} id={id} isAppDir={false} pageRouter={router} pathname={null} />;
};
const EventTypeAppWrapper = ({ id, ...rest }: EventTypeSetupProps & { id: number }) => {
const pathname = usePathname();
return <EventTypeWeb {...rest} id={id} isAppDir={true} pathname={pathname ?? ""} pageRouter={null} />;
};
const EventTypeWeb = ({
id,
isAppDir,
pageRouter,
pathname,
...rest
}: EventTypeSetupProps & EventTypeAppPageComponentProp) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const isTeamEventTypeDeleted = useRef(false);
const leaveWithoutAssigningHosts = useRef(false);
const telemetry = useTelemetry();
const [isOpenAssignmentWarnDialog, setIsOpenAssignmentWarnDialog] = useState<boolean>(false);
const [pendingRoute, setPendingRoute] = useState("");
const { eventType, locationOptions, team, teamMembers, destinationCalendar } = rest;
const [slugExistsChildrenDialogOpen, setSlugExistsChildrenDialogOpen] = useState<ChildrenEventType[]>([]);
const { data: eventTypeApps } = trpc.viewer.integrations.useQuery({
extendsFeature: "EventType",
teamId: eventType.team?.id || eventType.parent?.teamId,
onlyInstalled: true,
});
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
onSuccess: async () => {
const currentValues = form.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
form.reset(currentValues);
showToast(t("event_type_updated_successfully", { eventTypeTitle: eventType.title }), "success");
},
async onSettled() {
await utils.viewer.eventTypes.get.invalidate();
await utils.viewer.eventTypes.getByViewer.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 { form, handleSubmit } = useEventTypeForm({ eventType, onSubmit: updateMutation.mutate });
const slug = form.watch("slug") ?? eventType.slug;
const { data: allActiveWorkflows } = trpc.viewer.workflows.getAllActiveWorkflows.useQuery({
eventType: {
id,
teamId: eventType.teamId,
userId: eventType.userId,
parent: eventType.parent,
metadata: eventType.metadata,
},
});
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: allActiveWorkflows ? (
<EventWorkflowsTab eventType={eventType} workflows={allActiveWorkflows} />
) : (
<></>
),
webhooks: <EventWebhooksTab eventType={eventType} />,
ai: <EventAITab eventType={eventType} isTeamEvent={!!team} />,
} as const;
useHandleRouteChange({
watchTrigger: isAppDir ? pageRouter : pathname,
isTeamEventTypeDeleted: isTeamEventTypeDeleted.current,
isleavingWithoutAssigningHosts: leaveWithoutAssigningHosts.current,
isTeamEventType: !!team,
assignedUsers: eventType.children,
hosts: eventType.hosts,
assignAllTeamMembers: eventType.assignAllTeamMembers,
isManagedEventType: eventType.schedulingType === SchedulingType.MANAGED,
onError: (url) => {
setIsOpenAssignmentWarnDialog(true);
setPendingRoute(url);
if (!isAppDir) {
pageRouter.events.emit(
"routeChangeError",
new Error(`Aborted route change to ${url} because none was assigned to team event`)
);
throw "Aborted";
}
if (isAppDir) throw new Error(`Aborted route change to ${url} because none was assigned to team event`);
},
onStart: (handleRouteChange) => {
!isAppDir && pageRouter.events.on("routeChangeStart", handleRouteChange);
isAppDir && handleRouteChange(pathname || "");
},
onEnd: (handleRouteChange) => {
!isAppDir && pageRouter.events.off("routeChangeStart", handleRouteChange);
},
});
useEffect(() => {
const timeout = setTimeout(() => {
const Components = [
EventSetupTab,
EventAvailabilityTab,
EventTeamAssignmentTab,
EventLimitsTab,
EventAdvancedTab,
EventInstantTab,
EventRecurringTab,
EventAppsTab,
EventWorkflowsTab,
EventWebhooksTab,
];
Components.forEach((C) => {
// how to preload with app dir?
// @ts-expect-error Property 'render' does not exist on type 'ComponentClass
C.render?.preload();
});
}, 300);
return () => {
clearTimeout(timeout);
};
}, []);
const onDelete = () => {
isTeamEventTypeDeleted.current = true;
};
const onConflict = (conflicts: ChildrenEventType[]) => {
setSlugExistsChildrenDialogOpen(conflicts);
};
return (
<EventTypeComponent
{...rest}
allActiveWorkflows={allActiveWorkflows}
tabMap={tabMap}
onDelete={onDelete}
onConflict={onConflict}
handleSubmit={handleSubmit}
eventTypeApps={eventTypeApps}
formMethods={form}
isUpdating={updateMutation.isPending}>
<>
{slugExistsChildrenDialogOpen.length ? (
<ManagedEventTypeDialog
slugExistsChildrenDialogOpen={slugExistsChildrenDialogOpen}
isPending={form.formState.isSubmitting}
onOpenChange={() => {
setSlugExistsChildrenDialogOpen([]);
}}
slug={slug}
onConfirm={(e: { preventDefault: () => void }) => {
e.preventDefault();
handleSubmit(form.getValues());
telemetry.event(telemetryEventTypes.slugReplacementAction);
setSlugExistsChildrenDialogOpen([]);
}}
/>
) : null}
<AssignmentWarningDialog
isOpenAssignmentWarnDialog={isOpenAssignmentWarnDialog}
setIsOpenAssignmentWarnDialog={setIsOpenAssignmentWarnDialog}
pendingRoute={pendingRoute}
leaveWithoutAssigningHosts={leaveWithoutAssigningHosts}
id={eventType.id}
/>
</>
</EventTypeComponent>
);
};
+1
View File
@@ -9,3 +9,4 @@ export { Timezone } from "./timezone";
export { SelectedCalendarsSettingsWebWrapper } from "./selected-calendars/wrappers/SelectedCalendarsSettingsWebWrapper";
export { DestinationCalendarSettingsWebWrapper } from "./destination-calendar/wrappers/DestinationCalendarSettingsWebWrapper";
export * from "./availability";
export { EventTypeWebWrapper as EventType } from "./event-types/wrappers/EventTypeWebWrapper";