From f1b4d7d9e03862481f84595d33dcf79d316c19ee Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Mon, 8 Jul 2024 22:45:57 +0530 Subject: [PATCH] feat: app install flow followup (#15193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Omar López Co-authored-by: Joe Au-Yeung --- apps/web/components/apps/AppPage.tsx | 77 +-- .../components/apps/InstallAppButtonChild.tsx | 157 +----- .../apps/installation/AccountsStepCard.tsx | 40 +- .../apps/installation/ConfigureStepCard.tsx | 95 ++-- .../EventTypeAppSettingsWrapper.tsx | 41 ++ .../EventTypeConferencingAppSettings.tsx | 118 +++++ .../components/eventtype/EventSetupTab.tsx | 405 +--------------- apps/web/components/eventtype/Locations.tsx | 447 ++++++++++++++++++ .../web/components/ui/form/LocationSelect.tsx | 8 +- apps/web/lib/apps/getServerSideProps.tsx | 6 +- .../views/event-types-single-view.tsx | 128 ++--- apps/web/pages/api/integrations/[...args].ts | 4 +- apps/web/pages/apps/[slug]/index.tsx | 1 - .../pages/apps/installation/[[...step]].tsx | 180 ++++--- .../apps/analytics/analyticsApps.e2e.ts | 68 +-- .../apps/conferencing/conferencingApps.e2e.ts | 146 ++++++ apps/web/playwright/event-types.e2e.ts | 34 +- apps/web/playwright/fixtures/apps.ts | 117 +++-- apps/web/playwright/lib/testUtils.ts | 19 + apps/web/playwright/teams.e2e.ts | 1 + packages/app-store/_appRegistry.ts | 10 +- .../_components/OmniInstallAppButton.tsx | 1 - .../_utils/oauth/decodeOAuthState.ts | 3 - .../_utils/throwIfNotHaveAdminAccessToTeam.ts | 6 +- .../app-store/_utils/useAddAppMutation.ts | 61 +-- packages/app-store/around/api/_getAdd.ts | 3 +- packages/app-store/basecamp3/api/callback.ts | 4 - packages/app-store/googlevideo/api/_getAdd.ts | 5 +- packages/app-store/huddle01video/api/add.ts | 6 +- packages/app-store/intercom/api/callback.ts | 4 - packages/app-store/jelly/api/callback.ts | 5 +- packages/app-store/jitsivideo/api/add.ts | 6 +- .../app-store/office365video/api/callback.ts | 4 - .../components/AccountDialog.tsx | 21 +- packages/app-store/ping/api/_getAdd.ts | 3 +- packages/app-store/riverside/api/_getAdd.ts | 3 +- packages/app-store/salesforce/api/callback.ts | 4 - packages/app-store/server.ts | 5 +- .../app-store/stripepayment/api/callback.ts | 4 - packages/app-store/sylapsvideo/api/add.ts | 5 +- .../app-store/tandemvideo/api/callback.ts | 7 +- packages/app-store/types.d.ts | 3 +- packages/app-store/utils.ts | 5 +- packages/app-store/webex/api/callback.ts | 7 +- packages/app-store/whereby/api/_getAdd.ts | 3 +- packages/app-store/zoho-bigin/api/callback.ts | 4 - packages/app-store/zohocrm/api/callback.ts | 4 - packages/app-store/zoomvideo/api/callback.ts | 7 +- .../embed-core/src/useCompatSearchParams.tsx | 2 +- .../ee/teams/lib/getUserAdminTeams.ts | 96 ---- packages/features/eventtypes/lib/types.ts | 2 + packages/lib/apps/getAppOnboardingUrl.ts | 6 - packages/lib/server/repository/user.ts | 26 + .../appCredentialsByType.handler.ts | 11 +- packages/ui/components/apps/AllApps.tsx | 2 +- packages/ui/components/apps/AppCard.tsx | 243 +++------- packages/ui/components/apps/appCard.test.tsx | 12 +- 57 files changed, 1471 insertions(+), 1224 deletions(-) create mode 100644 apps/web/components/apps/installation/EventTypeAppSettingsWrapper.tsx create mode 100644 apps/web/components/apps/installation/EventTypeConferencingAppSettings.tsx create mode 100644 apps/web/components/eventtype/Locations.tsx create mode 100644 apps/web/playwright/apps/conferencing/conferencingApps.e2e.ts delete mode 100644 packages/features/ee/teams/lib/getUserAdminTeams.ts diff --git a/apps/web/components/apps/AppPage.tsx b/apps/web/components/apps/AppPage.tsx index ea34189836..ac556f7045 100644 --- a/apps/web/components/apps/AppPage.tsx +++ b/apps/web/components/apps/AppPage.tsx @@ -1,12 +1,16 @@ import Link from "next/link"; +import { useRouter } from "next/navigation"; import type { IframeHTMLAttributes } from "react"; import React, { useEffect, useState } from "react"; import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation"; import { AppDependencyComponent, InstallAppButton } from "@calcom/app-store/components"; +import { doesAppSupportTeamInstall, isConferencing } from "@calcom/app-store/utils"; import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration"; +import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps"; +import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl"; import classNames from "@calcom/lib/classNames"; -import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants"; +import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS, WEBAPP_URL } from "@calcom/lib/constants"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; @@ -22,7 +26,6 @@ export type AppPageProps = { isGlobal?: AppType["isGlobal"]; logo: string; slug: string; - dirName: string | undefined; variant: string; body: React.ReactNode; categories: string[]; @@ -70,9 +73,9 @@ export const AppPage = ({ dependencies, concurrentMeetings, paid, - dirName, }: AppPageProps) => { const { t, i18n } = useLocale(); + const router = useRouter(); const searchParams = useCompatSearchParams(); const hasDescriptionItems = descriptionItems && descriptionItems.length > 0; @@ -80,13 +83,49 @@ export const AppPage = ({ const mutation = useAddAppMutation(null, { onSuccess: (data) => { if (data?.setupPending) return; + setIsLoading(false); showToast(t("app_successfully_installed"), "success"); }, onError: (error) => { if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error"); + setIsLoading(false); }, }); + /** + * @todo Refactor to eliminate the isLoading state by using mutation.isPending directly. + * Currently, the isLoading state is used to manage the loading indicator due to the delay in loading the next page, + * which is caused by heavy queries in getServersideProps. This causes the loader to turn off before the page changes. + */ + const [isLoading, setIsLoading] = useState(mutation.isPending); + + const handleAppInstall = () => { + setIsLoading(true); + if (isConferencing(categories)) { + mutation.mutate({ + type, + variant, + slug, + returnTo: + WEBAPP_URL + + getAppOnboardingUrl({ + slug, + step: AppOnboardingSteps.EVENT_TYPES_STEP, + }), + }); + } else if ( + !doesAppSupportTeamInstall({ + appCategories: categories, + concurrentMeetings: concurrentMeetings, + isPaid: !!paid, + }) + ) { + mutation.mutate({ type }); + } else { + router.push(getAppOnboardingUrl({ slug, step: AppOnboardingSteps.ACCOUNTS_STEP })); + } + }; + const priceInDollar = Intl.NumberFormat("en-US", { style: "currency", currency: "USD", @@ -220,23 +259,12 @@ export const AppPage = ({ props = { ...props, onClick: () => { - mutation.mutate({ type, variant, slug }); + handleAppInstall(); }, - loading: mutation.isPending, + loading: isLoading, }; } - return ( - - ); + return ; }} /> )} @@ -260,22 +288,13 @@ export const AppPage = ({ props = { ...props, onClick: () => { - mutation.mutate({ type, variant, slug }); + handleAppInstall(); }, - loading: mutation.isPending, + loading: isLoading, }; } return ( - + ); }} /> diff --git a/apps/web/components/apps/InstallAppButtonChild.tsx b/apps/web/components/apps/InstallAppButtonChild.tsx index 52900a523b..572680ba9f 100644 --- a/apps/web/components/apps/InstallAppButtonChild.tsx +++ b/apps/web/components/apps/InstallAppButtonChild.tsx @@ -1,77 +1,22 @@ -import { useRouter } from "next/navigation"; -import { useMemo } from "react"; - -import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation"; -import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; -import { doesAppSupportTeamInstall } from "@calcom/app-store/utils"; -import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner"; -import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams"; -import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps"; -import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl"; -import { shouldRedirectToAppOnboarding } from "@calcom/lib/apps/shouldRedirectToAppOnboarding"; -import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { RouterOutputs } from "@calcom/trpc/react"; import type { AppFrontendPayload } from "@calcom/types/App"; import type { ButtonProps } from "@calcom/ui"; -import { - Avatar, - Button, - Dropdown, - DropdownItem, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuPortal, - DropdownMenuTrigger, - showToast, -} from "@calcom/ui"; +import { Button } from "@calcom/ui"; export const InstallAppButtonChild = ({ - userAdminTeams, - addAppMutationInput, - appCategories, multiInstall, credentials, - concurrentMeetings, - dirName, paid, - onClick, ...props }: { - userAdminTeams?: UserAdminTeams; - addAppMutationInput: { type: AppFrontendPayload["type"]; variant: string; slug: string }; - appCategories: string[]; multiInstall?: boolean; credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"]; - concurrentMeetings?: boolean; paid?: AppFrontendPayload["paid"]; - dirName: string | undefined; } & ButtonProps) => { const { t } = useLocale(); - const router = useRouter(); - const mutation = useAddAppMutation(null, { - onSuccess: (data) => { - if (data?.setupPending) return; - showToast(t("app_successfully_installed"), "success"); - }, - onError: (error) => { - if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error"); - }, - }); const shouldDisableInstallation = !multiInstall ? !!(credentials && credentials.length) : false; - const appMetadata = appStoreMetadata[dirName as keyof typeof appStoreMetadata]; - const redirectToAppOnboarding = useMemo(() => shouldRedirectToAppOnboarding(appMetadata), [appMetadata]); - - const _onClick = (e: React.MouseEvent) => { - if (redirectToAppOnboarding) { - router.push( - getAppOnboardingUrl({ slug: addAppMutationInput.slug, step: AppOnboardingSteps.ACCOUNTS_STEP }) - ); - } else if (onClick) { - onClick(e); - } - }; // Paid apps don't support team installs at the moment // Also, cal.ai(the only paid app at the moment) doesn't support team install either @@ -79,7 +24,6 @@ export const InstallAppButtonChild = ({ return ( - ); - } - - if (redirectToAppOnboarding) { - return ( - - ); - } return ( - - - - - - { - if (mutation.isPending) event.preventDefault(); - }}> - {mutation.isPending && ( -
- -
- )} - {t("install_app_on")} - {userAdminTeams.map((team) => { - const isInstalled = - credentials && - credentials.some((credential) => - credential?.teamId ? credential?.teamId === team.id : credential.userId === team.id - ); - - return ( - - } - onClick={() => { - mutation.mutate( - team.isUser ? addAppMutationInput : { ...addAppMutationInput, teamId: team.id } - ); - }}> -

- {t(team.name)} {isInstalled && `(${t("installed")})`} -

-
- ); - })} -
-
-
+ ); }; diff --git a/apps/web/components/apps/installation/AccountsStepCard.tsx b/apps/web/components/apps/installation/AccountsStepCard.tsx index 1c67f70de8..f14d5c1bb6 100644 --- a/apps/web/components/apps/installation/AccountsStepCard.tsx +++ b/apps/web/components/apps/installation/AccountsStepCard.tsx @@ -2,7 +2,7 @@ import type { FC } from "react"; import React, { useState } from "react"; import { classNames } from "@calcom/lib"; -import { CAL_URL } from "@calcom/lib/constants"; +import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { Team, User } from "@calcom/prisma/client"; import { Avatar, StepCard } from "@calcom/ui"; @@ -31,7 +31,7 @@ const AccountSelector: FC = ({ className={classNames( "hover:bg-muted flex cursor-pointer flex-row items-center gap-2 p-1", (alreadyInstalled || loading) && "cursor-not-allowed", - selected && "bg-muted animate-pulse" + selected && loading && "bg-muted animate-pulse" )} data-testid={testId} onClick={() => { @@ -42,7 +42,7 @@ const AccountSelector: FC = ({ }}>
@@ -60,13 +60,20 @@ export type TeamsProp = (Pick & { })[]; type AccountStepCardProps = { - teams: TeamsProp; + teams?: TeamsProp; personalAccount: PersonalAccountProps; onSelect: (id?: number) => void; loading: boolean; + installableOnTeams: boolean; }; -export const AccountsStepCard: FC = ({ teams, personalAccount, onSelect, loading }) => { +export const AccountsStepCard: FC = ({ + teams, + personalAccount, + onSelect, + loading, + installableOnTeams, +}) => { const { t } = useLocale(); return ( @@ -80,17 +87,18 @@ export const AccountsStepCard: FC = ({ teams, personalAcco onClick={() => onSelect()} loading={loading} /> - {teams.map((team) => ( - onSelect(team.id)} - loading={loading} - /> - ))} + {installableOnTeams && + teams?.map((team) => ( + onSelect(team.id)} + loading={loading} + /> + ))}
); diff --git a/apps/web/components/apps/installation/ConfigureStepCard.tsx b/apps/web/components/apps/installation/ConfigureStepCard.tsx index 118ad0cc47..a6f762baae 100644 --- a/apps/web/components/apps/installation/ConfigureStepCard.tsx +++ b/apps/web/components/apps/installation/ConfigureStepCard.tsx @@ -1,3 +1,4 @@ +import { zodResolver } from "@hookform/resolvers/zod"; import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]"; import { X } from "lucide-react"; import type { Dispatch, SetStateAction } from "react"; @@ -6,27 +7,34 @@ import React, { forwardRef, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useFieldArray, useFormContext } from "react-hook-form"; import { useForm } from "react-hook-form"; -import type { z } from "zod"; +import { z } from "zod"; -import { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface"; -import type { EventTypeAppsList } from "@calcom/app-store/utils"; +import type { LocationObject } from "@calcom/core/location"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { AppCategories } from "@calcom/prisma/enums"; -import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; +import type { EventTypeMetaDataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils"; import { Button, Form } from "@calcom/ui"; -import useAppsData from "@lib/hooks/useAppsData"; +import EventTypeAppSettingsWrapper from "@components/apps/installation/EventTypeAppSettingsWrapper"; +import EventTypeConferencingAppSettings from "@components/apps/installation/EventTypeConferencingAppSettings"; -type TFormType = { +import { locationsResolver } from "~/event-types/views/event-types-single-view"; + +export type TFormType = { + id: number; metadata: z.infer; + locations: LocationObject[]; + bookingFields: z.infer; + seatsPerTimeSlot: number | null; }; -type ConfigureStepCardProps = { +export type ConfigureStepCardProps = { slug: string; userName: string; categories: AppCategories[]; credentialId?: number; loading?: boolean; + isConferencing: boolean; formPortalRef: React.RefObject; eventTypes: TEventType[] | undefined; setConfigureStep: Dispatch>; @@ -35,51 +43,39 @@ type ConfigureStepCardProps = { type EventTypeAppSettingsFormProps = Pick< ConfigureStepCardProps, - "slug" | "userName" | "categories" | "credentialId" | "loading" + "slug" | "userName" | "categories" | "credentialId" | "loading" | "isConferencing" > & { eventType: TEventType; handleDelete: () => void; - onSubmit: (values: z.infer) => void; -}; - -type EventTypeAppSettingsWrapperProps = Pick< - ConfigureStepCardProps, - "slug" | "userName" | "categories" | "credentialId" -> & { - eventType: TEventType; -}; - -const EventTypeAppSettingsWrapper: FC = ({ - slug, - eventType, - categories, - credentialId, -}) => { - const { getAppDataGetter, getAppDataSetter } = useAppsData(); - - useEffect(() => { - const appDataSetter = getAppDataSetter(slug as EventTypeAppsList, categories, credentialId); - appDataSetter("enabled", true); - }, []); - - return ( - - ); + onSubmit: ({ + locations, + bookingFields, + metadata, + }: { + metadata?: z.infer; + bookingFields?: z.infer; + locations?: LocationObject[]; + }) => void; }; const EventTypeAppSettingsForm = forwardRef( function EventTypeAppSettingsForm(props, ref) { - const { handleDelete, onSubmit, eventType, loading } = props; + const { handleDelete, onSubmit, eventType, loading, isConferencing } = props; + const { t } = useLocale(); const formMethods = useForm({ defaultValues: { - metadata: eventType?.metadata, + id: eventType.id, + metadata: eventType?.metadata ?? undefined, + locations: eventType?.locations ?? undefined, + bookingFields: eventType?.bookingFields ?? undefined, + seatsPerTimeSlot: eventType?.seatsPerTimeSlot ?? undefined, }, + resolver: zodResolver( + z.object({ + locations: locationsResolver(t), + }) + ), }); return ( @@ -87,8 +83,10 @@ const EventTypeAppSettingsForm = forwardRef { - const data = formMethods.getValues("metadata"); - onSubmit(data); + const metadata = formMethods.getValues("metadata"); + const locations = formMethods.getValues("locations"); + const bookingFields = formMethods.getValues("bookingFields"); + onSubmit({ metadata, locations, bookingFields }); }}>
@@ -98,13 +96,17 @@ const EventTypeAppSettingsForm = forwardRef
- + {isConferencing ? ( + + ) : ( + + )} !loading && handleDelete()} /> -
@@ -148,6 +150,7 @@ export const ConfigureStepCard: FC = ({ if (!fields.some((field) => field.selected)) { setConfigureStep(false); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [fields]); useEffect(() => { @@ -176,7 +179,7 @@ export const ConfigureStepCard: FC = ({ update(index, { ...field, selected: false, metadata: eventMetadataDb }); }} onSubmit={(data) => { - update(index, { ...field, metadata: data }); + update(index, { ...field, ...data }); setUpdatedEventTypesStatus((prev) => prev.map((item) => (item.id === field.id ? { ...item, updated: true } : item)) ); diff --git a/apps/web/components/apps/installation/EventTypeAppSettingsWrapper.tsx b/apps/web/components/apps/installation/EventTypeAppSettingsWrapper.tsx new file mode 100644 index 0000000000..d0adca57de --- /dev/null +++ b/apps/web/components/apps/installation/EventTypeAppSettingsWrapper.tsx @@ -0,0 +1,41 @@ +import type { TEventType } from "@pages/apps/installation/[[...step]]"; +import { useEffect, type FC } from "react"; + +import { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface"; +import type { EventTypeAppsList } from "@calcom/app-store/utils"; + +import useAppsData from "@lib/hooks/useAppsData"; + +import type { ConfigureStepCardProps } from "@components/apps/installation/ConfigureStepCard"; + +type EventTypeAppSettingsWrapperProps = Pick< + ConfigureStepCardProps, + "slug" | "userName" | "categories" | "credentialId" +> & { + eventType: TEventType; +}; + +const EventTypeAppSettingsWrapper: FC = ({ + slug, + eventType, + categories, + credentialId, +}) => { + const { getAppDataGetter, getAppDataSetter } = useAppsData(); + + useEffect(() => { + const appDataSetter = getAppDataSetter(slug as EventTypeAppsList, categories, credentialId); + appDataSetter("enabled", true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + ); +}; +export default EventTypeAppSettingsWrapper; diff --git a/apps/web/components/apps/installation/EventTypeConferencingAppSettings.tsx b/apps/web/components/apps/installation/EventTypeConferencingAppSettings.tsx new file mode 100644 index 0000000000..1832ac7f03 --- /dev/null +++ b/apps/web/components/apps/installation/EventTypeConferencingAppSettings.tsx @@ -0,0 +1,118 @@ +import type { TEventType } from "@pages/apps/installation/[[...step]]"; +import { useMemo } from "react"; +import { useFormContext } from "react-hook-form"; +import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form"; + +import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { SchedulingType } from "@calcom/prisma/client"; +import { trpc } from "@calcom/trpc/react"; +import { SkeletonContainer, SkeletonText } from "@calcom/ui"; +import { Skeleton, Label } from "@calcom/ui"; + +import { QueryCell } from "@lib/QueryCell"; + +import type { TFormType } from "@components/apps/installation/ConfigureStepCard"; +import type { TLocationOptions } from "@components/eventtype/Locations"; +import type { TEventTypeLocation } from "@components/eventtype/Locations"; +import Locations from "@components/eventtype/Locations"; +import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect"; + +const LocationsWrapper = ({ + eventType, + slug, +}: { + eventType: TEventType & { + locationOptions?: TLocationOptions; + }; + slug: string; +}) => { + const { t } = useLocale(); + const formMethods = useFormContext(); + + const prefillLocation = useMemo(() => { + let res: SingleValueLocationOption | undefined = undefined; + for (const item of eventType?.locationOptions || []) { + for (const option of item.options) { + if (option.slug === slug) { + res = { + ...option, + }; + } + } + return res; + } + }, [slug, eventType?.locationOptions]); + + return ( +
+ + {t("location")} + + } + setValue={formMethods.setValue as unknown as UseFormSetValue} + control={formMethods.control as unknown as Control} + formState={formMethods.formState as unknown as FormState} + /> +
+ ); +}; + +const EventTypeConferencingAppSettings = ({ eventType, slug }: { eventType: TEventType; slug: string }) => { + const locationsQuery = trpc.viewer.locationOptions.useQuery({}); + const { t } = useLocale(); + + const SkeletonLoader = () => { + return ( + + + + ); + }; + + return ( + } + success={({ data }) => { + let updatedEventType: TEventType & { + locationOptions?: TLocationOptions; + } = { ...eventType }; + + if (updatedEventType.schedulingType === SchedulingType.MANAGED) { + updatedEventType = { + ...updatedEventType, + locationOptions: [ + { + label: t("default"), + options: [ + { + label: t("members_default_location"), + value: "", + icon: "/user-check.svg", + }, + ], + }, + ...data, + ], + }; + } else { + updatedEventType = { ...updatedEventType, locationOptions: data }; + } + return ; + }} + /> + ); +}; + +export default EventTypeConferencingAppSettings; diff --git a/apps/web/components/eventtype/EventSetupTab.tsx b/apps/web/components/eventtype/EventSetupTab.tsx index f8bd97235f..7a5a765ecd 100644 --- a/apps/web/components/eventtype/EventSetupTab.tsx +++ b/apps/web/components/eventtype/EventSetupTab.tsx @@ -1,18 +1,13 @@ -import { useAutoAnimate } from "@formkit/auto-animate/react"; -import { ErrorMessage } from "@hookform/error-message"; -import { Trans } from "next-i18next"; -import Link from "next/link"; import type { EventTypeSetupProps } from "pages/event-types/[type]"; import { useEffect, useState } from "react"; -import { Controller, useFormContext, useFieldArray } from "react-hook-form"; +import { Controller, useFormContext } from "react-hook-form"; +import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form"; import type { MultiValue } from "react-select"; -import type { EventLocationType } from "@calcom/app-store/locations"; -import { getEventLocationType, MeetLocationType } from "@calcom/app-store/locations"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; -import type { FormValues } from "@calcom/features/eventtypes/lib/types"; -import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants"; +import type { FormValues, LocationFormValues } from "@calcom/features/eventtypes/lib/types"; +import { WEBSITE_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { md } from "@calcom/lib/markdownIt"; import { slugify } from "@calcom/lib/slugify"; @@ -23,54 +18,12 @@ import { SettingsToggle, Skeleton, TextField, - Icon, Editor, SkeletonContainer, SkeletonText, - Input, - PhoneInput, - Button, - showToast, } from "@calcom/ui"; -import CheckboxField from "@components/ui/form/CheckboxField"; -import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect"; -import LocationSelect from "@components/ui/form/LocationSelect"; - -const getLocationFromType = ( - type: EventLocationType["type"], - locationOptions: Pick["locationOptions"] -) => { - for (const locationOption of locationOptions) { - const option = locationOption.options.find((option) => option.value === type); - if (option) { - return option; - } - } -}; - -const getLocationInfo = ({ - eventType, - locationOptions, -}: Pick) => { - const locationAvailable = - eventType.locations && - eventType.locations.length > 0 && - locationOptions.some((op) => op.options.find((opt) => opt.value === eventType.locations[0].type)); - const locationDetails = eventType.locations && - eventType.locations.length > 0 && - !locationAvailable && { - slug: eventType.locations[0].type.replace("integrations:", "").replace(":", "-").replace("_video", ""), - name: eventType.locations[0].type - .replace("integrations:", "") - .replace(":", " ") - .replace("_video", "") - .split(" ") - .map((word) => word[0].toUpperCase() + word.slice(1)) - .join(" "), - }; - return { locationAvailable, locationDetails }; -}; +import Locations from "@components/eventtype/Locations"; const DescriptionEditor = ({ isEditable }: { isEditable: boolean }) => { const formMethods = useFormContext(); @@ -106,25 +59,13 @@ export const EventSetupTab = ( ) => { const { t } = useLocale(); const formMethods = useFormContext(); - const { eventType, team, destinationCalendar } = props; + const { eventType, team } = props; const [multipleDuration, setMultipleDuration] = useState( formMethods.getValues("metadata")?.multipleDuration ); const orgBranding = useOrgBranding(); const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled"); - const locationOptions = props.locationOptions.map((locationOption) => { - const options = locationOption.options.filter((option) => { - // Skip "Organizer's Default App" for non-team members - return !team ? option.label !== t("organizer_default_conferencing_app") : true; - }); - - return { - ...locationOption, - options, - }; - }); - const multipleDurationOptions = [ 5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480, ].map((mins) => ({ @@ -144,326 +85,6 @@ export const EventSetupTab = ( const { isChildrenManagedEventType, isManagedEventType, shouldLockIndicator, shouldLockDisableProps } = useLockedFieldsManager({ eventType, translate: t, formMethods }); - const Locations = () => { - const { t } = useLocale(); - const { - fields: locationFields, - append, - remove, - update: updateLocationField, - } = useFieldArray({ - control: formMethods.control, - name: "locations", - }); - - const [animationRef] = useAutoAnimate(); - - const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled"); - const validLocations = formMethods.getValues("locations").filter((location) => { - const eventLocation = getEventLocationType(location.type); - if (!eventLocation) { - // It's possible that the location app in use got uninstalled. - return false; - } - return true; - }); - - const defaultValue = isManagedEventType - ? locationOptions.find((op) => op.label === t("default"))?.options[0] - : undefined; - - const { locationDetails, locationAvailable } = getLocationInfo(props); - - const LocationInput = (props: { - eventLocationType: EventLocationType; - defaultValue?: string; - index: number; - }) => { - const { eventLocationType, index, ...remainingProps } = props; - if (eventLocationType?.organizerInputType === "text") { - const { defaultValue, ...rest } = remainingProps; - - return ( - { - return ( - - ); - }} - /> - ); - } else if (eventLocationType?.organizerInputType === "phone") { - const { defaultValue, ...rest } = remainingProps; - - return ( - { - return ( - - ); - }} - /> - ); - } - return null; - }; - - const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false); - const defaultInitialLocation = defaultValue || null; - const [selectedNewOption, setSelectedNewOption] = useState( - defaultInitialLocation - ); - - return ( -
-
    - {locationFields.map((field, index) => { - const eventLocationType = getEventLocationType(field.type); - const defaultLocation = field; - - const option = getLocationFromType(field.type, locationOptions); - - return ( -
  • -
    - { - if (e?.value) { - const newLocationType = e.value; - const eventLocationType = getEventLocationType(newLocationType); - if (!eventLocationType) { - return; - } - const canAddLocation = - eventLocationType.organizerInputType || - !validLocations.find((location) => location.type === newLocationType); - - if (canAddLocation) { - updateLocationField(index, { - type: newLocationType, - ...(e.credentialId && { - credentialId: e.credentialId, - teamName: e.teamName, - }), - }); - } else { - updateLocationField(index, { - type: field.type, - ...(field.credentialId && { - credentialId: field.credentialId, - teamName: field.teamName, - }), - }); - showToast(t("location_already_exists"), "warning"); - } - // Whenever location changes, we need to reset the locations item in booking questions list else it overflows - // previously added values resulting in wrong behaviour - const existingBookingFields = formMethods.getValues("bookingFields"); - const findLocation = existingBookingFields.findIndex( - (field) => field.name === "location" - ); - if (findLocation >= 0) { - existingBookingFields[findLocation] = { - ...existingBookingFields[findLocation], - type: "radioInput", - label: "", - placeholder: "", - }; - formMethods.setValue("bookingFields", existingBookingFields, { - shouldDirty: true, - }); - } - } - }} - /> - {!(shouldLockDisableProps("locations").disabled && isChildrenManagedEventType) && ( - - )} -
    - - {eventLocationType?.organizerInputType && ( -
    -
    -
    -
    - -
    - -
    - -
    -
    - { - const fieldValues = formMethods.getValues("locations")[index]; - updateLocationField(index, { - ...fieldValues, - displayLocationPublicly: e.target.checked, - }); - }} - informationIconText={t("display_location_info_badge")} - /> -
    -
    - )} -
  • - ); - })} - {(validLocations.length === 0 || showEmptyLocationSelect) && ( -
    - { - if (e?.value) { - const newLocationType = e.value; - const eventLocationType = getEventLocationType(newLocationType); - if (!eventLocationType) { - return; - } - - const canAppendLocation = - eventLocationType.organizerInputType || - !validLocations.find((location) => location.type === newLocationType); - - if (canAppendLocation) { - append({ - type: newLocationType, - ...(e.credentialId && { - credentialId: e.credentialId, - teamName: e.teamName, - }), - }); - setSelectedNewOption(e); - } else { - showToast(t("location_already_exists"), "warning"); - setSelectedNewOption(null); - } - } - }} - /> -
    - )} - {validLocations.some( - (location) => - location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar" - ) && ( -
    -
    - -
    -

    - - The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work. - Connect it - - here - - . - -

    -
    - )} - {isChildrenManagedEventType && !locationAvailable && locationDetails && ( -

    - {t("app_not_connected", { appName: locationDetails.name })}{" "} - - {t("connect_now")} - -

    - )} - {validLocations.length > 0 && !shouldLockDisableProps("locations").disabled && ( - // && !isChildrenManagedEventType : Add this to hide add-location button only when location is disabled by Admin -
  • - -
  • - )} -
-

- - Can't find the right video app? Visit our - - App Store - - . - -

-
- ); - }; const lengthLockedProps = shouldLockDisableProps("length"); const descriptionLockedProps = shouldLockDisableProps("description"); @@ -625,7 +246,19 @@ export const EventSetupTab = ( name="locations" control={formMethods.control} defaultValue={eventType.locations || []} - render={() => } + render={() => ( + } + setValue={formMethods.setValue as unknown as UseFormSetValue} + control={formMethods.control as unknown as Control} + formState={formMethods.formState as unknown as FormState} + {...props} + /> + )} /> diff --git a/apps/web/components/eventtype/Locations.tsx b/apps/web/components/eventtype/Locations.tsx new file mode 100644 index 0000000000..4f5de1f5cc --- /dev/null +++ b/apps/web/components/eventtype/Locations.tsx @@ -0,0 +1,447 @@ +import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { ErrorMessage } from "@hookform/error-message"; +import { Trans } from "next-i18next"; +import Link from "next/link"; +import type { EventTypeSetupProps } from "pages/event-types/[type]"; +import { useEffect, useState } from "react"; +import { Controller, useFieldArray } from "react-hook-form"; +import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form"; + +import type { EventLocationType } from "@calcom/app-store/locations"; +import { getEventLocationType, MeetLocationType } from "@calcom/app-store/locations"; +import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Icon, Input, PhoneInput, Button, showToast } from "@calcom/ui"; + +import CheckboxField from "@components/ui/form/CheckboxField"; +import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect"; +import LocationSelect from "@components/ui/form/LocationSelect"; + +export type TEventTypeLocation = Pick; +export type TLocationOptions = Pick["locationOptions"]; +export type TDestinationCalendar = { integration: string } | null; +export type TPrefillLocation = { credentialId?: number; type: string }; + +type LocationsProps = { + team: { id: number } | null; + destinationCalendar: TDestinationCalendar; + showAppStoreLink: boolean; + isChildrenManagedEventType?: boolean; + isManagedEventType?: boolean; + disableLocationProp?: boolean; + getValues: UseFormGetValues; + setValue: UseFormSetValue; + control: Control; + formState: FormState; + eventType: TEventTypeLocation; + locationOptions: TLocationOptions; + prefillLocation?: SingleValueLocationOption; +}; + +const getLocationFromType = (type: EventLocationType["type"], locationOptions: TLocationOptions) => { + for (const locationOption of locationOptions) { + const option = locationOption.options.find((option) => option.value === type); + if (option) { + return option; + } + } +}; + +const getLocationInfo = ({ + eventType, + locationOptions, +}: { + eventType: TEventTypeLocation; + locationOptions: TLocationOptions; +}) => { + const locationAvailable = + eventType.locations && + eventType.locations.length > 0 && + locationOptions.some((op) => op.options.find((opt) => opt.value === eventType.locations[0].type)); + const locationDetails = eventType.locations && + eventType.locations.length > 0 && + !locationAvailable && { + slug: eventType.locations[0].type.replace("integrations:", "").replace(":", "-").replace("_video", ""), + name: eventType.locations[0].type + .replace("integrations:", "") + .replace(":", " ") + .replace("_video", "") + .split(" ") + .map((word) => word[0].toUpperCase() + word.slice(1)) + .join(" "), + }; + return { locationAvailable, locationDetails }; +}; + +const Locations: React.FC = ({ + isChildrenManagedEventType, + disableLocationProp, + isManagedEventType, + getValues, + setValue, + control, + formState, + team, + eventType, + prefillLocation, + ...props +}) => { + const { t } = useLocale(); + const { + fields: locationFields, + append, + remove, + update: updateLocationField, + } = useFieldArray({ + control, + name: "locations", + }); + + const locationOptions = props.locationOptions.map((locationOption) => { + const options = locationOption.options.filter((option) => { + // Skip "Organizer's Default App" for non-team members + return !team?.id ? option.label !== t("organizer_default_conferencing_app") : true; + }); + + return { + ...locationOption, + options, + }; + }); + + const [animationRef] = useAutoAnimate(); + const seatsEnabled = !!getValues("seatsPerTimeSlot"); + + const validLocations = + getValues("locations")?.filter((location) => { + const eventLocation = getEventLocationType(location.type); + if (!eventLocation) { + // It's possible that the location app in use got uninstalled. + return false; + } + return true; + }) || []; + + const defaultValue = isManagedEventType + ? locationOptions.find((op) => op.label === t("default"))?.options[0] + : undefined; + + const { locationDetails, locationAvailable } = getLocationInfo({ + eventType, + locationOptions: props.locationOptions, + }); + + const LocationInput = (props: { + eventLocationType: EventLocationType; + defaultValue?: string; + index: number; + }) => { + const { eventLocationType, index, ...remainingProps } = props; + if (eventLocationType?.organizerInputType === "text") { + const { defaultValue, ...rest } = remainingProps; + + return ( + { + return ( + + ); + }} + /> + ); + } else if (eventLocationType?.organizerInputType === "phone") { + const { defaultValue, ...rest } = remainingProps; + + return ( + { + return ( + + ); + }} + /> + ); + } + return null; + }; + + const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false); + const defaultInitialLocation = defaultValue || null; + const [selectedNewOption, setSelectedNewOption] = useState( + defaultInitialLocation + ); + + useEffect(() => { + if (!!prefillLocation) { + const newLocationType = prefillLocation.value; + + const canAppendLocation = !validLocations.find((location) => location.type === newLocationType); + + if (canAppendLocation && !seatsEnabled) { + append({ + type: newLocationType, + credentialId: prefillLocation?.credentialId, + }); + setSelectedNewOption(prefillLocation); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [prefillLocation, seatsEnabled]); + + return ( +
+
    + {locationFields.map((field, index) => { + const eventLocationType = getEventLocationType(field.type); + const defaultLocation = field; + + const option = getLocationFromType(field.type, locationOptions); + return ( +
  • +
    + { + setShowEmptyLocationSelect(false); + if (e?.value) { + const newLocationType = e.value; + const eventLocationType = getEventLocationType(newLocationType); + if (!eventLocationType) { + return; + } + const canAddLocation = + eventLocationType.organizerInputType || + !validLocations?.find((location) => location.type === newLocationType); + + if (canAddLocation) { + updateLocationField(index, { + type: newLocationType, + ...(e.credentialId && { + credentialId: e.credentialId, + teamName: e.teamName ?? undefined, + }), + }); + } else { + updateLocationField(index, { + type: field.type, + ...(field.credentialId && { + credentialId: field.credentialId, + teamName: field.teamName ?? undefined, + }), + }); + showToast(t("location_already_exists"), "warning"); + } + // Whenever location changes, we need to reset the locations item in booking questions list else it overflows + // previously added values resulting in wrong behaviour + const existingBookingFields = getValues("bookingFields"); + const findLocation = existingBookingFields.findIndex( + (field) => field.name === "location" + ); + if (findLocation >= 0) { + existingBookingFields[findLocation] = { + ...existingBookingFields[findLocation], + type: "radioInput", + label: "", + placeholder: "", + }; + setValue("bookingFields", existingBookingFields, { + shouldDirty: true, + }); + } + } + }} + /> + {!(disableLocationProp && isChildrenManagedEventType) && ( + + )} +
    + + {eventLocationType?.organizerInputType && ( +
    +
    +
    +
    + +
    + +
    + +
    +
    + { + const fieldValues = getValues("locations")[index]; + updateLocationField(index, { + ...fieldValues, + displayLocationPublicly: e.target.checked, + }); + }} + informationIconText={t("display_location_info_badge")} + /> +
    +
    + )} +
  • + ); + })} + {(validLocations.length === 0 || showEmptyLocationSelect) && ( +
    + { + setShowEmptyLocationSelect(false); + if (e?.value) { + const newLocationType = e.value; + const eventLocationType = getEventLocationType(newLocationType); + if (!eventLocationType) { + return; + } + + const canAppendLocation = + eventLocationType.organizerInputType || + !validLocations.find((location) => location.type === newLocationType); + + if (canAppendLocation) { + append({ + type: newLocationType, + ...(e.credentialId && { + credentialId: e.credentialId, + teamName: e.teamName ?? undefined, + }), + }); + setSelectedNewOption(e); + } else { + showToast(t("location_already_exists"), "warning"); + setSelectedNewOption(null); + } + } + }} + /> +
    + )} + {validLocations.some( + (location) => + location.type === MeetLocationType && props.destinationCalendar?.integration !== "google_calendar" + ) && ( +
    +
    + +
    +

    + + The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work. + Connect it + + here + + . + +

    +
    + )} + {isChildrenManagedEventType && !locationAvailable && locationDetails && ( +

    + {t("app_not_connected", { appName: locationDetails.name })}{" "} + + {t("connect_now")} + +

    + )} + {validLocations.length > 0 && !disableLocationProp && ( + // && !isChildrenManagedEventType : Add this to hide add-location button only when location is disabled by Admin +
  • + +
  • + )} +
+ {props.showAppStoreLink && ( +

+ + Can't find the right video app? Visit our + + App Store + + . + +

+ )} +
+ ); +}; + +export default Locations; diff --git a/apps/web/components/ui/form/LocationSelect.tsx b/apps/web/components/ui/form/LocationSelect.tsx index 9c3964bcb0..681a710d6b 100644 --- a/apps/web/components/ui/form/LocationSelect.tsx +++ b/apps/web/components/ui/form/LocationSelect.tsx @@ -39,13 +39,17 @@ export default function LocationSelect(props: Props { return ( - +
+ +
); }, SingleValue: (props) => ( - +
+ +
), }} diff --git a/apps/web/lib/apps/getServerSideProps.tsx b/apps/web/lib/apps/getServerSideProps.tsx index e662f3cf17..ce6dc94cdd 100644 --- a/apps/web/lib/apps/getServerSideProps.tsx +++ b/apps/web/lib/apps/getServerSideProps.tsx @@ -2,8 +2,8 @@ import type { GetServerSidePropsContext } from "next"; import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import getUserAdminTeams from "@calcom/features/ee/teams/lib/getUserAdminTeams"; -import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams"; +import type { UserAdminTeams } from "@calcom/lib/server/repository/user"; +import { UserRepository } from "@calcom/lib/server/repository/user"; import type { AppCategories } from "@calcom/prisma/enums"; import { ssrInit } from "@server/lib/ssr"; @@ -17,7 +17,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => let appStore, userAdminTeams: UserAdminTeams; if (session?.user?.id) { - userAdminTeams = await getUserAdminTeams({ userId: session.user.id, getUserInfo: true }); + userAdminTeams = await UserRepository.getUserAdminTeams(session.user.id); appStore = await getAppRegistryWithCredentials(session.user.id, userAdminTeams); } else { appStore = await getAppRegistry(); 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 aaeadb75b6..dd2e094468 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,6 +4,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import { zodResolver } from "@hookform/resolvers/zod"; import { isValidPhoneNumber } from "libphonenumber-js"; +import type { TFunction } from "next-i18next"; import dynamic from "next/dynamic"; // eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router import { useRouter } from "next/router"; @@ -145,6 +146,69 @@ const querySchema = z.object({ export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"]; export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]; +export const locationsResolver = (t: TFunction) => { + return z + .array( + z + .object({ + type: z.string(), + address: z.string().optional(), + link: z.string().url().optional(), + phone: z + .string() + .refine((val) => isValidPhoneNumber(val)) + .optional(), + hostPhoneNumber: z + .string() + .refine((val) => isValidPhoneNumber(val)) + .optional(), + displayLocationPublicly: z.boolean().optional(), + credentialId: z.number().optional(), + teamName: z.string().optional(), + }) + .passthrough() + .superRefine((val, ctx) => { + if (val?.link) { + const link = val.link; + const eventLocationType = getEventLocationType(val.type); + if ( + eventLocationType && + !eventLocationType.default && + eventLocationType.linkType === "static" && + eventLocationType.urlRegExp + ) { + const valid = z.string().regex(new RegExp(eventLocationType.urlRegExp)).safeParse(link).success; + + if (!valid) { + const sampleUrl = eventLocationType.organizerInputPlaceholder; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [eventLocationType?.defaultValueVariable ?? "link"], + message: t("invalid_url_error_message", { + label: eventLocationType.label, + sampleUrl: sampleUrl ?? "https://cal.com", + }), + }); + } + return; + } + + const valid = z.string().url().optional().safeParse(link).success; + + if (!valid) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [eventLocationType?.defaultValueVariable ?? "link"], + message: `Invalid URL`, + }); + } + } + return; + }) + ) + .optional(); +}; + const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workflow[] }) => { const { t } = useLocale(); const utils = trpc.useUtils(); @@ -327,69 +391,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf 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: z - .array( - z - .object({ - type: z.string(), - address: z.string().optional(), - link: z.string().url().optional(), - phone: z - .string() - .refine((val) => isValidPhoneNumber(val)) - .optional(), - hostPhoneNumber: z - .string() - .refine((val) => isValidPhoneNumber(val)) - .optional(), - displayLocationPublicly: z.boolean().optional(), - credentialId: z.number().optional(), - teamName: z.string().optional(), - }) - .passthrough() - .superRefine((val, ctx) => { - if (val?.link) { - const link = val.link; - const eventLocationType = getEventLocationType(val.type); - if ( - eventLocationType && - !eventLocationType.default && - eventLocationType.linkType === "static" && - eventLocationType.urlRegExp - ) { - const valid = z - .string() - .regex(new RegExp(eventLocationType.urlRegExp)) - .safeParse(link).success; - - if (!valid) { - const sampleUrl = eventLocationType.organizerInputPlaceholder; - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: [eventLocationType?.defaultValueVariable ?? "link"], - message: t("invalid_url_error_message", { - label: eventLocationType.label, - sampleUrl: sampleUrl ?? "https://cal.com", - }), - }); - } - return; - } - - const valid = z.string().url().optional().safeParse(link).success; - - if (!valid) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: [eventLocationType?.defaultValueVariable ?? "link"], - message: `Invalid URL`, - }); - } - } - return; - }) - ) - .optional(), + locations: locationsResolver(t), }) // TODO: Add schema for other fields later. .passthrough() diff --git a/apps/web/pages/api/integrations/[...args].ts b/apps/web/pages/api/integrations/[...args].ts index f4e840b021..8bb1d30d9b 100644 --- a/apps/web/pages/api/integrations/[...args].ts +++ b/apps/web/pages/api/integrations/[...args].ts @@ -1,7 +1,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; import type { Session } from "next-auth"; -import getInstalledAppPath from "@calcom/app-store/_utils/getInstalledAppPath"; import { throwIfNotHaveAdminAccessToTeam } from "@calcom/app-store/_utils/throwIfNotHaveAdminAccessToTeam"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType"; @@ -62,7 +61,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handlers = await handlerMap[handlerKey as keyof typeof handlerMap]; if (!handlers) throw new HttpError({ statusCode: 404, message: `No handlers found for ${handlerKey}` }); const handler = handlers[apiEndpoint as keyof typeof handlers] as AppHandler; - let redirectUrl = "/apps/installed"; if (typeof handler === "undefined") throw new HttpError({ statusCode: 404, message: `API handler not found` }); @@ -70,7 +68,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { await handler(req, res); } else { await defaultIntegrationAddHandler({ user: req.session?.user, teamId: Number(teamId), ...handler }); - redirectUrl = handler.redirect?.url || getInstalledAppPath(handler); + const redirectUrl = handler.redirect?.url ?? undefined; res.json({ url: redirectUrl, newTab: handler.redirect?.newTab }); } if (!res.writableEnded) return res.status(200); diff --git a/apps/web/pages/apps/[slug]/index.tsx b/apps/web/pages/apps/[slug]/index.tsx index 1927fc8671..8eee52c3f5 100644 --- a/apps/web/pages/apps/[slug]/index.tsx +++ b/apps/web/pages/apps/[slug]/index.tsx @@ -53,7 +53,6 @@ function SingleAppPage(props: inferSSRProps) { slug={data.slug} variant={data.variant} type={data.type} - dirName={data.dirName} logo={data.logo} categories={data.categories ?? [data.category]} author={data.publisher} diff --git a/apps/web/pages/apps/installation/[[...step]].tsx b/apps/web/pages/apps/installation/[[...step]].tsx index bc3b280a51..bcb307a9eb 100644 --- a/apps/web/pages/apps/installation/[[...step]].tsx +++ b/apps/web/pages/apps/installation/[[...step]].tsx @@ -8,17 +8,22 @@ import { Toaster } from "react-hot-toast"; import { z } from "zod"; import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps"; +import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import type { EventTypeAppSettingsComponentProps, EventTypeModel } from "@calcom/app-store/types"; +import { isConferencing as isConferencingApp } from "@calcom/app-store/utils"; +import type { LocationObject } from "@calcom/core/location"; import { getLocale } from "@calcom/features/auth/lib/getLocale"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types"; import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps"; -import { getAppOnboardingRedirectUrl } from "@calcom/lib/apps/getAppOnboardingRedirectUrl"; import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl"; +import { WEBAPP_URL } from "@calcom/lib/constants"; import { CAL_URL } from "@calcom/lib/constants"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import prisma from "@calcom/prisma"; +import { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { trpc } from "@calcom/trpc/react"; import type { AppMeta } from "@calcom/types/App"; @@ -34,8 +39,13 @@ import { EventTypesStepCard } from "@components/apps/installation/EventTypesStep import { StepHeader } from "@components/apps/installation/StepHeader"; export type TEventType = EventTypeAppSettingsComponentProps["eventType"] & - Pick & { + Pick< + EventTypeModel, + "metadata" | "schedulingType" | "slug" | "requiresConfirmation" | "position" | "destinationCalendar" + > & { selected: boolean; + locations: LocationFormValues["locations"]; + bookingFields?: LocationFormValues["bookingFields"]; }; export type TEventTypesForm = { @@ -47,7 +57,6 @@ const STEPS = [ AppOnboardingSteps.EVENT_TYPES_STEP, AppOnboardingSteps.CONFIGURE_STEP, ] as const; -const MAX_NUMBER_OF_STEPS = STEPS.length; type StepType = (typeof STEPS)[number]; @@ -63,11 +72,21 @@ type StepObj = Record< type OnboardingPageProps = { appMetadata: AppMeta; step: StepType; - teams: TeamsProp; + teams?: TeamsProp; personalAccount: PersonalAccountProps; eventTypes?: TEventType[]; userName: string; credentialId?: number; + showEventTypesStep: boolean; + isConferencing: boolean; + installableOnTeams: boolean; +}; + +type TUpdateObject = { + id: number; + metadata?: z.infer; + bookingFields?: z.infer; + locations?: LocationObject[]; }; const OnboardingPage = ({ @@ -78,6 +97,9 @@ const OnboardingPage = ({ eventTypes, userName, credentialId, + showEventTypesStep, + isConferencing, + installableOnTeams, }: OnboardingPageProps) => { const { t } = useLocale(); const pathname = usePathname(); @@ -92,16 +114,15 @@ const OnboardingPage = ({ [AppOnboardingSteps.EVENT_TYPES_STEP]: { getTitle: () => `${t("select_event_types_header")}`, getDescription: (appName) => `${t("select_event_types_description", { appName })}`, - stepNumber: 2, + stepNumber: installableOnTeams ? 2 : 1, }, [AppOnboardingSteps.CONFIGURE_STEP]: { getTitle: (appName) => `${t("configure_app_header", { appName })}`, getDescription: () => `${t("configure_app_description")}`, - stepNumber: 3, + stepNumber: installableOnTeams ? 3 : 2, }, } as const; const [configureStep, setConfigureStep] = useState(false); - const [isSelectingAccount, setIsSelectingAccount] = useState(false); const currentStep: AppOnboardingSteps = useMemo(() => { if (step == AppOnboardingSteps.EVENT_TYPES_STEP && configureStep) { @@ -111,6 +132,13 @@ const OnboardingPage = ({ }, [step, configureStep]); const stepObj = STEPS_MAP[currentStep]; + const maxSteps = useMemo(() => { + if (!showEventTypesStep) { + return 1; + } + return installableOnTeams ? STEPS.length : STEPS.length - 1; + }, [showEventTypesStep, installableOnTeams]); + const utils = trpc.useContext(); const formPortalRef = useRef(null); @@ -120,9 +148,19 @@ const OnboardingPage = ({ eventTypes, }, }); + const mutation = useAddAppMutation(null, { + onSuccess: (data) => { + if (data?.setupPending) return; + showToast(t("app_successfully_installed"), "success"); + }, + onError: (error) => { + if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error"); + }, + }); useEffect(() => { eventTypes && formMethods.setValue("eventTypes", eventTypes); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [eventTypes]); const updateMutation = trpc.viewer.eventTypes.update.useMutation({ @@ -156,47 +194,22 @@ const OnboardingPage = ({ }); const handleSelectAccount = async (teamId?: number) => { - try { - setIsSelectingAccount(true); - if (appMetadata.isOAuth) { - const state = JSON.stringify({ - appOnboardingRedirectUrl: getAppOnboardingRedirectUrl(appMetadata.slug, teamId), - teamId, - }); - - const res = await fetch( - `/api/integrations/${ - appMetadata.slug == "stripe" ? "stripepayment" : appMetadata.slug - }/add?state=${state}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - } - ); - const oAuthUrl = (await res.json())?.url; - router.push(oAuthUrl); - return; - } else { - await fetch(`/api/integrations/${appMetadata.slug}/add${teamId ? `?teamId=${teamId}` : ""}`, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - router.push( + mutation.mutate({ + type: appMetadata.type, + variant: appMetadata.variant, + slug: appMetadata.slug, + ...(teamId && { teamId }), + // for oAuth apps + ...(showEventTypesStep && { + returnTo: + WEBAPP_URL + getAppOnboardingUrl({ slug: appMetadata.slug, - step: AppOnboardingSteps.EVENT_TYPES_STEP, teamId, - }) - ); - } - } catch (error) { - setIsSelectingAccount(false); - router.push(`/apps`); - } + step: AppOnboardingSteps.EVENT_TYPES_STEP, + }), + }), + }); }; const handleSetUpLater = () => { @@ -233,10 +246,21 @@ const OnboardingPage = ({ if (value.metadata?.apps?.stripe?.paymentOption === "HOLD" && value.seatsPerTimeSlot) { throw new Error(t("seats_and_no_show_fee_error")); } - return updateMutation.mutateAsync({ - id: value.id, - metadata: value.metadata, - }); + let updateObject: TUpdateObject = { id: value.id }; + if (isConferencing) { + updateObject = { + ...updateObject, + locations: value.locations, + bookingFields: value.bookingFields ? value.bookingFields : undefined, + }; + } else { + updateObject = { + ...updateObject, + metadata: value.metadata, + }; + } + + return updateMutation.mutateAsync(updateObject); }); try { await Promise.all(mutationPromises); @@ -248,14 +272,15 @@ const OnboardingPage = ({ - + {currentStep === AppOnboardingSteps.ACCOUNTS_STEP && ( )} {currentStep === AppOnboardingSteps.EVENT_TYPES_STEP && @@ -278,6 +303,7 @@ const OnboardingPage = ({ setConfigureStep={setConfigureStep} eventTypes={eventTypes} handleSetUpLater={handleSetUpLater} + isConferencing={isConferencing} /> )} @@ -385,19 +411,31 @@ const getEventTypes = async (userId: number, teamId?: number) => { users: { select: { username: true } }, seatsPerTimeSlot: true, slug: true, + locations: true, + userId: true, + destinationCalendar: true, + bookingFields: true, }, - where: teamId ? { teamId } : { userId, teamId: null }, + /** + * filter out managed events for now + * @todo: can install apps to managed event types + */ + where: teamId ? { teamId } : { userId, parent: null, teamId: null }, }) ).sort((eventTypeA, eventTypeB) => { return eventTypeB.position - eventTypeA.position; }); + if (eventTypes.length === 0) { return []; } + return eventTypes.map((item) => ({ ...item, URL: `${CAL_URL}/${item.team ? `team/${item.team.slug}` : item?.users?.[0]?.username}/${item.slug}`, selected: false, + locations: item.locations as unknown as LocationObject[], + bookingFields: eventTypeBookingFields.parse(item.bookingFields || []), })); }; @@ -434,12 +472,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const locale = await getLocale(context.req); const app = await getAppBySlug(parsedAppSlug); const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata]; - const hasEventTypes = appMetadata?.extendsFeature === "EventType"; + const extendsEventType = appMetadata?.extendsFeature === "EventType"; + + const isConferencing = isConferencingApp(appMetadata.categories); + const showEventTypesStep = extendsEventType || isConferencing; + console.log("sshowEventTypesStephowEventTypesStep: ", showEventTypesStep); if (!session?.user?.id) throw new Error(ERROR_MESSAGES.userNotAuthed); - if (!hasEventTypes) { - throw new Error(ERROR_MESSAGES.appNotExtendsEventType); - } const user = await getUser(session.user.id); @@ -460,7 +499,31 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => } if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP) { + if (!showEventTypesStep) { + return { + redirect: { + permanent: false, + destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`, + }, + }; + } eventTypes = await getEventTypes(user.id, parsedTeamIdParam); + if (isConferencing) { + const destinationCalendar = await prisma.destinationCalendar.findFirst({ + where: { + userId: user.id, + eventTypeId: null, + }, + }); + for (let index = 0; index < eventTypes.length; index++) { + let eventType = eventTypes[index]; + if (!eventType.destinationCalendar) { + eventType = { ...eventType, destinationCalendar }; + } + eventTypes[index] = eventType; + } + } + if (eventTypes.length === 0) { return { redirect: { @@ -498,6 +561,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => ...(await serverSideTranslations(locale, ["common"])), app, appMetadata, + showEventTypesStep, step: parsedStepParam, teams: teamsWithIsAppInstalled, personalAccount, @@ -505,9 +569,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => teamId: parsedTeamIdParam ?? null, userName: user.username, credentialId, + isConferencing, + // conferencing apps dont support team install + installableOnTeams: !isConferencing, } as OnboardingPageProps, }; } catch (err) { + console.log("eerrerrerrerrerrerrerrerrrr: ", err); if (err instanceof z.ZodError) { return { redirect: { permanent: false, destination: "/apps" } }; } diff --git a/apps/web/playwright/apps/analytics/analyticsApps.e2e.ts b/apps/web/playwright/apps/analytics/analyticsApps.e2e.ts index 3fa89085d1..b0838416d9 100644 --- a/apps/web/playwright/apps/analytics/analyticsApps.e2e.ts +++ b/apps/web/playwright/apps/analytics/analyticsApps.e2e.ts @@ -5,39 +5,41 @@ const ALL_APPS = ["fathom", "matomo", "plausible", "ga4", "gtm", "metapixel"]; test.describe.configure({ mode: "parallel" }); test.afterEach(({ users }) => users.deleteAll()); -test.describe("Check analytics Apps ", () => { - test("Check analytics Apps by skipping the configure step", async ({ appsPage, page, users }) => { - const user = await users.create(); - await user.apiLogin(); - await page.goto("/apps/"); - await appsPage.goToAppsCategory("analytics"); - for (const app of ALL_APPS) { - await appsPage.installAppSkipConfigure(app); - await appsPage.goBackToAppsPage(); - } - await page.goto("/event-types"); - await appsPage.goToEventType("30 min"); - await appsPage.goToAppsTab(); - await appsPage.verifyAppsInfo(0); - for (const app of ALL_APPS) { - await appsPage.activeApp(app); - } - await appsPage.verifyAppsInfo(6); +test.describe("check analytics Apps", () => { + test.describe("check analytics apps by skipping the configure step", () => { + ALL_APPS.forEach((app) => { + test(`check analytics app: ${app} by skipping the configure step`, async ({ + appsPage, + page, + users, + }) => { + const user = await users.create(); + await user.apiLogin(); + await page.goto("apps/categories/analytics"); + await appsPage.installAnalyticsAppSkipConfigure(app); + + await page.goto("/event-types"); + await appsPage.goToEventType("30 min"); + await appsPage.goToAppsTab(); + await appsPage.verifyAppsInfo(0); + await appsPage.activeApp(app); + await appsPage.verifyAppsInfo(1); + }); + }); }); - - test("Check analytics Apps using the new flow", async ({ appsPage, page, users }) => { - const user = await users.create(); - await user.apiLogin(); - const eventTypes = await user.getUserEventsAsOwner(); - const eventTypesIds = eventTypes.map((item) => item.id); - - for (const app of ALL_APPS) { - await page.goto("/apps/categories/analytics"); - await appsPage.installApp(app, eventTypesIds); - } - - for (const id of eventTypesIds) { - await appsPage.verifyAppsInfoNew(ALL_APPS, id); - } + test.describe("check analytics apps using the new flow", () => { + ALL_APPS.forEach((app) => { + test(`check analytics app: ${app}`, async ({ appsPage, page, users }) => { + const user = await users.create(); + await user.apiLogin(); + const eventTypes = await user.getUserEventsAsOwner(); + const eventTypesIds = eventTypes.map((item) => item.id); + await page.goto("/apps/categories/analytics"); + await appsPage.installAnalyticsApp(app, eventTypesIds); + for (const id of eventTypesIds) { + await appsPage.verifyAppsInfoNew(app, id); + } + }); + }); }); }); diff --git a/apps/web/playwright/apps/conferencing/conferencingApps.e2e.ts b/apps/web/playwright/apps/conferencing/conferencingApps.e2e.ts new file mode 100644 index 0000000000..de5942b9ca --- /dev/null +++ b/apps/web/playwright/apps/conferencing/conferencingApps.e2e.ts @@ -0,0 +1,146 @@ +import { test } from "../../lib/fixtures"; + +export type TApp = { + slug: string; + type: string; + organizerInputPlaceholder?: string; + label: string; +}; +type TAllApps = { + [key: string]: TApp; +}; + +const ALL_APPS: TAllApps = { + around: { + slug: "around", + type: "integrations:around_video", + organizerInputPlaceholder: "https://www.around.co/rick", + label: "Around Video", + }, + campfire: { + slug: "campfire", + type: "integrations:campfire_video", + organizerInputPlaceholder: "https://party.campfire.to/your-team", + label: "Campfire", + }, + demodesk: { + slug: "demodesk", + type: "integrations:demodesk_video", + organizerInputPlaceholder: "https://demodesk.com/meet/mylink", + label: "Demodesk", + }, + discord: { + slug: "discord", + type: "integrations:discord_video", + organizerInputPlaceholder: "https://discord.gg/420gg69", + label: "Discord", + }, + eightxeight: { + slug: "eightxeight", + type: "integrations:eightxeight_video", + organizerInputPlaceholder: "https://8x8.vc/company", + label: "8x8", + }, + "element-call": { + slug: "element-call", + type: "integrations:element-call_video", + organizerInputPlaceholder: "https://call.element.io/", + label: "Element Call", + }, + facetime: { + slug: "facetime", + type: "integrations:facetime_video", + organizerInputPlaceholder: "https://facetime.apple.com/join=#v=1&p=zU9w7QzuEe", + label: "Facetime", + }, + mirotalk: { + slug: "mirotalk", + type: "integrations:mirotalk_video", + organizerInputPlaceholder: "https://p2p.mirotalk.com/join/80085ShinyPhone", + label: "Mirotalk", + }, + ping: { + slug: "ping", + type: "integrations:ping_video", + organizerInputPlaceholder: "https://www.ping.gg/call/theo", + label: "Ping.gg", + }, + riverside: { + slug: "riverside", + type: "integrations:riverside_video", + organizerInputPlaceholder: "https://riverside.fm/studio/abc123", + label: "Riverside Video", + }, + roam: { + slug: "roam", + type: "integrations:roam_video", + organizerInputPlaceholder: "https://ro.am/r/#/p/yHwFBQrRTMuptqKYo_wu8A/huzRiHnR-np4RGYKV-c0pQ", + label: "Roam", + }, + salesroom: { + slug: "salesroom", + type: "integrations:salesroom_video", + organizerInputPlaceholder: "https://user.sr.chat", + label: "Salesroom", + }, + sirius_video: { + slug: "sirius_video", + type: "integrations:sirius_video_video", + organizerInputPlaceholder: "https://sirius.video/sebastian", + label: "Sirius Video", + }, + whereby: { + slug: "whereby", + type: "integrations:whereby_video", + label: "Whereby Video", + organizerInputPlaceholder: "https://www.whereby.com/cal", + }, +}; + +const ALL_APPS_ARRAY: TApp[] = Object.values(ALL_APPS); +/** + * @todo add tests for + * shimmervideo + * sylapsvideo + * googlevideo + * huddle + * jelly + * jistivideo + * office365video + * mirotalk + * tandemvideo + * webex + * zoomvideo + */ + +test.describe.configure({ mode: "parallel" }); +test.afterEach(({ users }) => users.deleteAll()); + +test.describe("check non-oAuth link-based conferencing apps", () => { + ALL_APPS_ARRAY.forEach((app) => { + test(`check conferencing app: ${app.slug} by skipping the configure step`, async ({ + appsPage, + page, + users, + }) => { + const user = await users.create(); + await user.apiLogin(); + await page.goto("apps/categories/conferencing"); + await appsPage.installConferencingAppSkipConfigure(app.slug); + await appsPage.verifyConferencingApp(app); + }); + }); +}); + +test.describe("check non-oAuth link-based conferencing apps using the new flow", () => { + ALL_APPS_ARRAY.forEach((app) => { + test(`can add ${app.slug} app and book with it`, async ({ appsPage, page, users }) => { + const user = await users.create(); + await user.apiLogin(); + const eventTypes = await user.getUserEventsAsOwner(); + const eventTypeIds = eventTypes.map((item) => item.id).filter((item, index) => index < 2); + await appsPage.installConferencingAppNewFlow(app, eventTypeIds); + await appsPage.verifyConferencingAppNew(app, eventTypeIds); + }); + }); +}); diff --git a/apps/web/playwright/event-types.e2e.ts b/apps/web/playwright/event-types.e2e.ts index dc8eea1755..f6233590da 100644 --- a/apps/web/playwright/event-types.e2e.ts +++ b/apps/web/playwright/event-types.e2e.ts @@ -6,7 +6,14 @@ import { randomString } from "@calcom/lib/random"; import { test } from "./lib/fixtures"; import { testBothFutureAndLegacyRoutes } from "./lib/future-legacy-routes"; -import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils"; +import { + bookTimeSlot, + createNewEventType, + gotoBookingPage, + gotoFirstEventType, + saveEventType, + selectFirstAvailableTimeSlotNextMonth, +} from "./lib/testUtils"; test.describe.configure({ mode: "parallel" }); @@ -335,11 +342,11 @@ testBothFutureAndLegacyRoutes.describe("Event Types tests", () => { // Remove Both of the locations const removeButtomId = "delete-locations.0.type"; - await page.getByTestId(removeButtomId).click(); - await page.getByTestId(removeButtomId).click(); + await page.getByTestId(removeButtomId).nth(0).click(); + await page.getByTestId(removeButtomId).nth(0).click(); // Add Multiple Organizer Phone Number options - await page.getByTestId("location-select").click(); + await page.getByTestId("location-select").last().click(); await page.locator(`text="Organizer Phone Number"`).click(); const organizerPhoneNumberInputName = (idx: number) => `locations[${idx}].hostPhoneNumber`; @@ -369,25 +376,6 @@ const selectAttendeePhoneNumber = async (page: Page) => { await page.locator(`text=${locationOptionText}`).click(); }; -async function gotoFirstEventType(page: Page) { - const $eventTypes = page.locator("[data-testid=event-types] > li a"); - const firstEventTypeElement = $eventTypes.first(); - await firstEventTypeElement.click(); - await page.waitForURL((url) => { - return !!url.pathname.match(/\/event-types\/.+/); - }); -} - -async function saveEventType(page: Page) { - await page.locator("[data-testid=update-eventtype]").click(); -} - -async function gotoBookingPage(page: Page) { - const previewLink = await page.locator("[data-testid=preview-button]").getAttribute("href"); - - await page.goto(previewLink ?? ""); -} - /** * Adds n+1 location to the event type */ diff --git a/apps/web/playwright/fixtures/apps.ts b/apps/web/playwright/fixtures/apps.ts index 179b3c05ff..2fa3499416 100644 --- a/apps/web/playwright/fixtures/apps.ts +++ b/apps/web/playwright/fixtures/apps.ts @@ -1,7 +1,13 @@ import { expect, type Page } from "@playwright/test"; -import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; -import { shouldRedirectToAppOnboarding } from "@calcom/lib/apps/shouldRedirectToAppOnboarding"; +import type { TApp } from "../apps/conferencing/conferencingApps.e2e"; +import { + bookTimeSlot, + gotoBookingPage, + gotoFirstEventType, + saveEventType, + selectFirstAvailableTimeSlotNextMonth, +} from "../lib/testUtils"; export function createAppsFixture(page: Page) { return { @@ -9,39 +15,92 @@ export function createAppsFixture(page: Page) { await page.getByTestId(`app-store-category-${category}`).nth(1).click(); await page.goto("apps/categories/analytics"); }, - installAppSkipConfigure: async (app: string) => { + installAnalyticsAppSkipConfigure: async (app: string) => { await page.getByTestId(`app-store-app-card-${app}`).click(); await page.getByTestId("install-app-button").click(); - const appMetadata = appStoreMetadata[app as keyof typeof appStoreMetadata]; - if (shouldRedirectToAppOnboarding(appMetadata)) { - await page.click('[data-testid="install-app-button-personal"]'); - await page.waitForURL(`apps/installation/event-types?slug=${app}`); - await page.click('[data-testid="set-up-later"]'); - } + await page.click('[data-testid="install-app-button-personal"]'); + await page.waitForURL(`apps/installation/event-types?slug=${app}`); + await page.click('[data-testid="set-up-later"]'); }, - installApp: async (app: string, eventTypeIds: number[]) => { + installAnalyticsApp: async (app: string, eventTypeIds: number[]) => { await page.getByTestId(`app-store-app-card-${app}`).click(); (await page.waitForSelector('[data-testid="install-app-button"]')).click(); - const appMetadata = appStoreMetadata[app as keyof typeof appStoreMetadata]; - if (shouldRedirectToAppOnboarding(appMetadata)) { - await page.click('[data-testid="install-app-button-personal"]'); - await page.waitForURL(`apps/installation/event-types?slug=${app}`); + await page.click('[data-testid="install-app-button-personal"]'); + await page.waitForURL(`apps/installation/event-types?slug=${app}`); - for (const id of eventTypeIds) { - await page.click(`[data-testid="select-event-type-${id}"]`); + for (const id of eventTypeIds) { + await page.click(`[data-testid="select-event-type-${id}"]`); + } + + await page.click(`[data-testid="save-event-types"]`); + + // adding random-tracking-id to gtm-tracking-id-input because this field is required and the test fails without it + if (app === "gtm") { + await page.waitForLoadState("domcontentloaded"); + for (let index = 0; index < eventTypeIds.length; index++) { + await page.getByTestId("gtm-tracking-id-input").nth(index).fill("random-tracking-id"); } + } + await page.click(`[data-testid="configure-step-save"]`); + await page.waitForURL("/event-types"); + }, - await page.click(`[data-testid="save-event-types"]`); + installConferencingAppSkipConfigure: async (app: string) => { + await page.getByTestId(`app-store-app-card-${app}`).click(); + await page.getByTestId("install-app-button").click(); + await page.waitForURL(`apps/installation/event-types?slug=${app}`); + await page.click('[data-testid="set-up-later"]'); + }, + verifyConferencingApp: async (app: TApp) => { + await page.goto("/event-types"); + await gotoFirstEventType(page); + await page.getByTestId("location-select").last().click(); + await page.getByTestId(`location-select-item-${app.type}`).click(); + if (app.organizerInputPlaceholder) { + await page.getByTestId(`${app.type}-location-input`).fill(app.organizerInputPlaceholder); + } + await page.locator("[data-testid=display-location]").last().check(); + await saveEventType(page); + await page.waitForLoadState("networkidle"); + await gotoBookingPage(page); + await selectFirstAvailableTimeSlotNextMonth(page); + await bookTimeSlot(page); + await page.waitForLoadState("networkidle"); - // adding random-tracking-id to gtm-tracking-id-input because this field is required and the test fails without it - if (app === "gtm") { - await page.waitForLoadState("domcontentloaded"); - for (let index = 0; index < eventTypeIds.length; index++) { - await page.getByTestId("gtm-tracking-id-input").nth(index).fill("random-tracking-id"); - } - } - await page.click(`[data-testid="configure-step-save"]`); + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + await expect(page.locator("[data-testid=where] ")).toContainText(app.label); + }, + + installConferencingAppNewFlow: async (app: TApp, eventTypeIds: number[]) => { + await page.goto("apps/categories/conferencing"); + await page.getByTestId(`app-store-app-card-${app.slug}`).click(); + await page.getByTestId("install-app-button").click(); + await page.waitForURL(`apps/installation/event-types?slug=${app.slug}`); + + for (const id of eventTypeIds) { + await page.click(`[data-testid="select-event-type-${id}"]`); + } + await page.click(`[data-testid="save-event-types"]`); + + for (let eindex = 0; eindex < eventTypeIds.length; eindex++) { + if (!app.organizerInputPlaceholder) continue; + await page.getByTestId(`${app.type}-location-input`).nth(eindex).fill(app.organizerInputPlaceholder); + } + await page.click(`[data-testid="configure-step-save"]`); + await page.waitForURL("/event-types"); + }, + + verifyConferencingAppNew: async (app: TApp, eventTypeIds: number[]) => { + for (const id of eventTypeIds) { + await page.goto(`/event-types/${id}`); + await page.waitForLoadState("networkidle"); + await gotoBookingPage(page); + await selectFirstAvailableTimeSlotNextMonth(page); + await bookTimeSlot(page, { name: `Test Testson`, email: `test@example.com` }); + await page.waitForLoadState("networkidle"); + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + await expect(page.locator("[data-testid=where] ")).toContainText(app.label); } }, goBackToAppsPage: async () => { @@ -57,14 +116,12 @@ export function createAppsFixture(page: Page) { await page.locator(`[data-testid='${app}-app-switch']`).click(); }, verifyAppsInfo: async (activeApps: number) => { - await expect(page.locator(`text=6 apps, ${activeApps} active`)).toBeVisible(); + await expect(page.locator(`text=1 apps, ${activeApps} active`)).toBeVisible(); }, - verifyAppsInfoNew: async (apps: string[], eventTypeId: number) => { + verifyAppsInfoNew: async (app: string, eventTypeId: number) => { await page.goto(`event-types/${eventTypeId}?tabName=apps`); await page.waitForLoadState("domcontentloaded"); - for (const app of apps) { - await expect(page.locator(`[data-testid='${app}-app-switch'][data-state="checked"]`)).toBeVisible(); - } + await expect(page.locator(`[data-testid='${app}-app-switch'][data-state="checked"]`)).toBeVisible(); }, }; } diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index 01720d5947..19ab59aebf 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -371,3 +371,22 @@ export async function doOnOrgDomain( // When App directory is there, this is the 404 page text. We should work on fixing the 404 page as it changed due to app directory. export const NotFoundPageTextAppDir = "This page does not exist."; // export const NotFoundPageText = "ERROR 404"; + +export async function gotoFirstEventType(page: Page) { + const $eventTypes = page.locator("[data-testid=event-types] > li a"); + const firstEventTypeElement = $eventTypes.first(); + await firstEventTypeElement.click(); + await page.waitForURL((url) => { + return !!url.pathname.match(/\/event-types\/.+/); + }); +} + +export async function gotoBookingPage(page: Page) { + const previewLink = await page.locator("[data-testid=preview-button]").getAttribute("href"); + + await page.goto(previewLink ?? ""); +} + +export async function saveEventType(page: Page) { + await page.locator("[data-testid=update-eventtype]").click(); +} diff --git a/apps/web/playwright/teams.e2e.ts b/apps/web/playwright/teams.e2e.ts index 0a10459fb9..123cc143c1 100644 --- a/apps/web/playwright/teams.e2e.ts +++ b/apps/web/playwright/teams.e2e.ts @@ -45,6 +45,7 @@ testBothFutureAndLegacyRoutes.describe("Teams - NonOrg", (routeVariant) => { await user.apiLogin(); page.goto(`/settings/teams/${team.id}/onboard-members`); + await page.waitForLoadState("networkidle"); await test.step("Can add members", async () => { // Click [data-testid="new-member-button"] diff --git a/packages/app-store/_appRegistry.ts b/packages/app-store/_appRegistry.ts index 40a838772d..a6b3d9d73a 100644 --- a/packages/app-store/_appRegistry.ts +++ b/packages/app-store/_appRegistry.ts @@ -1,7 +1,7 @@ import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { getAppFromSlug } from "@calcom/app-store/utils"; -import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams"; import getInstallCountPerApp from "@calcom/lib/apps/getInstallCountPerApp"; +import type { UserAdminTeams } from "@calcom/lib/server/repository/user"; import prisma, { safeAppSelect, safeCredentialSelect } from "@calcom/prisma"; import { userMetadata } from "@calcom/prisma/zod-utils"; import type { AppFrontendPayload as App } from "@calcom/types/App"; @@ -58,19 +58,13 @@ export async function getAppRegistry() { export async function getAppRegistryWithCredentials(userId: number, userAdminTeams: UserAdminTeams = []) { // Get teamIds to grab existing credentials - const teamIds = []; - for (const team of userAdminTeams) { - if (!team.isUser) { - teamIds.push(team.id); - } - } const dbApps = await prisma.app.findMany({ where: { enabled: true }, select: { ...safeAppSelect, credentials: { - where: { OR: [{ userId }, { teamId: { in: teamIds } }] }, + where: { OR: [{ userId }, { teamId: { in: userAdminTeams } }] }, select: safeCredentialSelect, }, }, diff --git a/packages/app-store/_components/OmniInstallAppButton.tsx b/packages/app-store/_components/OmniInstallAppButton.tsx index c3848f3cdf..f772ed8683 100644 --- a/packages/app-store/_components/OmniInstallAppButton.tsx +++ b/packages/app-store/_components/OmniInstallAppButton.tsx @@ -61,7 +61,6 @@ export default function OmniInstallAppButton({ type: app.type, variant: app.variant, slug: app.slug, - isOmniInstall: true, ...(teamId && { teamId }), }); }, diff --git a/packages/app-store/_utils/oauth/decodeOAuthState.ts b/packages/app-store/_utils/oauth/decodeOAuthState.ts index 2a432997ef..c300a808c6 100644 --- a/packages/app-store/_utils/oauth/decodeOAuthState.ts +++ b/packages/app-store/_utils/oauth/decodeOAuthState.ts @@ -7,9 +7,6 @@ export function decodeOAuthState(req: NextApiRequest) { return undefined; } const state: IntegrationOAuthCallbackState = JSON.parse(req.query.state); - if (state.appOnboardingRedirectUrl) { - state.appOnboardingRedirectUrl = decodeURIComponent(state.appOnboardingRedirectUrl); - } return state; } diff --git a/packages/app-store/_utils/throwIfNotHaveAdminAccessToTeam.ts b/packages/app-store/_utils/throwIfNotHaveAdminAccessToTeam.ts index 63b9cf9105..dbbeba1d63 100644 --- a/packages/app-store/_utils/throwIfNotHaveAdminAccessToTeam.ts +++ b/packages/app-store/_utils/throwIfNotHaveAdminAccessToTeam.ts @@ -1,5 +1,5 @@ -import getUserAdminTeams from "@calcom/features/ee/teams/lib/getUserAdminTeams"; import { HttpError } from "@calcom/lib/http-error"; +import { UserRepository } from "@calcom/lib/server/repository/user"; export const throwIfNotHaveAdminAccessToTeam = async ({ teamId, @@ -11,8 +11,8 @@ export const throwIfNotHaveAdminAccessToTeam = async ({ if (!teamId) { return; } - const teamsUserHasAdminAccessFor = await getUserAdminTeams({ userId }); - const hasAdminAccessToTeam = teamsUserHasAdminAccessFor.some((team) => team.id === teamId); + const teamsUserHasAdminAccessFor = await UserRepository.getUserAdminTeams(userId); + const hasAdminAccessToTeam = teamsUserHasAdminAccessFor.some((id) => id === teamId); if (!hasAdminAccessToTeam) { throw new HttpError({ statusCode: 401, message: "You must be an admin of the team to do this" }); diff --git a/packages/app-store/_utils/useAddAppMutation.ts b/packages/app-store/_utils/useAddAppMutation.ts index 537b0b739d..9a34640f61 100644 --- a/packages/app-store/_utils/useAddAppMutation.ts +++ b/packages/app-store/_utils/useAddAppMutation.ts @@ -6,8 +6,6 @@ import type { IntegrationOAuthCallbackState } from "@calcom/app-store/types"; import { WEBAPP_URL } from "@calcom/lib/constants"; import type { App } from "@calcom/types/App"; -import getInstalledAppPath from "./getInstalledAppPath"; - function gotoUrl(url: string, newTab?: boolean) { if (newTab) { window.open(url, "_blank"); @@ -27,17 +25,7 @@ type UseAddAppMutationOptions = CustomUseMutationOptions & { returnTo?: string; }; -type useAddAppMutationVariables = { - type?: App["type"]; - variant?: string; - slug?: string; - isOmniInstall?: boolean; - teamId?: number; - onErrorReturnTo?: string; -}; - -function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMutationOptions) { - const { returnTo, ...options } = allOptions || {}; +function useAddAppMutation(_type: App["type"] | null, options?: UseAddAppMutationOptions) { const pathname = usePathname(); const onErrorReturnTo = `${WEBAPP_URL}${pathname}`; @@ -48,8 +36,8 @@ function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMuta type?: App["type"]; variant?: string; slug?: string; - isOmniInstall?: boolean; teamId?: number; + returnTo?: string; defaultInstall?: boolean; } | "" @@ -57,13 +45,16 @@ function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMuta ...options, mutationFn: async (variables) => { let type: string | null | undefined; - let isOmniInstall; const teamId = variables && variables.teamId ? variables.teamId : undefined; const defaultInstall = variables && variables.defaultInstall ? variables.defaultInstall : undefined; + const returnTo = options?.returnTo + ? options.returnTo + : variables && variables.returnTo + ? variables.returnTo + : undefined; if (variables === "") { type = _type; } else { - isOmniInstall = variables.isOmniInstall; type = variables.type; } if (type?.endsWith("_other_calendar")) { @@ -74,22 +65,20 @@ function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMuta throw new Error("Could not install Google Meet"); const state: IntegrationOAuthCallbackState = { - returnTo: - returnTo || - WEBAPP_URL + - getInstalledAppPath( - { variant: variables && variables.variant, slug: variables && variables.slug }, - location.search - ), onErrorReturnTo, fromApp: true, ...(type === "google_calendar" && { installGoogleVideo: options?.installGoogleVideo }), ...(teamId && { teamId }), + ...(returnTo && { returnTo }), ...(defaultInstall && { defaultInstall }), }; const stateStr = JSON.stringify(state); - const searchParams = generateSearchParamString({ stateStr, teamId, returnTo }); + const searchParams = generateSearchParamString({ + stateStr, + teamId, + returnTo, + }); const res = await fetch(`/api/integrations/${type}/add${searchParams}`); @@ -99,23 +88,25 @@ function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMuta } const json = await res.json(); - const externalUrl = /https?:\/\//.test(json.url) && !json.url.startsWith(window.location.origin); - if (!isOmniInstall) { - gotoUrl(json.url, json.newTab); - return { setupPending: externalUrl || json.url.endsWith("/setup") }; - } - - // Skip redirection only if it is an OmniInstall and redirect URL isn't of some other origin - // This allows installation of apps like Stripe to still redirect to their authentication pages. + const externalUrl = /https?:\/\//.test(json?.url) && !json?.url?.startsWith(window.location.origin); // Check first that the URL is absolute, then check that it is of different origin from the current. if (externalUrl) { // TODO: For Omni installation to authenticate and come back to the page where installation was initiated, some changes need to be done in all apps' add callbacks gotoUrl(json.url, json.newTab); - return { setupPending: externalUrl }; + return { setupPending: !json.newTab }; + } else if (json.url) { + gotoUrl(json.url, json.newTab); + return { + setupPending: + json?.url?.endsWith("/setup") || json?.url?.includes("/apps/installation/event-types"), + }; + } else if (returnTo) { + gotoUrl(returnTo, false); + return { setupPending: true }; + } else { + return { setupPending: false }; } - - return { setupPending: externalUrl || json.url.endsWith("/setup") }; }, }); diff --git a/packages/app-store/around/api/_getAdd.ts b/packages/app-store/around/api/_getAdd.ts index 839a2d182d..17b7933dcf 100644 --- a/packages/app-store/around/api/_getAdd.ts +++ b/packages/app-store/around/api/_getAdd.ts @@ -13,6 +13,7 @@ export async function getHandler(req: NextApiRequest) { const variant = appConfig.variant; const appType = appConfig.type; const teamId = req.query.teamId ? Number(req.query.teamId) : undefined; + const returnTo = req.query?.returnTo; await checkInstalled(slug, session.user.id); await createDefaultInstallation({ @@ -23,7 +24,7 @@ export async function getHandler(req: NextApiRequest) { teamId, }); - return { url: getInstalledAppPath({ variant, slug }) }; + return { url: returnTo ?? getInstalledAppPath({ variant, slug }) }; } export default defaultResponder(getHandler); diff --git a/packages/app-store/basecamp3/api/callback.ts b/packages/app-store/basecamp3/api/callback.ts index fb121835f5..58ea38bf3c 100644 --- a/packages/app-store/basecamp3/api/callback.ts +++ b/packages/app-store/basecamp3/api/callback.ts @@ -89,9 +89,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const state = decodeOAuthState(req); - if (state?.appOnboardingRedirectUrl && state.appOnboardingRedirectUrl !== "") { - return res.redirect(state.appOnboardingRedirectUrl); - } - res.redirect(getInstalledAppPath({ variant: appConfig.variant, slug: appConfig.slug })); } diff --git a/packages/app-store/googlevideo/api/_getAdd.ts b/packages/app-store/googlevideo/api/_getAdd.ts index 40dc19847d..f310528d02 100644 --- a/packages/app-store/googlevideo/api/_getAdd.ts +++ b/packages/app-store/googlevideo/api/_getAdd.ts @@ -9,6 +9,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(401).json({ message: "You must be logged in to do this" }); } const appType = "google_video"; + const returnTo = req.query?.returnTo; try { const alreadyInstalled = await prisma.credential.findFirst({ where: { @@ -36,5 +37,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } return res.status(500); } - return res.status(200).json({ url: getInstalledAppPath({ variant: "conferencing", slug: "google-meet" }) }); + return res + .status(200) + .json({ url: returnTo ?? getInstalledAppPath({ variant: "conferencing", slug: "google-meet" }) }); } diff --git a/packages/app-store/huddle01video/api/add.ts b/packages/app-store/huddle01video/api/add.ts index fa4d8b4146..62708dee6c 100644 --- a/packages/app-store/huddle01video/api/add.ts +++ b/packages/app-store/huddle01video/api/add.ts @@ -14,7 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (!req.session?.user?.id) { return res.status(401).json({ message: "You must be logged in to do this" }); } - const { teamId } = req.query; + const { teamId, returnTo } = req.query; await throwIfNotHaveAdminAccessToTeam({ teamId: Number(teamId) ?? null, userId: req.session.user.id }); const installForObject = teamId ? { teamId: Number(teamId) } : { userId: req.session.user.id }; @@ -48,5 +48,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(500); } // need to return a json object with the response status - return res.status(200).json({ url: getInstalledAppPath({ variant: "conferencing", slug: "huddle01" }) }); + return res + .status(200) + .json({ url: returnTo ?? getInstalledAppPath({ variant: "conferencing", slug: "huddle01" }) }); } diff --git a/packages/app-store/intercom/api/callback.ts b/packages/app-store/intercom/api/callback.ts index 64c05ceb5d..3ec59c1c6c 100644 --- a/packages/app-store/intercom/api/callback.ts +++ b/packages/app-store/intercom/api/callback.ts @@ -88,10 +88,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const state = decodeOAuthState(req); - if (state?.appOnboardingRedirectUrl && state.appOnboardingRedirectUrl !== "") { - return res.redirect(state.appOnboardingRedirectUrl); - } - res.redirect( getSafeRedirectUrl(`${WEBAPP_URL}/apps/installed/automation?hl=intercom`) ?? getInstalledAppPath({ variant: "automation", slug: "intercom" }) diff --git a/packages/app-store/jelly/api/callback.ts b/packages/app-store/jelly/api/callback.ts index 714173cde4..2e7ee26c4e 100644 --- a/packages/app-store/jelly/api/callback.ts +++ b/packages/app-store/jelly/api/callback.ts @@ -1,5 +1,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import prisma from "@calcom/prisma"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; @@ -60,5 +61,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) setDefaultConferencingApp(userId, "jelly"); } - res.redirect(getInstalledAppPath({ variant: "conferencing", slug: "jelly" })); + res.redirect( + getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "conferencing", slug: "jelly" }) + ); } diff --git a/packages/app-store/jitsivideo/api/add.ts b/packages/app-store/jitsivideo/api/add.ts index 51e7a8528e..45df37e61d 100644 --- a/packages/app-store/jitsivideo/api/add.ts +++ b/packages/app-store/jitsivideo/api/add.ts @@ -14,7 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (!req.session?.user?.id) { return res.status(401).json({ message: "You must be logged in to do this" }); } - const { teamId } = req.query; + const { teamId, returnTo } = req.query; await throwIfNotHaveAdminAccessToTeam({ teamId: Number(teamId) ?? null, userId: req.session.user.id }); @@ -47,5 +47,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } return res.status(500); } - return res.status(200).json({ url: getInstalledAppPath({ variant: "conferencing", slug: "jitsi" }) }); + return res + .status(200) + .json({ url: returnTo ?? getInstalledAppPath({ variant: "conferencing", slug: "jitsi" }) }); } diff --git a/packages/app-store/office365video/api/callback.ts b/packages/app-store/office365video/api/callback.ts index cc1da5656d..8d13922560 100644 --- a/packages/app-store/office365video/api/callback.ts +++ b/packages/app-store/office365video/api/callback.ts @@ -104,10 +104,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await createOAuthAppCredential({ appId: "msteams", type: "office365_video" }, responseBody, req); - if (state?.appOnboardingRedirectUrl && state.appOnboardingRedirectUrl !== "") { - return res.redirect(state.appOnboardingRedirectUrl); - } - return res.redirect( getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "conferencing", slug: "msteams" }) ); diff --git a/packages/app-store/office365video/components/AccountDialog.tsx b/packages/app-store/office365video/components/AccountDialog.tsx index 257f14eedb..b23513065a 100644 --- a/packages/app-store/office365video/components/AccountDialog.tsx +++ b/packages/app-store/office365video/components/AccountDialog.tsx @@ -1,3 +1,6 @@ +import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps"; +import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl"; +import { WEBAPP_URL } from "@calcom/lib/constants"; import type { DialogProps } from "@calcom/ui"; import { Button } from "@calcom/ui"; import { Dialog, DialogClose, DialogContent, DialogFooter } from "@calcom/ui"; @@ -5,7 +8,7 @@ import { Dialog, DialogClose, DialogContent, DialogFooter } from "@calcom/ui"; import useAddAppMutation from "../../_utils/useAddAppMutation"; export function AccountDialog(props: DialogProps) { - const mutation = useAddAppMutation("office365_video"); + const mutation = useAddAppMutation(null); return ( - diff --git a/packages/app-store/ping/api/_getAdd.ts b/packages/app-store/ping/api/_getAdd.ts index 8ad004144a..fd686045cc 100644 --- a/packages/app-store/ping/api/_getAdd.ts +++ b/packages/app-store/ping/api/_getAdd.ts @@ -11,6 +11,7 @@ export async function getHandler(req: NextApiRequest) { const session = checkSession(req); const slug = appConfig.slug; const appType = appConfig.type; + const returnTo = req.query?.returnTo; await checkInstalled(slug, session.user.id); await createDefaultInstallation({ @@ -20,7 +21,7 @@ export async function getHandler(req: NextApiRequest) { key: {}, }); - return { url: getInstalledAppPath({ variant: appConfig.variant, slug: "ping" }) }; + return { url: returnTo ?? getInstalledAppPath({ variant: appConfig.variant, slug: "ping" }) }; } export default defaultResponder(getHandler); diff --git a/packages/app-store/riverside/api/_getAdd.ts b/packages/app-store/riverside/api/_getAdd.ts index b51b921fe2..c8582bb6d6 100644 --- a/packages/app-store/riverside/api/_getAdd.ts +++ b/packages/app-store/riverside/api/_getAdd.ts @@ -11,6 +11,7 @@ export async function getHandler(req: NextApiRequest) { const session = checkSession(req); const slug = appConfig.slug; const appType = appConfig.type; + const returnTo = req.query?.returnTo; await checkInstalled(slug, session.user.id); await createDefaultInstallation({ @@ -20,7 +21,7 @@ export async function getHandler(req: NextApiRequest) { key: {}, }); - return { url: getInstalledAppPath({ variant: "conferencing", slug: "riverside" }) }; + return { url: returnTo ?? getInstalledAppPath({ variant: "conferencing", slug: "riverside" }) }; } export default defaultResponder(getHandler); diff --git a/packages/app-store/salesforce/api/callback.ts b/packages/app-store/salesforce/api/callback.ts index 67ab7001ac..7d863c68ea 100644 --- a/packages/app-store/salesforce/api/callback.ts +++ b/packages/app-store/salesforce/api/callback.ts @@ -42,10 +42,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await createOAuthAppCredential({ appId: appConfig.slug, type: appConfig.type }, salesforceTokenInfo, req); - if (state?.appOnboardingRedirectUrl && state.appOnboardingRedirectUrl !== "") { - return res.redirect(state.appOnboardingRedirectUrl); - } - res.redirect( getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "other", slug: "salesforce" }) ); diff --git a/packages/app-store/server.ts b/packages/app-store/server.ts index 9bea796991..f6ee92e415 100644 --- a/packages/app-store/server.ts +++ b/packages/app-store/server.ts @@ -21,6 +21,7 @@ export async function getLocationGroupedOptions( disabled?: boolean; icon?: string; slug?: string; + credentialId?: number; }[] > = {}; @@ -93,7 +94,9 @@ export async function getLocationGroupedOptions( label, icon: app.logo, slug: app.slug, - ...(app.credential ? { credentialId: app.credential.id, teamName: app.credential.team?.name } : {}), + ...(app.credential + ? { credentialId: app.credential.id, teamName: app.credential.team?.name ?? null } + : {}), }; if (apps[groupByCategory]) { apps[groupByCategory] = [...apps[groupByCategory], option]; diff --git a/packages/app-store/stripepayment/api/callback.ts b/packages/app-store/stripepayment/api/callback.ts index 61d16f7506..98e3b4ca87 100644 --- a/packages/app-store/stripepayment/api/callback.ts +++ b/packages/app-store/stripepayment/api/callback.ts @@ -53,10 +53,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) req ); - if (state?.appOnboardingRedirectUrl && state.appOnboardingRedirectUrl !== "") { - return res.redirect(state.appOnboardingRedirectUrl); - } - const returnTo = getReturnToValueFromQueryState(req); res.redirect(returnTo || getInstalledAppPath({ variant: "payment", slug: "stripe" })); } diff --git a/packages/app-store/sylapsvideo/api/add.ts b/packages/app-store/sylapsvideo/api/add.ts index 59548d385e..edda5533b7 100644 --- a/packages/app-store/sylapsvideo/api/add.ts +++ b/packages/app-store/sylapsvideo/api/add.ts @@ -14,6 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (!req.session?.user?.id) { return res.status(401).json({ message: "You must be logged in to do this" }); } + const returnTo = req.query?.returnTo; const appType = config.type; try { const alreadyInstalled = await prisma.credential.findFirst({ @@ -42,5 +43,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } return res.status(500); } - return res.status(200).json({ url: getInstalledAppPath({ variant: "conferencing", slug: config.slug }) }); + return res + .status(200) + .json({ url: returnTo ?? getInstalledAppPath({ variant: "conferencing", slug: config.slug }) }); } diff --git a/packages/app-store/tandemvideo/api/callback.ts b/packages/app-store/tandemvideo/api/callback.ts index 7d94f7dc17..5b16330487 100644 --- a/packages/app-store/tandemvideo/api/callback.ts +++ b/packages/app-store/tandemvideo/api/callback.ts @@ -1,10 +1,12 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import prisma from "@calcom/prisma"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getInstalledAppPath from "../../_utils/getInstalledAppPath"; import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential"; +import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState"; let client_id = ""; let client_secret = ""; @@ -17,6 +19,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } const code = req.query.code as string; + const state = decodeOAuthState(req); const appKeys = await getAppKeysFromSlug("tandem"); if (typeof appKeys.client_id === "string") client_id = appKeys.client_id; @@ -62,6 +65,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await createOAuthAppCredential({ appId: "tandem", type: "tandem_video" }, responseBody, req); - res.redirect(getInstalledAppPath({ variant: "conferencing", slug: "tandem" })); + res.redirect( + getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "conferencing", slug: "tandem" }) + ); } } diff --git a/packages/app-store/types.d.ts b/packages/app-store/types.d.ts index 060bbd4750..b2911f5d3b 100644 --- a/packages/app-store/types.d.ts +++ b/packages/app-store/types.d.ts @@ -6,11 +6,10 @@ import type { RouterOutputs } from "@calcom/trpc/react"; import type { ButtonProps } from "@calcom/ui"; export type IntegrationOAuthCallbackState = { - returnTo: string; + returnTo?: string; onErrorReturnTo: string; fromApp: boolean; installGoogleVideo?: boolean; - appOnboardingRedirectUrl?: string; teamId?: number; defaultInstall?: boolean; }; diff --git a/packages/app-store/utils.ts b/packages/app-store/utils.ts index 7947f843ed..dfd3a2cfa9 100644 --- a/packages/app-store/utils.ts +++ b/packages/app-store/utils.ts @@ -162,9 +162,12 @@ export function doesAppSupportTeamInstall({ ); } +export function isConferencing(appCategories: string[]) { + return appCategories.some((category) => category === "conferencing" || category === "video"); +} export const defaultVideoAppCategories: AppCategories[] = [ - "conferencing", "messaging", + "conferencing", // Legacy name for conferencing "video", ]; diff --git a/packages/app-store/webex/api/callback.ts b/packages/app-store/webex/api/callback.ts index 6fb99b6a22..2daf4aa333 100644 --- a/packages/app-store/webex/api/callback.ts +++ b/packages/app-store/webex/api/callback.ts @@ -1,16 +1,19 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import prisma from "@calcom/prisma"; import getInstalledAppPath from "../../_utils/getInstalledAppPath"; import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential"; +import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState"; import config from "../config.json"; import { getWebexAppKeys } from "../lib/getWebexAppKeys"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { code } = req.query; const { client_id, client_secret } = await getWebexAppKeys(); + const state = decodeOAuthState(req); /** @link https://developer.webex.com/docs/integrations#getting-an-access-token **/ @@ -82,5 +85,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await createOAuthAppCredential({ appId: config.slug, type: config.type }, responseBody, req); - res.redirect(getInstalledAppPath({ variant: config.variant, slug: config.slug })); + res.redirect( + getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: config.variant, slug: config.slug }) + ); } diff --git a/packages/app-store/whereby/api/_getAdd.ts b/packages/app-store/whereby/api/_getAdd.ts index df69b9f23c..177f1a9c68 100644 --- a/packages/app-store/whereby/api/_getAdd.ts +++ b/packages/app-store/whereby/api/_getAdd.ts @@ -11,6 +11,7 @@ export async function getHandler(req: NextApiRequest) { const session = checkSession(req); const slug = appConfig.slug; const appType = appConfig.type; + const returnTo = req.query?.returnTo; await checkInstalled(slug, session.user.id); await createDefaultInstallation({ @@ -20,7 +21,7 @@ export async function getHandler(req: NextApiRequest) { key: {}, }); - return { url: getInstalledAppPath({ variant: "conferencing", slug: "whereby" }) }; + return { url: returnTo ?? getInstalledAppPath({ variant: "conferencing", slug: "whereby" }) }; } export default defaultResponder(getHandler); diff --git a/packages/app-store/zoho-bigin/api/callback.ts b/packages/app-store/zoho-bigin/api/callback.ts index 60d939cf1d..b2241a66b3 100644 --- a/packages/app-store/zoho-bigin/api/callback.ts +++ b/packages/app-store/zoho-bigin/api/callback.ts @@ -55,10 +55,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await createOAuthAppCredential({ appId: appConfig.slug, type: appConfig.type }, tokenInfo.data, req); - if (state?.appOnboardingRedirectUrl && state.appOnboardingRedirectUrl !== "") { - return res.redirect(state.appOnboardingRedirectUrl); - } - res.redirect( getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: appConfig.variant, slug: appConfig.slug }) diff --git a/packages/app-store/zohocrm/api/callback.ts b/packages/app-store/zohocrm/api/callback.ts index 935460d63f..91a2ea7f41 100644 --- a/packages/app-store/zohocrm/api/callback.ts +++ b/packages/app-store/zohocrm/api/callback.ts @@ -57,10 +57,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const state = decodeOAuthState(req); - if (state?.appOnboardingRedirectUrl && state.appOnboardingRedirectUrl !== "") { - return res.redirect(state.appOnboardingRedirectUrl); - } - res.redirect( getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "other", slug: "zohocrm" }) ); diff --git a/packages/app-store/zoomvideo/api/callback.ts b/packages/app-store/zoomvideo/api/callback.ts index 77f648f23a..af5651eb11 100644 --- a/packages/app-store/zoomvideo/api/callback.ts +++ b/packages/app-store/zoomvideo/api/callback.ts @@ -1,13 +1,16 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import prisma from "@calcom/prisma"; import getInstalledAppPath from "../../_utils/getInstalledAppPath"; import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential"; +import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState"; import { getZoomAppKeys } from "../lib"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const state = decodeOAuthState(req); const { code } = req.query; const { client_id, client_secret } = await getZoomAppKeys(); @@ -74,5 +77,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await createOAuthAppCredential({ appId: "zoom", type: "zoom_video" }, responseBody, req); - res.redirect(getInstalledAppPath({ variant: "conferencing", slug: "zoom" })); + res.redirect( + getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "conferencing", slug: "zoom" }) + ); } diff --git a/packages/embeds/embed-core/src/useCompatSearchParams.tsx b/packages/embeds/embed-core/src/useCompatSearchParams.tsx index 68bb1e6d6d..ef6c6587ef 100644 --- a/packages/embeds/embed-core/src/useCompatSearchParams.tsx +++ b/packages/embeds/embed-core/src/useCompatSearchParams.tsx @@ -12,7 +12,7 @@ export const useCompatSearchParams = () => { const param = params[key]; const paramArr = typeof param === "string" ? param.split("/") : param; - paramArr.forEach((p) => { + paramArr?.forEach((p) => { searchParams.append(key, p); }); }); diff --git a/packages/features/ee/teams/lib/getUserAdminTeams.ts b/packages/features/ee/teams/lib/getUserAdminTeams.ts deleted file mode 100644 index ed4b0c74f6..0000000000 --- a/packages/features/ee/teams/lib/getUserAdminTeams.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { Prisma } from "@prisma/client"; - -import { prisma } from "@calcom/prisma"; -import { MembershipRole } from "@calcom/prisma/enums"; - -export type UserAdminTeams = (Prisma.TeamGetPayload<{ - select: { - id: true; - name: true; - logoUrl: true; - credentials?: true; - parent?: { - select: { - id: true; - name: true; - logoUrl: true; - credentials: true; - }; - }; - }; -}> & { isUser?: boolean })[]; - -/** Get a user's team & orgs they are admins/owners of. Abstracted to a function to call in tRPC endpoint and SSR. */ -const getUserAdminTeams = async ({ - userId, - getUserInfo, - getParentInfo, - includeCredentials = false, -}: { - userId: number; - getUserInfo?: boolean; - getParentInfo?: boolean; - includeCredentials?: boolean; -}): Promise => { - const teams = await prisma.team.findMany({ - where: { - members: { - some: { - userId: userId, - accepted: true, - role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] }, - }, - }, - }, - select: { - id: true, - name: true, - logoUrl: true, - ...(includeCredentials && { credentials: true }), - ...(getParentInfo && { - parent: { - select: { - id: true, - name: true, - logoUrl: true, - credentials: true, - }, - }, - }), - }, - // FIXME - OrgNewSchema: Fix this orderBy - // orderBy: { - // orgUsers: { _count: "desc" }, - // }, - }); - - if (teams.length && getUserInfo) { - const user = await prisma.user.findUnique({ - where: { - id: userId, - }, - select: { - id: true, - name: true, - avatarUrl: true, - ...(includeCredentials && { credentials: true }), - }, - }); - - if (user) { - const userObject = { - id: user.id, - name: user.name || "me", - logoUrl: user?.avatarUrl, // bit ugly, no? - isUser: true, - credentials: includeCredentials ? user.credentials : [], - parent: null, - }; - teams.unshift(userObject); - } - } - - return teams; -}; - -export default getUserAdminTeams; diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 0363b86891..3760621c3a 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -118,3 +118,5 @@ export type FormValues = { forwardParamsSuccessRedirect: boolean | null; secondaryEmailId?: number; }; + +export type LocationFormValues = Pick; diff --git a/packages/lib/apps/getAppOnboardingUrl.ts b/packages/lib/apps/getAppOnboardingUrl.ts index fda259dfe7..297dba0ba9 100644 --- a/packages/lib/apps/getAppOnboardingUrl.ts +++ b/packages/lib/apps/getAppOnboardingUrl.ts @@ -6,18 +6,12 @@ export const getAppOnboardingUrl = ({ slug, step, teamId, - eventTypeIds, }: { slug: string; step: AppOnboardingSteps; teamId?: number; - eventTypeIds?: number[]; }) => { const params: { [key: string]: string | number | number[] } = { slug }; - if (!!eventTypeIds && eventTypeIds.length > 0) { - params.eventTypeIds = eventTypeIds.join(","); - } - if (!!teamId) { params.teamId = teamId; } diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts index a0b12fbcba..57c4c96b37 100644 --- a/packages/lib/server/repository/user.ts +++ b/packages/lib/server/repository/user.ts @@ -8,6 +8,7 @@ import { getTranslation } from "@calcom/lib/server/i18n"; import prisma from "@calcom/prisma"; import { Prisma } from "@calcom/prisma/client"; import type { User as UserType } from "@calcom/prisma/client"; +import { MembershipRole } from "@calcom/prisma/enums"; import type { UpId, UserProfile } from "@calcom/types/UserProfile"; import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "../../availability"; @@ -15,6 +16,8 @@ import slugify from "../../slugify"; import { ProfileRepository } from "./profile"; import { getParsedTeam } from "./teamUtils"; +export type UserAdminTeams = number[]; + const log = logger.getSubLogger({ prefix: ["[repository/user]"] }); export const ORGANIZATION_ID_UNKNOWN = "ORGANIZATION_ID_UNKNOWN"; @@ -489,4 +492,27 @@ export class UserRepository { }, }); } + + static async getUserAdminTeams(userId: number): Promise { + const user = await prisma.user.findFirst({ + where: { + id: userId, + }, + select: { + teams: { + where: { + accepted: true, + role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] }, + }, + select: { teamId: true }, + }, + }, + }); + + const teamIds = []; + for (const team of user?.teams || []) { + teamIds.push(team.teamId); + } + return teamIds; + } } diff --git a/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts b/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts index 33f59f4ba7..69db8c273c 100644 --- a/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts @@ -1,4 +1,4 @@ -import getUserAdminTeams from "@calcom/features/ee/teams/lib/getUserAdminTeams"; +import { UserRepository } from "@calcom/lib/server/repository/user"; import { prisma } from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; @@ -14,12 +14,7 @@ type AppCredentialsByTypeOptions = { /** Used for grabbing credentials on specific app pages */ export const appCredentialsByTypeHandler = async ({ ctx, input }: AppCredentialsByTypeOptions) => { const { user } = ctx; - const userAdminTeams = await getUserAdminTeams({ userId: ctx.user.id, getUserInfo: true }); - - const teamIds = userAdminTeams.reduce((teamIds, team) => { - if (!team.isUser) teamIds.push(team.id); - return teamIds; - }, [] as number[]); + const userAdminTeams = await UserRepository.getUserAdminTeams(ctx.user.id); const credentials = await prisma.credential.findMany({ where: { @@ -27,7 +22,7 @@ export const appCredentialsByTypeHandler = async ({ ctx, input }: AppCredentials { userId: user.id }, { teamId: { - in: teamIds, + in: userAdminTeams, }, }, ], diff --git a/packages/ui/components/apps/AllApps.tsx b/packages/ui/components/apps/AllApps.tsx index 6bbbc7ec1e..b12467b6db 100644 --- a/packages/ui/components/apps/AllApps.tsx +++ b/packages/ui/components/apps/AllApps.tsx @@ -4,10 +4,10 @@ import { usePathname, useRouter } from "next/navigation"; import type { UIEvent } from "react"; import { useEffect, useRef, useState } from "react"; -import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams"; import { classNames } from "@calcom/lib"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { UserAdminTeams } from "@calcom/lib/server/repository/user"; import type { AppFrontendPayload as App } from "@calcom/types/App"; import type { CredentialFrontendPayload as Credential } from "@calcom/types/Credential"; diff --git a/packages/ui/components/apps/AppCard.tsx b/packages/ui/components/apps/AppCard.tsx index 140108445e..d39445ebf1 100644 --- a/packages/ui/components/apps/AppCard.tsx +++ b/packages/ui/components/apps/AppCard.tsx @@ -1,35 +1,21 @@ -import { usePathname, useRouter } from "next/navigation"; -import { useMemo } from "react"; +import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation"; -import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { InstallAppButton } from "@calcom/app-store/components"; -import { doesAppSupportTeamInstall } from "@calcom/app-store/utils"; -import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner"; -import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams"; +import { doesAppSupportTeamInstall, isConferencing } from "@calcom/app-store/utils"; import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps"; import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl"; -import { shouldRedirectToAppOnboarding } from "@calcom/lib/apps/shouldRedirectToAppOnboarding"; import classNames from "@calcom/lib/classNames"; -import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; +import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { UserAdminTeams } from "@calcom/lib/server/repository/user"; import type { AppFrontendPayload as App } from "@calcom/types/App"; import type { CredentialFrontendPayload as Credential } from "@calcom/types/Credential"; import type { ButtonProps } from "@calcom/ui"; -import { - Avatar, - Badge, - Dropdown, - DropdownItem, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuPortal, - DropdownMenuTrigger, -} from "@calcom/ui"; +import { Badge, showToast } from "@calcom/ui"; import { Button } from "../button"; -import { showToast } from "../toast"; interface AppCardProps { app: App; @@ -40,6 +26,7 @@ interface AppCardProps { export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCardProps) { const { t } = useLocale(); + const router = useRouter(); const allowedMultipleInstalls = app.categories && app.categories.indexOf("calendar") > -1; const appAdded = (credentials && credentials.length) || 0; const enabledOnTeams = doesAppSupportTeamInstall({ @@ -50,12 +37,57 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar const appInstalled = enabledOnTeams && userAdminTeams ? userAdminTeams.length < appAdded : appAdded > 0; + const mutation = useAddAppMutation(null, { + onSuccess: (data) => { + if (data?.setupPending) return; + setIsLoading(false); + showToast(t("app_successfully_installed"), "success"); + }, + onError: (error) => { + if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error"); + setIsLoading(false); + }, + }); + const [searchTextIndex, setSearchTextIndex] = useState(undefined); + /** + * @todo Refactor to eliminate the isLoading state by using mutation.isPending directly. + * Currently, the isLoading state is used to manage the loading indicator due to the delay in loading the next page, + * which is caused by heavy queries in getServersideProps. This causes the loader to turn off before the page changes. + */ + const [isLoading, setIsLoading] = useState(mutation.isPending); useEffect(() => { setSearchTextIndex(searchText ? app.name.toLowerCase().indexOf(searchText.toLowerCase()) : undefined); }, [app.name, searchText]); + const handleAppInstall = () => { + setIsLoading(true); + if (isConferencing(app.categories)) { + mutation.mutate({ + type: app.type, + variant: app.variant, + slug: app.slug, + returnTo: + WEBAPP_URL + + getAppOnboardingUrl({ + slug: app.slug, + step: AppOnboardingSteps.EVENT_TYPES_STEP, + }), + }); + } else if ( + !doesAppSupportTeamInstall({ + appCategories: app.categories, + concurrentMeetings: app.concurrentMeetings, + isPaid: !!app.paid, + }) + ) { + mutation.mutate({ type: app.type }); + } else { + router.push(getAppOnboardingUrl({ slug: app.slug, step: AppOnboardingSteps.ACCOUNTS_STEP })); + } + }; + return (
@@ -117,19 +149,13 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar if (useDefaultComponent) { props = { ...props, + onClick: () => { + handleAppInstall(); + }, + loading: isLoading, }; } - return ( - - ); + return ; }} /> ) @@ -145,26 +171,19 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar props = { ...props, disabled: !!props.disabled, + onClick: () => { + handleAppInstall(); + }, + loading: isLoading, }; } - return ( - - ); + return ; }} /> )}
- {appInstalled ? {t("installed", { count: appAdded })} : null} + {appAdded > 0 ? {t("installed", { count: appAdded })} : null} {app.isTemplate && ( Template )} @@ -179,54 +198,12 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar } const InstallAppButtonChild = ({ - userAdminTeams, - addAppMutationInput, - appCategories, - credentials, - concurrentMeetings, paid, - dirName, - onClick, ...props }: { - userAdminTeams?: UserAdminTeams; - addAppMutationInput: { type: App["type"]; variant: string; slug: string }; - appCategories: string[]; - credentials?: Credential[]; - concurrentMeetings?: boolean; - dirName: string | undefined; paid: App["paid"]; } & ButtonProps) => { const { t } = useLocale(); - const router = useRouter(); - const pathname = usePathname(); - - const mutation = useAddAppMutation(null, { - onSuccess: (data) => { - // Refresh SSR page content without actual reload - if (pathname !== null) { - router.replace(pathname); - } - if (data?.setupPending) return; - showToast(t("app_successfully_installed"), "success"); - }, - onError: (error) => { - if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error"); - }, - }); - - const appMetadata = appStoreMetadata[dirName as keyof typeof appStoreMetadata]; - const redirectToAppOnboarding = useMemo(() => shouldRedirectToAppOnboarding(appMetadata), [appMetadata]); - - const _onClick = (e: React.MouseEvent) => { - if (redirectToAppOnboarding) { - router.push( - getAppOnboardingUrl({ slug: addAppMutationInput.slug, step: AppOnboardingSteps.ACCOUNTS_STEP }) - ); - } else if (onClick) { - onClick(e); - } - }; // Paid apps don't support team installs at the moment // Also, cal.ai(the only paid app at the moment) doesn't support team install either if (paid) { @@ -236,99 +213,21 @@ const InstallAppButtonChild = ({ className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center" StartIcon="plus" data-testid="install-app-button" - onClick={_onClick} {...props}> {paid.trial ? t("start_paid_trial") : t("subscribe")} ); } - if ( - !userAdminTeams?.length || - !doesAppSupportTeamInstall({ appCategories, concurrentMeetings, isPaid: !!paid }) - ) { - return ( - - ); - } - - if (redirectToAppOnboarding) { - return ( - - ); - } return ( - - - - - - { - if (mutation.isPending) event.preventDefault(); - }}> - {mutation.isPending && ( -
- -
- )} - {t("install_app_on")} - {userAdminTeams.map((team) => { - const isInstalledTeamOrUser = - credentials && - credentials.some((credential) => - credential?.teamId ? credential?.teamId === team.id : credential.userId === team.id - ); - return ( - - } - onClick={() => { - mutation.mutate( - team.isUser ? addAppMutationInput : { ...addAppMutationInput, teamId: team.id } - ); - }}> -

- {t(team.name)} {isInstalledTeamOrUser && `(${t("installed")})`} -

-
- ); - })} -
-
-
+ ); }; diff --git a/packages/ui/components/apps/appCard.test.tsx b/packages/ui/components/apps/appCard.test.tsx index de1f97bae2..fc3760651a 100644 --- a/packages/ui/components/apps/appCard.test.tsx +++ b/packages/ui/components/apps/appCard.test.tsx @@ -1,4 +1,5 @@ /* eslint-disable playwright/missing-playwright-await */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; import type { AppFrontendPayload } from "@calcom/types/App"; @@ -22,9 +23,14 @@ describe("Tests for AppCard component", () => { }; // Abstracted render function - const renderAppCard = (appProps = {}) => { + const renderAppCard = (appProps = {}, appCardProps = {}) => { const appData = { ...mockApp, ...appProps }; - render(); + const queryClient = new QueryClient(); + render( + + ; + + ); }; describe("Tests for app description", () => { @@ -45,7 +51,7 @@ describe("Tests for AppCard component", () => { }); test("Should highlight the app name based on searchText", () => { - render(); + renderAppCard({}, { searchText: "test" }); const highlightedText = screen.getByTestId("highlighted-text"); expect(highlightedText).toBeInTheDocument(); });