From b5a49068e4961991b94bcc106ccf7abb9a1acd7f Mon Sep 17 00:00:00 2001 From: Morgan Vernay Date: Mon, 9 Sep 2024 11:19:26 +0300 Subject: [PATCH 01/12] chore: EventTypeWebWrapper base pr --- .../views/event-types-single-view.tsx | 28 +--- .../eventtypes/components/EventType.tsx | 98 +----------- packages/features/eventtypes/lib/types.ts | 14 ++ .../wrappers/EventTypeWebWrapper.tsx | 151 ++++++++++++++++++ packages/platform/atoms/monorepo.ts | 1 + 5 files changed, 173 insertions(+), 119 deletions(-) create mode 100644 packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx diff --git a/apps/web/modules/event-types/views/event-types-single-view.tsx b/apps/web/modules/event-types/views/event-types-single-view.tsx index 0a256138f2cc5d..8d6a70b0916750 100644 --- a/apps/web/modules/event-types/views/event-types-single-view.tsx +++ b/apps/web/modules/event-types/views/event-types-single-view.tsx @@ -1,12 +1,9 @@ "use client"; -import { EventType } from "@calcom/features/eventtypes/components/EventType"; -import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types"; +import { EventType } from "@calcom/atoms/monorepo"; /* eslint-disable @typescript-eslint/no-empty-function */ // eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router -import { trpc } from "@calcom/trpc/react"; - import type { AppProps } from "@lib/app-providers"; import { type PageProps } from "~/event-types/views/event-types-single-view.getServerSideProps"; @@ -15,28 +12,7 @@ const EventTypePageWrapper: React.FC & { PageWrapper?: AppProps["Component"]["PageWrapper"]; getLayout?: AppProps["Component"]["getLayout"]; } = (props) => { - 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 ; + return ; }; export default EventTypePageWrapper; diff --git a/packages/features/eventtypes/components/EventType.tsx b/packages/features/eventtypes/components/EventType.tsx index 9b5180f98538f7..fa5cd1de2c1749 100644 --- a/packages/features/eventtypes/components/EventType.tsx +++ b/packages/features/eventtypes/components/EventType.tsx @@ -19,9 +19,8 @@ import { 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 type { FormValues, TabMap, EventTypeSetupProps } 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"; @@ -39,43 +38,6 @@ 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")); @@ -114,7 +76,9 @@ export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["event 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[] }) => { +export const EventType = ( + props: EventTypeSetupProps & { allActiveWorkflows?: Workflow[]; tabMap: TabMap } +) => { const { t } = useLocale(); const utils = trpc.useUtils(); const telemetry = useTelemetry(); @@ -128,7 +92,7 @@ export const EventType = (props: EventTypeSetupProps & { allActiveWorkflows?: Wo onlyInstalled: true, }); - const { eventType, locationOptions, team, teamMembers, currentUserMembership, destinationCalendar } = props; + const { eventType, team, currentUserMembership, tabMap } = props; const [isOpenAssignmentWarnDialog, setIsOpenAssignmentWarnDialog] = useState(false); const [pendingRoute, setPendingRoute] = useState(""); const leaveWithoutAssigningHosts = useRef(false); @@ -350,34 +314,6 @@ 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: ( - - ), - availability: , - team: , - limits: , - advanced: , - instant: , - recurring: , - apps: , - workflows: props.allActiveWorkflows ? ( - - ) : ( - <> - ), - webhooks: , - ai: , - } as const; const isObject = (value: T): boolean => { return value !== null && typeof value === "object" && !Array.isArray(value); }; @@ -584,31 +520,7 @@ export const EventType = (props: EventTypeSetupProps & { allActiveWorkflows?: Wo 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 ( <> + // 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; +}; + +const EventType = ({ id, ...rest }: EventTypeSetupProps & { id: number }) => { + const { eventType, locationOptions, team, teamMembers, destinationCalendar } = rest; + 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: ( + + ), + availability: , + team: , + limits: , + advanced: , + instant: , + recurring: , + apps: , + workflows: allActiveWorkflows ? ( + + ) : ( + <> + ), + webhooks: , + ai: , + } as const; + + 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 ; +}; + +export const EventTypeWebWrapper = ({ id }: EventTypeWebWrapperProps) => { + const { data: eventTypeQueryData } = trpc.viewer.eventTypes.get.useQuery({ id }); + + if (!eventTypeQueryData) return null; + + return ; +}; diff --git a/packages/platform/atoms/monorepo.ts b/packages/platform/atoms/monorepo.ts index 98c961139113c0..6b75d91aa324b6 100644 --- a/packages/platform/atoms/monorepo.ts +++ b/packages/platform/atoms/monorepo.ts @@ -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"; From 02eae067cfdc2101c51d1d4178ac62270dd9b20c Mon Sep 17 00:00:00 2001 From: Morgan Vernay Date: Mon, 9 Sep 2024 11:33:54 +0300 Subject: [PATCH 02/12] fixup! chore: EventTypeWebWrapper base pr --- packages/features/eventtypes/components/EventType.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/features/eventtypes/components/EventType.tsx b/packages/features/eventtypes/components/EventType.tsx index fa5cd1de2c1749..7166d8e03e7a31 100644 --- a/packages/features/eventtypes/components/EventType.tsx +++ b/packages/features/eventtypes/components/EventType.tsx @@ -71,7 +71,6 @@ 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"]; From 3b568f79e9e41e59d08786b4c632a7204305d150 Mon Sep 17 00:00:00 2001 From: Morgan Vernay Date: Mon, 9 Sep 2024 15:11:35 +0300 Subject: [PATCH 03/12] fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base --- .../platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx index 08e8caa34b0d60..13495dedbd9bc0 100644 --- a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx +++ b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx @@ -1,3 +1,5 @@ +"use client"; + import dynamic from "next/dynamic"; import React, { useEffect } from "react"; From 44a5c66c17b85ef3482fe877857147615196f455 Mon Sep 17 00:00:00 2001 From: Morgan Vernay Date: Fri, 13 Sep 2024 20:24:40 +0300 Subject: [PATCH 04/12] wip --- .../views/event-types-single-view.tsx | 31 ++----------------- .../wrappers/EventTypeWebWrapper.tsx | 9 +++--- 2 files changed, 7 insertions(+), 33 deletions(-) diff --git a/apps/web/modules/event-types/views/event-types-single-view.tsx b/apps/web/modules/event-types/views/event-types-single-view.tsx index cd0dfee920b298..7694f8cb6f9f2f 100644 --- a/apps/web/modules/event-types/views/event-types-single-view.tsx +++ b/apps/web/modules/event-types/views/event-types-single-view.tsx @@ -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 ? : ; + return ; }; export default EventTypePageWrapper; diff --git a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx index 13495dedbd9bc0..75f58686c1a526 100644 --- a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx +++ b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx @@ -73,9 +73,10 @@ const EventAITab = dynamic(() => export type EventTypeWebWrapperProps = { id: number; + isAppDir?: boolean; }; -const EventType = ({ id, ...rest }: EventTypeSetupProps & { id: number }) => { +const EventType = ({ id, ...rest }: EventTypeSetupProps & { id: number; isAppDir?: boolean }) => { const { eventType, locationOptions, team, teamMembers, destinationCalendar } = rest; const { data: allActiveWorkflows } = trpc.viewer.workflows.getAllActiveWorkflows.useQuery({ eventType: { @@ -100,8 +101,8 @@ const EventType = ({ id, ...rest }: EventTypeSetupProps & { id: number }) => { destinationCalendar={destinationCalendar} /> ), - availability: , - team: , + //availability: , + //team: , limits: , advanced: , instant: , @@ -149,5 +150,5 @@ export const EventTypeWebWrapper = ({ id }: EventTypeWebWrapperProps) => { if (!eventTypeQueryData) return null; - return ; + return ; }; From caf62ab33aaad73d31a7023c3f8297bf3a17cc16 Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Mon, 16 Sep 2024 16:04:13 +0530 Subject: [PATCH 05/12] fix: error --- .../modules/event-types/views/event-types-single-view.tsx | 2 +- .../atoms/event-types/wrappers/EventTypeWebWrapper.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/modules/event-types/views/event-types-single-view.tsx b/apps/web/modules/event-types/views/event-types-single-view.tsx index 7694f8cb6f9f2f..7859883cf3f094 100644 --- a/apps/web/modules/event-types/views/event-types-single-view.tsx +++ b/apps/web/modules/event-types/views/event-types-single-view.tsx @@ -5,7 +5,7 @@ import { EventType } from "@calcom/atoms/monorepo"; import type { PageProps } from "@lib/event-types/[type]/getServerSideProps"; const EventTypePageWrapper = (props: PageProps & { isAppDir?: boolean }) => { - return ; + return ; }; export default EventTypePageWrapper; diff --git a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx index 75f58686c1a526..2e45559f081393 100644 --- a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx +++ b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx @@ -72,7 +72,7 @@ const EventAITab = dynamic(() => ); export type EventTypeWebWrapperProps = { - id: number; + type: number; isAppDir?: boolean; }; @@ -145,10 +145,10 @@ const EventType = ({ id, ...rest }: EventTypeSetupProps & { id: number; isAppDir return ; }; -export const EventTypeWebWrapper = ({ id }: EventTypeWebWrapperProps) => { - const { data: eventTypeQueryData } = trpc.viewer.eventTypes.get.useQuery({ id }); +export const EventTypeWebWrapper = ({ type, isAppDir }: EventTypeWebWrapperProps) => { + const { data: eventTypeQueryData } = trpc.viewer.eventTypes.get.useQuery({ id: type }); if (!eventTypeQueryData) return null; - return ; + return ; }; From d22b114c17f0dfde68503686af2e094a6ae9361c Mon Sep 17 00:00:00 2001 From: Morgan Vernay Date: Tue, 17 Sep 2024 10:24:12 +0300 Subject: [PATCH 06/12] Event type atom wrapper handles app dir --- .../app/future/event-types/[type]/page.tsx | 6 +- .../views/event-types-single-view.tsx | 4 +- .../eventtypes/components/EventType.tsx | 612 +------------- .../eventtypes/components/EventTypeAppDir.tsx | 755 ------------------ packages/features/eventtypes/lib/types.ts | 5 +- .../event-types/hooks/useEventTypeForm.ts | 370 +++++++++ .../event-types/hooks/useHandleRouteChange.ts | 72 ++ .../wrappers/EventTypeWebWrapper.tsx | 215 ++++- 8 files changed, 681 insertions(+), 1358 deletions(-) delete mode 100644 packages/features/eventtypes/components/EventTypeAppDir.tsx create mode 100644 packages/platform/atoms/event-types/hooks/useEventTypeForm.ts create mode 100644 packages/platform/atoms/event-types/hooks/useHandleRouteChange.ts diff --git a/apps/web/app/future/event-types/[type]/page.tsx b/apps/web/app/future/event-types/[type]/page.tsx index 6cc40aa59ce0e5..d4c122dc34db86 100644 --- a/apps/web/app/future/event-types/[type]/page.tsx +++ b/apps/web/app/future/event-types/[type]/page.tsx @@ -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) => ; +const Page = (props: EventTypePageProps) => ; export default WithLayout({ getLayout: null, getData, Page })<"P">; diff --git a/apps/web/modules/event-types/views/event-types-single-view.tsx b/apps/web/modules/event-types/views/event-types-single-view.tsx index 7859883cf3f094..d860f83e4aab47 100644 --- a/apps/web/modules/event-types/views/event-types-single-view.tsx +++ b/apps/web/modules/event-types/views/event-types-single-view.tsx @@ -4,8 +4,8 @@ import { EventType } from "@calcom/atoms/monorepo"; import type { PageProps } from "@lib/event-types/[type]/getServerSideProps"; -const EventTypePageWrapper = (props: PageProps & { isAppDir?: boolean }) => { - return ; +const EventTypePageWrapper = ({ type, ...rest }: PageProps) => { + return ; }; export default EventTypePageWrapper; diff --git a/packages/features/eventtypes/components/EventType.tsx b/packages/features/eventtypes/components/EventType.tsx index 4f319856ad5d0f..932c6b1a22506f 100644 --- a/packages/features/eventtypes/components/EventType.tsx +++ b/packages/features/eventtypes/components/EventType.tsx @@ -2,46 +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, TabMap, EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types"; -import { validateIntervalLimitOrder } from "@calcom/lib"; -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"; -const ManagedEventTypeDialog = dynamic(() => import("./dialogs/ManagedEventDialog")); - -const AssignmentWarningDialog = dynamic(() => import("./dialogs/AssignmentWarningDialog")); - export type Host = { isFixed: boolean; userId: number; @@ -76,232 +55,26 @@ export type EventTypeAssignedUsers = RouterOutputs["viewer"]["eventTypes"]["get" export type EventTypeHosts = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["hosts"]; export const EventType = ( - props: EventTypeSetupProps & { allActiveWorkflows?: Workflow[]; tabMap: TabMap } + props: EventTypeSetupProps & { + allActiveWorkflows?: Workflow[]; + tabMap: TabMap; + onDelete: () => void; + onConflict: (eventTypes: ChildrenEventType[]) => void; + children?: React.ReactNode; + handleSubmit: () => void; + formMethods: UseFormReturn; + eventTypeApps?: EventTypeApps; + isUpdating: boolean; + } ) => { - 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 { formMethods, eventTypeApps } = props; + const { eventType, team, currentUserMembership, tabMap, isUpdating } = props; - const { eventType, team, currentUserMembership, tabMap } = props; - const [isOpenAssignmentWarnDialog, setIsOpenAssignmentWarnDialog] = useState(false); - const [pendingRoute, setPendingRoute] = useState(""); - const leaveWithoutAssigningHosts = useRef(false); - const isTeamEventTypeDeleted = useRef(false); const [animationParentRef] = useAutoAnimate(); - 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({ - 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"); @@ -314,211 +87,6 @@ export const EventType = ( ).length; } - const isObject = (value: T): boolean => { - return value !== null && typeof value === "object" && !Array.isArray(value); - }; - - const isArray = (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 => { - if (!isFormDirty) { - return {}; - } - const updatedFields: Partial = {}; - 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)) - 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([]); - const slug = formMethods.watch("slug") ?? eventType.slug; - // Optional prerender all tabs after 300 ms on mount return ( @@ -531,147 +99,19 @@ export const EventType = ( 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}> -
{ - 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)) - 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}> +
{tabMap[tabName]}
- {slugExistsChildrenDialogOpen.length ? ( - { - setSlugExistsChildrenDialogOpen([]); - }} - slug={slug} - onConfirm={(e: { preventDefault: () => void }) => { - e.preventDefault(); - handleSubmit(formMethods.getValues()); - telemetry.event(telemetryEventTypes.slugReplacementAction); - setSlugExistsChildrenDialogOpen([]); - }} - /> - ) : null} - + {props.children} ); }; diff --git a/packages/features/eventtypes/components/EventTypeAppDir.tsx b/packages/features/eventtypes/components/EventTypeAppDir.tsx deleted file mode 100644 index 666f5219830d42..00000000000000 --- a/packages/features/eventtypes/components/EventTypeAppDir.tsx +++ /dev/null @@ -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(false); - const [pendingRoute, setPendingRoute] = useState(""); - const leaveWithoutAssigningHosts = useRef(false); - const isTeamEventTypeDeleted = useRef(false); - const [animationParentRef] = useAutoAnimate(); - 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({ - 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: ( - - ), - availability: , - team: , - limits: , - advanced: , - instant: , - recurring: , - apps: , - workflows: props.allActiveWorkflows ? ( - - ) : ( - <> - ), - webhooks: , - ai: , - } as const; - const isObject = (value: T): boolean => { - return value !== null && typeof value === "object" && !Array.isArray(value); - }; - - const isArray = (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 => { - if (!isFormDirty) { - return {}; - } - const updatedFields: Partial = {}; - 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)) - 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([]); - 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 ( - <> - 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}> -
{ - 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)) - 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 }); - } - }}> -
{tabMap[tabName]}
- -
- {slugExistsChildrenDialogOpen.length ? ( - { - setSlugExistsChildrenDialogOpen([]); - }} - slug={slug} - onConfirm={(e: { preventDefault: () => void }) => { - e.preventDefault(); - handleSubmit(formMethods.getValues()); - telemetry.event(telemetryEventTypes.slugReplacementAction); - setSlugExistsChildrenDialogOpen([]); - }} - /> - ) : null} - - - ); -}; diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index d938b60c21bbfc..4dbe945348483b 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -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,7 +138,7 @@ export type LocationFormValues = Pick; + +export const useEventTypeForm = ({ + eventType, + onSubmit, +}: { + eventType: EventTypeSetupProps["eventType"]; + onSubmit: (data: EventTypeUpdateInput) => void; +}) => { + const { t } = useLocale(); + const bookingFields: Record = {}; + 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 ?? undefined, + requiresBookerEmailVerification: eventType.requiresBookerEmailVerification, + seatsPerTimeSlot: eventType.seatsPerTimeSlot, + seatsShowAttendees: eventType.seatsShowAttendees, + seatsShowAvailabilityCount: eventType.seatsShowAvailabilityCount, + lockTimeZoneToggleOnBookingPage: eventType.lockTimeZoneToggleOnBookingPage, + locations: eventType.locations || [], + destinationCalendar: eventType.destinationCalendar ?? undefined, + 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: Boolean(eventType.seatsPerTimeSlot ?? undefined), + 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({ + 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 = (value: T): boolean => { + return value !== null && typeof value === "object" && !Array.isArray(value); + }; + + const isArray = (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 => { + 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 = {}; + 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 () => { + const values = form.getValues(); + 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)) + 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 }; +}; diff --git a/packages/platform/atoms/event-types/hooks/useHandleRouteChange.ts b/packages/platform/atoms/event-types/hooks/useHandleRouteChange.ts new file mode 100644 index 00000000000000..0c2ca7dcf65d80 --- /dev/null +++ b/packages/platform/atoms/event-types/hooks/useHandleRouteChange.ts @@ -0,0 +1,72 @@ +// 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) => { + console.log("URL", url); + 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); + //setIsOpenAssignmentWarnDialog(true); + //setPendingRoute(url); + /* router.events.emit( + "routeChangeError", + new Error(`Aborted route change to ${url} because none was assigned to team event`) + ); + throw "Aborted"; */ + } + }; + onStart?.(handleRouteChange); + //router.events.on("routeChangeStart", handleRouteChange); + return () => { + //router.events.off("routeChangeStart", handleRouteChange); + onEnd?.(handleRouteChange); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [watchTrigger, hosts, assignedUsers, assignAllTeamMembers, onError, onStart, onEnd]); +}; diff --git a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx index 2e45559f081393..3526a78c3f0c78 100644 --- a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx +++ b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx @@ -1,12 +1,34 @@ "use client"; import dynamic from "next/dynamic"; -import React, { useEffect } from "react"; +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 @@ -72,12 +94,95 @@ const EventAITab = dynamic(() => ); export type EventTypeWebWrapperProps = { - type: number; + id: number; isAppDir?: boolean; }; -const EventType = ({ id, ...rest }: EventTypeSetupProps & { 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; + +const EventType = ({ + 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(false); + const [pendingRoute, setPendingRoute] = useState(""); const { eventType, locationOptions, team, teamMembers, destinationCalendar } = rest; + const [slugExistsChildrenDialogOpen, setSlugExistsChildrenDialogOpen] = useState([]); + 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, @@ -101,8 +206,8 @@ const EventType = ({ id, ...rest }: EventTypeSetupProps & { id: number; isAppDir destinationCalendar={destinationCalendar} /> ), - //availability: , - //team: , + availability: , + team: , limits: , advanced: , instant: , @@ -117,6 +222,37 @@ const EventType = ({ id, ...rest }: EventTypeSetupProps & { id: number; isAppDir ai: , } 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"; + } + + 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 = [ @@ -133,8 +269,9 @@ const EventType = ({ id, ...rest }: EventTypeSetupProps & { id: number; isAppDir ]; Components.forEach((C) => { + // how to preload with app dir? // @ts-expect-error Property 'render' does not exist on type 'ComponentClass - C.render.preload(); + C.render?.preload(); }); }, 300); @@ -142,13 +279,71 @@ const EventType = ({ id, ...rest }: EventTypeSetupProps & { id: number; isAppDir clearTimeout(timeout); }; }, []); - return ; + + const onDelete = () => { + isTeamEventTypeDeleted.current = true; + }; + const onConflict = (conflicts: ChildrenEventType[]) => { + setSlugExistsChildrenDialogOpen(conflicts); + }; + return ( + + <> + {slugExistsChildrenDialogOpen.length ? ( + { + setSlugExistsChildrenDialogOpen([]); + }} + slug={slug} + onConfirm={(e: { preventDefault: () => void }) => { + e.preventDefault(); + handleSubmit(); + telemetry.event(telemetryEventTypes.slugReplacementAction); + setSlugExistsChildrenDialogOpen([]); + }} + /> + ) : null} + + + + ); +}; + +const EventTypePageWrapper = ({ id, ...rest }: EventTypeSetupProps & { id: number; isAppDir?: boolean }) => { + const router = usePageRouter(); + return ; +}; + +const EventTypeAppWrapper = ({ id, ...rest }: EventTypeSetupProps & { id: number; isAppDir?: boolean }) => { + const pathname = usePathname(); + return ; }; -export const EventTypeWebWrapper = ({ type, isAppDir }: EventTypeWebWrapperProps) => { - const { data: eventTypeQueryData } = trpc.viewer.eventTypes.get.useQuery({ id: type }); +export const EventTypeWebWrapper = ({ id, isAppDir }: EventTypeWebWrapperProps & { isAppDir?: boolean }) => { + const { data: eventTypeQueryData } = trpc.viewer.eventTypes.get.useQuery({ id }); if (!eventTypeQueryData) return null; - return ; + return isAppDir ? ( + + ) : ( + + ); }; From 681159eeaa9c3ac8cfbaf9954f079a2a66a473f6 Mon Sep 17 00:00:00 2001 From: Morgan Vernay Date: Tue, 17 Sep 2024 10:28:11 +0300 Subject: [PATCH 07/12] fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base --- apps/web/app/future/event-types/[type]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/future/event-types/[type]/page.tsx b/apps/web/app/future/event-types/[type]/page.tsx index d4c122dc34db86..6bd794a2b1aac0 100644 --- a/apps/web/app/future/event-types/[type]/page.tsx +++ b/apps/web/app/future/event-types/[type]/page.tsx @@ -21,5 +21,5 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => { }; const getData = withAppDirSsr(getServerSideProps); -const Page = (props: EventTypePageProps) => ; +const Page = ({ type, ...rest }: EventTypePageProps) => ; export default WithLayout({ getLayout: null, getData, Page })<"P">; From c05b94e3fedddaf0ea718abfba406b31ed3350d3 Mon Sep 17 00:00:00 2001 From: Morgan Vernay Date: Tue, 17 Sep 2024 10:30:06 +0300 Subject: [PATCH 08/12] fixup! fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base --- .../atoms/event-types/hooks/useHandleRouteChange.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/platform/atoms/event-types/hooks/useHandleRouteChange.ts b/packages/platform/atoms/event-types/hooks/useHandleRouteChange.ts index 0c2ca7dcf65d80..ce274c55556a71 100644 --- a/packages/platform/atoms/event-types/hooks/useHandleRouteChange.ts +++ b/packages/platform/atoms/event-types/hooks/useHandleRouteChange.ts @@ -52,19 +52,10 @@ export const useHandleRouteChange = ({ }) ) { onError?.(url); - //setIsOpenAssignmentWarnDialog(true); - //setPendingRoute(url); - /* router.events.emit( - "routeChangeError", - new Error(`Aborted route change to ${url} because none was assigned to team event`) - ); - throw "Aborted"; */ } }; onStart?.(handleRouteChange); - //router.events.on("routeChangeStart", handleRouteChange); return () => { - //router.events.off("routeChangeStart", handleRouteChange); onEnd?.(handleRouteChange); }; // eslint-disable-next-line react-hooks/exhaustive-deps From 1877a251cb093082b20141ba767e82d0292215b9 Mon Sep 17 00:00:00 2001 From: Morgan Vernay Date: Tue, 17 Sep 2024 10:38:13 +0300 Subject: [PATCH 09/12] remove console log --- .../platform/atoms/event-types/hooks/useHandleRouteChange.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/platform/atoms/event-types/hooks/useHandleRouteChange.ts b/packages/platform/atoms/event-types/hooks/useHandleRouteChange.ts index ce274c55556a71..b1256b48fca66d 100644 --- a/packages/platform/atoms/event-types/hooks/useHandleRouteChange.ts +++ b/packages/platform/atoms/event-types/hooks/useHandleRouteChange.ts @@ -34,7 +34,6 @@ export const useHandleRouteChange = ({ }) => { useEffect(() => { const handleRouteChange = (url: string) => { - console.log("URL", url); const paths = url.split("/"); // If the event-type is deleted, we can't show the empty assignment warning From e2260c5ab9d839add2c182f5334b10040d311b42 Mon Sep 17 00:00:00 2001 From: Morgan Vernay Date: Tue, 17 Sep 2024 12:01:43 +0300 Subject: [PATCH 10/12] review comments --- .../wrappers/EventTypeWebWrapper.tsx | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx index 3526a78c3f0c78..bb0ec0e51d34c0 100644 --- a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx +++ b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx @@ -116,7 +116,29 @@ type EventTypePageComponentProp = { type EventTypeAppPageComponentProp = EventTypeAppComponentProp | EventTypePageComponentProp; -const EventType = ({ +export const EventTypeWebWrapper = ({ id, isAppDir }: EventTypeWebWrapperProps & { isAppDir?: boolean }) => { + const { data: eventTypeQueryData } = trpc.viewer.eventTypes.get.useQuery({ id }); + + if (!eventTypeQueryData) return null; + + return isAppDir ? ( + + ) : ( + + ); +}; + +const EventTypePageWrapper = ({ id, ...rest }: EventTypeSetupProps & { id: number }) => { + const router = usePageRouter(); + return ; +}; + +const EventTypeAppWrapper = ({ id, ...rest }: EventTypeSetupProps & { id: number }) => { + const pathname = usePathname(); + return ; +}; + +const EventTypeWeb = ({ id, isAppDir, pageRouter, @@ -242,7 +264,7 @@ const EventType = ({ throw "Aborted"; } - throw new Error(`Aborted route change to ${url} because none was assigned to team event`); + 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); @@ -325,25 +347,3 @@ const EventType = ({ ); }; - -const EventTypePageWrapper = ({ id, ...rest }: EventTypeSetupProps & { id: number; isAppDir?: boolean }) => { - const router = usePageRouter(); - return ; -}; - -const EventTypeAppWrapper = ({ id, ...rest }: EventTypeSetupProps & { id: number; isAppDir?: boolean }) => { - const pathname = usePathname(); - return ; -}; - -export const EventTypeWebWrapper = ({ id, isAppDir }: EventTypeWebWrapperProps & { isAppDir?: boolean }) => { - const { data: eventTypeQueryData } = trpc.viewer.eventTypes.get.useQuery({ id }); - - if (!eventTypeQueryData) return null; - - return isAppDir ? ( - - ) : ( - - ); -}; From b840fb2a47dea93a9254f4bd158aba908123fe8e Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Tue, 17 Sep 2024 16:17:38 +0530 Subject: [PATCH 11/12] refactor: event limits tab for atoms --- .../eventtypes/components/tabs/limits/EventLimitsTab.tsx | 4 +++- .../event-types/wrappers/EventLimitsTabWebWrapper.tsx | 8 ++++++++ .../atoms/event-types/wrappers/EventTypeWebWrapper.tsx | 4 +--- 3 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 packages/platform/atoms/event-types/wrappers/EventLimitsTabWebWrapper.tsx diff --git a/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx b/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx index 83b7e58551a883..b320369bdbb7a6 100644 --- a/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx +++ b/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx @@ -23,6 +23,8 @@ import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, Tex type IPeriodType = (typeof PeriodType)[keyof typeof PeriodType]; +export type EventLimitsTabProps = Pick; + /** * We technically have a ROLLING_WINDOW future limit option that isn't shown as a Radio Option. Because UX is better by providing it as a toggle with ROLLING Limit radio option. * Also, ROLLING_WINDOW reuses the same `periodDays` field and `periodCountCalendarDays` fields @@ -286,7 +288,7 @@ const MinimumBookingNoticeInput = React.forwardRef< ); }); -export const EventLimitsTab = ({ eventType }: Pick) => { +export const EventLimitsTab = ({ eventType }: EventLimitsTabProps) => { const { t, i18n } = useLocale(); const formMethods = useFormContext(); diff --git a/packages/platform/atoms/event-types/wrappers/EventLimitsTabWebWrapper.tsx b/packages/platform/atoms/event-types/wrappers/EventLimitsTabWebWrapper.tsx new file mode 100644 index 00000000000000..70607aeb38f30c --- /dev/null +++ b/packages/platform/atoms/event-types/wrappers/EventLimitsTabWebWrapper.tsx @@ -0,0 +1,8 @@ +import { EventLimitsTab } from "@calcom/features/eventtypes/components/tabs/limits/EventLimitsTab"; +import type { EventLimitsTabProps } from "@calcom/features/eventtypes/components/tabs/limits/EventLimitsTab"; + +const EventLimitsTabWebWrapper = (props: EventLimitsTabProps) => { + return ; +}; + +export default EventLimitsTabWebWrapper; diff --git a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx index bb0ec0e51d34c0..a73aa947c687da 100644 --- a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx +++ b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx @@ -50,9 +50,7 @@ const EventTeamAssignmentTab = dynamic(() => const EventLimitsTab = dynamic(() => // import web wrapper when it's ready - import("@calcom/features/eventtypes/components/tabs/limits/EventLimitsTab").then( - (mod) => mod.EventLimitsTab - ) + import("./EventLimitsTabWebWrapper").then((mod) => mod) ); const EventAdvancedTab = dynamic(() => From 5dbaf212cd50876ca62055813fc4ce7d8c61fddc Mon Sep 17 00:00:00 2001 From: Morgan Vernay Date: Tue, 17 Sep 2024 14:14:58 +0300 Subject: [PATCH 12/12] fixup! review comments --- .../platform/atoms/event-types/hooks/useEventTypeForm.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts index 016a3e45fa0410..f2dd90ef12ec12 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -53,19 +53,19 @@ export const useEventTypeForm = ({ beforeEventBuffer: eventType.beforeEventBuffer, eventName: eventType.eventName || "", scheduleName: eventType.scheduleName, - periodDays: eventType.periodDays ?? undefined, + periodDays: eventType.periodDays, requiresBookerEmailVerification: eventType.requiresBookerEmailVerification, seatsPerTimeSlot: eventType.seatsPerTimeSlot, seatsShowAttendees: eventType.seatsShowAttendees, seatsShowAvailabilityCount: eventType.seatsShowAvailabilityCount, lockTimeZoneToggleOnBookingPage: eventType.lockTimeZoneToggleOnBookingPage, locations: eventType.locations || [], - destinationCalendar: eventType.destinationCalendar ?? undefined, + destinationCalendar: eventType.destinationCalendar, recurringEvent: eventType.recurringEvent || null, isInstantEvent: eventType.isInstantEvent, instantMeetingExpiryTimeOffsetInSeconds: eventType.instantMeetingExpiryTimeOffsetInSeconds, description: eventType.description ?? undefined, - schedule: eventType.schedule ?? undefined, + schedule: eventType.schedule || undefined, instantMeetingSchedule: eventType.instantMeetingSchedule || undefined, bookingLimits: eventType.bookingLimits || undefined, onlyShowFirstAvailableSlot: eventType.onlyShowFirstAvailableSlot || undefined, @@ -107,7 +107,7 @@ export const useEventTypeForm = ({ .filter((slug) => slug !== eventType.slug) ?? [], }, })), - seatsPerTimeSlotEnabled: Boolean(eventType.seatsPerTimeSlot ?? undefined), + seatsPerTimeSlotEnabled: eventType.seatsPerTimeSlot, rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost, assignAllTeamMembers: eventType.assignAllTeamMembers, aiPhoneCallConfig: {