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:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user